Release v0.3.0
This commit is contained in:
commit
df7ce7cf08
@ -1,6 +1,7 @@
|
||||
include LICENSE pylintrc *.rst *.txt
|
||||
include LICENSE pylintrc *.rst data/mopidy.desktop
|
||||
include mopidy/backends/libspotify/spotify_appkey.key
|
||||
recursive-include docs *
|
||||
prune docs/_build
|
||||
recursive-include requirements *
|
||||
recursive-include tests *.py
|
||||
recursive-include tests/data *
|
||||
|
||||
15
README.rst
15
README.rst
@ -6,14 +6,15 @@ Mopidy is a music server which can play music from `Spotify
|
||||
<http://www.spotify.com/>`_ or from your local hard drive. To search for music
|
||||
in Spotify's vast archive, manage playlists, and play music, you can use most
|
||||
`MPD clients <http://mpd.wikia.com/>`_. MPD clients are available for most
|
||||
platforms, including Windows, Mac OS X, Linux, and iPhone and Android phones.
|
||||
platforms, including Windows, Mac OS X, Linux, Android and iOS.
|
||||
|
||||
To install Mopidy, check out
|
||||
`the installation docs <http://www.mopidy.com/docs/master/installation/>`_.
|
||||
|
||||
* `Documentation (latest release) <http://www.mopidy.com/docs/master/>`_
|
||||
* `Documentation (development version) <http://www.mopidy.com/docs/develop/>`_
|
||||
* `Source code <http://github.com/mopidy/mopidy>`_
|
||||
* `Issue tracker <http://github.com/mopidy/mopidy/issues>`_
|
||||
* IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
|
||||
* `Download development snapshot <http://github.com/mopidy/mopidy/tarball/develop#egg=mopidy-dev>`_
|
||||
- `Documentation for the latest release <http://www.mopidy.com/docs/master/>`_
|
||||
- `Documentation for the development version
|
||||
<http://www.mopidy.com/docs/develop/>`_
|
||||
- `Source code <http://github.com/mopidy/mopidy>`_
|
||||
- `Issue tracker <http://github.com/mopidy/mopidy/issues>`_
|
||||
- IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
|
||||
- `Download development snapshot <http://github.com/mopidy/mopidy/tarball/develop#egg=mopidy-dev>`_
|
||||
|
||||
0
bin/mopidy
Normal file → Executable file
0
bin/mopidy
Normal file → Executable file
31
bin/mopidy-scan
Executable file
31
bin/mopidy-scan
Executable file
@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.scanner import Scanner, translator
|
||||
from mopidy.frontends.mpd.translator import tracks_to_tag_cache_format
|
||||
|
||||
tracks = []
|
||||
|
||||
def store(data):
|
||||
track = translator(data)
|
||||
tracks.append(track)
|
||||
print >> sys.stderr, 'Added %s' % track.uri
|
||||
|
||||
def debug(uri, error):
|
||||
print >> sys.stderr, 'Failed %s: %s' % (uri, error)
|
||||
|
||||
print >> sys.stderr, 'Scanning %s' % settings.LOCAL_MUSIC_PATH
|
||||
|
||||
scanner = Scanner(settings.LOCAL_MUSIC_PATH, store, debug)
|
||||
scanner.start()
|
||||
|
||||
print >> sys.stderr, 'Done'
|
||||
|
||||
for a in tracks_to_tag_cache_format(tracks):
|
||||
if len(a) == 1:
|
||||
print (u'%s' % a).encode('utf-8')
|
||||
else:
|
||||
print (u'%s: %s' % a).encode('utf-8')
|
||||
10
data/mopidy.desktop
Normal file
10
data/mopidy.desktop
Normal file
@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Version=1.0
|
||||
Name=Mopidy Music Server
|
||||
Comment=MPD music server with Spotify support
|
||||
Icon=audio-x-generic
|
||||
TryExec=mopidy
|
||||
Exec=mopidy
|
||||
Terminal=true
|
||||
Categories=AudioVideo;Audio;Player;ConsoleOnly;
|
||||
130
docs/Makefile
130
docs/Makefile
@ -4,101 +4,127 @@
|
||||
# You can set these variables from the command line.
|
||||
SPHINXOPTS =
|
||||
SPHINXBUILD = sphinx-build
|
||||
PAPER =
|
||||
PAPER =
|
||||
BUILDDIR = _build
|
||||
|
||||
# Internal variables.
|
||||
PAPEROPT_a4 = -D latex_paper_size=a4
|
||||
PAPEROPT_letter = -D latex_paper_size=letter
|
||||
ALLSPHINXOPTS = -d _build/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||
ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
|
||||
|
||||
.PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest
|
||||
.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest
|
||||
|
||||
help:
|
||||
@echo "Please use \`make <target>' where <target> is one of"
|
||||
@echo " html to make standalone HTML files"
|
||||
@echo " dirhtml to make HTML files named index.html in directories"
|
||||
@echo " pickle to make pickle files"
|
||||
@echo " json to make JSON files"
|
||||
@echo " htmlhelp to make HTML files and a HTML help project"
|
||||
@echo " qthelp to make HTML files and a qthelp project"
|
||||
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
|
||||
@echo " changes to make an overview of all changed/added/deprecated items"
|
||||
@echo " linkcheck to check all external links for integrity"
|
||||
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
||||
@echo " html to make standalone HTML files"
|
||||
@echo " dirhtml to make HTML files named index.html in directories"
|
||||
@echo " singlehtml to make a single large HTML file"
|
||||
@echo " pickle to make pickle files"
|
||||
@echo " json to make JSON files"
|
||||
@echo " htmlhelp to make HTML files and a HTML help project"
|
||||
@echo " qthelp to make HTML files and a qthelp project"
|
||||
@echo " devhelp to make HTML files and a Devhelp project"
|
||||
@echo " epub to make an epub"
|
||||
@echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
|
||||
@echo " latexpdf to make LaTeX files and run them through pdflatex"
|
||||
@echo " text to make text files"
|
||||
@echo " man to make manual pages"
|
||||
@echo " changes to make an overview of all changed/added/deprecated items"
|
||||
@echo " linkcheck to check all external links for integrity"
|
||||
@echo " doctest to run all doctests embedded in the documentation (if enabled)"
|
||||
|
||||
clean:
|
||||
-rm -rf _build/*
|
||||
-rm -rf $(BUILDDIR)/*
|
||||
|
||||
html:
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) _build/html
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in _build/html."
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||
|
||||
dirhtml:
|
||||
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) _build/dirhtml
|
||||
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in _build/dirhtml."
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
|
||||
|
||||
singlehtml:
|
||||
$(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
|
||||
@echo
|
||||
@echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
|
||||
|
||||
pickle:
|
||||
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) _build/pickle
|
||||
$(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
|
||||
@echo
|
||||
@echo "Build finished; now you can process the pickle files."
|
||||
|
||||
json:
|
||||
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) _build/json
|
||||
$(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
|
||||
@echo
|
||||
@echo "Build finished; now you can process the JSON files."
|
||||
|
||||
htmlhelp:
|
||||
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) _build/htmlhelp
|
||||
$(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run HTML Help Workshop with the" \
|
||||
".hhp project file in _build/htmlhelp."
|
||||
".hhp project file in $(BUILDDIR)/htmlhelp."
|
||||
|
||||
qthelp:
|
||||
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) _build/qthelp
|
||||
$(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
|
||||
@echo
|
||||
@echo "Build finished; now you can run "qcollectiongenerator" with the" \
|
||||
".qhcp project file in _build/qthelp, like this:"
|
||||
@echo "# qcollectiongenerator _build/qthelp/Mopidy.qhcp"
|
||||
".qhcp project file in $(BUILDDIR)/qthelp, like this:"
|
||||
@echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Mopidy.qhcp"
|
||||
@echo "To view the help file:"
|
||||
@echo "# assistant -collectionFile _build/qthelp/Mopidy.qhc"
|
||||
@echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Mopidy.qhc"
|
||||
|
||||
devhelp:
|
||||
$(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
|
||||
@echo
|
||||
@echo "Build finished."
|
||||
@echo "To view the help file:"
|
||||
@echo "# mkdir -p $$HOME/.local/share/devhelp/Mopidy"
|
||||
@echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Mopidy"
|
||||
@echo "# devhelp"
|
||||
|
||||
epub:
|
||||
$(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
|
||||
@echo
|
||||
@echo "Build finished. The epub file is in $(BUILDDIR)/epub."
|
||||
|
||||
latex:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) _build/latex
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo
|
||||
@echo "Build finished; the LaTeX files are in _build/latex."
|
||||
@echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \
|
||||
"run these through (pdf)latex."
|
||||
@echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
|
||||
@echo "Run \`make' in that directory to run these through (pdf)latex" \
|
||||
"(use \`make latexpdf' here to do that automatically)."
|
||||
|
||||
latexpdf:
|
||||
$(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
|
||||
@echo "Running LaTeX files through pdflatex..."
|
||||
make -C $(BUILDDIR)/latex all-pdf
|
||||
@echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
|
||||
|
||||
text:
|
||||
$(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
|
||||
@echo
|
||||
@echo "Build finished. The text files are in $(BUILDDIR)/text."
|
||||
|
||||
man:
|
||||
$(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
|
||||
@echo
|
||||
@echo "Build finished. The manual pages are in $(BUILDDIR)/man."
|
||||
|
||||
changes:
|
||||
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) _build/changes
|
||||
$(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
|
||||
@echo
|
||||
@echo "The overview file is in _build/changes."
|
||||
@echo "The overview file is in $(BUILDDIR)/changes."
|
||||
|
||||
linkcheck:
|
||||
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) _build/linkcheck
|
||||
$(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
|
||||
@echo
|
||||
@echo "Link check complete; look for any errors in the above output " \
|
||||
"or in _build/linkcheck/output.txt."
|
||||
"or in $(BUILDDIR)/linkcheck/output.txt."
|
||||
|
||||
doctest:
|
||||
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) _build/doctest
|
||||
$(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
|
||||
@echo "Testing of doctests in the sources finished, look at the " \
|
||||
"results in _build/doctest/output.txt."
|
||||
|
||||
public: clean dirhtml
|
||||
rm -rf /tmp/mopidy-html && cp -r _build/dirhtml /tmp/mopidy-html
|
||||
git stash save
|
||||
cd .. && \
|
||||
git checkout gh-pages && \
|
||||
git pull && \
|
||||
rm -r * && \
|
||||
cp -r /tmp/mopidy-html/* . && \
|
||||
mv _sources sources && \
|
||||
(find . -type f | xargs sed -i -e 's/_sources/sources/g') && \
|
||||
mv _static static && \
|
||||
(find . -type f | xargs sed -i -e 's/_static/static/g') && \
|
||||
if [ -d _images ]; then mv _images images; fi && \
|
||||
(find . -type f | xargs sed -i -e 's/_images/images/g') && \
|
||||
git add *
|
||||
"results in $(BUILDDIR)/doctest/output.txt."
|
||||
|
||||
BIN
docs/_static/mopidy.png
vendored
Normal file
BIN
docs/_static/mopidy.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
2
docs/_themes/nature/static/nature.css_t
vendored
2
docs/_themes/nature/static/nature.css_t
vendored
@ -214,7 +214,7 @@ p.admonition-title:after {
|
||||
|
||||
pre {
|
||||
padding: 10px;
|
||||
background-color: #fafafa;
|
||||
background-color: #eeeeee;
|
||||
color: #222222;
|
||||
line-height: 1.5em;
|
||||
font-size: 1.1em;
|
||||
|
||||
@ -1,106 +0,0 @@
|
||||
**********************
|
||||
:mod:`mopidy.backends`
|
||||
**********************
|
||||
|
||||
.. automodule:: mopidy.backends
|
||||
:synopsis: Backend API
|
||||
|
||||
|
||||
The backend and its controllers
|
||||
===============================
|
||||
|
||||
.. graph:: backend_relations
|
||||
|
||||
backend -- current_playlist
|
||||
backend -- library
|
||||
backend -- playback
|
||||
backend -- stored_playlists
|
||||
|
||||
|
||||
Backend API
|
||||
===========
|
||||
|
||||
.. note::
|
||||
|
||||
Currently this only documents the API that is available for use by
|
||||
frontends like :mod:`mopidy.frontends.mpd`, and not what is required to
|
||||
implement your own backend. :class:`mopidy.backends.base.BaseBackend` and
|
||||
its controllers implements many of these methods in a matter that should be
|
||||
independent of most concrete backend implementations, so you should
|
||||
generally just implement or override a few of these methods yourself to
|
||||
create a new backend with a complete feature set.
|
||||
|
||||
.. autoclass:: mopidy.backends.base.BaseBackend
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
Playback controller
|
||||
-------------------
|
||||
|
||||
Manages playback, with actions like play, pause, stop, next, previous, and
|
||||
seek.
|
||||
|
||||
.. autoclass:: mopidy.backends.base.BasePlaybackController
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
Mixer controller
|
||||
----------------
|
||||
|
||||
Manages volume. See :class:`mopidy.mixers.BaseMixer`.
|
||||
|
||||
|
||||
Current playlist controller
|
||||
---------------------------
|
||||
|
||||
Manages everything related to the currently loaded playlist.
|
||||
|
||||
.. autoclass:: mopidy.backends.base.BaseCurrentPlaylistController
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
Stored playlists controller
|
||||
---------------------------
|
||||
|
||||
Manages stored playlist.
|
||||
|
||||
.. autoclass:: mopidy.backends.base.BaseStoredPlaylistsController
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
Library controller
|
||||
------------------
|
||||
|
||||
Manages the music library, e.g. searching for tracks to be added to a playlist.
|
||||
|
||||
.. autoclass:: mopidy.backends.base.BaseLibraryController
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
:mod:`mopidy.backends.dummy` -- Dummy backend for testing
|
||||
=========================================================
|
||||
|
||||
.. automodule:: mopidy.backends.dummy
|
||||
:synopsis: Dummy backend used for testing
|
||||
:members:
|
||||
|
||||
|
||||
:mod:`mopidy.backends.libspotify` -- Libspotify backend
|
||||
=======================================================
|
||||
|
||||
.. automodule:: mopidy.backends.libspotify
|
||||
:synopsis: Spotify backend using the libspotify library
|
||||
:members:
|
||||
|
||||
|
||||
:mod:`mopidy.backends.local` -- Local backend
|
||||
=====================================================
|
||||
|
||||
.. automodule:: mopidy.backends.local
|
||||
:synopsis: Backend for playing music files on local storage
|
||||
:members:
|
||||
30
docs/api/backends/concepts.rst
Normal file
30
docs/api/backends/concepts.rst
Normal file
@ -0,0 +1,30 @@
|
||||
.. _backend-concepts:
|
||||
|
||||
**********************************************
|
||||
The backend, controller, and provider concepts
|
||||
**********************************************
|
||||
|
||||
Backend:
|
||||
The backend is mostly for convenience. It is a container that holds
|
||||
references to all the controllers.
|
||||
Controllers:
|
||||
Each controller has responsibility for a given part of the backend
|
||||
functionality. Most, but not all, controllers delegates some work to one or
|
||||
more providers. The controllers are responsible for choosing the right
|
||||
provider for any given task based upon i.e. the track's URI. See
|
||||
:ref:`backend-controller-api` for more details.
|
||||
Providers:
|
||||
Anything specific to i.e. Spotify integration or local storage is contained
|
||||
in the providers. To integrate with new music sources, you just add new
|
||||
providers. See :ref:`backend-provider-api` for more details.
|
||||
|
||||
.. digraph:: backend_relations
|
||||
|
||||
Backend -> "Current\nplaylist\ncontroller"
|
||||
Backend -> "Library\ncontroller"
|
||||
"Library\ncontroller" -> "Library\nproviders"
|
||||
Backend -> "Playback\ncontroller"
|
||||
"Playback\ncontroller" -> "Playback\nproviders"
|
||||
Backend -> "Stored\nplaylists\ncontroller"
|
||||
"Stored\nplaylists\ncontroller" -> "Stored\nplaylist\nproviders"
|
||||
Backend -> Mixer
|
||||
65
docs/api/backends/controllers.rst
Normal file
65
docs/api/backends/controllers.rst
Normal file
@ -0,0 +1,65 @@
|
||||
.. _backend-controller-api:
|
||||
|
||||
**********************
|
||||
Backend controller API
|
||||
**********************
|
||||
|
||||
|
||||
The backend controller API is the interface that is used by frontends like
|
||||
:mod:`mopidy.frontends.mpd`. If you want to implement your own backend, see the
|
||||
:ref:`backend-provider-api`.
|
||||
|
||||
|
||||
The backend
|
||||
===========
|
||||
|
||||
.. autoclass:: mopidy.backends.base.Backend
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
Playback controller
|
||||
===================
|
||||
|
||||
Manages playback, with actions like play, pause, stop, next, previous, and
|
||||
seek.
|
||||
|
||||
.. autoclass:: mopidy.backends.base.PlaybackController
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
Mixer controller
|
||||
================
|
||||
|
||||
Manages volume. See :class:`mopidy.mixers.base.BaseMixer`.
|
||||
|
||||
|
||||
Current playlist controller
|
||||
===========================
|
||||
|
||||
Manages everything related to the currently loaded playlist.
|
||||
|
||||
.. autoclass:: mopidy.backends.base.CurrentPlaylistController
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
Stored playlists controller
|
||||
===========================
|
||||
|
||||
Manages stored playlist.
|
||||
|
||||
.. autoclass:: mopidy.backends.base.StoredPlaylistsController
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
Library controller
|
||||
==================
|
||||
|
||||
Manages the music library, e.g. searching for tracks to be added to a playlist.
|
||||
|
||||
.. autoclass:: mopidy.backends.base.LibraryController
|
||||
:members:
|
||||
:undoc-members:
|
||||
41
docs/api/backends/providers.rst
Normal file
41
docs/api/backends/providers.rst
Normal file
@ -0,0 +1,41 @@
|
||||
.. _backend-provider-api:
|
||||
|
||||
********************
|
||||
Backend provider API
|
||||
********************
|
||||
|
||||
The backend provider API is the interface that must be implemented when you
|
||||
create a backend. If you are working on a frontend and need to access the
|
||||
backend, see the :ref:`backend-controller-api`.
|
||||
|
||||
|
||||
Playback provider
|
||||
=================
|
||||
|
||||
.. autoclass:: mopidy.backends.base.BasePlaybackProvider
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
Stored playlists provider
|
||||
=========================
|
||||
|
||||
.. autoclass:: mopidy.backends.base.BaseStoredPlaylistsProvider
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
Library provider
|
||||
================
|
||||
|
||||
.. autoclass:: mopidy.backends.base.BaseLibraryProvider
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
Backend provider implementations
|
||||
================================
|
||||
|
||||
* :mod:`mopidy.backends.dummy`
|
||||
* :mod:`mopidy.backends.spotify`
|
||||
* :mod:`mopidy.backends.local`
|
||||
26
docs/api/frontends.rst
Normal file
26
docs/api/frontends.rst
Normal file
@ -0,0 +1,26 @@
|
||||
************
|
||||
Frontend API
|
||||
************
|
||||
|
||||
A frontend may do whatever it wants to, including creating threads, opening TCP
|
||||
ports and exposing Mopidy for a type of clients.
|
||||
|
||||
Frontends got one main limitation: they are restricted to passing messages
|
||||
through the ``core_queue`` for all communication with the rest of Mopidy. Thus,
|
||||
the frontend API is very small and reveals little of what a frontend may do.
|
||||
|
||||
.. warning::
|
||||
|
||||
A stable frontend API is not available yet, as we've only implemented a
|
||||
couple of frontend modules.
|
||||
|
||||
.. automodule:: mopidy.frontends.base
|
||||
:synopsis: Base class for frontends
|
||||
:members:
|
||||
|
||||
|
||||
Frontend implementations
|
||||
========================
|
||||
|
||||
* :mod:`mopidy.frontends.lastfm`
|
||||
* :mod:`mopidy.frontends.mpd`
|
||||
@ -1,25 +0,0 @@
|
||||
***********************
|
||||
:mod:`mopidy.frontends`
|
||||
***********************
|
||||
|
||||
A frontend is responsible for exposing Mopidy for a type of clients.
|
||||
|
||||
|
||||
Frontend API
|
||||
============
|
||||
|
||||
.. warning::
|
||||
|
||||
A stable frontend API is not available yet, as we've only implemented a
|
||||
couple of frontend modules.
|
||||
|
||||
.. automodule:: mopidy.frontends.base
|
||||
:synopsis: Base class for frontends
|
||||
:members:
|
||||
|
||||
|
||||
Frontends
|
||||
=========
|
||||
|
||||
* :mod:`mopidy.frontends.lastfm`
|
||||
* :mod:`mopidy.frontends.mpd`
|
||||
@ -1,7 +0,0 @@
|
||||
******************************
|
||||
:mod:`mopidy.frontends.lastfm`
|
||||
******************************
|
||||
|
||||
.. automodule:: mopidy.frontends.lastfm
|
||||
:synopsis: Last.fm scrobbler frontend
|
||||
:members:
|
||||
@ -1,8 +1,11 @@
|
||||
*****************
|
||||
API documentation
|
||||
*****************
|
||||
*************
|
||||
API reference
|
||||
*************
|
||||
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
**
|
||||
backends/concepts
|
||||
backends/controllers
|
||||
backends/providers
|
||||
*
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
********************
|
||||
:mod:`mopidy.mixers`
|
||||
********************
|
||||
*********
|
||||
Mixer API
|
||||
*********
|
||||
|
||||
Mixers are responsible for controlling volume. Clients of the mixers will
|
||||
simply instantiate a mixer and read/write to the ``volume`` attribute::
|
||||
@ -24,74 +24,21 @@ enable one of the hardware device mixers, you must the set
|
||||
:attr:`mopidy.settings.MIXER` setting to point to one of the classes found
|
||||
below, and possibly add some extra settings required by the mixer you choose.
|
||||
|
||||
|
||||
Mixer API
|
||||
=========
|
||||
|
||||
All mixers should subclass :class:`mopidy.mixers.BaseMixer` and override
|
||||
All mixers should subclass :class:`mopidy.mixers.base.BaseMixer` and override
|
||||
methods as described below.
|
||||
|
||||
.. automodule:: mopidy.mixers
|
||||
.. automodule:: mopidy.mixers.base
|
||||
:synopsis: Mixer API
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
|
||||
:mod:`mopidy.mixers.alsa` -- ALSA mixer for Linux
|
||||
=================================================
|
||||
Mixer implementations
|
||||
=====================
|
||||
|
||||
.. inheritance-diagram:: mopidy.mixers.alsa
|
||||
|
||||
.. automodule:: mopidy.mixers.alsa
|
||||
:synopsis: ALSA mixer for Linux
|
||||
:members:
|
||||
|
||||
|
||||
:mod:`mopidy.mixers.denon` -- Hardware mixer for Denon amplifiers
|
||||
=================================================================
|
||||
|
||||
.. inheritance-diagram:: mopidy.mixers.denon
|
||||
|
||||
.. automodule:: mopidy.mixers.denon
|
||||
:synopsis: Hardware mixer for Denon amplifiers
|
||||
:members:
|
||||
|
||||
|
||||
:mod:`mopidy.mixers.dummy` -- Dummy mixer for testing
|
||||
=====================================================
|
||||
|
||||
.. inheritance-diagram:: mopidy.mixers.dummy
|
||||
|
||||
.. automodule:: mopidy.mixers.dummy
|
||||
:synopsis: Dummy mixer for testing
|
||||
:members:
|
||||
|
||||
|
||||
:mod:`mopidy.mixers.gstreamer_software` -- Software mixer for all platforms
|
||||
===========================================================================
|
||||
|
||||
.. inheritance-diagram:: mopidy.mixers.gstreamer_software
|
||||
|
||||
.. automodule:: mopidy.mixers.gstreamer_software
|
||||
:synopsis: Software mixer for all platforms
|
||||
:members:
|
||||
|
||||
|
||||
:mod:`mopidy.mixers.osa` -- Osa mixer for OS X
|
||||
==============================================
|
||||
|
||||
.. inheritance-diagram:: mopidy.mixers.osa
|
||||
|
||||
.. automodule:: mopidy.mixers.osa
|
||||
:synopsis: Osa mixer for OS X
|
||||
:members:
|
||||
|
||||
|
||||
:mod:`mopidy.mixers.nad` -- Hardware mixer for NAD amplifiers
|
||||
=============================================================
|
||||
|
||||
.. inheritance-diagram:: mopidy.mixers.nad
|
||||
|
||||
.. automodule:: mopidy.mixers.nad
|
||||
:synopsis: Hardware mixer for NAD amplifiers
|
||||
:members:
|
||||
* :mod:`mopidy.mixers.alsa`
|
||||
* :mod:`mopidy.mixers.denon`
|
||||
* :mod:`mopidy.mixers.dummy`
|
||||
* :mod:`mopidy.mixers.gstreamer_software`
|
||||
* :mod:`mopidy.mixers.osa`
|
||||
* :mod:`mopidy.mixers.nad`
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
********************
|
||||
:mod:`mopidy.models`
|
||||
********************
|
||||
***********
|
||||
Data models
|
||||
***********
|
||||
|
||||
These immutable data models are used for all data transfer within the Mopidy
|
||||
backends and between the backends and the MPD frontend. All fields are optional
|
||||
|
||||
@ -1,22 +1,20 @@
|
||||
*********************
|
||||
:mod:`mopidy.outputs`
|
||||
*********************
|
||||
**********
|
||||
Output API
|
||||
**********
|
||||
|
||||
Outputs are responsible for playing audio.
|
||||
|
||||
.. warning::
|
||||
|
||||
Output API
|
||||
==========
|
||||
A stable output API is not available yet, as we've only implemented a
|
||||
single output module.
|
||||
|
||||
A stable output API is not available yet, as we've only implemented a single
|
||||
output module.
|
||||
|
||||
|
||||
:mod:`mopidy.outputs.gstreamer` -- GStreamer output for all platforms
|
||||
=====================================================================
|
||||
|
||||
.. inheritance-diagram:: mopidy.outputs.gstreamer
|
||||
|
||||
.. automodule:: mopidy.outputs.gstreamer
|
||||
:synopsis: GStreamer output for all platforms
|
||||
.. automodule:: mopidy.outputs.base
|
||||
:synopsis: Base class for outputs
|
||||
:members:
|
||||
|
||||
|
||||
Output implementations
|
||||
======================
|
||||
|
||||
* :mod:`mopidy.outputs.gstreamer`
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
**********************
|
||||
:mod:`mopidy.settings`
|
||||
**********************
|
||||
|
||||
|
||||
Changing settings
|
||||
=================
|
||||
|
||||
For any Mopidy installation you will need to change at least a couple of
|
||||
settings. To do this, create a new file in the ``~/.mopidy/`` directory
|
||||
named ``settings.py`` and add settings you need to change from their defaults
|
||||
there.
|
||||
|
||||
A complete ``~/.mopidy/settings.py`` may look like this::
|
||||
|
||||
MPD_SERVER_HOSTNAME = u'::'
|
||||
SPOTIFY_USERNAME = u'alice'
|
||||
SPOTIFY_PASSWORD = u'mysecret'
|
||||
|
||||
|
||||
Available settings
|
||||
==================
|
||||
|
||||
.. automodule:: mopidy.settings
|
||||
:synopsis: Available settings and their default values
|
||||
:members:
|
||||
:undoc-members:
|
||||
@ -10,13 +10,20 @@ Contributors to Mopidy in the order of appearance:
|
||||
- Kristian Klette <klette@klette.us>
|
||||
|
||||
|
||||
Donations
|
||||
=========
|
||||
Showing your appreciation
|
||||
=========================
|
||||
|
||||
If you already enjoy Mopidy, or don't enjoy it and want to help us making
|
||||
Mopidy better, you can `donate money <http://pledgie.com/campaigns/12647>`_ to
|
||||
Mopidy's development.
|
||||
Mopidy better, the best way to do so is to contribute back to the community.
|
||||
You can contribute code, documentation, tests, bug reports, or help other
|
||||
users, spreading the word, etc.
|
||||
|
||||
If you want to show your appreciation in a less time consuming way, you can
|
||||
`flattr us <https://flattr.com/thing/82288/Mopidy>`_, or `donate money
|
||||
<http://pledgie.com/campaigns/12647>`_ to Mopidy's development.
|
||||
|
||||
We promise that any money donated -- to Pledgie, not Flattr, due to the size of
|
||||
the amounts -- will be used to cover costs related to Mopidy development, like
|
||||
service subscriptions (Spotify, Last.fm, etc.) and hardware devices like an
|
||||
used iPod Touch for testing Mopidy with MPod.
|
||||
|
||||
Any donated money will be used to cover service subscriptions (e.g. Spotify
|
||||
and Last.fm) and hardware devices (e.g. an used iPod Touch for testing Mopidy
|
||||
with MPod) needed for developing Mopidy.
|
||||
|
||||
155
docs/changes.rst
155
docs/changes.rst
@ -5,6 +5,161 @@ Changes
|
||||
This change log is used to track all major changes to Mopidy.
|
||||
|
||||
|
||||
0.3.0 (2010-01-22)
|
||||
==================
|
||||
|
||||
Mopidy 0.3.0 brings a bunch of small changes all over the place, but no large
|
||||
changes. The main features are support for high bitrate audio from Spotify, and
|
||||
MPD password authentication.
|
||||
|
||||
Regarding the docs, we've improved the :ref:`installation instructions
|
||||
<installation>` and done a bit of testing of the available :ref:`Android
|
||||
<android_mpd_clients>` and :ref:`iOS clients <ios_mpd_clients>` for MPD.
|
||||
|
||||
Please note that 0.3.0 requires some updated dependencies, as listed under
|
||||
*Important changes* below. Also, there is a known bug in the Spotify playlist
|
||||
loading, as described below. As the bug will take some time to fix and has a
|
||||
known workaround, we did not want to delay the release while waiting for a fix
|
||||
to this problem.
|
||||
|
||||
|
||||
.. warning:: Known bug in Spotify playlist loading
|
||||
|
||||
There is a known bug in the loading of Spotify playlists. This bug affects
|
||||
both Mopidy 0.2.1 and 0.3.0, given that you use libspotify 0.0.6. To avoid
|
||||
the bug, either use Mopidy 0.2.1 with libspotify 0.0.4, or use either
|
||||
Mopidy version with libspotify 0.0.6 and follow the simple workaround
|
||||
described at :issue:`59`.
|
||||
|
||||
|
||||
**Important changes**
|
||||
|
||||
- If you use the Spotify backend, you need to upgrade to libspotify 0.0.6 and
|
||||
the latest pyspotify from the Mopidy developers. Follow the instructions at
|
||||
:doc:`/installation/libspotify/`.
|
||||
|
||||
- If you use the Last.fm frontend, you need to upgrade to pylast 0.5.7. Run
|
||||
``sudp pip install --upgrade pylast`` or install Mopidy from APT.
|
||||
|
||||
|
||||
**Changes**
|
||||
|
||||
- Spotify backend:
|
||||
|
||||
- Support high bitrate (320k) audio. Set the new setting
|
||||
:attr:`mopidy.settings.SPOTIFY_HIGH_BITRATE` to :class:`True` to switch to
|
||||
high bitrate audio.
|
||||
|
||||
- Rename :mod:`mopidy.backends.libspotify` to :mod:`mopidy.backends.spotify`.
|
||||
If you have set :attr:`mopidy.settings.BACKENDS` explicitly, you may need
|
||||
to update the setting's value.
|
||||
|
||||
- Catch and log error caused by playlist folder boundaries being threated as
|
||||
normal playlists. More permanent fix requires support for checking playlist
|
||||
types in pyspotify (see :issue:`62`).
|
||||
|
||||
- Fix crash on failed lookup of track by URI. (Fixes: :issue:`60`)
|
||||
|
||||
- Local backend:
|
||||
|
||||
- Add :command:`mopidy-scan` command to generate ``tag_cache`` files without
|
||||
any help from the original MPD server. See :ref:`generating_a_tag_cache`
|
||||
for instructions on how to use it.
|
||||
|
||||
- Fix support for UTF-8 encoding in tag caches.
|
||||
|
||||
- MPD frontend:
|
||||
|
||||
- Add support for password authentication. See
|
||||
:attr:`mopidy.settings.MPD_SERVER_PASSWORD` and
|
||||
:ref:`use_mpd_on_a_network` for details on how to use it. (Fixes:
|
||||
:issue:`41`)
|
||||
|
||||
- Support ``setvol 50`` without quotes around the argument. Fixes volume
|
||||
control in Droid MPD.
|
||||
|
||||
- Support ``seek 1 120`` without quotes around the arguments. Fixes seek in
|
||||
Droid MPD.
|
||||
|
||||
- Last.fm frontend:
|
||||
|
||||
- Update to use Last.fm's new Scrobbling 2.0 API, as the old Submissions
|
||||
Protocol 1.2.1 is deprecated. (Fixes: :issue:`33`)
|
||||
|
||||
- Fix crash when track object does not contain all the expected meta data.
|
||||
|
||||
- Fix crash when response from Last.fm cannot be decoded as UTF-8. (Fixes:
|
||||
:issue:`37`)
|
||||
|
||||
- Fix crash when response from Last.fm contains invalid XML.
|
||||
|
||||
- Fix crash when response from Last.fm has an invalid HTTP status line.
|
||||
|
||||
- Mixers:
|
||||
|
||||
- Support use of unicode strings for settings specific to
|
||||
:mod:`mopidy.mixers.nad`.
|
||||
|
||||
- Settings:
|
||||
|
||||
- Automatically expand the "~" characted to the user's home directory and
|
||||
make the path absolute for settings with names ending in ``_PATH`` or
|
||||
``_FILE``.
|
||||
|
||||
- Rename the following settings. The settings validator will warn you if you
|
||||
need to change your local settings.
|
||||
|
||||
- ``LOCAL_MUSIC_FOLDER`` to :attr:`mopidy.settings.LOCAL_MUSIC_PATH`
|
||||
- ``LOCAL_PLAYLIST_FOLDER`` to
|
||||
:attr:`mopidy.settings.LOCAL_PLAYLIST_PATH`
|
||||
- ``LOCAL_TAG_CACHE`` to :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE`
|
||||
- ``SPOTIFY_LIB_CACHE`` to :attr:`mopidy.settings.SPOTIFY_CACHE_PATH`
|
||||
|
||||
- Fix bug which made settings set to :class:`None` or 0 cause a
|
||||
:exc:`mopidy.SettingsError` to be raised.
|
||||
|
||||
- Packaging and distribution:
|
||||
|
||||
- Setup APT repository and crate Debian packages of Mopidy. See
|
||||
:ref:`installation` for instructions for how to install Mopidy, including
|
||||
all dependencies, from APT.
|
||||
|
||||
- Install ``mopidy.desktop`` file that makes Mopidy available from e.g. Gnome
|
||||
application menus.
|
||||
|
||||
- API:
|
||||
|
||||
- Rename and generalize ``Playlist._with(**kwargs)`` to
|
||||
:meth:`mopidy.models.ImmutableObject.copy`.
|
||||
|
||||
- Add ``musicbrainz_id`` field to :class:`mopidy.models.Artist`,
|
||||
:class:`mopidy.models.Album`, and :class:`mopidy.models.Track`.
|
||||
|
||||
- Prepare for multi-backend support (see :issue:`40`) by introducing the
|
||||
:ref:`provider concept <backend-concepts>`. Split the backend API into a
|
||||
:ref:`backend controller API <backend-controller-api>` (for frontend use)
|
||||
and a :ref:`backend provider API <backend-provider-api>` (for backend
|
||||
implementation use), which includes the following changes:
|
||||
|
||||
- Rename ``BaseBackend`` to :class:`mopidy.backends.base.Backend`.
|
||||
- Rename ``BaseCurrentPlaylistController`` to
|
||||
:class:`mopidy.backends.base.CurrentPlaylistController`.
|
||||
- Split ``BaseLibraryController`` to
|
||||
:class:`mopidy.backends.base.LibraryController` and
|
||||
:class:`mopidy.backends.base.BaseLibraryProvider`.
|
||||
- Split ``BasePlaybackController`` to
|
||||
:class:`mopidy.backends.base.PlaybackController` and
|
||||
:class:`mopidy.backends.base.BasePlaybackProvider`.
|
||||
- Split ``BaseStoredPlaylistsController`` to
|
||||
:class:`mopidy.backends.base.StoredPlaylistsController` and
|
||||
:class:`mopidy.backends.base.BaseStoredPlaylistsProvider`.
|
||||
|
||||
- Move ``BaseMixer`` to :class:`mopidy.mixers.base.BaseMixer`.
|
||||
|
||||
- Add docs for the current non-stable output API,
|
||||
:class:`mopidy.outputs.base.BaseOutput`.
|
||||
|
||||
|
||||
0.2.1 (2011-01-07)
|
||||
==================
|
||||
|
||||
|
||||
@ -16,11 +16,14 @@ mpc
|
||||
A command line client. Version 0.14 had some issues with Mopidy (see
|
||||
:issue:`5`), but 0.16 seems to work nicely.
|
||||
|
||||
|
||||
ncmpc
|
||||
-----
|
||||
|
||||
A console client. Uses the ``idle`` command heavily, which Mopidy doesn't
|
||||
support yet. If you want a console client, use ncmpcpp instead.
|
||||
support yet (see :issue:`32`). If you want a console client, use ncmpcpp
|
||||
instead.
|
||||
|
||||
|
||||
ncmpcpp
|
||||
-------
|
||||
@ -40,59 +43,266 @@ If you run Ubuntu 10.04 or older, you can fetch an updated version of ncmpcpp
|
||||
from `Launchpad <https://launchpad.net/ubuntu/+source/ncmpcpp>`_.
|
||||
|
||||
|
||||
|
||||
Graphical clients
|
||||
=================
|
||||
|
||||
GMPC
|
||||
----
|
||||
|
||||
A GTK+ client which works well with Mopidy, and is regularly used by Mopidy
|
||||
developers.
|
||||
`GMPC <http://gmpc.wikia.com>`_ is a graphical MPD client (GTK+) which works
|
||||
well with Mopidy, and is regularly used by Mopidy developers.
|
||||
|
||||
GMPC may sometimes requests a lot of meta data of related albums, artists, etc.
|
||||
This takes more time with Mopidy, which needs to query Spotify for the data,
|
||||
than with a normal MPD server, which has a local cache of meta data. Thus, GMPC
|
||||
may sometimes feel frozen, but usually you just need to give it a bit of slack
|
||||
before it will catch up.
|
||||
|
||||
|
||||
Sonata
|
||||
------
|
||||
|
||||
A GTK+ client. Generally works well with Mopidy.
|
||||
`Sonata <http://sonata.berlios.de/>`_ is a graphical MPD client (GTK+).
|
||||
It generally works well with Mopidy, except for search.
|
||||
|
||||
Search does not work, because they do most of the search on the client side.
|
||||
See :issue:`1` for details.
|
||||
When you search in Sonata, it only sends the first to letters of the search
|
||||
query to Mopidy, and then does the rest of the filtering itself on the client
|
||||
side. Since Spotify has a collection of millions of tracks and they only return
|
||||
the first 100 hits for any search query, searching for two-letter combinations
|
||||
seldom returns any useful results. See :issue:`1` and the matching `Sonata
|
||||
bug`_ for details.
|
||||
|
||||
.. _Sonata bug: http://developer.berlios.de/feature/?func=detailfeature&feature_id=5038&group_id=7323
|
||||
|
||||
|
||||
Theremin
|
||||
--------
|
||||
|
||||
`Theremin <http://theremin.sigterm.eu/>`_ is a graphical MPD client for OS X.
|
||||
It generally works well with Mopidy.
|
||||
|
||||
|
||||
.. _android_mpd_clients:
|
||||
|
||||
Android clients
|
||||
===============
|
||||
|
||||
We've tested all six MPD clients we could find for Android with Mopidy 0.3 on a
|
||||
HTC Hero with Android 2.1, using the following test procedure:
|
||||
|
||||
#. Connect to Mopidy
|
||||
#. Search for ``foo``, with search type "any" if it can be selected
|
||||
#. Add "The Pretender" from the search results to the current playlist
|
||||
#. Start playback
|
||||
#. Pause and resume playback
|
||||
#. Adjust volume
|
||||
#. Find a playlist and append it to the current playlist
|
||||
#. Skip to next track
|
||||
#. Skip to previous track
|
||||
#. Select the last track from the current playlist
|
||||
#. Turn on repeat mode
|
||||
#. Seek to 10 seconds or so before the end of the track
|
||||
#. Wait for the end of the track and confirm that playback continues at the
|
||||
start of the playlist
|
||||
#. Turn off repeat mode
|
||||
#. Turn on random mode
|
||||
#. Skip to next track and confirm that it random mode works
|
||||
#. Turn off random mode
|
||||
#. Stop playback
|
||||
#. Check if the app got support for single mode and consume mode
|
||||
#. Kill Mopidy and confirm that the app handles it without crashing
|
||||
|
||||
In summary:
|
||||
|
||||
- BitMPC lacks finishing touches on its user interface but supports all
|
||||
features tested.
|
||||
- Droid MPD Client works well, but got a couple of bugs one can live with and
|
||||
does not expose stored playlist anywhere.
|
||||
- IcyBeats is not usable yet.
|
||||
- MPDroid is working well and looking good, but does not have search
|
||||
functionality.
|
||||
- PMix is just a lesser MPDroid, so use MPDroid instead.
|
||||
- ThreeMPD is too buggy to even get connected to Mopidy.
|
||||
|
||||
Our recommendation:
|
||||
|
||||
- If you do not care about looks, use BitMPC.
|
||||
- If you do not care about stored playlists, use Droid MPD Client.
|
||||
- If you do not care about searching, use MPDroid.
|
||||
|
||||
|
||||
BitMPC
|
||||
------
|
||||
|
||||
Works well with Mopidy.
|
||||
We tested version 1.0.0, which at the time had 1k-5k downloads, <100 ratings,
|
||||
3.5 stars.
|
||||
|
||||
Droid MPD
|
||||
---------
|
||||
The user interface lacks some finishing touches. E.g. you can't enter a
|
||||
hostname for the server. Only IPv4 addresses are allowed.
|
||||
|
||||
All features exercised in the test procedure works. BitMPC lacks support for
|
||||
single mode and consume mode. BitMPC crashes if Mopidy is killed or crash.
|
||||
|
||||
|
||||
Droid MPD Client
|
||||
----------------
|
||||
|
||||
We tested version 0.4.0, which at the time had 5k-10k downloads, >200 ratings,
|
||||
4 stars.
|
||||
|
||||
To find the search functionality, you have to select the menu, then "Playlist
|
||||
manager", then the search tab. I do not understand why search is hidden inside
|
||||
"Playlist manager".
|
||||
|
||||
The user interface have some French remnants, like "Rechercher" in the search
|
||||
field.
|
||||
|
||||
When selecting the artist tab, it issues the ``list Artist`` command and
|
||||
becomes stuck waiting for the results. Same thing happens for the album tab,
|
||||
which issues ``list Album``, and the folder tab, which issues ``lsinfo``.
|
||||
Mopidy returned zero hits immediately on all three commands. If Mopidy has
|
||||
loaded your stored playlists and returns more than zero hits on these commands,
|
||||
they artist and album tabs do not hang. The folder tab still freezes when
|
||||
``lsinfo`` returns a list of stored playlists, though zero files. Thus, we've
|
||||
discovered a couple of bugs in Droid MPD Client.
|
||||
|
||||
The volume control is very slick, with a turn knob, just like on an amplifier.
|
||||
It lends itself to showing off to friends when combined with Mopidy's external
|
||||
amplifier mixers. Everybody loves turning a knob on a touch screen and see the
|
||||
physical knob on the amplifier turn as well ;-)
|
||||
|
||||
Even though ``lsinfo`` returns the stored playlists for the folder tab, they
|
||||
are not displayed anywhere. Thus, we had to select an album in the album tab to
|
||||
complete the test procedure.
|
||||
|
||||
At one point, I had problems turning off repeat mode. After I adjusted the
|
||||
volume and tried again, it worked.
|
||||
|
||||
Droid MPD client does not support single mode or consume mode. It does not
|
||||
detect that the server is killed/crashed. You'll only notice it by no actions
|
||||
having any effect, e.g. you can't turn the volume knob any more.
|
||||
|
||||
In conclusion, some bugs and caveats, but most of the test procedure was
|
||||
possible to perform.
|
||||
|
||||
|
||||
IcyBeats
|
||||
--------
|
||||
|
||||
We tested version 0.2, which at the time had 50-100 downloads, no ratings.
|
||||
The app was still in beta when we tried it.
|
||||
|
||||
IcyBeats successfully connected to Mopidy and I was able to adjust volume. When
|
||||
I was searching for some tracks, I could not figure out how to actually start
|
||||
the search, as there was no search button and pressing enter in the input field
|
||||
just added a new line. I was stuck. In other words, IcyBeats 0.2 is not usable
|
||||
with Mopidy.
|
||||
|
||||
IcyBeats does have something going for it: IcyBeats uses IPv6 to connect to
|
||||
Mopidy. The future is just around the corner!
|
||||
|
||||
Works well with Mopidy.
|
||||
|
||||
MPDroid
|
||||
-------
|
||||
|
||||
Works well with Mopidy, and is regularly used by Mopidy developers.
|
||||
We tested version 0.6.9, which at the time had 5k-10k downloads, <200 ratings,
|
||||
4.5 stars. MPDroid started out as a fork of PMix.
|
||||
|
||||
First of all, MPDroid's user interface looks nice.
|
||||
|
||||
I couldn't find any search functionality, so I added the initial track using
|
||||
another client. Other than the missing search functionality, everything in the
|
||||
test procedure worked out flawlessly. Like all other Android clients, MPDroid
|
||||
does not support single mode or consume mode. When Mopidy is killed, MPDroid
|
||||
handles it gracefully and asks if you want to try to reconnect.
|
||||
|
||||
All in all, MPDroid is a good MPD client without search support.
|
||||
|
||||
|
||||
PMix
|
||||
----
|
||||
|
||||
Works well with Mopidy.
|
||||
We tested version 0.4.0, which at the time had 10k-50k downloads, >200 ratings,
|
||||
4 stars.
|
||||
|
||||
Add MPDroid is a fork from PMix, it is no surprise that PMix does not support
|
||||
search either. In addition, I could not find stored playlists. Other than that,
|
||||
I was able to complete the test procedure. PMix crashed once during testing,
|
||||
but handled the killing of Mopidy just as nicely as MPDroid. It does not
|
||||
support single mode or consume mode.
|
||||
|
||||
All in all, PMix works but can do less than MPDroid. Use MPDroid instead.
|
||||
|
||||
|
||||
ThreeMPD
|
||||
--------
|
||||
|
||||
Does not work well with Mopidy, because we haven't implemented ``listallinfo``
|
||||
yet.
|
||||
We tested version 0.3.0, which at the time had 1k-5k downloads, <25 ratings,
|
||||
2.5 average. The developer request users to use MPDroid instead, due to limited
|
||||
time for maintenance. Does not support password authentication.
|
||||
|
||||
ThreeMPD froze during startup, so we were not able to test it.
|
||||
|
||||
|
||||
.. _ios_mpd_clients:
|
||||
|
||||
iPhone/iPod Touch clients
|
||||
=========================
|
||||
|
||||
impdclient
|
||||
----------
|
||||
|
||||
There's an open source MPD client for iOS called `impdclient
|
||||
<http://code.google.com/p/impdclient/>`_ which has not seen any updates since
|
||||
August 2008. So far, we've not heard of users trying it with Mopidy. Please
|
||||
notify us of your successes and/or problems if you do try it out.
|
||||
|
||||
|
||||
MPod
|
||||
----
|
||||
|
||||
Works well with Mopidy as far as we've heard from users.
|
||||
The `MPoD <http://www.katoemba.net/makesnosenseatall/mpod/>`_ client can be
|
||||
installed from the `iTunes Store
|
||||
<http://itunes.apple.com/us/app/mpod/id285063020>`_.
|
||||
|
||||
Users have reported varying success in using MPoD together with Mopidy. Thus,
|
||||
we've tested a fresh install of MPoD 1.5.1 with Mopidy as of revision e7ed28d
|
||||
(pre-0.3) on an iPod Touch 3rd generation. The following are our findings:
|
||||
|
||||
- **Works:** Playback control generally works, including stop, play, pause,
|
||||
previous, next, repeat, random, seek, and volume control.
|
||||
|
||||
- **Bug:** Search does not work, neither in the artist, album, or song
|
||||
tabs. Mopidy gets no requests at all from MPoD when executing searches. Seems
|
||||
like MPoD only searches in local cache, even if "Use local cache" is turned
|
||||
off in MPoD's settings. Until this is fixed by the MPoD developer, MPoD will
|
||||
be much less useful with Mopidy.
|
||||
|
||||
- **Bug:** When adding another playlist to the current playlist in MPoD,
|
||||
the currently playing track restarts at the beginning. I do not currently
|
||||
know enough about this bug, because I'm not sure if MPoD was in the "add to
|
||||
active playlist" or "replace active playlist" mode when I tested it. I only
|
||||
later learned what that button was for. Anyway, what I experienced was:
|
||||
|
||||
#. I play a track
|
||||
#. I select a new playlist
|
||||
#. MPoD reconnects to Mopidy for unknown reason
|
||||
#. MPoD issues MPD command ``load "a playlist name"``
|
||||
#. MPoD issues MPD command ``play "-1"``
|
||||
#. MPoD issues MPD command ``playlistinfo "-1"``
|
||||
#. I hear that the currently playing tracks restarts playback
|
||||
|
||||
- **Tips:** MPoD seems to cache stored playlists, but they won't work if the
|
||||
server hasn't loaded stored playlists from e.g. Spotify yet. A trick to force
|
||||
refetching of playlists from Mopidy is to add a new empty playlist in MPoD.
|
||||
|
||||
- **Wishlist:** Modifying the current playlists is not supported by MPoD it
|
||||
seems.
|
||||
|
||||
- **Wishlist:** MPoD supports playback of Last.fm radio streams through the MPD
|
||||
server. Mopidy does not currently support this, but there is a wishlist bug
|
||||
at :issue:`38`.
|
||||
|
||||
- **Wishlist:** MPoD supports autodetection/-configuration of MPD servers
|
||||
through the use of Bonjour. Mopidy does not currently support this, but there
|
||||
is a wishlist bug at :issue:`39`.
|
||||
|
||||
10
docs/conf.py
10
docs/conf.py
@ -16,8 +16,8 @@ import sys, os
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
sys.path.append(os.path.abspath(os.path.dirname(__file__)))
|
||||
sys.path.append(os.path.abspath(os.path.dirname(__file__) + '/../'))
|
||||
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
|
||||
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../'))
|
||||
|
||||
import mopidy
|
||||
|
||||
@ -43,7 +43,7 @@ master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = u'Mopidy'
|
||||
copyright = u'2010, Stein Magnus Jodal and contributors'
|
||||
copyright = u'2010-2011, Stein Magnus Jodal and contributors'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
@ -116,7 +116,7 @@ html_theme_path = ['_themes']
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top
|
||||
# of the sidebar.
|
||||
#html_logo = None
|
||||
html_logo = '_static/mopidy.png'
|
||||
|
||||
# The name of an image file (within the static path) to use as favicon of the
|
||||
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
|
||||
@ -153,7 +153,7 @@ html_last_updated_fmt = '%b %d, %Y'
|
||||
#html_split_index = False
|
||||
|
||||
# If true, links to the reST sources are added to the pages.
|
||||
html_show_sourcelink = False
|
||||
#html_show_sourcelink = True
|
||||
|
||||
# If true, an OpenSearch description file will be output, and all pages will
|
||||
# contain a <link> tag referring to it. The value of this option must be the
|
||||
|
||||
@ -2,84 +2,33 @@
|
||||
Roadmap
|
||||
*******
|
||||
|
||||
This is the current roadmap and collection of wild ideas for future Mopidy
|
||||
development. This is intended to be a living document and may change at any
|
||||
time.
|
||||
|
||||
We intend to have about one timeboxed release every month. Thus, the roadmap is
|
||||
oriented around "soon" and "later" instead of mapping each feature to a future
|
||||
release.
|
||||
Release schedule
|
||||
================
|
||||
|
||||
We intend to have about one timeboxed feature release every month
|
||||
in periods of active development. The feature releases are numbered 0.x.0. The
|
||||
features added is a mix of what we feel is most important/requested of the
|
||||
missing features, and features we develop just because we find them fun to
|
||||
make, even though they may be useful for very few users or for a limited use
|
||||
case.
|
||||
|
||||
Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs
|
||||
that are too serious to wait for the next feature release. We will only release
|
||||
bugfix releases for the last feature release. E.g. when 0.3.0 is released, we
|
||||
will no longer provide bugfix releases for the 0.2 series. In other words,
|
||||
there will be just a single supported release at any point in time.
|
||||
|
||||
|
||||
Possible targets for the next version
|
||||
=====================================
|
||||
Feature wishlist
|
||||
================
|
||||
|
||||
- Reintroduce support for OS X. See :issue:`14` for details.
|
||||
- Support for using multiple Mopidy backends simultaneously. Should make it
|
||||
possible to have both Spotify tracks and local tracks in the same playlist.
|
||||
- MPD frontend:
|
||||
|
||||
- ``idle`` support.
|
||||
|
||||
- Spotify backend:
|
||||
|
||||
- Write-support for Spotify, i.e. playlist management.
|
||||
- Virtual directories with e.g. starred tracks from Spotify.
|
||||
- Support for 320 kbps audio.
|
||||
|
||||
- Local backend:
|
||||
|
||||
- Better library support.
|
||||
- A script for creating a tag cache.
|
||||
- An alternative to tag cache for caching metadata, i.e. Sqlite.
|
||||
|
||||
- **[DONE]** Last.fm scrobbling.
|
||||
|
||||
|
||||
Stuff we want to do, but not right now, and maybe never
|
||||
=======================================================
|
||||
|
||||
- Packaging and distribution:
|
||||
|
||||
- **[PENDING]** Create `Homebrew <http://mxcl.github.com/homebrew/>`_
|
||||
recipies for all our dependencies and Mopidy itself to make OS X
|
||||
installation a breeze. See `Homebrew's issue #1612
|
||||
<http://github.com/mxcl/homebrew/issues/issue/1612>`_.
|
||||
- Create `Debian packages <http://www.debian.org/doc/maint-guide/>`_ of all
|
||||
our dependencies and Mopidy itself (hosted in our own Debian repo until we
|
||||
get stuff into the various distros) to make Debian/Ubuntu installation a
|
||||
breeze.
|
||||
|
||||
- Compatability:
|
||||
|
||||
- Run frontend tests against a real MPD server to ensure we are in sync.
|
||||
|
||||
- Backends:
|
||||
|
||||
- `Last.fm <http://www.last.fm/api>`_
|
||||
- `WIMP <http://twitter.com/wimp/status/8975885632>`_
|
||||
- DNLA/UPnP so Mopidy can play music from other DNLA MediaServers.
|
||||
|
||||
- Frontends:
|
||||
|
||||
- Publish the server's presence to the network using `Zeroconf
|
||||
<http://en.wikipedia.org/wiki/Zeroconf>`_/Avahi.
|
||||
- D-Bus/`MPRIS <http://www.mpris.org/>`_
|
||||
- REST/JSON web service with a jQuery client as example application. Maybe
|
||||
based upon `Tornado <http://github.com/facebook/tornado>`_ and `jQuery
|
||||
Mobile <http://jquerymobile.com/>`_.
|
||||
- DNLA/UPnP so Mopidy can be controlled from i.e. TVs.
|
||||
- `XMMS2 <http://www.xmms2.org/>`_
|
||||
- LIRC frontend for controlling Mopidy with a remote.
|
||||
|
||||
- Mixers:
|
||||
|
||||
- LIRC mixer for controlling arbitrary amplifiers remotely.
|
||||
|
||||
- Audio streaming:
|
||||
|
||||
- Ogg Vorbis/MP3 audio stream over HTTP, to MPD clients, `Squeezeboxes
|
||||
<http://www.logitechsqueezebox.com/>`_, etc.
|
||||
- Feed audio to an `Icecast <http://www.icecast.org/>`_ server.
|
||||
- Stream to AirPort Express using `RAOP
|
||||
<http://en.wikipedia.org/wiki/Remote_Audio_Output_Protocol>`_.
|
||||
We maintain our collection of sane or less sane ideas for future Mopidy
|
||||
features as `issues <https://github.com/mopidy/mopidy/issues>`_ at GitHub
|
||||
labeled with `the "wishlist" label
|
||||
<https://github.com/mopidy/mopidy/issues/labels/wishlist>`_. Feel free to vote
|
||||
up any feature you would love to see in Mopidy, but please refrain from adding
|
||||
a comment just to say "I want this too!". You are of course free to add
|
||||
comments if you have suggestions for how the feature should work or be
|
||||
implemented, and you may add new wishlist issues if your ideas are not already
|
||||
represented.
|
||||
|
||||
@ -1,4 +1,31 @@
|
||||
.. include:: ../README.rst
|
||||
******
|
||||
Mopidy
|
||||
******
|
||||
|
||||
Mopidy is a music server which can play music from `Spotify
|
||||
<http://www.spotify.com/>`_ or from your local hard drive. To search for music
|
||||
in Spotify's vast archive, manage playlists, and play music, you can use most
|
||||
`MPD clients <http://mpd.wikia.com/>`_. MPD clients are available for most
|
||||
platforms, including Windows, Mac OS X, Linux, Android, and iOS.
|
||||
|
||||
To install Mopidy, start out by reading :ref:`installation`.
|
||||
|
||||
If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net
|
||||
<http://freenode.net/>`_. If you stumble into a bug or got a feature request,
|
||||
please create an issue in the `issue tracker
|
||||
<http://github.com/mopidy/mopidy/issues>`_.
|
||||
|
||||
|
||||
Project resources
|
||||
=================
|
||||
|
||||
- `Documentation for the latest release <http://www.mopidy.com/docs/master/>`_
|
||||
- `Documentation for the development version
|
||||
<http://www.mopidy.com/docs/develop/>`_
|
||||
- `Source code <http://github.com/mopidy/mopidy>`_
|
||||
- `Issue tracker <http://github.com/mopidy/mopidy/issues>`_
|
||||
- IRC: ``#mopidy`` at `irc.freenode.net <http://freenode.net/>`_
|
||||
|
||||
|
||||
User documentation
|
||||
==================
|
||||
@ -6,11 +33,11 @@ User documentation
|
||||
.. toctree::
|
||||
:maxdepth: 3
|
||||
|
||||
changes
|
||||
installation/index
|
||||
settings
|
||||
running
|
||||
clients/index
|
||||
changes
|
||||
authors
|
||||
licenses
|
||||
|
||||
@ -21,6 +48,7 @@ Reference documentation
|
||||
:maxdepth: 3
|
||||
|
||||
api/index
|
||||
modules/index
|
||||
|
||||
Development documentation
|
||||
=========================
|
||||
|
||||
@ -5,23 +5,32 @@ GStreamer installation
|
||||
To use the Mopidy, you first need to install GStreamer and its Python bindings.
|
||||
|
||||
|
||||
Installing GStreamer on Linux
|
||||
=============================
|
||||
Installing GStreamer
|
||||
====================
|
||||
|
||||
GStreamer is packaged for most popular Linux distributions. If you use
|
||||
Debian/Ubuntu you can install GStreamer with Aptitude::
|
||||
On Linux
|
||||
--------
|
||||
|
||||
sudo aptitude install python-gst0.10 gstreamer0.10-plugins-good \
|
||||
GStreamer is packaged for most popular Linux distributions. Search for
|
||||
GStreamer in your package manager, and make sure to install the Python
|
||||
bindings, and the "good" and "ugly" plugin sets.
|
||||
|
||||
If you use Debian/Ubuntu you can install GStreamer like this::
|
||||
|
||||
sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \
|
||||
gstreamer0.10-plugins-ugly
|
||||
|
||||
If you install Mopidy from our APT archive, you don't need to install GStreamer
|
||||
yourself. The Mopidy Debian package will handle it for you.
|
||||
|
||||
Installing GStreamer on OS X
|
||||
============================
|
||||
|
||||
On OS X from Homebrew
|
||||
---------------------
|
||||
|
||||
.. note::
|
||||
|
||||
We have created GStreamer formulas for Homebrew to make the GStreamer
|
||||
installation easy for you, but our formulas has not been merged into
|
||||
installation easy for you, but not all our formulas have been merged into
|
||||
Homebrew's master branch yet. You should either fetch the formula files
|
||||
from `Homebrew's issue #1612
|
||||
<http://github.com/mxcl/homebrew/issues/issue/1612>`_ yourself, or fall
|
||||
@ -31,6 +40,10 @@ To install GStreamer on OS X using Homebrew::
|
||||
|
||||
brew install gst-python gst-plugins-good gst-plugins-ugly
|
||||
|
||||
|
||||
On OS X from MacPorts
|
||||
---------------------
|
||||
|
||||
To install GStreamer on OS X using MacPorts::
|
||||
|
||||
sudo port install py26-gst-python gstreamer-plugins-good \
|
||||
@ -46,3 +59,19 @@ you should see a long listing of installed plugins, ending in a summary line::
|
||||
$ gst-inspect-0.10
|
||||
... long list of installed plugins ...
|
||||
Total count: 218 plugins (1 blacklist entry not shown), 1031 features
|
||||
|
||||
You should be able to produce a audible tone by running::
|
||||
|
||||
gst-launch-0.10 audiotestsrc ! autoaudiosink
|
||||
|
||||
If you cannot hear any sound when running this command, you won't hear any
|
||||
sound from Mopidy either, as Mopidy uses GStreamer's ``autoaudiosink`` to play
|
||||
audio. Thus, make this work before you continue installing Mopidy.
|
||||
|
||||
|
||||
Using a custom audio sink
|
||||
=========================
|
||||
|
||||
If you for some reason want to use some other GStreamer audio sink than
|
||||
``autoaudiosink``, you can change :attr:`mopidy.settings.GSTREAMER_AUDIO_SINK`
|
||||
in your ``settings.py`` file.
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
.. _installation:
|
||||
|
||||
************
|
||||
Installation
|
||||
************
|
||||
|
||||
To get a basic version of Mopidy running, you need Python and the
|
||||
:doc:`GStreamer library <gstreamer>`. To use Spotify with Mopidy, you also need
|
||||
:doc:`libspotify and pyspotify <libspotify>`. Mopidy itself can either be
|
||||
installed from the Python package index, PyPI, or from git.
|
||||
There are several ways to install Mopidy. What way is best depends upon your
|
||||
setup and whether you want to use stable releases or less stable development
|
||||
versions.
|
||||
|
||||
|
||||
Install dependencies
|
||||
@ -17,89 +18,182 @@ Install dependencies
|
||||
gstreamer
|
||||
libspotify
|
||||
|
||||
Make sure you got the required dependencies installed.
|
||||
If you install Mopidy from the APT archive, as described below, you can skip
|
||||
the dependency installation part.
|
||||
|
||||
Otherwise, make sure you got the required dependencies installed.
|
||||
|
||||
- Python >= 2.6, < 3
|
||||
- :doc:`GStreamer <gstreamer>` >= 0.10, with Python bindings
|
||||
- Dependencies for at least one Mopidy mixer:
|
||||
|
||||
- :mod:`mopidy.mixers.alsa` (Linux only)
|
||||
- GStreamer >= 0.10, with Python bindings. See :doc:`gstreamer`.
|
||||
|
||||
- pyalsaaudio >= 0.2 (Debian/Ubuntu package: python-alsaaudio)
|
||||
|
||||
- :mod:`mopidy.mixers.denon` (Linux, OS X, and Windows)
|
||||
|
||||
- pyserial (Debian/Ubuntu package: python-serial)
|
||||
|
||||
- *Default:* :mod:`mopidy.mixers.gstreamer_software` (Linux, OS X, and
|
||||
Windows)
|
||||
|
||||
- No additional dependencies.
|
||||
|
||||
- :mod:`mopidy.mixers.nad` (Linux, OS X, and Windows)
|
||||
|
||||
- pyserial (Debian/Ubuntu package: python-serial)
|
||||
|
||||
- :mod:`mopidy.mixers.osa` (OS X only)
|
||||
|
||||
- No additional dependencies.
|
||||
- Mixer dependencies: The default mixer does not require any additional
|
||||
dependencies. If you use another mixer, see the mixer's docs for any
|
||||
additional requirements.
|
||||
|
||||
- Dependencies for at least one Mopidy backend:
|
||||
|
||||
- *Default:* :mod:`mopidy.backends.libspotify` (Linux, OS X, and Windows)
|
||||
- The default backend, :mod:`mopidy.backends.spotify`, requires libspotify
|
||||
and pyspotify. See :doc:`libspotify`.
|
||||
|
||||
- :doc:`libspotify and pyspotify <libspotify>`
|
||||
|
||||
- :mod:`mopidy.backends.local` (Linux, OS X, and Windows)
|
||||
|
||||
- No additional dependencies.
|
||||
- The local backend, :mod:`mopidy.backends.local`, requires no additional
|
||||
dependencies.
|
||||
|
||||
- Optional dependencies:
|
||||
|
||||
- :mod:`mopidy.frontends.lastfm`
|
||||
|
||||
- pylast >= 4.3.0
|
||||
- To use the Last.FM scrobbler, see :mod:`mopidy.frontends.lastfm` for
|
||||
additional requirements.
|
||||
|
||||
|
||||
Install latest release
|
||||
======================
|
||||
Install latest stable release
|
||||
=============================
|
||||
|
||||
To install the currently latest release of Mopidy using ``pip``::
|
||||
|
||||
sudo aptitude install python-setuptools python-pip # On Ubuntu/Debian
|
||||
sudo brew install pip # On OS X
|
||||
sudo pip install Mopidy
|
||||
From APT archive
|
||||
----------------
|
||||
|
||||
To later upgrade to the latest release::
|
||||
If you run a Debian based Linux distribution, like Ubuntu, the easiest way to
|
||||
install Mopidy is from the Mopidy APT archive. When installing from the APT
|
||||
archive, you will automatically get updates to Mopidy in the same way as you
|
||||
get updates to the rest of your distribution.
|
||||
|
||||
sudo pip install -U Mopidy
|
||||
#. Add the archive's GPG key::
|
||||
|
||||
If you for some reason can't use ``pip``, try ``easy_install``.
|
||||
wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add -
|
||||
|
||||
Next, you need to set a couple of :doc:`settings </settings>`, and then you're
|
||||
ready to :doc:`run Mopidy </running>`.
|
||||
#. Add the following to ``/etc/apt/sources.list``, or if you have the directory
|
||||
``/etc/apt/sources.list.d/``, add it to a file called ``mopidy.list`` in
|
||||
that directory::
|
||||
|
||||
# Mopidy APT archive
|
||||
deb http://apt.mopidy.com/ stable main contrib non-free
|
||||
deb-src http://apt.mopidy.com/ stable main contrib non-free
|
||||
|
||||
#. Install Mopidy and all dependencies::
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get install mopidy
|
||||
|
||||
#. Next, you need to set a couple of :doc:`settings </settings>`, and then
|
||||
you're ready to :doc:`run Mopidy </running>`.
|
||||
|
||||
When a new release is out, and you can't wait for you system to figure it out
|
||||
for itself, run the following to force an upgrade::
|
||||
|
||||
sudo apt-get update
|
||||
sudo apt-get dist-upgrade
|
||||
|
||||
|
||||
From PyPI using Pip
|
||||
-------------------
|
||||
|
||||
If you are on OS X or on Linux, but can't install from the APT archive, you can
|
||||
install Mopidy from PyPI using Pip.
|
||||
|
||||
#. When you install using Pip, you first need to ensure that all of Mopidy's
|
||||
dependencies have been installed. See the section on dependencies above.
|
||||
|
||||
#. Then, you need to install Pip::
|
||||
|
||||
sudo aptitude install python-setuptools python-pip # On Ubuntu/Debian
|
||||
sudo brew install pip # On OS X
|
||||
|
||||
#. To install the currently latest stable release of Mopidy::
|
||||
|
||||
sudo pip install -U Mopidy
|
||||
|
||||
To upgrade Mopidy to future releases, just rerun this command.
|
||||
|
||||
#. Next, you need to set a couple of :doc:`settings </settings>`, and then
|
||||
you're ready to :doc:`run Mopidy </running>`.
|
||||
|
||||
If you for some reason can't use Pip, try ``easy_install`` instead.
|
||||
|
||||
|
||||
Install development version
|
||||
===========================
|
||||
|
||||
If you want to follow Mopidy development closer, you may install the
|
||||
development version of Mopidy::
|
||||
If you want to follow the development of Mopidy closer, you may install a
|
||||
development version of Mopidy. These are not as stable as the releases, but
|
||||
you'll get access to new features earlier and may help us by reporting issues.
|
||||
|
||||
sudo aptitude install git-core # On Ubuntu/Debian
|
||||
sudo brew install git # On OS X
|
||||
git clone git://github.com/mopidy/mopidy.git
|
||||
cd mopidy/
|
||||
sudo python setup.py install
|
||||
|
||||
To later update to the very latest version::
|
||||
From snapshot using Pip
|
||||
-----------------------
|
||||
|
||||
If you want to follow Mopidy development closer, you may install a snapshot of
|
||||
Mopidy's ``develop`` branch.
|
||||
|
||||
#. When you install using Pip, you first need to ensure that all of Mopidy's
|
||||
dependencies have been installed. See the section on dependencies above.
|
||||
|
||||
#. Then, you need to install Pip::
|
||||
|
||||
sudo aptitude install python-setuptools python-pip # On Ubuntu/Debian
|
||||
sudo brew install pip # On OS X
|
||||
|
||||
#. To install the latest snapshot of Mopidy, run::
|
||||
|
||||
sudo pip install mopidy==dev
|
||||
|
||||
To upgrade Mopidy to future releases, just rerun this command.
|
||||
|
||||
#. Next, you need to set a couple of :doc:`settings </settings>`, and then
|
||||
you're ready to :doc:`run Mopidy </running>`.
|
||||
|
||||
|
||||
From Git
|
||||
--------
|
||||
|
||||
If you want to contribute to Mopidy, you should install Mopidy using Git.
|
||||
|
||||
#. When you install from Git, you first need to ensure that all of Mopidy's
|
||||
dependencies have been installed. See the section on dependencies above.
|
||||
|
||||
#. Then install Git, if haven't already::
|
||||
|
||||
sudo aptitude install git-core # On Ubuntu/Debian
|
||||
sudo brew install git # On OS X
|
||||
|
||||
#. Clone the official Mopidy repository, or your own fork of it::
|
||||
|
||||
git clone git://github.com/mopidy/mopidy.git
|
||||
|
||||
#. Next, you need to set a couple of :doc:`settings </settings>`.
|
||||
|
||||
#. You can then run Mopidy directly from the Git repository::
|
||||
|
||||
cd mopidy/ # Move into the Git repo dir
|
||||
python mopidy # Run python on the mopidy source code dir
|
||||
|
||||
#. Later, to get the latest changes to Mopidy::
|
||||
|
||||
cd mopidy/
|
||||
git pull
|
||||
sudo python setup.py install
|
||||
|
||||
For an introduction to ``git``, please visit `git-scm.com
|
||||
<http://git-scm.com/>`_.
|
||||
<http://git-scm.com/>`_. Also, please read our :doc:`developer documentation
|
||||
</development/index>`.
|
||||
|
||||
Next, you need to set a couple of :doc:`settings </settings>`, and then you're
|
||||
ready to :doc:`run Mopidy </running>`.
|
||||
|
||||
From AUR on ArchLinux
|
||||
---------------------
|
||||
|
||||
If you are running ArchLinux, you can install a development snapshot of Mopidy
|
||||
using the package found at http://aur.archlinux.org/packages.php?ID=44026.
|
||||
|
||||
#. First, you should consider installing any optional dependencies not included
|
||||
by the AUR package, like required for e.g. Last.fm scrobbling.
|
||||
|
||||
#. To install Mopidy with GStreamer, libspotify and pyspotify, you can use
|
||||
``packer``, ``yaourt``, or do it by hand like this::
|
||||
|
||||
wget http://aur.archlinux.org/packages/mopidy-git/mopidy-git.tar.gz
|
||||
tar xf mopidy-git.tar.gz
|
||||
cd mopidy-git/
|
||||
makepkg -si
|
||||
|
||||
To upgrade Mopidy to future releases, just rerun ``makepkg``.
|
||||
|
||||
#. Next, you need to set a couple of :doc:`settings </settings>`, and then
|
||||
you're ready to :doc:`run Mopidy </running>`.
|
||||
|
||||
@ -5,11 +5,11 @@ libspotify installation
|
||||
Mopidy uses `libspotify
|
||||
<http://developer.spotify.com/en/libspotify/overview/>`_ for playing music from
|
||||
the Spotify music service. To use :mod:`mopidy.backends.libspotify` you must
|
||||
install libspotify and `pyspotify <http://github.com/winjer/pyspotify>`_.
|
||||
install libspotify and `pyspotify <http://github.com/mopidy/pyspotify>`_.
|
||||
|
||||
.. warning::
|
||||
.. note::
|
||||
|
||||
This backend requires a `Spotify premium account
|
||||
This backend requires a paid `Spotify premium account
|
||||
<http://www.spotify.com/no/get-spotify/premium/>`_.
|
||||
|
||||
.. note::
|
||||
@ -19,8 +19,25 @@ install libspotify and `pyspotify <http://github.com/winjer/pyspotify>`_.
|
||||
Spotify Group.
|
||||
|
||||
|
||||
Installing libspotify on Linux
|
||||
==============================
|
||||
Installing libspotify
|
||||
=====================
|
||||
|
||||
|
||||
On Linux from APT archive
|
||||
-------------------------
|
||||
|
||||
If you run a Debian based Linux distribution, like Ubuntu, see
|
||||
http://apt.mopidy.com/ for how to the Mopidy APT archive as a software source
|
||||
on your installation. Then, simply run::
|
||||
|
||||
sudo apt-get install libspotify6
|
||||
|
||||
When libspotify has been installed, continue with
|
||||
:ref:`pyspotify_installation`.
|
||||
|
||||
|
||||
On Linux from source
|
||||
--------------------
|
||||
|
||||
Download and install libspotify 0.0.6 for your OS and CPU architecture from
|
||||
https://developer.spotify.com/en/libspotify/.
|
||||
@ -37,8 +54,8 @@ When libspotify has been installed, continue with
|
||||
:ref:`pyspotify_installation`.
|
||||
|
||||
|
||||
Installing libspotify on OS X
|
||||
=============================
|
||||
On OS X from Homebrew
|
||||
---------------------
|
||||
|
||||
In OS X you need to have `XCode <http://developer.apple.com/tools/xcode/>`_ and
|
||||
`Homebrew <http://mxcl.github.com/homebrew/>`_ installed. Then, to install
|
||||
@ -46,32 +63,51 @@ libspotify::
|
||||
|
||||
brew install libspotify
|
||||
|
||||
To update your existing libspotify installation using Homebrew::
|
||||
|
||||
brew update
|
||||
brew install `brew outdated`
|
||||
|
||||
When libspotify has been installed, continue with
|
||||
:ref:`pyspotify_installation`.
|
||||
|
||||
|
||||
Install libspotify on Windows
|
||||
=============================
|
||||
|
||||
**TODO** Test and document installation on Windows.
|
||||
|
||||
|
||||
.. _pyspotify_installation:
|
||||
|
||||
Installing pyspotify
|
||||
====================
|
||||
|
||||
Install pyspotify's dependencies. At Debian/Ubuntu systems::
|
||||
When you've installed libspotify, it's time for making it available from Python
|
||||
by installing pyspotify.
|
||||
|
||||
sudo aptitude install python-dev
|
||||
|
||||
In OS X no additional dependencies are needed.
|
||||
On Linux from APT archive
|
||||
-------------------------
|
||||
|
||||
Assuming that you've already set up http://apt.mopidy.com/ as a software
|
||||
source, run::
|
||||
|
||||
sudo apt-get install python-spotify
|
||||
|
||||
If you haven't already installed libspotify, this command will install both
|
||||
libspotify and pyspotify for you.
|
||||
|
||||
|
||||
On Linux/OS X from source
|
||||
-------------------------
|
||||
|
||||
On Linux, you need to get the Python development files installed. On
|
||||
Debian/Ubuntu systems run::
|
||||
|
||||
sudo apt-get install python-dev
|
||||
|
||||
On OS X no additional dependencies are needed.
|
||||
|
||||
Get the pyspotify code, and install it::
|
||||
|
||||
wget --no-check-certificate -O pyspotify.tar.gz https://github.com/mopidy/pyspotify/tarball/mopidy
|
||||
tar zxfv pyspotify.tar.gz
|
||||
cd pyspotify/pyspotify/
|
||||
cd pyspotify/
|
||||
sudo python setup.py install
|
||||
|
||||
It is important that you install pyspotify from the ``mopidy`` branch of the
|
||||
|
||||
@ -8,7 +8,7 @@ contributed what, please refer to our git repository.
|
||||
Source code license
|
||||
===================
|
||||
|
||||
Copyright 2009-2010 Stein Magnus Jodal and contributors
|
||||
Copyright 2009-2011 Stein Magnus Jodal and contributors
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@ -26,7 +26,7 @@ limitations under the License.
|
||||
Documentation license
|
||||
=====================
|
||||
|
||||
Copyright 2010 Stein Magnus Jodal and contributors
|
||||
Copyright 2010-2011 Stein Magnus Jodal and contributors
|
||||
|
||||
This work is licensed under the Creative Commons Attribution-ShareAlike 3.0
|
||||
Unported License. To view a copy of this license, visit
|
||||
|
||||
7
docs/modules/backends/dummy.rst
Normal file
7
docs/modules/backends/dummy.rst
Normal file
@ -0,0 +1,7 @@
|
||||
*********************************************************
|
||||
:mod:`mopidy.backends.dummy` -- Dummy backend for testing
|
||||
*********************************************************
|
||||
|
||||
.. automodule:: mopidy.backends.dummy
|
||||
:synopsis: Dummy backend used for testing
|
||||
:members:
|
||||
7
docs/modules/backends/local.rst
Normal file
7
docs/modules/backends/local.rst
Normal file
@ -0,0 +1,7 @@
|
||||
*********************************************
|
||||
:mod:`mopidy.backends.local` -- Local backend
|
||||
*********************************************
|
||||
|
||||
.. automodule:: mopidy.backends.local
|
||||
:synopsis: Backend for playing music files on local storage
|
||||
:members:
|
||||
7
docs/modules/backends/spotify.rst
Normal file
7
docs/modules/backends/spotify.rst
Normal file
@ -0,0 +1,7 @@
|
||||
*************************************************
|
||||
:mod:`mopidy.backends.spotify` -- Spotify backend
|
||||
*************************************************
|
||||
|
||||
.. automodule:: mopidy.backends.spotify
|
||||
:synopsis: Backend for the Spotify music streaming service
|
||||
:members:
|
||||
7
docs/modules/frontends/lastfm.rst
Normal file
7
docs/modules/frontends/lastfm.rst
Normal file
@ -0,0 +1,7 @@
|
||||
***************************************************
|
||||
:mod:`mopidy.frontends.lastfm` -- Last.fm Scrobbler
|
||||
***************************************************
|
||||
|
||||
.. automodule:: mopidy.frontends.lastfm
|
||||
:synopsis: Last.fm scrobbler frontend
|
||||
:members:
|
||||
@ -1,6 +1,6 @@
|
||||
***************************
|
||||
:mod:`mopidy.frontends.mpd`
|
||||
***************************
|
||||
*****************************************
|
||||
:mod:`mopidy.frontends.mpd` -- MPD server
|
||||
*****************************************
|
||||
|
||||
.. automodule:: mopidy.frontends.mpd
|
||||
:synopsis: MPD frontend
|
||||
8
docs/modules/index.rst
Normal file
8
docs/modules/index.rst
Normal file
@ -0,0 +1,8 @@
|
||||
****************
|
||||
Module reference
|
||||
****************
|
||||
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
**
|
||||
9
docs/modules/mixers/alsa.rst
Normal file
9
docs/modules/mixers/alsa.rst
Normal file
@ -0,0 +1,9 @@
|
||||
*************************************************
|
||||
:mod:`mopidy.mixers.alsa` -- ALSA mixer for Linux
|
||||
*************************************************
|
||||
|
||||
.. inheritance-diagram:: mopidy.mixers.alsa
|
||||
|
||||
.. automodule:: mopidy.mixers.alsa
|
||||
:synopsis: ALSA mixer for Linux
|
||||
:members:
|
||||
9
docs/modules/mixers/denon.rst
Normal file
9
docs/modules/mixers/denon.rst
Normal file
@ -0,0 +1,9 @@
|
||||
*****************************************************************
|
||||
:mod:`mopidy.mixers.denon` -- Hardware mixer for Denon amplifiers
|
||||
*****************************************************************
|
||||
|
||||
.. inheritance-diagram:: mopidy.mixers.denon
|
||||
|
||||
.. automodule:: mopidy.mixers.denon
|
||||
:synopsis: Hardware mixer for Denon amplifiers
|
||||
:members:
|
||||
9
docs/modules/mixers/dummy.rst
Normal file
9
docs/modules/mixers/dummy.rst
Normal file
@ -0,0 +1,9 @@
|
||||
*****************************************************
|
||||
:mod:`mopidy.mixers.dummy` -- Dummy mixer for testing
|
||||
*****************************************************
|
||||
|
||||
.. inheritance-diagram:: mopidy.mixers.dummy
|
||||
|
||||
.. automodule:: mopidy.mixers.dummy
|
||||
:synopsis: Dummy mixer for testing
|
||||
:members:
|
||||
9
docs/modules/mixers/gstreamer_software.rst
Normal file
9
docs/modules/mixers/gstreamer_software.rst
Normal file
@ -0,0 +1,9 @@
|
||||
***************************************************************************
|
||||
:mod:`mopidy.mixers.gstreamer_software` -- Software mixer for all platforms
|
||||
***************************************************************************
|
||||
|
||||
.. inheritance-diagram:: mopidy.mixers.gstreamer_software
|
||||
|
||||
.. automodule:: mopidy.mixers.gstreamer_software
|
||||
:synopsis: Software mixer for all platforms
|
||||
:members:
|
||||
9
docs/modules/mixers/nad.rst
Normal file
9
docs/modules/mixers/nad.rst
Normal file
@ -0,0 +1,9 @@
|
||||
*************************************************************
|
||||
:mod:`mopidy.mixers.nad` -- Hardware mixer for NAD amplifiers
|
||||
*************************************************************
|
||||
|
||||
.. inheritance-diagram:: mopidy.mixers.nad
|
||||
|
||||
.. automodule:: mopidy.mixers.nad
|
||||
:synopsis: Hardware mixer for NAD amplifiers
|
||||
:members:
|
||||
9
docs/modules/mixers/osa.rst
Normal file
9
docs/modules/mixers/osa.rst
Normal file
@ -0,0 +1,9 @@
|
||||
**********************************************
|
||||
:mod:`mopidy.mixers.osa` -- Osa mixer for OS X
|
||||
**********************************************
|
||||
|
||||
.. inheritance-diagram:: mopidy.mixers.osa
|
||||
|
||||
.. automodule:: mopidy.mixers.osa
|
||||
:synopsis: Osa mixer for OS X
|
||||
:members:
|
||||
9
docs/modules/outputs/gstreamer.rst
Normal file
9
docs/modules/outputs/gstreamer.rst
Normal file
@ -0,0 +1,9 @@
|
||||
*********************************************************************
|
||||
:mod:`mopidy.outputs.gstreamer` -- GStreamer output for all platforms
|
||||
*********************************************************************
|
||||
|
||||
.. inheritance-diagram:: mopidy.outputs.gstreamer
|
||||
|
||||
.. automodule:: mopidy.outputs.gstreamer
|
||||
:synopsis: GStreamer output for all platforms
|
||||
:members:
|
||||
@ -2,13 +2,31 @@
|
||||
Settings
|
||||
********
|
||||
|
||||
Mopidy has lots of settings. Luckily, you only need to change a few, and stay
|
||||
ignorant of the rest. Below you can find guides for typical configuration
|
||||
changes you may want to do, and a complete listing of available settings.
|
||||
|
||||
|
||||
Changing settings
|
||||
=================
|
||||
|
||||
Mopidy reads settings from the file ``~/.mopidy/settings.py``, where ``~``
|
||||
means your *home directory*. If your username is ``alice`` and you are running
|
||||
Linux, the settings file should probably be at
|
||||
``/home/alice/.mopidy/settings.py``.
|
||||
|
||||
You can either create this file yourself, or run the ``mopidy`` command, and it
|
||||
will create an empty settings file for you.
|
||||
You can either create the settings file yourself, or run the ``mopidy``
|
||||
command, and it will create an empty settings file for you.
|
||||
|
||||
When you have created the settings file, open it in a text editor, and add
|
||||
settings you want to change. If you want to keep the default value for setting,
|
||||
you should *not* redefine it in your own settings file.
|
||||
|
||||
A complete ``~/.mopidy/settings.py`` may look as simple as this::
|
||||
|
||||
MPD_SERVER_HOSTNAME = u'::'
|
||||
SPOTIFY_USERNAME = u'alice'
|
||||
SPOTIFY_PASSWORD = u'mysecret'
|
||||
|
||||
|
||||
Music from Spotify
|
||||
@ -34,6 +52,45 @@ file::
|
||||
You may also want to change some of the ``LOCAL_*`` settings. See
|
||||
:mod:`mopidy.settings`, for a full list of available settings.
|
||||
|
||||
.. note::
|
||||
|
||||
Currently, Mopidy supports using Spotify *or* local storage as a music
|
||||
source. We're working on using both sources simultaneously, and will
|
||||
hopefully have support for this in the 0.3 release.
|
||||
|
||||
|
||||
.. _generating_a_tag_cache:
|
||||
|
||||
Generating a tag cache
|
||||
----------------------
|
||||
|
||||
Before Mopidy 0.3 the local storage backend relied purely on ``tag_cache``
|
||||
files generated by the original MPD server. To remedy this the command
|
||||
:command:`mopidy-scan` has been created. The program will scan your current
|
||||
:attr:`mopidy.settings.LOCAL_MUSIC_PATH` and build a MPD compatible
|
||||
``tag_cache``.
|
||||
|
||||
To make a ``tag_cache`` of your local music available for Mopidy:
|
||||
|
||||
#. Ensure that :attr:`mopidy.settings.LOCAL_MUSIC_PATH` points to where your
|
||||
music is located. Check the current setting by running::
|
||||
|
||||
mopidy --list-settings
|
||||
|
||||
#. Scan your music library. Currently the command outputs the ``tag_cache`` to
|
||||
``stdout``, which means that you will need to redirect the output to a file
|
||||
yourself::
|
||||
|
||||
mopidy-scan > tag_cache
|
||||
|
||||
#. Move the ``tag_cache`` file to the location
|
||||
:attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` is set to, or change the
|
||||
setting to point to where your ``tag_cache`` file is.
|
||||
|
||||
#. Start Mopidy, find the music library in a client, and play some local music!
|
||||
|
||||
|
||||
.. _use_mpd_on_a_network:
|
||||
|
||||
Connecting from other machines on the network
|
||||
=============================================
|
||||
@ -42,6 +99,13 @@ As a secure default, Mopidy only accepts connections from ``localhost``. If you
|
||||
want to open it for connections from other machines on your network, see
|
||||
the documentation for :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`.
|
||||
|
||||
If you open up Mopidy for your local network, you should consider turning on
|
||||
MPD password authentication by setting
|
||||
:attr:`mopidy.settings.MPD_SERVER_PASSWORD` to the password you want to use.
|
||||
If the password is set, Mopidy will require MPD clients to provide the password
|
||||
before they can do anything else. Mopidy only supports a single password, and
|
||||
do not support different permission schemes like the original MPD server.
|
||||
|
||||
|
||||
Scrobbling tracks to Last.fm
|
||||
============================
|
||||
@ -53,3 +117,12 @@ file::
|
||||
|
||||
LASTFM_USERNAME = u'myusername'
|
||||
LASTFM_PASSWORD = u'mysecret'
|
||||
|
||||
|
||||
Available settings
|
||||
==================
|
||||
|
||||
.. automodule:: mopidy.settings
|
||||
:synopsis: Available settings and their default values
|
||||
:members:
|
||||
:undoc-members:
|
||||
|
||||
@ -3,7 +3,7 @@ if not (2, 6) <= sys.version_info < (3,):
|
||||
sys.exit(u'Mopidy requires Python >= 2.6, < 3')
|
||||
|
||||
def get_version():
|
||||
return u'0.2.1'
|
||||
return u'0.3.0'
|
||||
|
||||
class MopidyException(Exception):
|
||||
def __init__(self, message, *args, **kwargs):
|
||||
|
||||
@ -4,21 +4,19 @@ import random
|
||||
import time
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.backends.base.current_playlist import BaseCurrentPlaylistController
|
||||
from mopidy.backends.base.library import BaseLibraryController
|
||||
from mopidy.backends.base.playback import BasePlaybackController
|
||||
from mopidy.backends.base.stored_playlists import BaseStoredPlaylistsController
|
||||
from mopidy.frontends.mpd import translator
|
||||
from mopidy.models import Playlist
|
||||
from mopidy.utils import get_class
|
||||
|
||||
from .current_playlist import CurrentPlaylistController
|
||||
from .library import LibraryController, BaseLibraryProvider
|
||||
from .playback import PlaybackController, BasePlaybackProvider
|
||||
from .stored_playlists import (StoredPlaylistsController,
|
||||
BaseStoredPlaylistsProvider)
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.base')
|
||||
|
||||
__all__ = ['BaseBackend', 'BasePlaybackController',
|
||||
'BaseCurrentPlaylistController', 'BaseStoredPlaylistsController',
|
||||
'BaseLibraryController']
|
||||
|
||||
class BaseBackend(object):
|
||||
class Backend(object):
|
||||
"""
|
||||
:param core_queue: a queue for sending messages to
|
||||
:class:`mopidy.process.CoreProcess`
|
||||
@ -44,22 +42,22 @@ class BaseBackend(object):
|
||||
core_queue = None
|
||||
|
||||
#: The current playlist controller. An instance of
|
||||
#: :class:`mopidy.backends.base.BaseCurrentPlaylistController`.
|
||||
#: :class:`mopidy.backends.base.CurrentPlaylistController`.
|
||||
current_playlist = None
|
||||
|
||||
#: The library controller. An instance of
|
||||
# :class:`mopidy.backends.base.BaseLibraryController`.
|
||||
# :class:`mopidy.backends.base.LibraryController`.
|
||||
library = None
|
||||
|
||||
#: The sound mixer. An instance of :class:`mopidy.mixers.BaseMixer`.
|
||||
mixer = None
|
||||
|
||||
#: The playback controller. An instance of
|
||||
#: :class:`mopidy.backends.base.BasePlaybackController`.
|
||||
#: :class:`mopidy.backends.base.PlaybackController`.
|
||||
playback = None
|
||||
|
||||
#: The stored playlists controller. An instance of
|
||||
#: :class:`mopidy.backends.base.BaseStoredPlaylistsController`.
|
||||
#: :class:`mopidy.backends.base.StoredPlaylistsController`.
|
||||
stored_playlists = None
|
||||
|
||||
#: List of URI prefixes this backend can handle.
|
||||
|
||||
@ -6,10 +6,10 @@ from mopidy.frontends.mpd import translator
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.base')
|
||||
|
||||
class BaseCurrentPlaylistController(object):
|
||||
class CurrentPlaylistController(object):
|
||||
"""
|
||||
:param backend: backend the controller is a part of
|
||||
:type backend: :class:`BaseBackend`
|
||||
:type backend: :class:`mopidy.backends.base.Backend`
|
||||
"""
|
||||
|
||||
def __init__(self, backend):
|
||||
|
||||
@ -2,18 +2,21 @@ import logging
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.base')
|
||||
|
||||
class BaseLibraryController(object):
|
||||
class LibraryController(object):
|
||||
"""
|
||||
:param backend: backend the controller is a part of
|
||||
:type backend: :class:`BaseBackend`
|
||||
:type backend: :class:`mopidy.backends.base.Backend`
|
||||
:param provider: provider the controller should use
|
||||
:type provider: instance of :class:`BaseLibraryProvider`
|
||||
"""
|
||||
|
||||
def __init__(self, backend):
|
||||
def __init__(self, backend, provider):
|
||||
self.backend = backend
|
||||
self.provider = provider
|
||||
|
||||
def destroy(self):
|
||||
"""Cleanup after component."""
|
||||
pass
|
||||
self.provider.destroy()
|
||||
|
||||
def find_exact(self, **query):
|
||||
"""
|
||||
@ -32,7 +35,7 @@ class BaseLibraryController(object):
|
||||
:type query: dict
|
||||
:rtype: :class:`mopidy.models.Playlist`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
return self.provider.find_exact(**query)
|
||||
|
||||
def lookup(self, uri):
|
||||
"""
|
||||
@ -42,7 +45,7 @@ class BaseLibraryController(object):
|
||||
:type uri: string
|
||||
:rtype: :class:`mopidy.models.Track` or :class:`None`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
return self.provider.lookup(uri)
|
||||
|
||||
def refresh(self, uri=None):
|
||||
"""
|
||||
@ -51,7 +54,7 @@ class BaseLibraryController(object):
|
||||
:param uri: directory or track URI
|
||||
:type uri: string
|
||||
"""
|
||||
raise NotImplementedError
|
||||
return self.provider.refresh(uri)
|
||||
|
||||
def search(self, **query):
|
||||
"""
|
||||
@ -70,4 +73,54 @@ class BaseLibraryController(object):
|
||||
:type query: dict
|
||||
:rtype: :class:`mopidy.models.Playlist`
|
||||
"""
|
||||
return self.provider.search(**query)
|
||||
|
||||
|
||||
class BaseLibraryProvider(object):
|
||||
"""
|
||||
:param backend: backend the controller is a part of
|
||||
:type backend: :class:`mopidy.backends.base.Backend`
|
||||
"""
|
||||
|
||||
def __init__(self, backend):
|
||||
self.backend = backend
|
||||
|
||||
def destroy(self):
|
||||
"""
|
||||
Cleanup after component.
|
||||
|
||||
*MAY be implemented by subclasses.*
|
||||
"""
|
||||
pass
|
||||
|
||||
def find_exact(self, **query):
|
||||
"""
|
||||
See :meth:`mopidy.backends.base.LibraryController.find_exact`.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def lookup(self, uri):
|
||||
"""
|
||||
See :meth:`mopidy.backends.base.LibraryController.lookup`.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def refresh(self, uri=None):
|
||||
"""
|
||||
See :meth:`mopidy.backends.base.LibraryController.refresh`.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def search(self, **query):
|
||||
"""
|
||||
See :meth:`mopidy.backends.base.LibraryController.search`.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@ -4,12 +4,17 @@ import time
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.base')
|
||||
|
||||
class BasePlaybackController(object):
|
||||
class PlaybackController(object):
|
||||
"""
|
||||
:param backend: backend the controller is a part of
|
||||
:type backend: :class:`BaseBackend`
|
||||
:param backend: the backend
|
||||
:type backend: :class:`mopidy.backends.base.Backend`
|
||||
:param provider: provider the controller should use
|
||||
:type provider: instance of :class:`BasePlaybackProvider`
|
||||
"""
|
||||
|
||||
# pylint: disable = R0902
|
||||
# Too many instance attributes
|
||||
|
||||
#: Constant representing the paused state.
|
||||
PAUSED = u'paused'
|
||||
|
||||
@ -51,8 +56,9 @@ class BasePlaybackController(object):
|
||||
#: Playback continues after current song.
|
||||
single = False
|
||||
|
||||
def __init__(self, backend):
|
||||
def __init__(self, backend, provider):
|
||||
self.backend = backend
|
||||
self.provider = provider
|
||||
self._state = self.STOPPED
|
||||
self._shuffled = []
|
||||
self._first_shuffle = True
|
||||
@ -62,10 +68,8 @@ class BasePlaybackController(object):
|
||||
def destroy(self):
|
||||
"""
|
||||
Cleanup after component.
|
||||
|
||||
May be overridden by subclasses.
|
||||
"""
|
||||
pass
|
||||
self.provider.destroy()
|
||||
|
||||
def _get_cpid(self, cp_track):
|
||||
if cp_track is None:
|
||||
@ -130,6 +134,9 @@ class BasePlaybackController(object):
|
||||
|
||||
Not necessarily the same track as :attr:`cp_track_at_next`.
|
||||
"""
|
||||
# pylint: disable = R0911
|
||||
# Too many return statements
|
||||
|
||||
cp_tracks = self.backend.current_playlist.cp_tracks
|
||||
|
||||
if not cp_tracks:
|
||||
@ -149,10 +156,9 @@ class BasePlaybackController(object):
|
||||
return cp_tracks[0]
|
||||
|
||||
if self.repeat and self.single:
|
||||
return cp_tracks[
|
||||
(self.current_playlist_position) % len(cp_tracks)]
|
||||
return cp_tracks[self.current_playlist_position]
|
||||
|
||||
if self.repeat:
|
||||
if self.repeat and not self.single:
|
||||
return cp_tracks[
|
||||
(self.current_playlist_position + 1) % len(cp_tracks)]
|
||||
|
||||
@ -325,7 +331,7 @@ class BasePlaybackController(object):
|
||||
"""
|
||||
Tell the playback controller that the current playlist has changed.
|
||||
|
||||
Used by :class:`mopidy.backends.base.BaseCurrentPlaylistController`.
|
||||
Used by :class:`mopidy.backends.base.CurrentPlaylistController`.
|
||||
"""
|
||||
self._first_shuffle = True
|
||||
self._shuffled = []
|
||||
@ -348,18 +354,9 @@ class BasePlaybackController(object):
|
||||
|
||||
def pause(self):
|
||||
"""Pause playback."""
|
||||
if self.state == self.PLAYING and self._pause():
|
||||
if self.state == self.PLAYING and self.provider.pause():
|
||||
self.state = self.PAUSED
|
||||
|
||||
def _pause(self):
|
||||
"""
|
||||
To be overridden by subclass. Implement your backend's pause
|
||||
functionality here.
|
||||
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def play(self, cp_track=None, on_error_step=1):
|
||||
"""
|
||||
Play the given track, or if the given track is :class:`None`, play the
|
||||
@ -386,7 +383,7 @@ class BasePlaybackController(object):
|
||||
self.state = self.STOPPED
|
||||
self.current_cp_track = cp_track
|
||||
self.state = self.PLAYING
|
||||
if not self._play(cp_track[1]):
|
||||
if not self.provider.play(cp_track[1]):
|
||||
# Track is not playable
|
||||
if self.random and self._shuffled:
|
||||
self._shuffled.remove(cp_track)
|
||||
@ -400,18 +397,6 @@ class BasePlaybackController(object):
|
||||
|
||||
self._trigger_started_playing_event()
|
||||
|
||||
def _play(self, track):
|
||||
"""
|
||||
To be overridden by subclass. Implement your backend's play
|
||||
functionality here.
|
||||
|
||||
:param track: the track to play
|
||||
:type track: :class:`mopidy.models.Track`
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
|
||||
raise NotImplementedError
|
||||
|
||||
def previous(self):
|
||||
"""Play the previous track."""
|
||||
if self.cp_track_at_previous is None:
|
||||
@ -423,18 +408,9 @@ class BasePlaybackController(object):
|
||||
|
||||
def resume(self):
|
||||
"""If paused, resume playing the current track."""
|
||||
if self.state == self.PAUSED and self._resume():
|
||||
if self.state == self.PAUSED and self.provider.resume():
|
||||
self.state = self.PLAYING
|
||||
|
||||
def _resume(self):
|
||||
"""
|
||||
To be overridden by subclass. Implement your backend's resume
|
||||
functionality here.
|
||||
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def seek(self, time_position):
|
||||
"""
|
||||
Seeks to time position given in milliseconds.
|
||||
@ -460,18 +436,7 @@ class BasePlaybackController(object):
|
||||
self._play_time_started = self._current_wall_time
|
||||
self._play_time_accumulated = time_position
|
||||
|
||||
return self._seek(time_position)
|
||||
|
||||
def _seek(self, time_position):
|
||||
"""
|
||||
To be overridden by subclass. Implement your backend's seek
|
||||
functionality here.
|
||||
|
||||
:param time_position: time position in milliseconds
|
||||
:type time_position: int
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
return self.provider.seek(time_position)
|
||||
|
||||
def stop(self, clear_current_track=False):
|
||||
"""
|
||||
@ -484,20 +449,11 @@ class BasePlaybackController(object):
|
||||
if self.state == self.STOPPED:
|
||||
return
|
||||
self._trigger_stopped_playing_event()
|
||||
if self._stop():
|
||||
if self.provider.stop():
|
||||
self.state = self.STOPPED
|
||||
if clear_current_track:
|
||||
self.current_cp_track = None
|
||||
|
||||
def _stop(self):
|
||||
"""
|
||||
To be overridden by subclass. Implement your backend's stop
|
||||
functionality here.
|
||||
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _trigger_started_playing_event(self):
|
||||
"""
|
||||
Notifies frontends that a track has started playing.
|
||||
@ -527,3 +483,75 @@ class BasePlaybackController(object):
|
||||
'track': self.current_track,
|
||||
'stop_position': self.time_position,
|
||||
})
|
||||
|
||||
|
||||
class BasePlaybackProvider(object):
|
||||
"""
|
||||
:param backend: the backend
|
||||
:type backend: :class:`mopidy.backends.base.Backend`
|
||||
"""
|
||||
|
||||
def __init__(self, backend):
|
||||
self.backend = backend
|
||||
|
||||
def destroy(self):
|
||||
"""
|
||||
Cleanup after component.
|
||||
|
||||
*MAY be implemented by subclasses.*
|
||||
"""
|
||||
pass
|
||||
|
||||
def pause(self):
|
||||
"""
|
||||
Pause playback.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def play(self, track):
|
||||
"""
|
||||
Play given track.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:param track: the track to play
|
||||
:type track: :class:`mopidy.models.Track`
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def resume(self):
|
||||
"""
|
||||
Resume playback at the same time position playback was paused.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def seek(self, time_position):
|
||||
"""
|
||||
Seek to a given time position.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:param time_position: time position in milliseconds
|
||||
:type time_position: int
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def stop(self):
|
||||
"""
|
||||
Stop playback.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@ -3,28 +3,34 @@ import logging
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.base')
|
||||
|
||||
class BaseStoredPlaylistsController(object):
|
||||
class StoredPlaylistsController(object):
|
||||
"""
|
||||
:param backend: backend the controller is a part of
|
||||
:type backend: :class:`BaseBackend`
|
||||
:type backend: :class:`mopidy.backends.base.Backend`
|
||||
:param provider: provider the controller should use
|
||||
:type provider: instance of :class:`BaseStoredPlaylistsProvider`
|
||||
"""
|
||||
|
||||
def __init__(self, backend):
|
||||
def __init__(self, backend, provider):
|
||||
self.backend = backend
|
||||
self._playlists = []
|
||||
self.provider = provider
|
||||
|
||||
def destroy(self):
|
||||
"""Cleanup after component."""
|
||||
pass
|
||||
self.provider.destroy()
|
||||
|
||||
@property
|
||||
def playlists(self):
|
||||
"""List of :class:`mopidy.models.Playlist`."""
|
||||
return copy(self._playlists)
|
||||
"""
|
||||
Currently stored playlists.
|
||||
|
||||
Read/write. List of :class:`mopidy.models.Playlist`.
|
||||
"""
|
||||
return self.provider.playlists
|
||||
|
||||
@playlists.setter
|
||||
def playlists(self, playlists):
|
||||
self._playlists = playlists
|
||||
self.provider.playlists = playlists
|
||||
|
||||
def create(self, name):
|
||||
"""
|
||||
@ -34,7 +40,7 @@ class BaseStoredPlaylistsController(object):
|
||||
:type name: string
|
||||
:rtype: :class:`mopidy.models.Playlist`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
return self.provider.create(name)
|
||||
|
||||
def delete(self, playlist):
|
||||
"""
|
||||
@ -43,7 +49,7 @@ class BaseStoredPlaylistsController(object):
|
||||
:param playlist: the playlist to delete
|
||||
:type playlist: :class:`mopidy.models.Playlist`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
return self.provider.delete(playlist)
|
||||
|
||||
def get(self, **criteria):
|
||||
"""
|
||||
@ -55,13 +61,14 @@ class BaseStoredPlaylistsController(object):
|
||||
|
||||
get(name='a') # Returns track with name 'a'
|
||||
get(uri='xyz') # Returns track with URI 'xyz'
|
||||
get(name='a', uri='xyz') # Returns track with name 'a' and URI 'xyz'
|
||||
get(name='a', uri='xyz') # Returns track with name 'a' and URI
|
||||
# 'xyz'
|
||||
|
||||
:param criteria: one or more criteria to match by
|
||||
:type criteria: dict
|
||||
:rtype: :class:`mopidy.models.Playlist`
|
||||
"""
|
||||
matches = self._playlists
|
||||
matches = self.playlists
|
||||
for (key, value) in criteria.iteritems():
|
||||
matches = filter(lambda p: getattr(p, key) == value, matches)
|
||||
if len(matches) == 1:
|
||||
@ -82,11 +89,14 @@ class BaseStoredPlaylistsController(object):
|
||||
:type uri: string
|
||||
:rtype: :class:`mopidy.models.Playlist`
|
||||
"""
|
||||
raise NotImplementedError
|
||||
return self.provider.lookup(uri)
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh stored playlists."""
|
||||
raise NotImplementedError
|
||||
"""
|
||||
Refresh the stored playlists in
|
||||
:attr:`mopidy.backends.base.StoredPlaylistsController.playlists`.
|
||||
"""
|
||||
return self.provider.refresh()
|
||||
|
||||
def rename(self, playlist, new_name):
|
||||
"""
|
||||
@ -97,7 +107,7 @@ class BaseStoredPlaylistsController(object):
|
||||
:param new_name: the new name
|
||||
:type new_name: string
|
||||
"""
|
||||
raise NotImplementedError
|
||||
return self.provider.rename(playlist, new_name)
|
||||
|
||||
def save(self, playlist):
|
||||
"""
|
||||
@ -106,4 +116,85 @@ class BaseStoredPlaylistsController(object):
|
||||
:param playlist: the playlist
|
||||
:type playlist: :class:`mopidy.models.Playlist`
|
||||
"""
|
||||
return self.provider.save(playlist)
|
||||
|
||||
|
||||
class BaseStoredPlaylistsProvider(object):
|
||||
"""
|
||||
:param backend: backend the controller is a part of
|
||||
:type backend: :class:`mopidy.backends.base.Backend`
|
||||
"""
|
||||
|
||||
def __init__(self, backend):
|
||||
self.backend = backend
|
||||
self._playlists = []
|
||||
|
||||
def destroy(self):
|
||||
"""
|
||||
Cleanup after component.
|
||||
|
||||
*MAY be implemented by subclass.*
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def playlists(self):
|
||||
"""
|
||||
Currently stored playlists.
|
||||
|
||||
Read/write. List of :class:`mopidy.models.Playlist`.
|
||||
"""
|
||||
return copy(self._playlists)
|
||||
|
||||
@playlists.setter
|
||||
def playlists(self, playlists):
|
||||
self._playlists = playlists
|
||||
|
||||
def create(self, name):
|
||||
"""
|
||||
See :meth:`mopidy.backends.base.StoredPlaylistsController.create`.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def delete(self, playlist):
|
||||
"""
|
||||
See :meth:`mopidy.backends.base.StoredPlaylistsController.delete`.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def lookup(self, uri):
|
||||
"""
|
||||
See :meth:`mopidy.backends.base.StoredPlaylistsController.lookup`.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def refresh(self):
|
||||
"""
|
||||
See :meth:`mopidy.backends.base.StoredPlaylistsController.refresh`.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def rename(self, playlist, new_name):
|
||||
"""
|
||||
See :meth:`mopidy.backends.base.StoredPlaylistsController.rename`.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def save(self, playlist):
|
||||
"""
|
||||
See :meth:`mopidy.backends.base.StoredPlaylistsController.save`.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@ -1,9 +1,19 @@
|
||||
from mopidy.backends.base import (BaseBackend, BaseCurrentPlaylistController,
|
||||
BasePlaybackController, BaseLibraryController,
|
||||
BaseStoredPlaylistsController)
|
||||
from mopidy.backends.base import (Backend, CurrentPlaylistController,
|
||||
PlaybackController, BasePlaybackProvider, LibraryController,
|
||||
BaseLibraryProvider, StoredPlaylistsController,
|
||||
BaseStoredPlaylistsProvider)
|
||||
from mopidy.models import Playlist
|
||||
|
||||
class DummyBackend(BaseBackend):
|
||||
|
||||
class DummyQueue(object):
|
||||
def __init__(self):
|
||||
self.received_messages = []
|
||||
|
||||
def put(self, message):
|
||||
self.received_messages.append(message)
|
||||
|
||||
|
||||
class DummyBackend(Backend):
|
||||
"""
|
||||
A backend which implements the backend API in the simplest way possible.
|
||||
Used in tests of the frontends.
|
||||
@ -13,19 +23,30 @@ class DummyBackend(BaseBackend):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(DummyBackend, self).__init__(*args, **kwargs)
|
||||
self.current_playlist = DummyCurrentPlaylistController(backend=self)
|
||||
self.library = DummyLibraryController(backend=self)
|
||||
self.playback = DummyPlaybackController(backend=self)
|
||||
self.stored_playlists = DummyStoredPlaylistsController(backend=self)
|
||||
|
||||
self.core_queue = DummyQueue()
|
||||
|
||||
self.current_playlist = CurrentPlaylistController(backend=self)
|
||||
|
||||
library_provider = DummyLibraryProvider(backend=self)
|
||||
self.library = LibraryController(backend=self,
|
||||
provider=library_provider)
|
||||
|
||||
playback_provider = DummyPlaybackProvider(backend=self)
|
||||
self.playback = PlaybackController(backend=self,
|
||||
provider=playback_provider)
|
||||
|
||||
stored_playlists_provider = DummyStoredPlaylistsProvider(backend=self)
|
||||
self.stored_playlists = StoredPlaylistsController(backend=self,
|
||||
provider=stored_playlists_provider)
|
||||
|
||||
self.uri_handlers = [u'dummy:']
|
||||
|
||||
|
||||
class DummyCurrentPlaylistController(BaseCurrentPlaylistController):
|
||||
pass
|
||||
|
||||
|
||||
class DummyLibraryController(BaseLibraryController):
|
||||
_library = []
|
||||
class DummyLibraryProvider(BaseLibraryProvider):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(DummyLibraryProvider, self).__init__(*args, **kwargs)
|
||||
self._library = []
|
||||
|
||||
def find_exact(self, **query):
|
||||
return Playlist()
|
||||
@ -42,41 +63,25 @@ class DummyLibraryController(BaseLibraryController):
|
||||
return Playlist()
|
||||
|
||||
|
||||
class DummyPlaybackController(BasePlaybackController):
|
||||
def _next(self, track):
|
||||
class DummyPlaybackProvider(BasePlaybackProvider):
|
||||
def pause(self):
|
||||
return True
|
||||
|
||||
def play(self, track):
|
||||
"""Pass None as track to force failure"""
|
||||
return track is not None
|
||||
|
||||
def _pause(self):
|
||||
def resume(self):
|
||||
return True
|
||||
|
||||
def _play(self, track):
|
||||
"""Pass None as track to force failure"""
|
||||
return track is not None
|
||||
|
||||
def _previous(self, track):
|
||||
"""Pass None as track to force failure"""
|
||||
return track is not None
|
||||
|
||||
def _resume(self):
|
||||
def seek(self, time_position):
|
||||
return True
|
||||
|
||||
def _seek(self, time_position):
|
||||
def stop(self):
|
||||
return True
|
||||
|
||||
def _stop(self):
|
||||
return True
|
||||
|
||||
def _trigger_started_playing_event(self):
|
||||
pass # noop
|
||||
|
||||
def _trigger_stopped_playing_event(self):
|
||||
pass # noop
|
||||
|
||||
|
||||
class DummyStoredPlaylistsController(BaseStoredPlaylistsController):
|
||||
_playlists = []
|
||||
|
||||
class DummyStoredPlaylistsProvider(BaseStoredPlaylistsProvider):
|
||||
def create(self, name):
|
||||
playlist = Playlist(name=name)
|
||||
self._playlists.append(playlist)
|
||||
@ -93,7 +98,7 @@ class DummyStoredPlaylistsController(BaseStoredPlaylistsController):
|
||||
|
||||
def rename(self, playlist, new_name):
|
||||
self._playlists[self._playlists.index(playlist)] = \
|
||||
playlist.with_(name=new_name)
|
||||
playlist.copy(name=new_name)
|
||||
|
||||
def save(self, playlist):
|
||||
self._playlists.append(playlist)
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
import logging
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.backends.base import BaseBackend, BaseCurrentPlaylistController
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.libspotify')
|
||||
|
||||
ENCODING = 'utf-8'
|
||||
|
||||
class LibspotifyBackend(BaseBackend):
|
||||
"""
|
||||
A `Spotify <http://www.spotify.com/>`_ backend which uses the official
|
||||
`libspotify <http://developer.spotify.com/en/libspotify/overview/>`_
|
||||
library and the `pyspotify <http://github.com/winjer/pyspotify/>`_ Python
|
||||
bindings for libspotify.
|
||||
|
||||
**Issues:**
|
||||
http://github.com/mopidy/mopidy/issues/labels/backend-libspotify
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.SPOTIFY_LIB_CACHE`
|
||||
- :attr:`mopidy.settings.SPOTIFY_USERNAME`
|
||||
- :attr:`mopidy.settings.SPOTIFY_PASSWORD`
|
||||
|
||||
.. note::
|
||||
|
||||
This product uses SPOTIFY(R) CORE but is not endorsed, certified or
|
||||
otherwise approved in any way by Spotify. Spotify is the registered
|
||||
trade mark of the Spotify Group.
|
||||
"""
|
||||
|
||||
# Imports inside methods are to prevent loading of __init__.py to fail on
|
||||
# missing spotify dependencies.
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
from .library import LibspotifyLibraryController
|
||||
from .playback import LibspotifyPlaybackController
|
||||
from .stored_playlists import LibspotifyStoredPlaylistsController
|
||||
|
||||
super(LibspotifyBackend, self).__init__(*args, **kwargs)
|
||||
|
||||
self.current_playlist = BaseCurrentPlaylistController(backend=self)
|
||||
self.library = LibspotifyLibraryController(backend=self)
|
||||
self.playback = LibspotifyPlaybackController(backend=self)
|
||||
self.stored_playlists = LibspotifyStoredPlaylistsController(
|
||||
backend=self)
|
||||
self.uri_handlers = [u'spotify:', u'http://open.spotify.com/']
|
||||
self.spotify = self._connect()
|
||||
|
||||
def _connect(self):
|
||||
from .session_manager import LibspotifySessionManager
|
||||
|
||||
logger.info(u'Mopidy uses SPOTIFY(R) CORE')
|
||||
logger.debug(u'Connecting to Spotify')
|
||||
spotify = LibspotifySessionManager(
|
||||
settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD,
|
||||
core_queue=self.core_queue,
|
||||
output=self.output)
|
||||
spotify.start()
|
||||
return spotify
|
||||
@ -5,7 +5,10 @@ import os
|
||||
import shutil
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.backends.base import *
|
||||
from mopidy.backends.base import (Backend, CurrentPlaylistController,
|
||||
LibraryController, BaseLibraryProvider, PlaybackController,
|
||||
BasePlaybackProvider, StoredPlaylistsController,
|
||||
BaseStoredPlaylistsProvider)
|
||||
from mopidy.models import Playlist, Track, Album
|
||||
from mopidy.utils.process import pickle_connection
|
||||
|
||||
@ -13,7 +16,7 @@ from .translator import parse_m3u, parse_mpd_tag_cache
|
||||
|
||||
logger = logging.getLogger(u'mopidy.backends.local')
|
||||
|
||||
class LocalBackend(BaseBackend):
|
||||
class LocalBackend(Backend):
|
||||
"""
|
||||
A backend for playing music from a local music archive.
|
||||
|
||||
@ -21,50 +24,64 @@ class LocalBackend(BaseBackend):
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.LOCAL_MUSIC_FOLDER`
|
||||
- :attr:`mopidy.settings.LOCAL_PLAYLIST_FOLDER`
|
||||
- :attr:`mopidy.settings.LOCAL_TAG_CACHE`
|
||||
- :attr:`mopidy.settings.LOCAL_MUSIC_PATH`
|
||||
- :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH`
|
||||
- :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE`
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LocalBackend, self).__init__(*args, **kwargs)
|
||||
|
||||
self.library = LocalLibraryController(self)
|
||||
self.stored_playlists = LocalStoredPlaylistsController(self)
|
||||
self.current_playlist = BaseCurrentPlaylistController(self)
|
||||
self.playback = LocalPlaybackController(self)
|
||||
self.current_playlist = CurrentPlaylistController(backend=self)
|
||||
|
||||
library_provider = LocalLibraryProvider(backend=self)
|
||||
self.library = LibraryController(backend=self,
|
||||
provider=library_provider)
|
||||
|
||||
playback_provider = LocalPlaybackProvider(backend=self)
|
||||
self.playback = LocalPlaybackController(backend=self,
|
||||
provider=playback_provider)
|
||||
|
||||
stored_playlists_provider = LocalStoredPlaylistsProvider(backend=self)
|
||||
self.stored_playlists = StoredPlaylistsController(backend=self,
|
||||
provider=stored_playlists_provider)
|
||||
|
||||
self.uri_handlers = [u'file://']
|
||||
|
||||
|
||||
class LocalPlaybackController(BasePlaybackController):
|
||||
def __init__(self, backend):
|
||||
super(LocalPlaybackController, self).__init__(backend)
|
||||
class LocalPlaybackController(PlaybackController):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LocalPlaybackController, self).__init__(*args, **kwargs)
|
||||
|
||||
# XXX Why do we call stop()? Is it to set GStreamer state to 'READY'?
|
||||
self.stop()
|
||||
|
||||
def _play(self, track):
|
||||
return self.backend.output.play_uri(track.uri)
|
||||
|
||||
def _stop(self):
|
||||
return self.backend.output.set_state('READY')
|
||||
|
||||
def _pause(self):
|
||||
return self.backend.output.set_state('PAUSED')
|
||||
|
||||
def _resume(self):
|
||||
return self.backend.output.set_state('PLAYING')
|
||||
|
||||
def _seek(self, time_position):
|
||||
return self.backend.output.set_position(time_position)
|
||||
|
||||
@property
|
||||
def time_position(self):
|
||||
return self.backend.output.get_position()
|
||||
|
||||
|
||||
class LocalStoredPlaylistsController(BaseStoredPlaylistsController):
|
||||
def __init__(self, *args):
|
||||
super(LocalStoredPlaylistsController, self).__init__(*args)
|
||||
self._folder = os.path.expanduser(settings.LOCAL_PLAYLIST_FOLDER)
|
||||
class LocalPlaybackProvider(BasePlaybackProvider):
|
||||
def pause(self):
|
||||
return self.backend.output.set_state('PAUSED')
|
||||
|
||||
def play(self, track):
|
||||
return self.backend.output.play_uri(track.uri)
|
||||
|
||||
def resume(self):
|
||||
return self.backend.output.set_state('PLAYING')
|
||||
|
||||
def seek(self, time_position):
|
||||
return self.backend.output.set_position(time_position)
|
||||
|
||||
def stop(self):
|
||||
return self.backend.output.set_state('READY')
|
||||
|
||||
|
||||
class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs)
|
||||
self._folder = settings.LOCAL_PLAYLIST_PATH
|
||||
self.refresh()
|
||||
|
||||
def lookup(self, uri):
|
||||
@ -114,7 +131,7 @@ class LocalStoredPlaylistsController(BaseStoredPlaylistsController):
|
||||
src = os.path.join(self._folder, playlist.name + '.m3u')
|
||||
dst = os.path.join(self._folder, name + '.m3u')
|
||||
|
||||
renamed = playlist.with_(name=name)
|
||||
renamed = playlist.copy(name=name)
|
||||
index = self._playlists.index(playlist)
|
||||
self._playlists[index] = renamed
|
||||
|
||||
@ -134,18 +151,19 @@ class LocalStoredPlaylistsController(BaseStoredPlaylistsController):
|
||||
self._playlists.append(playlist)
|
||||
|
||||
|
||||
class LocalLibraryController(BaseLibraryController):
|
||||
def __init__(self, backend):
|
||||
super(LocalLibraryController, self).__init__(backend)
|
||||
class LocalLibraryProvider(BaseLibraryProvider):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(LocalLibraryProvider, self).__init__(*args, **kwargs)
|
||||
self._uri_mapping = {}
|
||||
self.refresh()
|
||||
|
||||
def refresh(self, uri=None):
|
||||
tracks = parse_mpd_tag_cache(settings.LOCAL_TAG_CACHE,
|
||||
settings.LOCAL_MUSIC_FOLDER)
|
||||
tag_cache = settings.LOCAL_TAG_CACHE_FILE
|
||||
music_folder = settings.LOCAL_MUSIC_PATH
|
||||
|
||||
logger.info('Loading songs in %s from %s',
|
||||
settings.LOCAL_MUSIC_FOLDER, settings.LOCAL_TAG_CACHE)
|
||||
tracks = parse_mpd_tag_cache(tag_cache, music_folder)
|
||||
|
||||
logger.info('Loading songs in %s from %s', music_folder, tag_cache)
|
||||
|
||||
for track in tracks:
|
||||
self._uri_mapping[track.uri] = track
|
||||
|
||||
@ -84,7 +84,7 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''):
|
||||
_convert_mpd_data(current, tracks, music_dir)
|
||||
current.clear()
|
||||
|
||||
current[key.lower()] = value
|
||||
current[key.lower()] = value.decode('utf-8')
|
||||
|
||||
_convert_mpd_data(current, tracks, music_dir)
|
||||
|
||||
@ -96,29 +96,55 @@ def _convert_mpd_data(data, tracks, music_dir):
|
||||
|
||||
track_kwargs = {}
|
||||
album_kwargs = {}
|
||||
artist_kwargs = {}
|
||||
albumartist_kwargs = {}
|
||||
|
||||
if 'track' in data:
|
||||
album_kwargs['num_tracks'] = int(data['track'].split('/')[1])
|
||||
track_kwargs['track_no'] = int(data['track'].split('/')[0])
|
||||
|
||||
if 'artist' in data:
|
||||
artist = Artist(name=data['artist'])
|
||||
track_kwargs['artists'] = [artist]
|
||||
album_kwargs['artists'] = [artist]
|
||||
artist_kwargs['name'] = data['artist']
|
||||
albumartist_kwargs['name'] = data['artist']
|
||||
|
||||
if 'albumartist' in data:
|
||||
albumartist_kwargs['name'] = data['albumartist']
|
||||
|
||||
if 'album' in data:
|
||||
album_kwargs['name'] = data['album']
|
||||
album = Album(**album_kwargs)
|
||||
track_kwargs['album'] = album
|
||||
|
||||
if 'title' in data:
|
||||
track_kwargs['name'] = data['title']
|
||||
|
||||
if 'musicbrainz_trackid' in data:
|
||||
track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid']
|
||||
|
||||
if 'musicbrainz_albumid' in data:
|
||||
album_kwargs['musicbrainz_id'] = data['musicbrainz_albumid']
|
||||
|
||||
if 'musicbrainz_artistid' in data:
|
||||
artist_kwargs['musicbrainz_id'] = data['musicbrainz_artistid']
|
||||
|
||||
if 'musicbrainz_albumartistid' in data:
|
||||
albumartist_kwargs['musicbrainz_id'] = data['musicbrainz_albumartistid']
|
||||
|
||||
if data['file'][0] == '/':
|
||||
path = data['file'][1:]
|
||||
else:
|
||||
path = data['file']
|
||||
|
||||
if artist_kwargs:
|
||||
artist = Artist(**artist_kwargs)
|
||||
track_kwargs['artists'] = [artist]
|
||||
|
||||
if albumartist_kwargs:
|
||||
albumartist = Artist(**albumartist_kwargs)
|
||||
album_kwargs['artists'] = [albumartist]
|
||||
|
||||
if album_kwargs:
|
||||
album = Album(**album_kwargs)
|
||||
track_kwargs['album'] = album
|
||||
|
||||
track_kwargs['uri'] = path_to_uri(music_dir, path)
|
||||
track_kwargs['length'] = int(data.get('time', 0)) * 1000
|
||||
|
||||
|
||||
74
mopidy/backends/spotify/__init__.py
Normal file
74
mopidy/backends/spotify/__init__.py
Normal file
@ -0,0 +1,74 @@
|
||||
import logging
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.backends.base import (Backend, CurrentPlaylistController,
|
||||
LibraryController, PlaybackController, StoredPlaylistsController)
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.spotify')
|
||||
|
||||
ENCODING = 'utf-8'
|
||||
|
||||
class SpotifyBackend(Backend):
|
||||
"""
|
||||
A backend for playing music from the `Spotify <http://www.spotify.com/>`_
|
||||
music streaming service. The backend uses the official `libspotify
|
||||
<http://developer.spotify.com/en/libspotify/overview/>`_ library and the
|
||||
`pyspotify <http://github.com/winjer/pyspotify/>`_ Python bindings for
|
||||
libspotify.
|
||||
|
||||
.. note::
|
||||
|
||||
This product uses SPOTIFY(R) CORE but is not endorsed, certified or
|
||||
otherwise approved in any way by Spotify. Spotify is the registered
|
||||
trade mark of the Spotify Group.
|
||||
|
||||
**Issues:**
|
||||
http://github.com/mopidy/mopidy/issues/labels/backend-spotify
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.SPOTIFY_CACHE_PATH`
|
||||
- :attr:`mopidy.settings.SPOTIFY_USERNAME`
|
||||
- :attr:`mopidy.settings.SPOTIFY_PASSWORD`
|
||||
"""
|
||||
|
||||
# Imports inside methods are to prevent loading of __init__.py to fail on
|
||||
# missing spotify dependencies.
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
from .library import SpotifyLibraryProvider
|
||||
from .playback import SpotifyPlaybackProvider
|
||||
from .stored_playlists import SpotifyStoredPlaylistsProvider
|
||||
|
||||
super(SpotifyBackend, self).__init__(*args, **kwargs)
|
||||
|
||||
self.current_playlist = CurrentPlaylistController(backend=self)
|
||||
|
||||
library_provider = SpotifyLibraryProvider(backend=self)
|
||||
self.library = LibraryController(backend=self,
|
||||
provider=library_provider)
|
||||
|
||||
playback_provider = SpotifyPlaybackProvider(backend=self)
|
||||
self.playback = PlaybackController(backend=self,
|
||||
provider=playback_provider)
|
||||
|
||||
stored_playlists_provider = SpotifyStoredPlaylistsProvider(
|
||||
backend=self)
|
||||
self.stored_playlists = StoredPlaylistsController(backend=self,
|
||||
provider=stored_playlists_provider)
|
||||
|
||||
self.uri_handlers = [u'spotify:', u'http://open.spotify.com/']
|
||||
|
||||
self.spotify = self._connect()
|
||||
|
||||
def _connect(self):
|
||||
from .session_manager import SpotifySessionManager
|
||||
|
||||
logger.info(u'Mopidy uses SPOTIFY(R) CORE')
|
||||
logger.debug(u'Connecting to Spotify')
|
||||
spotify = SpotifySessionManager(
|
||||
settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD,
|
||||
core_queue=self.core_queue,
|
||||
output=self.output)
|
||||
spotify.start()
|
||||
return spotify
|
||||
@ -3,14 +3,14 @@ import multiprocessing
|
||||
|
||||
from spotify import Link, SpotifyError
|
||||
|
||||
from mopidy.backends.base import BaseLibraryController
|
||||
from mopidy.backends.libspotify import ENCODING
|
||||
from mopidy.backends.libspotify.translator import LibspotifyTranslator
|
||||
from mopidy.backends.base import BaseLibraryProvider
|
||||
from mopidy.backends.spotify import ENCODING
|
||||
from mopidy.backends.spotify.translator import SpotifyTranslator
|
||||
from mopidy.models import Playlist
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.libspotify.library')
|
||||
logger = logging.getLogger('mopidy.backends.spotify.library')
|
||||
|
||||
class LibspotifyLibraryController(BaseLibraryController):
|
||||
class SpotifyLibraryProvider(BaseLibraryProvider):
|
||||
def find_exact(self, **query):
|
||||
return self.search(**query)
|
||||
|
||||
@ -20,9 +20,9 @@ class LibspotifyLibraryController(BaseLibraryController):
|
||||
# TODO Block until metadata_updated callback is called. Before that
|
||||
# the track will be unloaded, unless it's already in the stored
|
||||
# playlists.
|
||||
return LibspotifyTranslator.to_mopidy_track(spotify_track)
|
||||
return SpotifyTranslator.to_mopidy_track(spotify_track)
|
||||
except SpotifyError as e:
|
||||
logger.warning(u'Failed to lookup: %s', uri, e)
|
||||
logger.debug(u'Failed to lookup "%s": %s', uri, e)
|
||||
return None
|
||||
|
||||
def refresh(self, uri=None):
|
||||
@ -2,17 +2,17 @@ import logging
|
||||
|
||||
from spotify import Link, SpotifyError
|
||||
|
||||
from mopidy.backends.base import BasePlaybackController
|
||||
from mopidy.backends.base import BasePlaybackProvider
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.libspotify.playback')
|
||||
logger = logging.getLogger('mopidy.backends.spotify.playback')
|
||||
|
||||
class LibspotifyPlaybackController(BasePlaybackController):
|
||||
def _pause(self):
|
||||
class SpotifyPlaybackProvider(BasePlaybackProvider):
|
||||
def pause(self):
|
||||
return self.backend.output.set_state('PAUSED')
|
||||
|
||||
def _play(self, track):
|
||||
def play(self, track):
|
||||
self.backend.output.set_state('READY')
|
||||
if self.state == self.PLAYING:
|
||||
if self.backend.playback.state == self.backend.playback.PLAYING:
|
||||
self.backend.spotify.session.play(0)
|
||||
if track.uri is None:
|
||||
return False
|
||||
@ -26,16 +26,16 @@ class LibspotifyPlaybackController(BasePlaybackController):
|
||||
logger.warning('Play %s failed: %s', track.uri, e)
|
||||
return False
|
||||
|
||||
def _resume(self):
|
||||
return self._seek(self.time_position)
|
||||
def resume(self):
|
||||
return self.seek(self.backend.playback.time_position)
|
||||
|
||||
def _seek(self, time_position):
|
||||
def seek(self, time_position):
|
||||
self.backend.output.set_state('READY')
|
||||
self.backend.spotify.session.seek(time_position)
|
||||
self.backend.output.set_state('PLAYING')
|
||||
return True
|
||||
|
||||
def _stop(self):
|
||||
def stop(self):
|
||||
result = self.backend.output.set_state('READY')
|
||||
self.backend.spotify.session.play(0)
|
||||
return result
|
||||
@ -2,25 +2,29 @@ import logging
|
||||
import os
|
||||
import threading
|
||||
|
||||
from spotify.manager import SpotifySessionManager
|
||||
import spotify.manager
|
||||
|
||||
from mopidy import get_version, settings
|
||||
from mopidy.backends.libspotify.translator import LibspotifyTranslator
|
||||
from mopidy.backends.spotify.translator import SpotifyTranslator
|
||||
from mopidy.models import Playlist
|
||||
from mopidy.utils.process import BaseThread
|
||||
|
||||
logger = logging.getLogger('mopidy.backends.libspotify.session_manager')
|
||||
logger = logging.getLogger('mopidy.backends.spotify.session_manager')
|
||||
|
||||
class LibspotifySessionManager(SpotifySessionManager, BaseThread):
|
||||
cache_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE)
|
||||
settings_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE)
|
||||
# pylint: disable = R0901
|
||||
# SpotifySessionManager: Too many ancestors (9/7)
|
||||
|
||||
class SpotifySessionManager(spotify.manager.SpotifySessionManager, BaseThread):
|
||||
cache_location = settings.SPOTIFY_CACHE_PATH
|
||||
settings_location = settings.SPOTIFY_CACHE_PATH
|
||||
appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key')
|
||||
user_agent = 'Mopidy %s' % get_version()
|
||||
|
||||
def __init__(self, username, password, core_queue, output):
|
||||
SpotifySessionManager.__init__(self, username, password)
|
||||
spotify.manager.SpotifySessionManager.__init__(
|
||||
self, username, password)
|
||||
BaseThread.__init__(self, core_queue)
|
||||
self.name = 'LibspotifySMThread'
|
||||
self.name = 'SpotifySMThread'
|
||||
self.output = output
|
||||
self.connected = threading.Event()
|
||||
self.session = None
|
||||
@ -32,6 +36,12 @@ class LibspotifySessionManager(SpotifySessionManager, BaseThread):
|
||||
"""Callback used by pyspotify"""
|
||||
logger.info(u'Connected to Spotify')
|
||||
self.session = session
|
||||
if settings.SPOTIFY_HIGH_BITRATE:
|
||||
logger.debug(u'Preferring high bitrate from Spotify')
|
||||
self.session.set_preferred_bitrate(1)
|
||||
else:
|
||||
logger.debug(u'Preferring normal bitrate from Spotify')
|
||||
self.session.set_preferred_bitrate(0)
|
||||
self.connected.set()
|
||||
|
||||
def logged_out(self, session):
|
||||
@ -40,15 +50,8 @@ class LibspotifySessionManager(SpotifySessionManager, BaseThread):
|
||||
|
||||
def metadata_updated(self, session):
|
||||
"""Callback used by pyspotify"""
|
||||
logger.debug(u'Metadata updated, refreshing stored playlists')
|
||||
playlists = []
|
||||
for spotify_playlist in session.playlist_container():
|
||||
playlists.append(
|
||||
LibspotifyTranslator.to_mopidy_playlist(spotify_playlist))
|
||||
self.core_queue.put({
|
||||
'command': 'set_stored_playlists',
|
||||
'playlists': playlists,
|
||||
})
|
||||
logger.debug(u'Metadata updated')
|
||||
self.refresh_stored_playlists()
|
||||
|
||||
def connection_error(self, session, error):
|
||||
"""Callback used by pyspotify"""
|
||||
@ -65,6 +68,8 @@ class LibspotifySessionManager(SpotifySessionManager, BaseThread):
|
||||
def music_delivery(self, session, frames, frame_size, num_frames,
|
||||
sample_type, sample_rate, channels):
|
||||
"""Callback used by pyspotify"""
|
||||
# pylint: disable = R0913
|
||||
# Too many arguments (8/5)
|
||||
assert sample_type == 0, u'Expects 16-bit signed integer samples'
|
||||
capabilites = """
|
||||
audio/x-raw-int,
|
||||
@ -94,12 +99,26 @@ class LibspotifySessionManager(SpotifySessionManager, BaseThread):
|
||||
logger.debug(u'End of data stream reached')
|
||||
self.output.end_of_data_stream()
|
||||
|
||||
def refresh_stored_playlists(self):
|
||||
"""Refresh the stored playlists in the backend with fresh meta data
|
||||
from Spotify"""
|
||||
playlists = []
|
||||
for spotify_playlist in self.session.playlist_container():
|
||||
playlists.append(
|
||||
SpotifyTranslator.to_mopidy_playlist(spotify_playlist))
|
||||
playlists = filter(None, playlists)
|
||||
self.core_queue.put({
|
||||
'command': 'set_stored_playlists',
|
||||
'playlists': playlists,
|
||||
})
|
||||
logger.debug(u'Refreshed %d stored playlist(s)', len(playlists))
|
||||
|
||||
def search(self, query, connection):
|
||||
"""Search method used by Mopidy backend"""
|
||||
def callback(results, userdata=None):
|
||||
# TODO Include results from results.albums(), etc. too
|
||||
playlist = Playlist(tracks=[
|
||||
LibspotifyTranslator.to_mopidy_track(t)
|
||||
SpotifyTranslator.to_mopidy_track(t)
|
||||
for t in results.tracks()])
|
||||
connection.send(playlist)
|
||||
self.connected.wait()
|
||||
@ -1,6 +1,6 @@
|
||||
from mopidy.backends.base import BaseStoredPlaylistsController
|
||||
from mopidy.backends.base import BaseStoredPlaylistsProvider
|
||||
|
||||
class LibspotifyStoredPlaylistsController(BaseStoredPlaylistsController):
|
||||
class SpotifyStoredPlaylistsProvider(BaseStoredPlaylistsProvider):
|
||||
def create(self, name):
|
||||
pass # TODO
|
||||
|
||||
@ -1,11 +1,15 @@
|
||||
import datetime as dt
|
||||
import logging
|
||||
|
||||
from spotify import Link
|
||||
from spotify import Link, SpotifyError
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.backends.spotify import ENCODING
|
||||
from mopidy.models import Artist, Album, Track, Playlist
|
||||
from mopidy.backends.libspotify import ENCODING
|
||||
|
||||
class LibspotifyTranslator(object):
|
||||
logger = logging.getLogger('mopidy.backends.spotify.translator')
|
||||
|
||||
class SpotifyTranslator(object):
|
||||
@classmethod
|
||||
def to_mopidy_artist(cls, spotify_artist):
|
||||
if not spotify_artist.is_loaded():
|
||||
@ -39,15 +43,22 @@ class LibspotifyTranslator(object):
|
||||
track_no=spotify_track.index(),
|
||||
date=date,
|
||||
length=spotify_track.duration(),
|
||||
bitrate=160,
|
||||
bitrate=(settings.SPOTIFY_HIGH_BITRATE and 320 or 160),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def to_mopidy_playlist(cls, spotify_playlist):
|
||||
if not spotify_playlist.is_loaded():
|
||||
return Playlist(name=u'[loading...]')
|
||||
return Playlist(
|
||||
uri=str(Link.from_playlist(spotify_playlist)),
|
||||
name=spotify_playlist.name().decode(ENCODING),
|
||||
tracks=[cls.to_mopidy_track(t) for t in spotify_playlist],
|
||||
)
|
||||
# FIXME Replace this try-except with a check on the playlist type,
|
||||
# which is currently not supported by pyspotify, to avoid handling
|
||||
# playlist folder boundaries like normal playlists.
|
||||
try:
|
||||
return Playlist(
|
||||
uri=str(Link.from_playlist(spotify_playlist)),
|
||||
name=spotify_playlist.name().decode(ENCODING),
|
||||
tracks=[cls.to_mopidy_track(t) for t in spotify_playlist],
|
||||
)
|
||||
except SpotifyError, e:
|
||||
logger.warning(u'Failed translating Spotify playlist '
|
||||
'(probably a playlist folder boundary): %s', e)
|
||||
@ -7,7 +7,7 @@ from mopidy import get_version, settings, OptionalDependencyError
|
||||
from mopidy.utils import get_class
|
||||
from mopidy.utils.log import setup_logging
|
||||
from mopidy.utils.path import get_or_create_folder, get_or_create_file
|
||||
from mopidy.utils.process import BaseThread
|
||||
from mopidy.utils.process import BaseThread, GObjectEventThread
|
||||
from mopidy.utils.settings import list_settings_optparse_callback
|
||||
|
||||
logger = logging.getLogger('mopidy.core')
|
||||
@ -18,6 +18,7 @@ class CoreProcess(BaseThread):
|
||||
super(CoreProcess, self).__init__(self.core_queue)
|
||||
self.name = 'CoreProcess'
|
||||
self.options = self.parse_options()
|
||||
self.gobject_loop = None
|
||||
self.output = None
|
||||
self.backend = None
|
||||
self.frontends = []
|
||||
@ -47,6 +48,7 @@ class CoreProcess(BaseThread):
|
||||
def setup(self):
|
||||
self.setup_logging()
|
||||
self.setup_settings()
|
||||
self.gobject_loop = self.setup_gobject_loop(self.core_queue)
|
||||
self.output = self.setup_output(self.core_queue)
|
||||
self.backend = self.setup_backend(self.core_queue, self.output)
|
||||
self.frontends = self.setup_frontends(self.core_queue, self.backend)
|
||||
@ -61,6 +63,11 @@ class CoreProcess(BaseThread):
|
||||
get_or_create_file('~/.mopidy/settings.py')
|
||||
settings.validate()
|
||||
|
||||
def setup_gobject_loop(self, core_queue):
|
||||
gobject_loop = GObjectEventThread(core_queue)
|
||||
gobject_loop.start()
|
||||
return gobject_loop
|
||||
|
||||
def setup_output(self, core_queue):
|
||||
output = get_class(settings.OUTPUT)(core_queue)
|
||||
output.start()
|
||||
|
||||
@ -5,7 +5,7 @@ class BaseFrontend(object):
|
||||
:param core_queue: queue for messaging the core
|
||||
:type core_queue: :class:`multiprocessing.Queue`
|
||||
:param backend: the backend
|
||||
:type backend: :class:`mopidy.backends.base.BaseBackend`
|
||||
:type backend: :class:`mopidy.backends.base.Backend`
|
||||
"""
|
||||
|
||||
def __init__(self, core_queue, backend):
|
||||
@ -13,17 +13,27 @@ class BaseFrontend(object):
|
||||
self.backend = backend
|
||||
|
||||
def start(self):
|
||||
"""Start the frontend."""
|
||||
"""
|
||||
Start the frontend.
|
||||
|
||||
*MAY be implemented by subclass.*
|
||||
"""
|
||||
pass
|
||||
|
||||
def destroy(self):
|
||||
"""Destroy the frontend."""
|
||||
"""
|
||||
Destroy the frontend.
|
||||
|
||||
*MAY be implemented by subclass.*
|
||||
"""
|
||||
pass
|
||||
|
||||
def process_message(self, message):
|
||||
"""
|
||||
Process messages for the frontend.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:param message: the message
|
||||
:type message: dict
|
||||
"""
|
||||
|
||||
@ -1,27 +1,21 @@
|
||||
import logging
|
||||
import multiprocessing
|
||||
import socket
|
||||
import time
|
||||
|
||||
try:
|
||||
import pylast
|
||||
except ImportError as e:
|
||||
except ImportError as import_error:
|
||||
from mopidy import OptionalDependencyError
|
||||
raise OptionalDependencyError(e)
|
||||
raise OptionalDependencyError(import_error)
|
||||
|
||||
from mopidy import get_version, settings, SettingsError
|
||||
from mopidy import settings, SettingsError
|
||||
from mopidy.frontends.base import BaseFrontend
|
||||
from mopidy.utils.process import BaseThread
|
||||
|
||||
logger = logging.getLogger('mopidy.frontends.lastfm')
|
||||
|
||||
CLIENT_ID = u'mop'
|
||||
CLIENT_VERSION = get_version()
|
||||
|
||||
# pylast raises UnicodeEncodeError on conversion from unicode objects to
|
||||
# ascii-encoded bytestrings, so we explicitly encode as utf-8 before passing
|
||||
# strings to pylast.
|
||||
ENCODING = u'utf-8'
|
||||
API_KEY = '2236babefa8ebb3d93ea467560d00d04'
|
||||
API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd'
|
||||
|
||||
class LastfmFrontend(BaseFrontend):
|
||||
"""
|
||||
@ -34,7 +28,7 @@ class LastfmFrontend(BaseFrontend):
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- `pylast <http://code.google.com/p/pylast/>`_ >= 0.4.30
|
||||
- `pylast <http://code.google.com/p/pylast/>`_ >= 0.5.7
|
||||
|
||||
**Settings:**
|
||||
|
||||
@ -64,12 +58,11 @@ class LastfmFrontendThread(BaseThread):
|
||||
self.name = u'LastfmFrontendThread'
|
||||
self.connection = connection
|
||||
self.lastfm = None
|
||||
self.scrobbler = None
|
||||
self.last_start_time = None
|
||||
|
||||
def run_inside_try(self):
|
||||
self.setup()
|
||||
while self.scrobbler is not None:
|
||||
while self.lastfm is not None:
|
||||
self.connection.poll(None)
|
||||
message = self.connection.recv()
|
||||
self.process_message(message)
|
||||
@ -78,16 +71,16 @@ class LastfmFrontendThread(BaseThread):
|
||||
try:
|
||||
username = settings.LASTFM_USERNAME
|
||||
password_hash = pylast.md5(settings.LASTFM_PASSWORD)
|
||||
self.lastfm = pylast.get_lastfm_network(
|
||||
self.lastfm = pylast.LastFMNetwork(
|
||||
api_key=API_KEY, api_secret=API_SECRET,
|
||||
username=username, password_hash=password_hash)
|
||||
self.scrobbler = self.lastfm.get_scrobbler(
|
||||
CLIENT_ID, CLIENT_VERSION)
|
||||
logger.info(u'Connected to Last.fm')
|
||||
except SettingsError as e:
|
||||
logger.info(u'Last.fm scrobbler not started')
|
||||
logger.debug(u'Last.fm settings error: %s', e)
|
||||
except (pylast.WSError, socket.error) as e:
|
||||
logger.error(u'Last.fm connection error: %s', e)
|
||||
except (pylast.NetworkError, pylast.MalformedResponseError,
|
||||
pylast.WSError) as e:
|
||||
logger.error(u'Error during Last.fm setup: %s', e)
|
||||
|
||||
def process_message(self, message):
|
||||
if message['command'] == 'started_playing':
|
||||
@ -99,22 +92,24 @@ class LastfmFrontendThread(BaseThread):
|
||||
|
||||
def started_playing(self, track):
|
||||
artists = ', '.join([a.name for a in track.artists])
|
||||
duration = track.length // 1000
|
||||
duration = track.length and track.length // 1000 or 0
|
||||
self.last_start_time = int(time.time())
|
||||
logger.debug(u'Now playing track: %s - %s', artists, track.name)
|
||||
try:
|
||||
self.scrobbler.report_now_playing(
|
||||
artists.encode(ENCODING),
|
||||
track.name.encode(ENCODING),
|
||||
album=track.album.name.encode(ENCODING),
|
||||
duration=duration,
|
||||
track_number=track.track_no)
|
||||
except (pylast.ScrobblingError, socket.error) as e:
|
||||
logger.warning(u'Last.fm now playing error: %s', e)
|
||||
self.lastfm.update_now_playing(
|
||||
artists,
|
||||
(track.name or ''),
|
||||
album=(track.album and track.album.name or ''),
|
||||
duration=str(duration),
|
||||
track_number=str(track.track_no),
|
||||
mbid=(track.musicbrainz_id or ''))
|
||||
except (pylast.ScrobblingError, pylast.NetworkError,
|
||||
pylast.MalformedResponseError, pylast.WSError) as e:
|
||||
logger.warning(u'Error submitting playing track to Last.fm: %s', e)
|
||||
|
||||
def stopped_playing(self, track, stop_position):
|
||||
artists = ', '.join([a.name for a in track.artists])
|
||||
duration = track.length // 1000
|
||||
duration = track.length and track.length // 1000 or 0
|
||||
stop_position = stop_position // 1000
|
||||
if duration < 30:
|
||||
logger.debug(u'Track too short to scrobble. (30s)')
|
||||
@ -127,14 +122,14 @@ class LastfmFrontendThread(BaseThread):
|
||||
self.last_start_time = int(time.time()) - duration
|
||||
logger.debug(u'Scrobbling track: %s - %s', artists, track.name)
|
||||
try:
|
||||
self.scrobbler.scrobble(
|
||||
artists.encode(ENCODING),
|
||||
track.name.encode(ENCODING),
|
||||
time_started=self.last_start_time,
|
||||
source=pylast.SCROBBLE_SOURCE_USER,
|
||||
mode=pylast.SCROBBLE_MODE_PLAYED,
|
||||
duration=duration,
|
||||
album=track.album.name.encode(ENCODING),
|
||||
track_number=track.track_no)
|
||||
except (pylast.ScrobblingError, socket.error) as e:
|
||||
logger.warning(u'Last.fm scrobbling error: %s', e)
|
||||
self.lastfm.scrobble(
|
||||
artists,
|
||||
(track.name or ''),
|
||||
str(self.last_start_time),
|
||||
album=(track.album and track.album.name or ''),
|
||||
track_number=str(track.track_no),
|
||||
duration=str(duration),
|
||||
mbid=(track.musicbrainz_id or ''))
|
||||
except (pylast.ScrobblingError, pylast.NetworkError,
|
||||
pylast.MalformedResponseError, pylast.WSError) as e:
|
||||
logger.warning(u'Error submitting played track to Last.fm: %s', e)
|
||||
|
||||
@ -14,6 +14,7 @@ class MpdFrontend(BaseFrontend):
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.MPD_SERVER_HOSTNAME`
|
||||
- :attr:`mopidy.settings.MPD_SERVER_PASSWORD`
|
||||
- :attr:`mopidy.settings.MPD_SERVER_PORT`
|
||||
"""
|
||||
|
||||
|
||||
@ -5,9 +5,11 @@ from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdArgError,
|
||||
from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers
|
||||
# Do not remove the following import. The protocol modules must be imported to
|
||||
# get them registered as request handlers.
|
||||
# pylint: disable = W0611
|
||||
from mopidy.frontends.mpd.protocol import (audio_output, command_list,
|
||||
connection, current_playlist, empty, music_db, playback, reflection,
|
||||
status, stickers, stored_playlists)
|
||||
# pylint: enable = W0611
|
||||
from mopidy.utils import flatten
|
||||
|
||||
class MpdDispatcher(object):
|
||||
|
||||
@ -1,22 +1,20 @@
|
||||
from mopidy import MopidyException
|
||||
|
||||
class MpdAckError(MopidyException):
|
||||
"""
|
||||
Available MPD error codes::
|
||||
"""See fields on this class for available MPD error codes"""
|
||||
|
||||
ACK_ERROR_NOT_LIST = 1
|
||||
ACK_ERROR_ARG = 2
|
||||
ACK_ERROR_PASSWORD = 3
|
||||
ACK_ERROR_PERMISSION = 4
|
||||
ACK_ERROR_UNKNOWN = 5
|
||||
ACK_ERROR_NO_EXIST = 50
|
||||
ACK_ERROR_PLAYLIST_MAX = 51
|
||||
ACK_ERROR_SYSTEM = 52
|
||||
ACK_ERROR_PLAYLIST_LOAD = 53
|
||||
ACK_ERROR_UPDATE_ALREADY = 54
|
||||
ACK_ERROR_PLAYER_SYNC = 55
|
||||
ACK_ERROR_EXIST = 56
|
||||
"""
|
||||
ACK_ERROR_NOT_LIST = 1
|
||||
ACK_ERROR_ARG = 2
|
||||
ACK_ERROR_PASSWORD = 3
|
||||
ACK_ERROR_PERMISSION = 4
|
||||
ACK_ERROR_UNKNOWN = 5
|
||||
ACK_ERROR_NO_EXIST = 50
|
||||
ACK_ERROR_PLAYLIST_MAX = 51
|
||||
ACK_ERROR_SYSTEM = 52
|
||||
ACK_ERROR_PLAYLIST_LOAD = 53
|
||||
ACK_ERROR_UPDATE_ALREADY = 54
|
||||
ACK_ERROR_PLAYER_SYNC = 55
|
||||
ACK_ERROR_EXIST = 56
|
||||
|
||||
def __init__(self, message=u'', error_code=0, index=0, command=u''):
|
||||
super(MpdAckError, self).__init__(message, error_code, index, command)
|
||||
@ -37,19 +35,24 @@ class MpdAckError(MopidyException):
|
||||
class MpdArgError(MpdAckError):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MpdArgError, self).__init__(*args, **kwargs)
|
||||
self.error_code = 2 # ACK_ERROR_ARG
|
||||
self.error_code = MpdAckError.ACK_ERROR_ARG
|
||||
|
||||
class MpdPasswordError(MpdAckError):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MpdPasswordError, self).__init__(*args, **kwargs)
|
||||
self.error_code = MpdAckError.ACK_ERROR_PASSWORD
|
||||
|
||||
class MpdUnknownCommand(MpdAckError):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MpdUnknownCommand, self).__init__(*args, **kwargs)
|
||||
self.message = u'unknown command "%s"' % self.command
|
||||
self.command = u''
|
||||
self.error_code = 5 # ACK_ERROR_UNKNOWN
|
||||
self.error_code = MpdAckError.ACK_ERROR_UNKNOWN
|
||||
|
||||
class MpdNoExistError(MpdAckError):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MpdNoExistError, self).__init__(*args, **kwargs)
|
||||
self.error_code = 50 # ACK_ERROR_NO_EXIST
|
||||
self.error_code = MpdAckError.ACK_ERROR_NO_EXIST
|
||||
|
||||
class MpdNotImplemented(MpdAckError):
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@ -13,7 +13,7 @@ implement our own MPD server which is compatible with the numerous existing
|
||||
import re
|
||||
|
||||
#: The MPD protocol uses UTF-8 for encoding all data.
|
||||
ENCODING = u'utf-8'
|
||||
ENCODING = u'UTF-8'
|
||||
|
||||
#: The MPD protocol uses ``\n`` as line terminator.
|
||||
LINE_TERMINATOR = u'\n'
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
from mopidy import settings
|
||||
from mopidy.frontends.mpd.protocol import handle_pattern
|
||||
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
|
||||
from mopidy.frontends.mpd.exceptions import MpdPasswordError
|
||||
|
||||
@handle_pattern(r'^close$')
|
||||
def close(frontend):
|
||||
@ -33,7 +34,11 @@ def password_(frontend, password):
|
||||
This is used for authentication with the server. ``PASSWORD`` is
|
||||
simply the plaintext password.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
# You will not get to this code without being authenticated. This is for
|
||||
# when you are already authenticated, and are sending additional 'password'
|
||||
# requests.
|
||||
if settings.MPD_SERVER_PASSWORD != password:
|
||||
raise MpdPasswordError(u'incorrect password', command=u'password')
|
||||
|
||||
@handle_pattern(r'^ping$')
|
||||
def ping(frontend):
|
||||
|
||||
@ -293,6 +293,7 @@ def replay_gain_status(frontend):
|
||||
"""
|
||||
return u'off' # TODO
|
||||
|
||||
@handle_pattern(r'^seek (?P<songpos>\d+) (?P<seconds>\d+)$')
|
||||
@handle_pattern(r'^seek "(?P<songpos>\d+)" "(?P<seconds>\d+)"$')
|
||||
def seek(frontend, songpos, seconds):
|
||||
"""
|
||||
@ -302,6 +303,10 @@ def seek(frontend, songpos, seconds):
|
||||
|
||||
Seeks to the position ``TIME`` (in seconds) of entry ``SONGPOS`` in
|
||||
the playlist.
|
||||
|
||||
*Droid MPD:*
|
||||
|
||||
- issues ``seek 1 120`` without quotes around the arguments.
|
||||
"""
|
||||
if frontend.backend.playback.current_playlist_position != songpos:
|
||||
playpos(frontend, songpos)
|
||||
@ -320,6 +325,7 @@ def seekid(frontend, cpid, seconds):
|
||||
playid(frontend, cpid)
|
||||
frontend.backend.playback.seek(int(seconds) * 1000)
|
||||
|
||||
@handle_pattern(r'^setvol (?P<volume>[-+]*\d+)$')
|
||||
@handle_pattern(r'^setvol "(?P<volume>[-+]*\d+)"$')
|
||||
def setvol(frontend, volume):
|
||||
"""
|
||||
@ -328,6 +334,10 @@ def setvol(frontend, volume):
|
||||
``setvol {VOL}``
|
||||
|
||||
Sets volume to ``VOL``, the range of volume is 0-100.
|
||||
|
||||
*Droid MPD:*
|
||||
|
||||
- issues ``setvol 50`` without quotes around the argument.
|
||||
"""
|
||||
volume = int(volume)
|
||||
if volume < 0:
|
||||
|
||||
@ -9,9 +9,12 @@ def commands(frontend):
|
||||
``commands``
|
||||
|
||||
Shows which commands the current user has access to.
|
||||
|
||||
As permissions is not implemented, any user has access to all commands.
|
||||
"""
|
||||
# FIXME When password auth is turned on and the client is not
|
||||
# authenticated, 'commands' should list only the commands the client does
|
||||
# have access to. To implement this we need access to the session object to
|
||||
# check if the client is authenticated or not.
|
||||
|
||||
sorted_commands = sorted(list(mpd_commands))
|
||||
|
||||
# Not shown by MPD in its command list
|
||||
@ -51,9 +54,11 @@ def notcommands(frontend):
|
||||
``notcommands``
|
||||
|
||||
Shows which commands the current user does not have access to.
|
||||
|
||||
As permissions is not implemented, any user has access to all commands.
|
||||
"""
|
||||
# FIXME When password auth is turned on and the client is not
|
||||
# authenticated, 'notcommands' should list all the commands the client does
|
||||
# not have access to. To implement this we need access to the session
|
||||
# object to check if the client is authenticated or not.
|
||||
pass
|
||||
|
||||
@handle_pattern(r'^tagtypes$')
|
||||
|
||||
@ -2,6 +2,7 @@ import asynchat
|
||||
import logging
|
||||
import multiprocessing
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.frontends.mpd.protocol import ENCODING, LINE_TERMINATOR, VERSION
|
||||
from mopidy.utils.log import indent
|
||||
from mopidy.utils.process import pickle_connection
|
||||
@ -22,6 +23,7 @@ class MpdSession(asynchat.async_chat):
|
||||
self.client_port = client_socket_address[1]
|
||||
self.core_queue = core_queue
|
||||
self.input_buffer = []
|
||||
self.authenticated = False
|
||||
self.set_terminator(LINE_TERMINATOR.encode(ENCODING))
|
||||
|
||||
def start(self):
|
||||
@ -46,6 +48,11 @@ class MpdSession(asynchat.async_chat):
|
||||
|
||||
def handle_request(self, request):
|
||||
"""Handle request by sending it to the MPD frontend."""
|
||||
if not self.authenticated:
|
||||
(self.authenticated, response) = self.check_password(request)
|
||||
if response is not None:
|
||||
self.send_response(response)
|
||||
return
|
||||
my_end, other_end = multiprocessing.Pipe()
|
||||
self.core_queue.put({
|
||||
'to': 'frontend',
|
||||
@ -69,3 +76,26 @@ class MpdSession(asynchat.async_chat):
|
||||
output = u'%s%s' % (output, LINE_TERMINATOR)
|
||||
data = output.encode(ENCODING)
|
||||
self.push(data)
|
||||
|
||||
def check_password(self, request):
|
||||
"""
|
||||
Takes any request and tries to authenticate the client using it.
|
||||
|
||||
:rtype: a two-tuple containing (is_authenticated, response_message). If
|
||||
the response_message is :class:`None`, normal processing should
|
||||
continue, even though the client may not be authenticated.
|
||||
"""
|
||||
if settings.MPD_SERVER_PASSWORD is None:
|
||||
return (True, None)
|
||||
command = request.split(' ')[0]
|
||||
if command == 'password':
|
||||
if request == 'password "%s"' % settings.MPD_SERVER_PASSWORD:
|
||||
return (True, u'OK')
|
||||
else:
|
||||
return (False, u'ACK [3@0] {password} incorrect password')
|
||||
if command in ('close', 'commands', 'notcommands', 'ping'):
|
||||
return (False, None)
|
||||
else:
|
||||
return (False,
|
||||
u'ACK [4@0] {%(c)s} you don\'t have permission for "%(c)s"' %
|
||||
{'c': command})
|
||||
|
||||
@ -1,3 +1,11 @@
|
||||
import os
|
||||
import re
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.utils.path import mtime as get_mtime
|
||||
from mopidy.frontends.mpd import protocol
|
||||
from mopidy.utils.path import uri_to_path, split_path
|
||||
|
||||
def track_to_mpd_format(track, position=None, cpid=None):
|
||||
"""
|
||||
Format track for output to MPD client.
|
||||
@ -8,12 +16,16 @@ def track_to_mpd_format(track, position=None, cpid=None):
|
||||
:type position: integer
|
||||
:param cpid: track's CPID (current playlist ID)
|
||||
:type cpid: integer
|
||||
:param key: if we should set key
|
||||
:type key: boolean
|
||||
:param mtime: if we should set mtime
|
||||
:type mtime: boolean
|
||||
:rtype: list of two-tuples
|
||||
"""
|
||||
result = [
|
||||
('file', track.uri or ''),
|
||||
('Time', track.length and (track.length // 1000) or 0),
|
||||
('Artist', track_artists_to_mpd_format(track)),
|
||||
('Artist', artists_to_mpd_format(track.artists)),
|
||||
('Title', track.name or ''),
|
||||
('Album', track.album and track.album.name or ''),
|
||||
('Date', track.date or ''),
|
||||
@ -23,20 +35,55 @@ def track_to_mpd_format(track, position=None, cpid=None):
|
||||
track.track_no, track.album.num_tracks)))
|
||||
else:
|
||||
result.append(('Track', track.track_no))
|
||||
if track.album is not None and track.album.artists:
|
||||
artists = artists_to_mpd_format(track.album.artists)
|
||||
result.append(('AlbumArtist', artists))
|
||||
if position is not None and cpid is not None:
|
||||
result.append(('Pos', position))
|
||||
result.append(('Id', cpid))
|
||||
if track.album is not None and track.album.musicbrainz_id is not None:
|
||||
result.append(('MUSICBRAINZ_ALBUMID', track.album.musicbrainz_id))
|
||||
# FIXME don't use first and best artist?
|
||||
# FIXME don't duplicate following code?
|
||||
if track.album is not None and track.album.artists:
|
||||
artists = filter(lambda a: a.musicbrainz_id is not None,
|
||||
track.album.artists)
|
||||
if artists:
|
||||
result.append(
|
||||
('MUSICBRAINZ_ALBUMARTISTID', artists[0].musicbrainz_id))
|
||||
if track.artists:
|
||||
artists = filter(lambda a: a.musicbrainz_id is not None, track.artists)
|
||||
if artists:
|
||||
result.append(('MUSICBRAINZ_ARTISTID', artists[0].musicbrainz_id))
|
||||
if track.musicbrainz_id is not None:
|
||||
result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id))
|
||||
return result
|
||||
|
||||
def track_artists_to_mpd_format(track):
|
||||
MPD_KEY_ORDER = '''
|
||||
key file Time Artist AlbumArtist Title Album Track Date MUSICBRAINZ_ALBUMID
|
||||
MUSICBRAINZ_ALBUMARTISTID MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime
|
||||
'''.split()
|
||||
|
||||
def order_mpd_track_info(result):
|
||||
"""
|
||||
Order results from :func:`mopidy.frontends.mpd.translator.track_to_mpd_format`
|
||||
so that it matches MPD's ordering. Simply a cosmetic fix for easier
|
||||
diffing of tag_caches.
|
||||
|
||||
:param result: the track info
|
||||
:type result: list of tuples
|
||||
:rtype: list of tuples
|
||||
"""
|
||||
return sorted(result, key=lambda i: MPD_KEY_ORDER.index(i[0]))
|
||||
|
||||
def artists_to_mpd_format(artists):
|
||||
"""
|
||||
Format track artists for output to MPD client.
|
||||
|
||||
:param track: the track
|
||||
:type track: :class:`mopidy.models.Track`
|
||||
:param artists: the artists
|
||||
:type track: array of :class:`mopidy.models.Artist`
|
||||
:rtype: string
|
||||
"""
|
||||
artists = track.artists
|
||||
artists.sort(key=lambda a: a.name)
|
||||
return u', '.join([a.name for a in artists])
|
||||
|
||||
@ -72,3 +119,64 @@ def playlist_to_mpd_format(playlist, *args, **kwargs):
|
||||
Arguments as for :func:`tracks_to_mpd_format`, except the first one.
|
||||
"""
|
||||
return tracks_to_mpd_format(playlist.tracks, *args, **kwargs)
|
||||
|
||||
def tracks_to_tag_cache_format(tracks):
|
||||
"""
|
||||
Format list of tracks for output to MPD tag cache
|
||||
|
||||
:param tracks: the tracks
|
||||
:type tracks: list of :class:`mopidy.models.Track`
|
||||
:rtype: list of lists of two-tuples
|
||||
"""
|
||||
result = [
|
||||
('info_begin',),
|
||||
('mpd_version', protocol.VERSION),
|
||||
('fs_charset', protocol.ENCODING),
|
||||
('info_end',)
|
||||
]
|
||||
tracks.sort(key=lambda t: t.uri)
|
||||
_add_to_tag_cache(result, *tracks_to_directory_tree(tracks))
|
||||
return result
|
||||
|
||||
def _add_to_tag_cache(result, folders, files):
|
||||
music_folder = settings.LOCAL_MUSIC_PATH
|
||||
regexp = '^' + re.escape(music_folder).rstrip('/') + '/?'
|
||||
|
||||
for path, entry in folders.items():
|
||||
name = os.path.split(path)[1]
|
||||
mtime = get_mtime(os.path.join(music_folder, path))
|
||||
result.append(('directory', path))
|
||||
result.append(('mtime', mtime))
|
||||
result.append(('begin', name))
|
||||
_add_to_tag_cache(result, *entry)
|
||||
result.append(('end', name))
|
||||
|
||||
result.append(('songList begin',))
|
||||
for track in files:
|
||||
track_result = dict(track_to_mpd_format(track))
|
||||
path = uri_to_path(track_result['file'])
|
||||
track_result['mtime'] = get_mtime(path)
|
||||
track_result['file'] = re.sub(regexp, '', path)
|
||||
track_result['key'] = os.path.basename(track_result['file'])
|
||||
track_result = order_mpd_track_info(track_result.items())
|
||||
result.extend(track_result)
|
||||
result.append(('songList end',))
|
||||
|
||||
def tracks_to_directory_tree(tracks):
|
||||
directories = ({}, [])
|
||||
for track in tracks:
|
||||
path = u''
|
||||
current = directories
|
||||
|
||||
local_folder = settings.LOCAL_MUSIC_PATH
|
||||
track_path = uri_to_path(track.uri)
|
||||
track_path = re.sub('^' + re.escape(local_folder), '', track_path)
|
||||
track_dir = os.path.dirname(track_path)
|
||||
|
||||
for part in split_path(track_dir):
|
||||
path = os.path.join(path, part)
|
||||
if path not in current[0]:
|
||||
current[0][path] = ({}, [])
|
||||
current = current[0][path]
|
||||
current[1].append(track)
|
||||
return directories
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
from mopidy import settings
|
||||
|
||||
class BaseMixer(object):
|
||||
"""
|
||||
:param backend: a backend instance
|
||||
:type mixer: :class:`mopidy.backends.base.BaseBackend`
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.MIXER_MAX_VOLUME`
|
||||
"""
|
||||
|
||||
def __init__(self, backend, *args, **kwargs):
|
||||
self.backend = backend
|
||||
self.amplification_factor = settings.MIXER_MAX_VOLUME / 100.0
|
||||
|
||||
@property
|
||||
def volume(self):
|
||||
"""
|
||||
The audio volume
|
||||
|
||||
Integer in range [0, 100]. :class:`None` if unknown. Values below 0 is
|
||||
equal to 0. Values above 100 is equal to 100.
|
||||
"""
|
||||
if self._get_volume() is None:
|
||||
return None
|
||||
return int(self._get_volume() / self.amplification_factor)
|
||||
|
||||
@volume.setter
|
||||
def volume(self, volume):
|
||||
volume = int(int(volume) * self.amplification_factor)
|
||||
if volume < 0:
|
||||
volume = 0
|
||||
elif volume > 100:
|
||||
volume = 100
|
||||
self._set_volume(volume)
|
||||
|
||||
def destroy(self):
|
||||
pass
|
||||
|
||||
def _get_volume(self):
|
||||
"""
|
||||
Return volume as integer in range [0, 100]. :class:`None` if unknown.
|
||||
|
||||
*Must be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _set_volume(self, volume):
|
||||
"""
|
||||
Set volume as integer in range [0, 100].
|
||||
|
||||
*Must be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@ -2,7 +2,7 @@ import alsaaudio
|
||||
import logging
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.mixers import BaseMixer
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
|
||||
logger = logging.getLogger('mopidy.mixers.alsa')
|
||||
|
||||
@ -11,6 +11,10 @@ class AlsaMixer(BaseMixer):
|
||||
Mixer which uses the Advanced Linux Sound Architecture (ALSA) to control
|
||||
volume.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- pyalsaaudio >= 0.2 (python-alsaaudio on Debian/Ubuntu)
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.MIXER_ALSA_CONTROL`
|
||||
|
||||
55
mopidy/mixers/base.py
Normal file
55
mopidy/mixers/base.py
Normal file
@ -0,0 +1,55 @@
|
||||
from mopidy import settings
|
||||
|
||||
class BaseMixer(object):
|
||||
"""
|
||||
:param backend: a backend instance
|
||||
:type backend: :class:`mopidy.backends.base.Backend`
|
||||
|
||||
**Settings:**
|
||||
|
||||
- :attr:`mopidy.settings.MIXER_MAX_VOLUME`
|
||||
"""
|
||||
|
||||
def __init__(self, backend, *args, **kwargs):
|
||||
self.backend = backend
|
||||
self.amplification_factor = settings.MIXER_MAX_VOLUME / 100.0
|
||||
|
||||
@property
|
||||
def volume(self):
|
||||
"""
|
||||
The audio volume
|
||||
|
||||
Integer in range [0, 100]. :class:`None` if unknown. Values below 0 is
|
||||
equal to 0. Values above 100 is equal to 100.
|
||||
"""
|
||||
if self._get_volume() is None:
|
||||
return None
|
||||
return int(self._get_volume() / self.amplification_factor)
|
||||
|
||||
@volume.setter
|
||||
def volume(self, volume):
|
||||
volume = int(int(volume) * self.amplification_factor)
|
||||
if volume < 0:
|
||||
volume = 0
|
||||
elif volume > 100:
|
||||
volume = 100
|
||||
self._set_volume(volume)
|
||||
|
||||
def destroy(self):
|
||||
pass
|
||||
|
||||
def _get_volume(self):
|
||||
"""
|
||||
Return volume as integer in range [0, 100]. :class:`None` if unknown.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def _set_volume(self, volume):
|
||||
"""
|
||||
Set volume as integer in range [0, 100].
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@ -1,10 +1,8 @@
|
||||
import logging
|
||||
from threading import Lock
|
||||
|
||||
from serial import Serial
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.mixers import BaseMixer
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
|
||||
logger = logging.getLogger(u'mopidy.mixers.denon')
|
||||
|
||||
@ -33,8 +31,11 @@ class DenonMixer(BaseMixer):
|
||||
"""
|
||||
super(DenonMixer, self).__init__(*args, **kwargs)
|
||||
device = kwargs.get('device', None)
|
||||
self._device = device or Serial(port=settings.MIXER_EXT_PORT,
|
||||
timeout=0.2)
|
||||
if device:
|
||||
self._device = device
|
||||
else:
|
||||
from serial import Serial
|
||||
self._device = Serial(port=settings.MIXER_EXT_PORT, timeout=0.2)
|
||||
self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)]
|
||||
self._volume = 0
|
||||
self._lock = Lock()
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from mopidy.mixers import BaseMixer
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
|
||||
class DummyMixer(BaseMixer):
|
||||
"""Mixer which just stores and reports the chosen volume."""
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
from mopidy.mixers import BaseMixer
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
|
||||
class GStreamerSoftwareMixer(BaseMixer):
|
||||
"""Mixer which uses GStreamer to control volume in software."""
|
||||
|
||||
@ -3,7 +3,7 @@ from serial import Serial
|
||||
from multiprocessing import Pipe
|
||||
|
||||
from mopidy import settings
|
||||
from mopidy.mixers import BaseMixer
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
from mopidy.utils.process import BaseThread
|
||||
|
||||
logger = logging.getLogger('mopidy.mixers.nad')
|
||||
@ -40,7 +40,7 @@ class NadMixer(BaseMixer):
|
||||
super(NadMixer, self).__init__(*args, **kwargs)
|
||||
self._volume = None
|
||||
self._pipe, other_end = Pipe()
|
||||
NadTalker(pipe=other_end).start()
|
||||
NadTalker(self.backend.core_queue, pipe=other_end).start()
|
||||
|
||||
def _get_volume(self):
|
||||
return self._volume
|
||||
@ -72,8 +72,9 @@ class NadTalker(BaseThread):
|
||||
# Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration.
|
||||
_nad_volume = None
|
||||
|
||||
def __init__(self, pipe=None):
|
||||
super(NadTalker, self).__init__(name='NadTalker')
|
||||
def __init__(self, core_queue, pipe=None):
|
||||
super(NadTalker, self).__init__(core_queue)
|
||||
self.name = u'NadTalker'
|
||||
self.pipe = pipe
|
||||
self._device = None
|
||||
|
||||
@ -146,6 +147,8 @@ class NadTalker(BaseThread):
|
||||
return self._readline().replace('%s=' % key, '')
|
||||
|
||||
def _command_device(self, key, value):
|
||||
if type(value) == unicode:
|
||||
value = value.encode('utf-8')
|
||||
self._write('%s=%s' % (key, value))
|
||||
self._readline()
|
||||
|
||||
|
||||
@ -1,10 +1,21 @@
|
||||
from subprocess import Popen, PIPE
|
||||
import time
|
||||
|
||||
from mopidy.mixers import BaseMixer
|
||||
from mopidy.mixers.base import BaseMixer
|
||||
|
||||
class OsaMixer(BaseMixer):
|
||||
"""Mixer which uses ``osascript`` on OS X to control volume."""
|
||||
"""
|
||||
Mixer which uses ``osascript`` on OS X to control volume.
|
||||
|
||||
**Dependencies:**
|
||||
|
||||
- None
|
||||
|
||||
**Settings:**
|
||||
|
||||
- None
|
||||
|
||||
"""
|
||||
|
||||
CACHE_TTL = 30
|
||||
|
||||
|
||||
@ -38,6 +38,32 @@ class ImmutableObject(object):
|
||||
def __ne__(self, other):
|
||||
return not self.__eq__(other)
|
||||
|
||||
def copy(self, **values):
|
||||
"""
|
||||
Copy the model with ``field`` updated to new value.
|
||||
|
||||
Examples::
|
||||
|
||||
# Returns a track with a new name
|
||||
Track(name='foo').copy(name='bar')
|
||||
# Return an album with a new number of tracks
|
||||
Album(num_tracks=2).copy(num_tracks=5)
|
||||
|
||||
:param values: the model fields to modify
|
||||
:type values: dict
|
||||
:rtype: new instance of the model being copied
|
||||
"""
|
||||
data = {}
|
||||
for key in self.__dict__.keys():
|
||||
public_key = key.lstrip('_')
|
||||
data[public_key] = values.pop(public_key, self.__dict__[key])
|
||||
for key in values.keys():
|
||||
if hasattr(self, key):
|
||||
data[key] = values.pop(key)
|
||||
if values:
|
||||
raise TypeError("copy() got an unexpected keyword argument '%s'"
|
||||
% key)
|
||||
return self.__class__(**data)
|
||||
|
||||
class Artist(ImmutableObject):
|
||||
"""
|
||||
@ -45,6 +71,8 @@ class Artist(ImmutableObject):
|
||||
:type uri: string
|
||||
:param name: artist name
|
||||
:type name: string
|
||||
:param musicbrainz_id: MusicBrainz ID
|
||||
:type musicbrainz_id: string
|
||||
"""
|
||||
|
||||
#: The artist URI. Read-only.
|
||||
@ -53,6 +81,9 @@ class Artist(ImmutableObject):
|
||||
#: The artist name. Read-only.
|
||||
name = None
|
||||
|
||||
#: The MusicBrainz ID of the artist. Read-only.
|
||||
musicbrainz_id = None
|
||||
|
||||
|
||||
class Album(ImmutableObject):
|
||||
"""
|
||||
@ -64,6 +95,8 @@ class Album(ImmutableObject):
|
||||
:type artists: list of :class:`Artist`
|
||||
:param num_tracks: number of tracks in album
|
||||
:type num_tracks: integer
|
||||
:param musicbrainz_id: MusicBrainz ID
|
||||
:type musicbrainz_id: string
|
||||
"""
|
||||
|
||||
#: The album URI. Read-only.
|
||||
@ -75,6 +108,9 @@ class Album(ImmutableObject):
|
||||
#: The number of tracks in the album. Read-only.
|
||||
num_tracks = 0
|
||||
|
||||
#: The MusicBrainz ID of the album. Read-only.
|
||||
musicbrainz_id = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._artists = frozenset(kwargs.pop('artists', []))
|
||||
super(Album, self).__init__(*args, **kwargs)
|
||||
@ -103,6 +139,8 @@ class Track(ImmutableObject):
|
||||
:type length: integer
|
||||
:param bitrate: bitrate in kbit/s
|
||||
:type bitrate: integer
|
||||
:param musicbrainz_id: MusicBrainz ID
|
||||
:type musicbrainz_id: string
|
||||
"""
|
||||
|
||||
#: The track URI. Read-only.
|
||||
@ -126,6 +164,9 @@ class Track(ImmutableObject):
|
||||
#: The track's bitrate in kbit/s. Read-only.
|
||||
bitrate = None
|
||||
|
||||
#: The MusicBrainz ID of the track. Read-only.
|
||||
musicbrainz_id = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._artists = frozenset(kwargs.pop('artists', []))
|
||||
super(Track, self).__init__(*args, **kwargs)
|
||||
@ -178,31 +219,3 @@ class Playlist(ImmutableObject):
|
||||
|
||||
def mpd_format(self, *args, **kwargs):
|
||||
return translator.playlist_to_mpd_format(self, *args, **kwargs)
|
||||
|
||||
def with_(self, uri=None, name=None, tracks=None, last_modified=None):
|
||||
"""
|
||||
Create a new playlist object with the given values. The values that are
|
||||
not given are taken from the object the method is called on.
|
||||
|
||||
Does not change the object on which it is called.
|
||||
|
||||
:param uri: playlist URI
|
||||
:type uri: string
|
||||
:param name: playlist name
|
||||
:type name: string
|
||||
:param tracks: playlist's tracks
|
||||
:type tracks: list of :class:`Track` elements
|
||||
:param last_modified: playlist's modification time
|
||||
:type last_modified: :class:`datetime.datetime`
|
||||
:rtype: :class:`Playlist`
|
||||
"""
|
||||
if uri is None:
|
||||
uri = self.uri
|
||||
if name is None:
|
||||
name = self.name
|
||||
if tracks is None:
|
||||
tracks = self.tracks
|
||||
if last_modified is None:
|
||||
last_modified = self.last_modified
|
||||
return Playlist(uri=uri, name=name, tracks=tracks,
|
||||
last_modified=last_modified)
|
||||
|
||||
@ -7,21 +7,35 @@ class BaseOutput(object):
|
||||
self.core_queue = core_queue
|
||||
|
||||
def start(self):
|
||||
"""Start the output."""
|
||||
"""
|
||||
Start the output.
|
||||
|
||||
*MAY be implemented by subclasses.*
|
||||
"""
|
||||
pass
|
||||
|
||||
def destroy(self):
|
||||
"""Destroy the output."""
|
||||
"""
|
||||
Destroy the output.
|
||||
|
||||
*MAY be implemented by subclasses.*
|
||||
"""
|
||||
pass
|
||||
|
||||
def process_message(self, message):
|
||||
"""Process messages with the output as destination."""
|
||||
"""
|
||||
Process messages with the output as destination.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def play_uri(self, uri):
|
||||
"""
|
||||
Play URI.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:param uri: the URI to play
|
||||
:type uri: string
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
@ -32,19 +46,27 @@ class BaseOutput(object):
|
||||
"""
|
||||
Deliver audio data to be played.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:param capabilities: a GStreamer capabilities string
|
||||
:type capabilities: string
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def end_of_data_stream(self):
|
||||
"""Signal that the last audio data has been delivered."""
|
||||
"""
|
||||
Signal that the last audio data has been delivered.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_position(self):
|
||||
"""
|
||||
Get position in milliseconds.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:rtype: int
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@ -53,6 +75,8 @@ class BaseOutput(object):
|
||||
"""
|
||||
Set position in milliseconds.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:param position: the position in milliseconds
|
||||
:type volume: int
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
@ -63,6 +87,8 @@ class BaseOutput(object):
|
||||
"""
|
||||
Set playback state.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:param state: the state
|
||||
:type state: string
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
@ -73,6 +99,8 @@ class BaseOutput(object):
|
||||
"""
|
||||
Get volume level for software mixer.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:rtype: int in range [0..100]
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@ -81,6 +109,8 @@ class BaseOutput(object):
|
||||
"""
|
||||
Set volume level for software mixer.
|
||||
|
||||
*MUST be implemented by subclass.*
|
||||
|
||||
:param volume: the volume in the range [0..100]
|
||||
:type volume: int
|
||||
:rtype: :class:`True` if successful, else :class:`False`
|
||||
|
||||
@ -5,6 +5,9 @@ class DummyOutput(BaseOutput):
|
||||
Audio output used for testing.
|
||||
"""
|
||||
|
||||
# pylint: disable = R0902
|
||||
# Too many instance attributes (9/7)
|
||||
|
||||
#: For testing. :class:`True` if :meth:`start` has been called.
|
||||
start_called = False
|
||||
|
||||
|
||||
@ -1,6 +1,3 @@
|
||||
import gobject
|
||||
gobject.threads_init()
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst
|
||||
@ -28,20 +25,14 @@ class GStreamerOutput(BaseOutput):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(GStreamerOutput, self).__init__(*args, **kwargs)
|
||||
# Start a helper thread that can run the gobject.MainLoop
|
||||
self.messages_thread = GStreamerMessagesThread(self.core_queue)
|
||||
|
||||
# Start a helper thread that can process the output_queue
|
||||
self.output_queue = multiprocessing.Queue()
|
||||
self.player_thread = GStreamerPlayerThread(self.core_queue,
|
||||
self.output_queue)
|
||||
|
||||
def start(self):
|
||||
self.messages_thread.start()
|
||||
self.player_thread.start()
|
||||
|
||||
def destroy(self):
|
||||
self.messages_thread.destroy()
|
||||
self.player_thread.destroy()
|
||||
|
||||
def process_message(self, message):
|
||||
@ -78,7 +69,8 @@ class GStreamerOutput(BaseOutput):
|
||||
return self._send_recv({'command': 'get_position'})
|
||||
|
||||
def set_position(self, position):
|
||||
return self._send_recv({'command': 'set_position', 'position': position})
|
||||
return self._send_recv({'command': 'set_position',
|
||||
'position': position})
|
||||
|
||||
def set_state(self, state):
|
||||
return self._send_recv({'command': 'set_state', 'state': state})
|
||||
@ -90,21 +82,15 @@ class GStreamerOutput(BaseOutput):
|
||||
return self._send_recv({'command': 'set_volume', 'volume': volume})
|
||||
|
||||
|
||||
class GStreamerMessagesThread(BaseThread):
|
||||
def __init__(self, core_queue):
|
||||
super(GStreamerMessagesThread, self).__init__(core_queue)
|
||||
self.name = u'GStreamerMessagesThread'
|
||||
|
||||
def run_inside_try(self):
|
||||
gobject.MainLoop().run()
|
||||
|
||||
|
||||
class GStreamerPlayerThread(BaseThread):
|
||||
"""
|
||||
A process for all work related to GStreamer.
|
||||
|
||||
The main loop processes events from both Mopidy and GStreamer.
|
||||
|
||||
This thread requires :class:`mopidy.utils.process.GObjectEventThread` to be
|
||||
running too. This is not enforced in any way by the code.
|
||||
|
||||
Make sure this subprocess is started by the MainThread in the top-most
|
||||
parent process, and not some other thread. If not, we can get into the
|
||||
problems described at
|
||||
|
||||
133
mopidy/scanner.py
Normal file
133
mopidy/scanner.py
Normal file
@ -0,0 +1,133 @@
|
||||
import gobject
|
||||
gobject.threads_init()
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst
|
||||
|
||||
import datetime
|
||||
|
||||
from mopidy.utils.path import path_to_uri, find_files
|
||||
from mopidy.models import Track, Artist, Album
|
||||
|
||||
def translator(data):
|
||||
albumartist_kwargs = {}
|
||||
album_kwargs = {}
|
||||
artist_kwargs = {}
|
||||
track_kwargs = {}
|
||||
|
||||
# FIXME replace with data.get('foo', None) ?
|
||||
|
||||
if 'album' in data:
|
||||
album_kwargs['name'] = data['album']
|
||||
|
||||
if 'track-count' in data:
|
||||
album_kwargs['num_tracks'] = data['track-count']
|
||||
|
||||
if 'artist' in data:
|
||||
artist_kwargs['name'] = data['artist']
|
||||
|
||||
if 'date' in data:
|
||||
date = data['date']
|
||||
date = datetime.date(date.year, date.month, date.day)
|
||||
track_kwargs['date'] = date
|
||||
|
||||
if 'title' in data:
|
||||
track_kwargs['name'] = data['title']
|
||||
|
||||
if 'track-number' in data:
|
||||
track_kwargs['track_no'] = data['track-number']
|
||||
|
||||
if 'album-artist' in data:
|
||||
albumartist_kwargs['name'] = data['album-artist']
|
||||
|
||||
if 'musicbrainz-trackid' in data:
|
||||
track_kwargs['musicbrainz_id'] = data['musicbrainz-trackid']
|
||||
|
||||
if 'musicbrainz-artistid' in data:
|
||||
artist_kwargs['musicbrainz_id'] = data['musicbrainz-artistid']
|
||||
|
||||
if 'musicbrainz-albumid' in data:
|
||||
album_kwargs['musicbrainz_id'] = data['musicbrainz-albumid']
|
||||
|
||||
if 'musicbrainz-albumartistid' in data:
|
||||
albumartist_kwargs['musicbrainz_id'] = data['musicbrainz-albumartistid']
|
||||
|
||||
if albumartist_kwargs:
|
||||
album_kwargs['artists'] = [Artist(**albumartist_kwargs)]
|
||||
|
||||
track_kwargs['uri'] = data['uri']
|
||||
track_kwargs['length'] = data['duration']
|
||||
track_kwargs['album'] = Album(**album_kwargs)
|
||||
track_kwargs['artists'] = [Artist(**artist_kwargs)]
|
||||
|
||||
return Track(**track_kwargs)
|
||||
|
||||
|
||||
class Scanner(object):
|
||||
def __init__(self, folder, data_callback, error_callback=None):
|
||||
self.uris = [path_to_uri(f) for f in find_files(folder)]
|
||||
self.data_callback = data_callback
|
||||
self.error_callback = error_callback
|
||||
self.loop = gobject.MainLoop()
|
||||
|
||||
caps = gst.Caps('audio/x-raw-int')
|
||||
fakesink = gst.element_factory_make('fakesink')
|
||||
pad = fakesink.get_pad('sink')
|
||||
|
||||
self.uribin = gst.element_factory_make('uridecodebin')
|
||||
self.uribin.connect('pad-added', self.process_new_pad, pad)
|
||||
self.uribin.set_property('caps', caps)
|
||||
|
||||
self.pipe = gst.element_factory_make('pipeline')
|
||||
self.pipe.add(fakesink)
|
||||
self.pipe.add(self.uribin)
|
||||
|
||||
bus = self.pipe.get_bus()
|
||||
bus.add_signal_watch()
|
||||
bus.connect('message::tag', self.process_tags)
|
||||
bus.connect('message::error', self.process_error)
|
||||
|
||||
def process_new_pad(self, source, pad, target_pad):
|
||||
pad.link(target_pad)
|
||||
|
||||
def process_tags(self, bus, message):
|
||||
data = message.parse_tag()
|
||||
data = dict([(k, data[k]) for k in data.keys()])
|
||||
data['uri'] = unicode(self.uribin.get_property('uri'))
|
||||
data['duration'] = self.get_duration()
|
||||
self.data_callback(data)
|
||||
self.next_uri()
|
||||
|
||||
def process_error(self, bus, message):
|
||||
if self.error_callback:
|
||||
uri = self.uribin.get_property('uri')
|
||||
errors = message.parse_error()
|
||||
self.error_callback(uri, errors)
|
||||
self.next_uri()
|
||||
|
||||
def get_duration(self):
|
||||
self.pipe.get_state()
|
||||
try:
|
||||
return self.pipe.query_duration(
|
||||
gst.FORMAT_TIME, None)[0] // gst.MSECOND
|
||||
except gst.QueryError:
|
||||
return None
|
||||
|
||||
def next_uri(self):
|
||||
if not self.uris:
|
||||
return self.stop()
|
||||
|
||||
self.pipe.set_state(gst.STATE_NULL)
|
||||
self.uribin.set_property('uri', self.uris.pop())
|
||||
self.pipe.set_state(gst.STATE_PAUSED)
|
||||
|
||||
def start(self):
|
||||
if not self.uris:
|
||||
return
|
||||
self.next_uri()
|
||||
self.loop.run()
|
||||
|
||||
def stop(self):
|
||||
self.pipe.set_state(gst.STATE_NULL)
|
||||
self.loop.quit()
|
||||
@ -12,12 +12,12 @@ Available settings and their default values.
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: BACKENDS = (u'mopidy.backends.libspotify.LibspotifyBackend',)
|
||||
#: BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',)
|
||||
#:
|
||||
#: .. note::
|
||||
#: Currently only the first backend in the list is used.
|
||||
BACKENDS = (
|
||||
u'mopidy.backends.libspotify.LibspotifyBackend',
|
||||
u'mopidy.backends.spotify.SpotifyBackend',
|
||||
)
|
||||
|
||||
#: The log format used for informational logging.
|
||||
@ -77,8 +77,8 @@ LASTFM_PASSWORD = u''
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: LOCAL_MUSIC_FOLDER = u'~/music'
|
||||
LOCAL_MUSIC_FOLDER = u'~/music'
|
||||
#: LOCAL_MUSIC_PATH = u'~/music'
|
||||
LOCAL_MUSIC_PATH = u'~/music'
|
||||
|
||||
#: Path to playlist folder with m3u files for local music.
|
||||
#:
|
||||
@ -86,8 +86,8 @@ LOCAL_MUSIC_FOLDER = u'~/music'
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: LOCAL_PLAYLIST_FOLDER = u'~/.mopidy/playlists'
|
||||
LOCAL_PLAYLIST_FOLDER = u'~/.mopidy/playlists'
|
||||
#: LOCAL_PLAYLIST_PATH = u'~/.mopidy/playlists'
|
||||
LOCAL_PLAYLIST_PATH = u'~/.mopidy/playlists'
|
||||
|
||||
#: Path to tag cache for local music.
|
||||
#:
|
||||
@ -95,8 +95,8 @@ LOCAL_PLAYLIST_FOLDER = u'~/.mopidy/playlists'
|
||||
#:
|
||||
#: Default::
|
||||
#:
|
||||
#: LOCAL_TAG_CACHE = u'~/.mopidy/tag_cache'
|
||||
LOCAL_TAG_CACHE = u'~/.mopidy/tag_cache'
|
||||
#: LOCAL_TAG_CACHE_FILE = u'~/.mopidy/tag_cache'
|
||||
LOCAL_TAG_CACHE_FILE = u'~/.mopidy/tag_cache'
|
||||
|
||||
#: Sound mixer to use. See :mod:`mopidy.mixers` for all available mixers.
|
||||
#:
|
||||
@ -164,22 +164,36 @@ OUTPUT = u'mopidy.outputs.gstreamer.GStreamerOutput'
|
||||
#: Listens on all interfaces, both IPv4 and IPv6.
|
||||
MPD_SERVER_HOSTNAME = u'127.0.0.1'
|
||||
|
||||
#: The password required for connecting to the MPD server.
|
||||
#:
|
||||
#: Default: :class:`None`, which means no password required.
|
||||
MPD_SERVER_PASSWORD = None
|
||||
|
||||
#: Which TCP port Mopidy's MPD server should listen to.
|
||||
#:
|
||||
#: Default: 6600
|
||||
MPD_SERVER_PORT = 6600
|
||||
|
||||
#: Path to the libspotify cache.
|
||||
#: Path to the Spotify cache.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.backends.libspotify`.
|
||||
SPOTIFY_LIB_CACHE = u'~/.mopidy/libspotify_cache'
|
||||
#: Used by :mod:`mopidy.backends.spotify`.
|
||||
SPOTIFY_CACHE_PATH = u'~/.mopidy/spotify_cache'
|
||||
|
||||
#: Your Spotify Premium username.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.backends.libspotify`.
|
||||
#: Used by :mod:`mopidy.backends.spotify`.
|
||||
SPOTIFY_USERNAME = u''
|
||||
|
||||
#: Your Spotify Premium password.
|
||||
#:
|
||||
#: Used by :mod:`mopidy.backends.libspotify`.
|
||||
#: Used by :mod:`mopidy.backends.spotify`.
|
||||
SPOTIFY_PASSWORD = u''
|
||||
|
||||
#: Do you prefer high bitrate (320k)?
|
||||
#:
|
||||
#: Used by :mod:`mopidy.backends.spotify`.
|
||||
#
|
||||
#: Default::
|
||||
#:
|
||||
#: SPOTIFY_HIGH_BITRATE = False # 160k
|
||||
SPOTIFY_HIGH_BITRATE = False
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import urllib
|
||||
|
||||
logger = logging.getLogger('mopidy.utils.path')
|
||||
@ -21,8 +22,57 @@ def get_or_create_file(filename):
|
||||
|
||||
def path_to_uri(*paths):
|
||||
path = os.path.join(*paths)
|
||||
#path = os.path.expanduser(path) # FIXME Waiting for test case?
|
||||
path = path.encode('utf-8')
|
||||
if sys.platform == 'win32':
|
||||
return 'file:' + urllib.pathname2url(path)
|
||||
return 'file://' + urllib.pathname2url(path)
|
||||
|
||||
def uri_to_path(uri):
|
||||
if sys.platform == 'win32':
|
||||
path = urllib.url2pathname(re.sub('^file:', '', uri))
|
||||
else:
|
||||
path = urllib.url2pathname(re.sub('^file://', '', uri))
|
||||
return path.encode('latin1').decode('utf-8') # Undo double encoding
|
||||
|
||||
def split_path(path):
|
||||
parts = []
|
||||
while True:
|
||||
path, part = os.path.split(path)
|
||||
if part:
|
||||
parts.insert(0, part)
|
||||
if not path or path == '/':
|
||||
break
|
||||
return parts
|
||||
|
||||
# pylint: disable = W0612
|
||||
# Unused variable 'dirnames'
|
||||
def find_files(path):
|
||||
if os.path.isfile(path):
|
||||
if not isinstance(path, unicode):
|
||||
path = path.decode('utf-8')
|
||||
yield path
|
||||
else:
|
||||
for dirpath, dirnames, filenames in os.walk(path):
|
||||
for filename in filenames:
|
||||
filename = os.path.join(dirpath, filename)
|
||||
if not isinstance(filename, unicode):
|
||||
filename = filename.decode('utf-8')
|
||||
yield filename
|
||||
# pylint: enable = W0612
|
||||
|
||||
class Mtime(object):
|
||||
def __init__(self):
|
||||
self.fake = None
|
||||
|
||||
def __call__(self, path):
|
||||
if self.fake is not None:
|
||||
return self.fake
|
||||
return int(os.stat(path).st_mtime)
|
||||
|
||||
def set_fake_time(self, time):
|
||||
self.fake = time
|
||||
|
||||
def undo_fake(self):
|
||||
self.fake = None
|
||||
|
||||
mtime = Mtime()
|
||||
|
||||
@ -3,7 +3,9 @@ import multiprocessing
|
||||
import multiprocessing.dummy
|
||||
from multiprocessing.reduction import reduce_connection
|
||||
import pickle
|
||||
import sys
|
||||
|
||||
import gobject
|
||||
gobject.threads_init()
|
||||
|
||||
from mopidy import SettingsError
|
||||
|
||||
@ -17,7 +19,6 @@ def unpickle_connection(pickled_connection):
|
||||
(func, args) = pickle.loads(pickled_connection)
|
||||
return func(*args)
|
||||
|
||||
|
||||
class BaseProcess(multiprocessing.Process):
|
||||
def __init__(self, core_queue):
|
||||
super(BaseProcess, self).__init__()
|
||||
@ -86,3 +87,25 @@ class BaseThread(multiprocessing.dummy.Process):
|
||||
self.core_queue.put({'to': 'core', 'command': 'exit',
|
||||
'status': status, 'reason': reason})
|
||||
self.destroy()
|
||||
|
||||
|
||||
class GObjectEventThread(BaseThread):
|
||||
"""
|
||||
A GObject event loop which is shared by all Mopidy components that uses
|
||||
libraries that need a GObject event loop, like GStreamer and D-Bus.
|
||||
|
||||
Should be started by Mopidy's core and used by
|
||||
:mod:`mopidy.output.gstreamer`, :mod:`mopidy.frontend.mpris`, etc.
|
||||
"""
|
||||
|
||||
def __init__(self, core_queue):
|
||||
super(GObjectEventThread, self).__init__(core_queue)
|
||||
self.name = u'GObjectEventThread'
|
||||
self.loop = None
|
||||
|
||||
def run_inside_try(self):
|
||||
self.loop = gobject.MainLoop().run()
|
||||
|
||||
def destroy(self):
|
||||
self.loop.quit()
|
||||
super(GObjectEventThread, self).destroy()
|
||||
|
||||
@ -23,7 +23,9 @@ class SettingsProxy(object):
|
||||
if not os.path.isfile(settings_file):
|
||||
return {}
|
||||
sys.path.insert(0, dotdir)
|
||||
# pylint: disable = F0401
|
||||
import settings as local_settings_module
|
||||
# pylint: enable = F0401
|
||||
return self._get_settings_dict_from_module(local_settings_module)
|
||||
|
||||
def _get_settings_dict_from_module(self, module):
|
||||
@ -47,8 +49,11 @@ class SettingsProxy(object):
|
||||
if attr not in self.current:
|
||||
raise SettingsError(u'Setting "%s" is not set.' % attr)
|
||||
value = self.current[attr]
|
||||
if type(value) != bool and not value:
|
||||
if isinstance(value, basestring) and len(value) == 0:
|
||||
raise SettingsError(u'Setting "%s" is empty.' % attr)
|
||||
if attr.endswith('_PATH') or attr.endswith('_FILE'):
|
||||
value = os.path.expanduser(value)
|
||||
value = os.path.abspath(value)
|
||||
return value
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
@ -92,10 +97,14 @@ def validate_settings(defaults, settings):
|
||||
'DUMP_LOG_FILENAME': 'DEBUG_LOG_FILENAME',
|
||||
'DUMP_LOG_FORMAT': 'DEBUG_LOG_FORMAT',
|
||||
'FRONTEND': 'FRONTENDS',
|
||||
'LOCAL_MUSIC_FOLDER': 'LOCAL_MUSIC_PATH',
|
||||
'LOCAL_PLAYLIST_FOLDER': 'LOCAL_PLAYLIST_PATH',
|
||||
'LOCAL_TAG_CACHE': 'LOCAL_TAG_CACHE_FILE',
|
||||
'SERVER': None,
|
||||
'SERVER_HOSTNAME': 'MPD_SERVER_HOSTNAME',
|
||||
'SERVER_PORT': 'MPD_SERVER_PORT',
|
||||
'SPOTIFY_LIB_APPKEY': None,
|
||||
'SPOTIFY_LIB_CACHE': 'SPOTIFY_CACHE_PATH',
|
||||
}
|
||||
|
||||
for setting, value in settings.iteritems():
|
||||
|
||||
11
pylintrc
11
pylintrc
@ -5,18 +5,19 @@
|
||||
#
|
||||
# C0103 - Invalid name "%s" (should match %s)
|
||||
# C0111 - Missing docstring
|
||||
# C0112 - Empty docstring
|
||||
# E0102 - %s already defined line %s
|
||||
# Does not understand @property getters and setters
|
||||
# E0202 - An attribute inherited from %s hide this method
|
||||
# Does not understand @property getters and setters
|
||||
# E1101 - %s %r has no %r member
|
||||
# Does not understand @property getters and setters
|
||||
# R0201 - Method could be a function
|
||||
# R0801 - Similar lines in %s files
|
||||
# R0903 - Too few public methods (%s/%s)
|
||||
# R0904 - Too many public methods (%s/%s)
|
||||
# W0141 - Used builtin function %r
|
||||
# R0921 - Abstract class not referenced
|
||||
# W0141 - Used builtin function '%s'
|
||||
# W0142 - Used * or ** magic
|
||||
# W0401 - Wildcard import %s
|
||||
# W0511 - TODO, FIXME and XXX in the code
|
||||
# W0613 - Unused argument %r
|
||||
#
|
||||
disable-msg = C0103,C0111,C0112,E0102,E0202,E1101,R0201,R0801,R0903,R0904,W0141,W0142,W0401,W0511,W0613
|
||||
disable = C0103,C0111,E0102,E0202,E1101,R0201,R0801,R0903,R0904,R0921,W0141,W0142,W0613
|
||||
|
||||
@ -1 +0,0 @@
|
||||
pylast >= 0.4.30
|
||||
11
requirements/README.rst
Normal file
11
requirements/README.rst
Normal file
@ -0,0 +1,11 @@
|
||||
*********************
|
||||
pip requirement files
|
||||
*********************
|
||||
|
||||
The files found here are `requirement files
|
||||
<http://pip.openplans.org/requirement-format.html>`_ that may be used with `pip
|
||||
<http://pip.openplans.org/>`_.
|
||||
|
||||
To install the dependencies found in one of these files, simply run e.g.::
|
||||
|
||||
pip install -r requirements/tests.txt
|
||||
1
requirements/lastfm.txt
Normal file
1
requirements/lastfm.txt
Normal file
@ -0,0 +1 @@
|
||||
pylast >= 0.5.7
|
||||
6
setup.py
6
setup.py
@ -69,16 +69,18 @@ for dirpath, dirnames, filenames in os.walk(project_dir):
|
||||
data_files.append([dirpath,
|
||||
[os.path.join(dirpath, f) for f in filenames]])
|
||||
|
||||
data_files.append(('/usr/local/share/applications', ['data/mopidy.desktop']))
|
||||
|
||||
setup(
|
||||
name='Mopidy',
|
||||
version=get_version(),
|
||||
author='Stein Magnus Jodal',
|
||||
author_email='stein.magnus@jodal.no',
|
||||
packages=packages,
|
||||
package_data={'mopidy': ['backends/libspotify/spotify_appkey.key']},
|
||||
package_data={'mopidy': ['backends/spotify/spotify_appkey.key']},
|
||||
cmdclass=cmdclasses,
|
||||
data_files=data_files,
|
||||
scripts=['bin/mopidy'],
|
||||
scripts=['bin/mopidy', 'bin/mopidy-scan'],
|
||||
url='http://www.mopidy.com/',
|
||||
license='Apache License, Version 2.0',
|
||||
description='MPD server with Spotify support',
|
||||
|
||||
@ -9,7 +9,7 @@ from mopidy.utils import get_class
|
||||
|
||||
from tests.backends.base import populate_playlist
|
||||
|
||||
class BaseCurrentPlaylistControllerTest(object):
|
||||
class CurrentPlaylistControllerTest(object):
|
||||
tracks = []
|
||||
|
||||
def setUp(self):
|
||||
|
||||
@ -3,7 +3,7 @@ from mopidy.models import Playlist, Track, Album, Artist
|
||||
|
||||
from tests import SkipTest, data_folder
|
||||
|
||||
class BaseLibraryControllerTest(object):
|
||||
class LibraryControllerTest(object):
|
||||
artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()]
|
||||
albums = [Album(name='album1', artists=artists[:1]),
|
||||
Album(name='album2', artists=artists[1:2]),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user