diff --git a/MANIFEST.in b/MANIFEST.in index 38819adb..f629bcc7 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -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 * diff --git a/README.rst b/README.rst index 4f31fb59..c063de79 100644 --- a/README.rst +++ b/README.rst @@ -6,14 +6,15 @@ Mopidy is a music server which can play music from `Spotify `_ 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 `_. 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 `_. -* `Documentation (latest release) `_ -* `Documentation (development version) `_ -* `Source code `_ -* `Issue tracker `_ -* IRC: ``#mopidy`` at `irc.freenode.net `_ -* `Download development snapshot `_ +- `Documentation for the latest release `_ +- `Documentation for the development version + `_ +- `Source code `_ +- `Issue tracker `_ +- IRC: ``#mopidy`` at `irc.freenode.net `_ +- `Download development snapshot `_ diff --git a/bin/mopidy b/bin/mopidy old mode 100644 new mode 100755 diff --git a/bin/mopidy-scan b/bin/mopidy-scan new file mode 100755 index 00000000..84cfee57 --- /dev/null +++ b/bin/mopidy-scan @@ -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') diff --git a/data/mopidy.desktop b/data/mopidy.desktop new file mode 100644 index 00000000..70257d58 --- /dev/null +++ b/data/mopidy.desktop @@ -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; diff --git a/docs/Makefile b/docs/Makefile index 4ad8691e..6a3272f4 100644 --- a/docs/Makefile +++ b/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 ' where 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." diff --git a/docs/_static/mopidy.png b/docs/_static/mopidy.png new file mode 100644 index 00000000..7d6ce5af Binary files /dev/null and b/docs/_static/mopidy.png differ diff --git a/docs/_themes/nature/static/nature.css_t b/docs/_themes/nature/static/nature.css_t index 63ef80d6..b6c0f22e 100644 --- a/docs/_themes/nature/static/nature.css_t +++ b/docs/_themes/nature/static/nature.css_t @@ -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; diff --git a/docs/api/backends.rst b/docs/api/backends.rst deleted file mode 100644 index f675541a..00000000 --- a/docs/api/backends.rst +++ /dev/null @@ -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: diff --git a/docs/api/backends/concepts.rst b/docs/api/backends/concepts.rst new file mode 100644 index 00000000..0d476213 --- /dev/null +++ b/docs/api/backends/concepts.rst @@ -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 diff --git a/docs/api/backends/controllers.rst b/docs/api/backends/controllers.rst new file mode 100644 index 00000000..28112cf7 --- /dev/null +++ b/docs/api/backends/controllers.rst @@ -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: diff --git a/docs/api/backends/providers.rst b/docs/api/backends/providers.rst new file mode 100644 index 00000000..903e220b --- /dev/null +++ b/docs/api/backends/providers.rst @@ -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` diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst new file mode 100644 index 00000000..0c1e32a3 --- /dev/null +++ b/docs/api/frontends.rst @@ -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` diff --git a/docs/api/frontends/index.rst b/docs/api/frontends/index.rst deleted file mode 100644 index 05595418..00000000 --- a/docs/api/frontends/index.rst +++ /dev/null @@ -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` diff --git a/docs/api/frontends/lastfm.rst b/docs/api/frontends/lastfm.rst deleted file mode 100644 index bd3e218e..00000000 --- a/docs/api/frontends/lastfm.rst +++ /dev/null @@ -1,7 +0,0 @@ -****************************** -:mod:`mopidy.frontends.lastfm` -****************************** - -.. automodule:: mopidy.frontends.lastfm - :synopsis: Last.fm scrobbler frontend - :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index 86f4e06e..1f37e9ff 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -1,8 +1,11 @@ -***************** -API documentation -***************** +************* +API reference +************* .. toctree:: :glob: - ** + backends/concepts + backends/controllers + backends/providers + * diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst index edaea306..6daa7a4e 100644 --- a/docs/api/mixers.rst +++ b/docs/api/mixers.rst @@ -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` diff --git a/docs/api/models.rst b/docs/api/models.rst index 62e6f75a..ef11547e 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -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 diff --git a/docs/api/outputs.rst b/docs/api/outputs.rst index 8f4e33c0..5ef1606d 100644 --- a/docs/api/outputs.rst +++ b/docs/api/outputs.rst @@ -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` diff --git a/docs/api/settings.rst b/docs/api/settings.rst deleted file mode 100644 index cfc270d6..00000000 --- a/docs/api/settings.rst +++ /dev/null @@ -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: diff --git a/docs/authors.rst b/docs/authors.rst index f56242a5..01e810e4 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -10,13 +10,20 @@ Contributors to Mopidy in the order of appearance: - Kristian Klette -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 `_ 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 `_, or `donate money +`_ 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. diff --git a/docs/changes.rst b/docs/changes.rst index 3232cfcc..cecf3ffa 100644 --- a/docs/changes.rst +++ b/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 +` and done a bit of testing of the available :ref:`Android +` and :ref:`iOS 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 `. Split the backend API into a + :ref:`backend controller API ` (for frontend use) + and a :ref:`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) ================== diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index de54dfcb..e27aa446 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -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 `_. - Graphical clients ================= GMPC ---- -A GTK+ client which works well with Mopidy, and is regularly used by Mopidy -developers. +`GMPC `_ 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 `_ 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 `_ 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 +`_ 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 `_ client can be +installed from the `iTunes Store +`_. + +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`. diff --git a/docs/conf.py b/docs/conf.py index bb9eb3ba..4587c16d 100644 --- a/docs/conf.py +++ b/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 tag referring to it. The value of this option must be the diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst index 645cbd30..cec8e9c7 100644 --- a/docs/development/roadmap.rst +++ b/docs/development/roadmap.rst @@ -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 `_ - recipies for all our dependencies and Mopidy itself to make OS X - installation a breeze. See `Homebrew's issue #1612 - `_. - - Create `Debian packages `_ 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 `_ - - `WIMP `_ - - DNLA/UPnP so Mopidy can play music from other DNLA MediaServers. - -- Frontends: - - - Publish the server's presence to the network using `Zeroconf - `_/Avahi. - - D-Bus/`MPRIS `_ - - REST/JSON web service with a jQuery client as example application. Maybe - based upon `Tornado `_ and `jQuery - Mobile `_. - - DNLA/UPnP so Mopidy can be controlled from i.e. TVs. - - `XMMS2 `_ - - 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 - `_, etc. - - Feed audio to an `Icecast `_ server. - - Stream to AirPort Express using `RAOP - `_. +We maintain our collection of sane or less sane ideas for future Mopidy +features as `issues `_ at GitHub +labeled with `the "wishlist" label +`_. 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. diff --git a/docs/index.rst b/docs/index.rst index 7a4dc27d..0af45835 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,4 +1,31 @@ -.. include:: ../README.rst +****** +Mopidy +****** + +Mopidy is a music server which can play music from `Spotify +`_ 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 `_. 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 +`_. If you stumble into a bug or got a feature request, +please create an issue in the `issue tracker +`_. + + +Project resources +================= + +- `Documentation for the latest release `_ +- `Documentation for the development version + `_ +- `Source code `_ +- `Issue tracker `_ +- IRC: ``#mopidy`` at `irc.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 ========================= diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst index ef66c673..72d55908 100644 --- a/docs/installation/gstreamer.rst +++ b/docs/installation/gstreamer.rst @@ -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 `_ 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. diff --git a/docs/installation/index.rst b/docs/installation/index.rst index c3bbddce..56f0015b 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -1,11 +1,12 @@ +.. _installation: + ************ Installation ************ -To get a basic version of Mopidy running, you need Python and the -:doc:`GStreamer library `. To use Spotify with Mopidy, you also need -:doc:`libspotify and pyspotify `. 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 ` >= 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 ` - - - :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 `, and then you're -ready to :doc:`run Mopidy `. +#. 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 `, and then + you're ready to :doc:`run Mopidy `. + +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 `, and then + you're ready to :doc:`run Mopidy `. + +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 `, and then + you're ready to :doc:`run Mopidy `. + + +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 `. + +#. 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 -`_. +`_. Also, please read our :doc:`developer documentation +`. -Next, you need to set a couple of :doc:`settings `, and then you're -ready to :doc:`run Mopidy `. + +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 `, and then + you're ready to :doc:`run Mopidy `. diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst index 4860fc4b..5d278fe2 100644 --- a/docs/installation/libspotify.rst +++ b/docs/installation/libspotify.rst @@ -5,11 +5,11 @@ libspotify installation Mopidy uses `libspotify `_ for playing music from the Spotify music service. To use :mod:`mopidy.backends.libspotify` you must -install libspotify and `pyspotify `_. +install libspotify and `pyspotify `_. -.. warning:: +.. note:: - This backend requires a `Spotify premium account + This backend requires a paid `Spotify premium account `_. .. note:: @@ -19,8 +19,25 @@ install libspotify and `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 `_ and `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 diff --git a/docs/licenses.rst b/docs/licenses.rst index c3a13904..7f4ed0ce 100644 --- a/docs/licenses.rst +++ b/docs/licenses.rst @@ -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 diff --git a/docs/modules/backends/dummy.rst b/docs/modules/backends/dummy.rst new file mode 100644 index 00000000..03b2e6ce --- /dev/null +++ b/docs/modules/backends/dummy.rst @@ -0,0 +1,7 @@ +********************************************************* +:mod:`mopidy.backends.dummy` -- Dummy backend for testing +********************************************************* + +.. automodule:: mopidy.backends.dummy + :synopsis: Dummy backend used for testing + :members: diff --git a/docs/modules/backends/local.rst b/docs/modules/backends/local.rst new file mode 100644 index 00000000..892f5a87 --- /dev/null +++ b/docs/modules/backends/local.rst @@ -0,0 +1,7 @@ +********************************************* +:mod:`mopidy.backends.local` -- Local backend +********************************************* + +.. automodule:: mopidy.backends.local + :synopsis: Backend for playing music files on local storage + :members: diff --git a/docs/modules/backends/spotify.rst b/docs/modules/backends/spotify.rst new file mode 100644 index 00000000..938d6337 --- /dev/null +++ b/docs/modules/backends/spotify.rst @@ -0,0 +1,7 @@ +************************************************* +:mod:`mopidy.backends.spotify` -- Spotify backend +************************************************* + +.. automodule:: mopidy.backends.spotify + :synopsis: Backend for the Spotify music streaming service + :members: diff --git a/docs/modules/frontends/lastfm.rst b/docs/modules/frontends/lastfm.rst new file mode 100644 index 00000000..a726f4a2 --- /dev/null +++ b/docs/modules/frontends/lastfm.rst @@ -0,0 +1,7 @@ +*************************************************** +:mod:`mopidy.frontends.lastfm` -- Last.fm Scrobbler +*************************************************** + +.. automodule:: mopidy.frontends.lastfm + :synopsis: Last.fm scrobbler frontend + :members: diff --git a/docs/api/frontends/mpd.rst b/docs/modules/frontends/mpd.rst similarity index 93% rename from docs/api/frontends/mpd.rst rename to docs/modules/frontends/mpd.rst index 6361e909..35128e70 100644 --- a/docs/api/frontends/mpd.rst +++ b/docs/modules/frontends/mpd.rst @@ -1,6 +1,6 @@ -*************************** -:mod:`mopidy.frontends.mpd` -*************************** +***************************************** +:mod:`mopidy.frontends.mpd` -- MPD server +***************************************** .. automodule:: mopidy.frontends.mpd :synopsis: MPD frontend diff --git a/docs/modules/index.rst b/docs/modules/index.rst new file mode 100644 index 00000000..44da0028 --- /dev/null +++ b/docs/modules/index.rst @@ -0,0 +1,8 @@ +**************** +Module reference +**************** + +.. toctree:: + :glob: + + ** diff --git a/docs/modules/mixers/alsa.rst b/docs/modules/mixers/alsa.rst new file mode 100644 index 00000000..05f429eb --- /dev/null +++ b/docs/modules/mixers/alsa.rst @@ -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: diff --git a/docs/modules/mixers/denon.rst b/docs/modules/mixers/denon.rst new file mode 100644 index 00000000..ac944ccc --- /dev/null +++ b/docs/modules/mixers/denon.rst @@ -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: diff --git a/docs/modules/mixers/dummy.rst b/docs/modules/mixers/dummy.rst new file mode 100644 index 00000000..6665f949 --- /dev/null +++ b/docs/modules/mixers/dummy.rst @@ -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: diff --git a/docs/modules/mixers/gstreamer_software.rst b/docs/modules/mixers/gstreamer_software.rst new file mode 100644 index 00000000..ef8cc310 --- /dev/null +++ b/docs/modules/mixers/gstreamer_software.rst @@ -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: diff --git a/docs/modules/mixers/nad.rst b/docs/modules/mixers/nad.rst new file mode 100644 index 00000000..d441b3fd --- /dev/null +++ b/docs/modules/mixers/nad.rst @@ -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: diff --git a/docs/modules/mixers/osa.rst b/docs/modules/mixers/osa.rst new file mode 100644 index 00000000..14bf9a49 --- /dev/null +++ b/docs/modules/mixers/osa.rst @@ -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: diff --git a/docs/modules/outputs/gstreamer.rst b/docs/modules/outputs/gstreamer.rst new file mode 100644 index 00000000..69c77dad --- /dev/null +++ b/docs/modules/outputs/gstreamer.rst @@ -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: diff --git a/docs/settings.rst b/docs/settings.rst index afdd39dc..1d4a4972 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -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: diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 350fc8d7..fffa25c7 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -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): diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index 491c5b73..096a433f 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -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. diff --git a/mopidy/backends/base/current_playlist.py b/mopidy/backends/base/current_playlist.py index 34a16369..fe7d1de9 100644 --- a/mopidy/backends/base/current_playlist.py +++ b/mopidy/backends/base/current_playlist.py @@ -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): diff --git a/mopidy/backends/base/library.py b/mopidy/backends/base/library.py index 94f40863..fd018b5f 100644 --- a/mopidy/backends/base/library.py +++ b/mopidy/backends/base/library.py @@ -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 diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index c4ef5fbf..8a3eeee5 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -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 diff --git a/mopidy/backends/base/stored_playlists.py b/mopidy/backends/base/stored_playlists.py index 61722c81..6578c046 100644 --- a/mopidy/backends/base/stored_playlists.py +++ b/mopidy/backends/base/stored_playlists.py @@ -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 + diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 62cbd7e2..9c6885bc 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -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) diff --git a/mopidy/backends/libspotify/__init__.py b/mopidy/backends/libspotify/__init__.py deleted file mode 100644 index f4087043..00000000 --- a/mopidy/backends/libspotify/__init__.py +++ /dev/null @@ -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 `_ backend which uses the official - `libspotify `_ - library and the `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 diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index bb5e6a5e..e3e1d5dc 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -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 diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 87ea15df..51522ead 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -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 diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py new file mode 100644 index 00000000..d36f6250 --- /dev/null +++ b/mopidy/backends/spotify/__init__.py @@ -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 `_ + music streaming service. The backend uses the official `libspotify + `_ library and the + `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 diff --git a/mopidy/backends/libspotify/library.py b/mopidy/backends/spotify/library.py similarity index 82% rename from mopidy/backends/libspotify/library.py rename to mopidy/backends/spotify/library.py index 972eaf03..5e2f66ae 100644 --- a/mopidy/backends/libspotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -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): diff --git a/mopidy/backends/libspotify/playback.py b/mopidy/backends/spotify/playback.py similarity index 68% rename from mopidy/backends/libspotify/playback.py rename to mopidy/backends/spotify/playback.py index 39c56bf6..a066d90e 100644 --- a/mopidy/backends/libspotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -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 diff --git a/mopidy/backends/libspotify/session_manager.py b/mopidy/backends/spotify/session_manager.py similarity index 68% rename from mopidy/backends/libspotify/session_manager.py rename to mopidy/backends/spotify/session_manager.py index 7f541236..9736f2eb 100644 --- a/mopidy/backends/libspotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -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() diff --git a/mopidy/backends/libspotify/spotify_appkey.key b/mopidy/backends/spotify/spotify_appkey.key similarity index 100% rename from mopidy/backends/libspotify/spotify_appkey.key rename to mopidy/backends/spotify/spotify_appkey.key diff --git a/mopidy/backends/libspotify/stored_playlists.py b/mopidy/backends/spotify/stored_playlists.py similarity index 69% rename from mopidy/backends/libspotify/stored_playlists.py rename to mopidy/backends/spotify/stored_playlists.py index 3339578c..054e2bd1 100644 --- a/mopidy/backends/libspotify/stored_playlists.py +++ b/mopidy/backends/spotify/stored_playlists.py @@ -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 diff --git a/mopidy/backends/libspotify/translator.py b/mopidy/backends/spotify/translator.py similarity index 62% rename from mopidy/backends/libspotify/translator.py rename to mopidy/backends/spotify/translator.py index ff8f3c5c..50ee07d1 100644 --- a/mopidy/backends/libspotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -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) diff --git a/mopidy/core.py b/mopidy/core.py index 69760094..1a4ed7cc 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -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() diff --git a/mopidy/frontends/base.py b/mopidy/frontends/base.py index 92545b73..bf1c9bda 100644 --- a/mopidy/frontends/base.py +++ b/mopidy/frontends/base.py @@ -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 """ diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index c4ea73c4..d2c9af88 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -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 `_ >= 0.4.30 + - `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) diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index ce9abc6d..2f87088c 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -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` """ diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 2a477e1c..ab5f2e8c 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -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): diff --git a/mopidy/frontends/mpd/exceptions.py b/mopidy/frontends/mpd/exceptions.py index 2a18b2f3..faf4ce2f 100644 --- a/mopidy/frontends/mpd/exceptions.py +++ b/mopidy/frontends/mpd/exceptions.py @@ -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): diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 756aa3c3..6689f627 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -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' diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py index 0ce3ef51..65811d09 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -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): diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 2f5dd29e..19922bc3 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -293,6 +293,7 @@ def replay_gain_status(frontend): """ return u'off' # TODO +@handle_pattern(r'^seek (?P\d+) (?P\d+)$') @handle_pattern(r'^seek "(?P\d+)" "(?P\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[-+]*\d+)$') @handle_pattern(r'^setvol "(?P[-+]*\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: diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index d2c9c599..83efdd94 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -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$') diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py index 580b5905..e8e3291d 100644 --- a/mopidy/frontends/mpd/session.py +++ b/mopidy/frontends/mpd/session.py @@ -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}) diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 07a58dd3..3ead23c7 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -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 diff --git a/mopidy/mixers/__init__.py b/mopidy/mixers/__init__.py index 332718a6..e69de29b 100644 --- a/mopidy/mixers/__init__.py +++ b/mopidy/mixers/__init__.py @@ -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 diff --git a/mopidy/mixers/alsa.py b/mopidy/mixers/alsa.py index 6eef6da4..4aa5952f 100644 --- a/mopidy/mixers/alsa.py +++ b/mopidy/mixers/alsa.py @@ -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` diff --git a/mopidy/mixers/base.py b/mopidy/mixers/base.py new file mode 100644 index 00000000..f7f9525c --- /dev/null +++ b/mopidy/mixers/base.py @@ -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 diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py index 32750f60..f0712f95 100644 --- a/mopidy/mixers/denon.py +++ b/mopidy/mixers/denon.py @@ -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() diff --git a/mopidy/mixers/dummy.py b/mopidy/mixers/dummy.py index b0ea0e47..12a8137e 100644 --- a/mopidy/mixers/dummy.py +++ b/mopidy/mixers/dummy.py @@ -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.""" diff --git a/mopidy/mixers/gstreamer_software.py b/mopidy/mixers/gstreamer_software.py index 333690ea..9dca3690 100644 --- a/mopidy/mixers/gstreamer_software.py +++ b/mopidy/mixers/gstreamer_software.py @@ -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.""" diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py index 7a8f006e..5cf92826 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/mixers/nad.py @@ -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() diff --git a/mopidy/mixers/osa.py b/mopidy/mixers/osa.py index 3aeaed5c..2ea04cf2 100644 --- a/mopidy/mixers/osa.py +++ b/mopidy/mixers/osa.py @@ -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 diff --git a/mopidy/models.py b/mopidy/models.py index c5877657..8e7585f1 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -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) diff --git a/mopidy/outputs/base.py b/mopidy/outputs/base.py index bb312323..372d7d70 100644 --- a/mopidy/outputs/base.py +++ b/mopidy/outputs/base.py @@ -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` diff --git a/mopidy/outputs/dummy.py b/mopidy/outputs/dummy.py index fd42b38b..060ee02f 100644 --- a/mopidy/outputs/dummy.py +++ b/mopidy/outputs/dummy.py @@ -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 diff --git a/mopidy/outputs/gstreamer.py b/mopidy/outputs/gstreamer.py index 3714fed6..3b037f62 100644 --- a/mopidy/outputs/gstreamer.py +++ b/mopidy/outputs/gstreamer.py @@ -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 diff --git a/mopidy/scanner.py b/mopidy/scanner.py new file mode 100644 index 00000000..93224331 --- /dev/null +++ b/mopidy/scanner.py @@ -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() diff --git a/mopidy/settings.py b/mopidy/settings.py index c9d7b9fc..6e33ffaa 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -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 diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 0dd163ec..540cb4fa 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -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() diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 7855d69c..11dafa8a 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -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() diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 1d3a0fa0..7715721e 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -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(): diff --git a/pylintrc b/pylintrc index d405a71f..d2f84b77 100644 --- a/pylintrc +++ b/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 diff --git a/requirements-lastfm.txt b/requirements-lastfm.txt deleted file mode 100644 index 642735be..00000000 --- a/requirements-lastfm.txt +++ /dev/null @@ -1 +0,0 @@ -pylast >= 0.4.30 diff --git a/requirements/README.rst b/requirements/README.rst new file mode 100644 index 00000000..cc061a7b --- /dev/null +++ b/requirements/README.rst @@ -0,0 +1,11 @@ +********************* +pip requirement files +********************* + +The files found here are `requirement files +`_ that may be used with `pip +`_. + +To install the dependencies found in one of these files, simply run e.g.:: + + pip install -r requirements/tests.txt diff --git a/requirements-docs.txt b/requirements/docs.txt similarity index 100% rename from requirements-docs.txt rename to requirements/docs.txt diff --git a/requirements-external-mixers.txt b/requirements/external_mixers.txt similarity index 100% rename from requirements-external-mixers.txt rename to requirements/external_mixers.txt diff --git a/requirements/lastfm.txt b/requirements/lastfm.txt new file mode 100644 index 00000000..314c4223 --- /dev/null +++ b/requirements/lastfm.txt @@ -0,0 +1 @@ +pylast >= 0.5.7 diff --git a/requirements-tests.txt b/requirements/tests.txt similarity index 100% rename from requirements-tests.txt rename to requirements/tests.txt diff --git a/setup.py b/setup.py index fabc8353..d9d6af42 100644 --- a/setup.py +++ b/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', diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index 05f08e18..2b6cb84e 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -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): diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 1239bd08..71f62147 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -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]), diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 4caaf44b..26662f96 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -13,7 +13,7 @@ from tests.backends.base import populate_playlist # TODO Test 'playlist repeat', e.g. repeat=1,single=0 -class BasePlaybackControllerTest(object): +class PlaybackControllerTest(object): tracks = [] def setUp(self): @@ -104,8 +104,8 @@ class BasePlaybackControllerTest(object): @populate_playlist def test_play_skips_to_next_track_on_failure(self): - # If _play() returns False, it is a failure. - self.playback._play = lambda track: track != self.tracks[0] + # If provider.play() returns False, it is a failure. + self.playback.provider.play = lambda track: track != self.tracks[0] self.playback.play() self.assertNotEqual(self.playback.current_track, self.tracks[0]) self.assertEqual(self.playback.current_track, self.tracks[1]) @@ -164,8 +164,8 @@ class BasePlaybackControllerTest(object): @populate_playlist def test_previous_skips_to_previous_track_on_failure(self): - # If _play() returns False, it is a failure. - self.playback._play = lambda track: track != self.tracks[1] + # If provider.play() returns False, it is a failure. + self.playback.provider.play = lambda track: track != self.tracks[1] self.playback.play(self.current_playlist.cp_tracks[2]) self.assertEqual(self.playback.current_track, self.tracks[2]) self.playback.previous() @@ -228,8 +228,8 @@ class BasePlaybackControllerTest(object): @populate_playlist def test_next_skips_to_next_track_on_failure(self): - # If _play() returns False, it is a failure. - self.playback._play = lambda track: track != self.tracks[1] + # If provider.play() returns False, it is a failure. + self.playback.provider.play = lambda track: track != self.tracks[1] self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.next() @@ -364,8 +364,8 @@ class BasePlaybackControllerTest(object): @populate_playlist def test_end_of_track_skips_to_next_track_on_failure(self): - # If _play() returns False, it is a failure. - self.playback._play = lambda track: track != self.tracks[1] + # If provider.play() returns False, it is a failure. + self.playback.provider.play = lambda track: track != self.tracks[1] self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.on_end_of_track() diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index ef5806ef..0ac0b167 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -8,11 +8,11 @@ from mopidy.models import Playlist from tests import SkipTest, data_folder -class BaseStoredPlaylistsControllerTest(object): +class StoredPlaylistsControllerTest(object): def setUp(self): - settings.LOCAL_PLAYLIST_FOLDER = tempfile.mkdtemp() - settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache') - settings.LOCAL_MUSIC_FOLDER = data_folder('') + settings.LOCAL_PLAYLIST_PATH = tempfile.mkdtemp() + settings.LOCAL_TAG_CACHE_FILE = data_folder('library_tag_cache') + settings.LOCAL_MUSIC_PATH = data_folder('') self.backend = self.backend_class(mixer_class=DummyMixer) self.stored = self.backend.stored_playlists @@ -20,8 +20,8 @@ class BaseStoredPlaylistsControllerTest(object): def tearDown(self): self.backend.destroy() - if os.path.exists(settings.LOCAL_PLAYLIST_FOLDER): - shutil.rmtree(settings.LOCAL_PLAYLIST_FOLDER) + if os.path.exists(settings.LOCAL_PLAYLIST_PATH): + shutil.rmtree(settings.LOCAL_PLAYLIST_PATH) settings.runtime.clear() diff --git a/tests/backends/libspotify/backend_integrationtest.py b/tests/backends/libspotify/backend_integrationtest.py deleted file mode 100644 index 8d1f0b0e..00000000 --- a/tests/backends/libspotify/backend_integrationtest.py +++ /dev/null @@ -1,44 +0,0 @@ -# TODO This integration test is work in progress. - -import unittest - -from mopidy.backends.libspotify import LibspotifyBackend -from mopidy.models import Track - -from tests.backends.base.current_playlist import \ - BaseCurrentPlaylistControllerTest -from tests.backends.base.library import BaseLibraryControllerTest -from tests.backends.base.playback import BasePlaybackControllerTest -from tests.backends.base.stored_playlists import \ - BaseStoredPlaylistsControllerTest - -uris = [ - 'spotify:track:6vqcpVcbI3Zu6sH3ieLDNt', - 'spotify:track:111sulhaZqgsnypz3MkiaW', - 'spotify:track:7t8oznvbeiAPMDRuK0R5ZT', -] - -class LibspotifyCurrentPlaylistControllerTest( - BaseCurrentPlaylistControllerTest, unittest.TestCase): - - backend_class = LibspotifyBackend - tracks = [Track(uri=uri, length=4464) for i, uri in enumerate(uris)] - - -class LibspotifyPlaybackControllerTest( - BasePlaybackControllerTest, unittest.TestCase): - - backend_class = LibspotifyBackend - tracks = [Track(uri=uri, length=4464) for i, uri in enumerate(uris)] - - -class LibspotifyStoredPlaylistsControllerTest( - BaseStoredPlaylistsControllerTest, unittest.TestCase): - - backend_class = LibspotifyBackend - - -class LibspotifyLibraryControllerTest( - BaseLibraryControllerTest, unittest.TestCase): - - backend_class = LibspotifyBackend diff --git a/tests/backends/local/current_playlist_test.py b/tests/backends/local/current_playlist_test.py index 3895497a..6f72d7d5 100644 --- a/tests/backends/local/current_playlist_test.py +++ b/tests/backends/local/current_playlist_test.py @@ -10,11 +10,10 @@ from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.models import Track -from tests.backends.base.current_playlist import \ - BaseCurrentPlaylistControllerTest +from tests.backends.base.current_playlist import CurrentPlaylistControllerTest from tests.backends.local import generate_song -class LocalCurrentPlaylistControllerTest(BaseCurrentPlaylistControllerTest, +class LocalCurrentPlaylistControllerTest(CurrentPlaylistControllerTest, unittest.TestCase): backend_class = LocalBackend diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index c0605ef2..0c44924a 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -10,15 +10,15 @@ from mopidy import settings from mopidy.backends.local import LocalBackend from tests import data_folder -from tests.backends.base.library import BaseLibraryControllerTest +from tests.backends.base.library import LibraryControllerTest -class LocalLibraryControllerTest(BaseLibraryControllerTest, unittest.TestCase): +class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): backend_class = LocalBackend def setUp(self): - settings.LOCAL_TAG_CACHE = data_folder('library_tag_cache') - settings.LOCAL_MUSIC_FOLDER = data_folder('') + settings.LOCAL_TAG_CACHE_FILE = data_folder('library_tag_cache') + settings.LOCAL_MUSIC_PATH = data_folder('') super(LocalLibraryControllerTest, self).setUp() diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index a84dfcde..2007cff8 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -12,12 +12,10 @@ from mopidy.models import Track from mopidy.utils.path import path_to_uri from tests import data_folder -from tests.backends.base.playback import BasePlaybackControllerTest +from tests.backends.base.playback import PlaybackControllerTest from tests.backends.local import generate_song -class LocalPlaybackControllerTest(BasePlaybackControllerTest, - unittest.TestCase): - +class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): backend_class = LocalBackend tracks = [Track(uri=generate_song(i), length=4464) for i in range(1, 4)] diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index bb03f997..a7d9043f 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -16,22 +16,22 @@ from mopidy.utils.path import path_to_uri from tests import data_folder from tests.backends.base.stored_playlists import \ - BaseStoredPlaylistsControllerTest + StoredPlaylistsControllerTest from tests.backends.local import generate_song -class LocalStoredPlaylistsControllerTest(BaseStoredPlaylistsControllerTest, +class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, unittest.TestCase): backend_class = LocalBackend def test_created_playlist_is_persisted(self): - path = os.path.join(settings.LOCAL_PLAYLIST_FOLDER, 'test.m3u') + path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') self.assert_(not os.path.exists(path)) self.stored.create('test') self.assert_(os.path.exists(path)) def test_saved_playlist_is_persisted(self): - path = os.path.join(settings.LOCAL_PLAYLIST_FOLDER, 'test2.m3u') + path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') self.assert_(not os.path.exists(path)) self.stored.save(Playlist(name='test2')) self.assert_(os.path.exists(path)) @@ -39,13 +39,13 @@ class LocalStoredPlaylistsControllerTest(BaseStoredPlaylistsControllerTest, def test_deleted_playlist_get_removed(self): playlist = self.stored.create('test') self.stored.delete(playlist) - path = os.path.join(settings.LOCAL_PLAYLIST_FOLDER, 'test.m3u') + path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') self.assert_(not os.path.exists(path)) def test_renamed_playlist_gets_moved(self): playlist = self.stored.create('test') - file1 = os.path.join(settings.LOCAL_PLAYLIST_FOLDER, 'test.m3u') - file2 = os.path.join(settings.LOCAL_PLAYLIST_FOLDER, 'test2.m3u') + file1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') + file2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') self.assert_(not os.path.exists(file2)) self.stored.rename(playlist, 'test2') self.assert_(not os.path.exists(file1)) @@ -55,7 +55,7 @@ class LocalStoredPlaylistsControllerTest(BaseStoredPlaylistsControllerTest, track = Track(uri=generate_song(1)) uri = track.uri[len('file://'):] playlist = Playlist(tracks=[track], name='test') - path = os.path.join(settings.LOCAL_PLAYLIST_FOLDER, 'test.m3u') + path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') self.stored.save(playlist) diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index a9fe58d8..b7fd212c 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -116,7 +116,16 @@ class MPDTagCacheToTracksTest(unittest.TestCase): self.assertEqual(set(expected_tracks), tracks) def test_unicode_cache(self): - raise SkipTest + tracks = parse_mpd_tag_cache(data_folder('utf8_tag_cache'), + data_folder('')) + + uri = path_to_uri(data_folder('song1.mp3')) + artists = [Artist(name=u'æøå')] + album = Album(name=u'æøå', artists=artists) + track = Track(uri=uri, name=u'æøå', artists=artists, + album=album, length=4000) + + self.assertEqual(track, list(tracks)[0]) def test_misencoded_cache(self): # FIXME not sure if this can happen @@ -127,3 +136,28 @@ class MPDTagCacheToTracksTest(unittest.TestCase): data_folder('')) uri = path_to_uri(data_folder('song1.mp3')) self.assertEqual(set([Track(uri=uri, length=4000)]), tracks) + + def test_musicbrainz_tagcache(self): + tracks = parse_mpd_tag_cache(data_folder('musicbrainz_tag_cache'), + data_folder('')) + artist = list(expected_tracks[0].artists)[0].copy( + musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') + albumartist = list(expected_tracks[0].artists)[0].copy( + name='albumartistname', + musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') + album = expected_tracks[0].album.copy(artists=[albumartist], + musicbrainz_id='cb5f1603-d314-4c9c-91e5-e295cfb125d2') + track = expected_tracks[0].copy(artists=[artist], album=album, + musicbrainz_id='90488461-8c1f-4a4e-826b-4c6dc70801f0') + + self.assertEqual(track, list(tracks)[0]) + + def test_albumartist_tag_cache(self): + tracks = parse_mpd_tag_cache(data_folder('albumartist_tag_cache'), + data_folder('')) + uri = path_to_uri(data_folder('song1.mp3')) + artist = Artist(name='albumartistname') + album = expected_albums[0].copy(artists=[artist]) + track = Track(name='trackname', artists=expected_artists, track_no=1, + album=album, length=4000, uri=uri) + self.assertEqual(track, list(tracks)[0]) diff --git a/tests/data/albumartist_tag_cache b/tests/data/albumartist_tag_cache new file mode 100644 index 00000000..29942a75 --- /dev/null +++ b/tests/data/albumartist_tag_cache @@ -0,0 +1,16 @@ +info_begin +mpd_version: 0.14.2 +fs_charset: UTF-8 +info_end +songList begin +key: song1.mp3 +file: /song1.mp3 +Time: 4 +Artist: name +Title: trackname +Album: albumname +AlbumArtist: albumartistname +Track: 1/2 +Date: 2006 +mtime: 1272319626 +songList end diff --git a/tests/data/blank.mp3 b/tests/data/blank.mp3 index 6aa48cd8..ef159a70 100644 Binary files a/tests/data/blank.mp3 and b/tests/data/blank.mp3 differ diff --git a/tests/data/musicbrainz_tag_cache b/tests/data/musicbrainz_tag_cache new file mode 100644 index 00000000..0e9dca46 --- /dev/null +++ b/tests/data/musicbrainz_tag_cache @@ -0,0 +1,20 @@ +info_begin +mpd_version: 0.16.0 +fs_charset: UTF-8 +info_end +songList begin +key: song1.mp3 +file: /song1.mp3 +Time: 4 +Artist: name +Title: trackname +Album: albumname +AlbumArtist: albumartistname +Track: 1/2 +Date: 2006 +MUSICBRAINZ_ALBUMID: cb5f1603-d314-4c9c-91e5-e295cfb125d2 +MUSICBRAINZ_ALBUMARTISTID: 7364dea6-ca9a-48e3-be01-b44ad0d19897 +MUSICBRAINZ_ARTISTID: 7364dea6-ca9a-48e3-be01-b44ad0d19897 +MUSICBRAINZ_TRACKID: 90488461-8c1f-4a4e-826b-4c6dc70801f0 +mtime: 1272319626 +songList end diff --git a/tests/data/scanner/advanced/song1.mp3 b/tests/data/scanner/advanced/song1.mp3 new file mode 120000 index 00000000..6896a7a2 --- /dev/null +++ b/tests/data/scanner/advanced/song1.mp3 @@ -0,0 +1 @@ +../sample.mp3 \ No newline at end of file diff --git a/tests/data/scanner/advanced/song2.mp3 b/tests/data/scanner/advanced/song2.mp3 new file mode 120000 index 00000000..6896a7a2 --- /dev/null +++ b/tests/data/scanner/advanced/song2.mp3 @@ -0,0 +1 @@ +../sample.mp3 \ No newline at end of file diff --git a/tests/data/scanner/advanced/song3.mp3 b/tests/data/scanner/advanced/song3.mp3 new file mode 120000 index 00000000..6896a7a2 --- /dev/null +++ b/tests/data/scanner/advanced/song3.mp3 @@ -0,0 +1 @@ +../sample.mp3 \ No newline at end of file diff --git a/tests/data/scanner/advanced/subdir1/song4.mp3 b/tests/data/scanner/advanced/subdir1/song4.mp3 new file mode 120000 index 00000000..45812ac5 --- /dev/null +++ b/tests/data/scanner/advanced/subdir1/song4.mp3 @@ -0,0 +1 @@ +../../sample.mp3 \ No newline at end of file diff --git a/tests/data/scanner/advanced/subdir1/song5.mp3 b/tests/data/scanner/advanced/subdir1/song5.mp3 new file mode 120000 index 00000000..45812ac5 --- /dev/null +++ b/tests/data/scanner/advanced/subdir1/song5.mp3 @@ -0,0 +1 @@ +../../sample.mp3 \ No newline at end of file diff --git a/tests/data/scanner/advanced/subdir1/subsubdir/song8.mp3 b/tests/data/scanner/advanced/subdir1/subsubdir/song8.mp3 new file mode 120000 index 00000000..e84bdc24 --- /dev/null +++ b/tests/data/scanner/advanced/subdir1/subsubdir/song8.mp3 @@ -0,0 +1 @@ +../../../sample.mp3 \ No newline at end of file diff --git a/tests/data/scanner/advanced/subdir1/subsubdir/song9.mp3 b/tests/data/scanner/advanced/subdir1/subsubdir/song9.mp3 new file mode 120000 index 00000000..e84bdc24 --- /dev/null +++ b/tests/data/scanner/advanced/subdir1/subsubdir/song9.mp3 @@ -0,0 +1 @@ +../../../sample.mp3 \ No newline at end of file diff --git a/tests/data/scanner/advanced/subdir2/song6.mp3 b/tests/data/scanner/advanced/subdir2/song6.mp3 new file mode 120000 index 00000000..45812ac5 --- /dev/null +++ b/tests/data/scanner/advanced/subdir2/song6.mp3 @@ -0,0 +1 @@ +../../sample.mp3 \ No newline at end of file diff --git a/tests/data/scanner/advanced/subdir2/song7.mp3 b/tests/data/scanner/advanced/subdir2/song7.mp3 new file mode 120000 index 00000000..45812ac5 --- /dev/null +++ b/tests/data/scanner/advanced/subdir2/song7.mp3 @@ -0,0 +1 @@ +../../sample.mp3 \ No newline at end of file diff --git a/tests/data/scanner/advanced_cache b/tests/data/scanner/advanced_cache new file mode 100644 index 00000000..60f7fca6 --- /dev/null +++ b/tests/data/scanner/advanced_cache @@ -0,0 +1,81 @@ +info_begin +mpd_version: 0.15.4 +fs_charset: UTF-8 +info_end +directory: subdir1 +mtime: 1288121499 +begin: subdir1 +songList begin +key: song4.mp3 +file: subdir1/song4.mp3 +Time: 5 +Artist: name +Title: trackname +Album: albumname +Track: 01/02 +Date: 2006 +mtime: 1288121370 +key: song5.mp3 +file: subdir1/song5.mp3 +Time: 5 +Artist: name +Title: trackname +Album: albumname +Track: 01/02 +Date: 2006 +mtime: 1288121370 +songList end +end: subdir1 +directory: subdir2 +mtime: 1288121499 +begin: subdir2 +songList begin +key: song6.mp3 +file: subdir2/song6.mp3 +Time: 5 +Artist: name +Title: trackname +Album: albumname +Track: 01/02 +Date: 2006 +mtime: 1288121370 +key: song7.mp3 +file: subdir2/song7.mp3 +Time: 5 +Artist: name +Title: trackname +Album: albumname +Track: 01/02 +Date: 2006 +mtime: 1288121370 +songList end +end: subdir2 +songList begin +key: song1.mp3 +file: /song1.mp3 +Time: 5 +Artist: name +Title: trackname +Album: albumname +Track: 01/02 +Date: 2006 +mtime: 1288121370 +key: song2.mp3 +file: /song2.mp3 +Time: 5 +Artist: name +Title: trackname +Album: albumname +Track: 01/02 +Date: 2006 +mtime: 1288121370 +key: song3.mp3 +file: /song3.mp3 +Time: 5 +Artist: name +Title: trackname +Album: albumname +Track: 01/02 +Date: 2006 +mtime: 1288121370 +songList end diff --git a/tests/backends/libspotify/__init__.py b/tests/data/scanner/empty/.gitignore similarity index 100% rename from tests/backends/libspotify/__init__.py rename to tests/data/scanner/empty/.gitignore diff --git a/tests/data/scanner/empty_cache b/tests/data/scanner/empty_cache new file mode 100644 index 00000000..3c466a32 --- /dev/null +++ b/tests/data/scanner/empty_cache @@ -0,0 +1,6 @@ +info_begin +mpd_version: 0.15.4 +fs_charset: UTF-8 +info_end +songList begin +songList end diff --git a/tests/data/scanner/image/test.png b/tests/data/scanner/image/test.png new file mode 100644 index 00000000..2aaf9c3d Binary files /dev/null and b/tests/data/scanner/image/test.png differ diff --git a/tests/data/scanner/sample.mp3 b/tests/data/scanner/sample.mp3 new file mode 100644 index 00000000..ad5aa37a Binary files /dev/null and b/tests/data/scanner/sample.mp3 differ diff --git a/tests/data/scanner/simple/song1.mp3 b/tests/data/scanner/simple/song1.mp3 new file mode 120000 index 00000000..6896a7a2 --- /dev/null +++ b/tests/data/scanner/simple/song1.mp3 @@ -0,0 +1 @@ +../sample.mp3 \ No newline at end of file diff --git a/tests/data/scanner/simple_cache b/tests/data/scanner/simple_cache new file mode 100644 index 00000000..db11c324 --- /dev/null +++ b/tests/data/scanner/simple_cache @@ -0,0 +1,15 @@ +info_begin +mpd_version: 0.15.4 +fs_charset: UTF-8 +info_end +songList begin +key: song1.mp3 +file: /song1.mp3 +Time: 5 +Artist: name +Title: trackname +Album: albumname +Track: 01/02 +Date: 2006 +mtime: 1288121370 +songList end diff --git a/tests/data/utf8_tag_cache b/tests/data/utf8_tag_cache new file mode 100644 index 00000000..6642ec77 --- /dev/null +++ b/tests/data/utf8_tag_cache @@ -0,0 +1,13 @@ +info_begin +mpd_version: 0.14.2 +fs_charset: UTF-8 +info_end +songList begin +key: song1.mp3 +file: /song1.mp3 +Time: 4 +Artist: æøå +Title: æøå +Album: æøå +mtime: 1272319626 +songList end diff --git a/tests/frontends/mpd/connection_test.py b/tests/frontends/mpd/connection_test.py index 21753054..44ce78ca 100644 --- a/tests/frontends/mpd/connection_test.py +++ b/tests/frontends/mpd/connection_test.py @@ -1,5 +1,6 @@ import unittest +from mopidy import settings from mopidy.backends.dummy import DummyBackend from mopidy.frontends.mpd import dispatcher from mopidy.mixers.dummy import DummyMixer @@ -9,6 +10,9 @@ class ConnectionHandlerTest(unittest.TestCase): self.b = DummyBackend(mixer_class=DummyMixer) self.h = dispatcher.MpdDispatcher(backend=self.b) + def tearDown(self): + settings.runtime.clear() + def test_close(self): result = self.h.handle_request(u'close') self.assert_(u'OK' in result) @@ -21,9 +25,20 @@ class ConnectionHandlerTest(unittest.TestCase): result = self.h.handle_request(u'kill') self.assert_(u'OK' in result) - def test_password(self): + def test_valid_password_is_accepted(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + result = self.h.handle_request(u'password "topsecret"') + self.assert_(u'OK' in result) + + def test_invalid_password_is_not_accepted(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' result = self.h.handle_request(u'password "secret"') - self.assert_(u'ACK [0@0] {} Not implemented' in result) + self.assert_(u'ACK [3@0] {password} incorrect password' in result) + + def test_any_password_is_not_accepted_when_password_check_turned_off(self): + settings.MPD_SERVER_PASSWORD = None + result = self.h.handle_request(u'password "secret"') + self.assert_(u'ACK [3@0] {password} incorrect password' in result) def test_ping(self): result = self.h.handle_request(u'ping') diff --git a/tests/frontends/mpd/current_playlist_test.py b/tests/frontends/mpd/current_playlist_test.py index 8a4b9ab5..a4179637 100644 --- a/tests/frontends/mpd/current_playlist_test.py +++ b/tests/frontends/mpd/current_playlist_test.py @@ -12,7 +12,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): def test_add(self): needle = Track(uri='dummy://foo') - self.b.library._library = [Track(), Track(), needle, Track()] + self.b.library.provider._library = [Track(), Track(), needle, Track()] self.b.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 5) @@ -40,7 +40,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): def test_addid_without_songpos(self): needle = Track(uri='dummy://foo') - self.b.library._library = [Track(), Track(), needle, Track()] + self.b.library.provider._library = [Track(), Track(), needle, Track()] self.b.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 5) @@ -58,7 +58,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): def test_addid_with_songpos(self): needle = Track(uri='dummy://foo') - self.b.library._library = [Track(), Track(), needle, Track()] + self.b.library.provider._library = [Track(), Track(), needle, Track()] self.b.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 5) @@ -71,7 +71,7 @@ class CurrentPlaylistHandlerTest(unittest.TestCase): def test_addid_with_songpos_out_of_bounds_should_ack(self): needle = Track(uri='dummy://foo') - self.b.library._library = [Track(), Track(), needle, Track()] + self.b.library.provider._library = [Track(), Track(), needle, Track()] self.b.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) self.assertEqual(len(self.b.current_playlist.tracks), 5) diff --git a/tests/frontends/mpd/playback_test.py b/tests/frontends/mpd/playback_test.py index 4e60546d..43614173 100644 --- a/tests/frontends/mpd/playback_test.py +++ b/tests/frontends/mpd/playback_test.py @@ -104,6 +104,11 @@ class PlaybackOptionsHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) self.assertEqual(10, self.b.mixer.volume) + def test_setvol_without_quotes(self): + result = self.h.handle_request(u'setvol 50') + self.assert_(u'OK' in result) + self.assertEqual(50, self.b.mixer.volume) + def test_single_off(self): result = self.h.handle_request(u'single "0"') self.assertFalse(self.b.playback.single) @@ -320,6 +325,13 @@ class PlaybackControlHandlerTest(unittest.TestCase): result = self.h.handle_request(u'seek "1" "30"') self.assertEqual(self.b.playback.current_track, seek_track) + def test_seek_without_quotes(self): + self.b.current_playlist.append([Track(length=40000)]) + self.h.handle_request(u'seek 0') + result = self.h.handle_request(u'seek 0 30') + self.assert_(u'OK' in result) + self.assert_(self.b.playback.time_position >= 30000) + def test_seekid(self): self.b.current_playlist.append([Track(length=40000)]) result = self.h.handle_request(u'seekid "0" "30"') diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py index 0e0f8183..7e4500ea 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -1,11 +1,35 @@ import datetime as dt +import os import unittest -from mopidy.frontends.mpd import translator +from mopidy import settings +from mopidy.utils.path import mtime, uri_to_path +from mopidy.frontends.mpd import translator, protocol from mopidy.models import Album, Artist, Playlist, Track +from tests import data_folder, SkipTest + class TrackMpdFormatTest(unittest.TestCase): - def test_mpd_format_for_empty_track(self): + track = Track( + uri=u'a uri', + artists=[Artist(name=u'an artist')], + name=u'a name', + album=Album(name=u'an album', num_tracks=13, + artists=[Artist(name=u'an other artist')]), + track_no=7, + date=dt.date(1977, 1, 1), + length=137000, + ) + + def setUp(self): + settings.LOCAL_MUSIC_PATH = '/dir/subdir' + mtime.set_fake_time(1234567) + + def tearDown(self): + settings.runtime.clear() + mtime.undo_fake() + + def test_track_to_mpd_format_for_empty_track(self): result = translator.track_to_mpd_format(Track()) self.assert_(('file', '') in result) self.assert_(('Time', 0) in result) @@ -14,32 +38,63 @@ class TrackMpdFormatTest(unittest.TestCase): self.assert_(('Album', '') in result) self.assert_(('Track', 0) in result) self.assert_(('Date', '') in result) + self.assertEqual(len(result), 7) - def test_mpd_format_for_nonempty_track(self): - track = Track( - uri=u'a uri', - artists=[Artist(name=u'an artist')], - name=u'a name', - album=Album(name=u'an album', num_tracks=13), - track_no=7, - date=dt.date(1977, 1, 1), - length=137000, - ) - result = translator.track_to_mpd_format(track, position=9, cpid=122) + def test_track_to_mpd_format_with_position(self): + result = translator.track_to_mpd_format(Track(), position=1) + self.assert_(('Pos', 1) not in result) + + def test_track_to_mpd_format_with_cpid(self): + result = translator.track_to_mpd_format(Track(), cpid=1) + self.assert_(('Id', 1) not in result) + + def test_track_to_mpd_format_with_position_and_cpid(self): + result = translator.track_to_mpd_format(Track(), position=1, cpid=2) + self.assert_(('Pos', 1) in result) + self.assert_(('Id', 2) in result) + + def test_track_to_mpd_format_for_nonempty_track(self): + result = translator.track_to_mpd_format(self.track, position=9, cpid=122) self.assert_(('file', 'a uri') in result) self.assert_(('Time', 137) in result) self.assert_(('Artist', 'an artist') in result) self.assert_(('Title', 'a name') in result) self.assert_(('Album', 'an album') in result) + self.assert_(('AlbumArtist', 'an other artist') in result) self.assert_(('Track', '7/13') in result) self.assert_(('Date', dt.date(1977, 1, 1)) in result) self.assert_(('Pos', 9) in result) self.assert_(('Id', 122) in result) + self.assertEqual(len(result), 10) - def test_mpd_format_artists(self): - track = Track(artists=[Artist(name=u'ABBA'), Artist(name=u'Beatles')]) - self.assertEqual(translator.track_artists_to_mpd_format(track), - u'ABBA, Beatles') + def test_track_to_mpd_format_musicbrainz_trackid(self): + track = self.track.copy(musicbrainz_id='foo') + result = translator.track_to_mpd_format(track) + self.assert_(('MUSICBRAINZ_TRACKID', 'foo') in result) + + def test_track_to_mpd_format_musicbrainz_albumid(self): + album = self.track.album.copy(musicbrainz_id='foo') + track = self.track.copy(album=album) + result = translator.track_to_mpd_format(track) + self.assert_(('MUSICBRAINZ_ALBUMID', 'foo') in result) + + def test_track_to_mpd_format_musicbrainz_albumid(self): + artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') + album = self.track.album.copy(artists=[artist]) + track = self.track.copy(album=album) + result = translator.track_to_mpd_format(track) + self.assert_(('MUSICBRAINZ_ALBUMARTISTID', 'foo') in result) + + def test_track_to_mpd_format_musicbrainz_artistid(self): + artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') + track = self.track.copy(artists=[artist]) + result = translator.track_to_mpd_format(track) + self.assert_(('MUSICBRAINZ_ARTISTID', 'foo') in result) + + def test_artists_to_mpd_format(self): + artists = [Artist(name=u'ABBA'), Artist(name=u'Beatles')] + translated = translator.artists_to_mpd_format(artists) + self.assertEqual(translated, u'ABBA, Beatles') class PlaylistMpdFormatTest(unittest.TestCase): @@ -55,3 +110,239 @@ class PlaylistMpdFormatTest(unittest.TestCase): result = translator.playlist_to_mpd_format(playlist, 1, 2) self.assertEqual(len(result), 1) self.assertEqual(dict(result[0])['Track'], 2) + + +class TracksToTagCacheFormatTest(unittest.TestCase): + def setUp(self): + settings.LOCAL_MUSIC_PATH = '/dir/subdir' + mtime.set_fake_time(1234567) + + def tearDown(self): + settings.runtime.clear() + mtime.undo_fake() + + def translate(self, track): + folder = settings.LOCAL_MUSIC_PATH + result = dict(translator.track_to_mpd_format(track)) + result['file'] = uri_to_path(result['file']) + result['file'] = result['file'][len(folder)+1:] + result['key'] = os.path.basename(result['file']) + result['mtime'] = mtime('') + return translator.order_mpd_track_info(result.items()) + + def consume_headers(self, result): + self.assertEqual(('info_begin',), result[0]) + self.assertEqual(('mpd_version', protocol.VERSION), result[1]) + self.assertEqual(('fs_charset', protocol.ENCODING), result[2]) + self.assertEqual(('info_end',), result[3]) + return result[4:] + + def consume_song_list(self, result): + self.assertEqual(('songList begin',), result[0]) + for i, row in enumerate(result): + if row == ('songList end',): + return result[1:i], result[i+1:] + self.fail("Couldn't find songList end in result") + + def consume_directory(self, result): + self.assertEqual('directory', result[0][0]) + self.assertEqual(('mtime', mtime('.')), result[1]) + self.assertEqual(('begin', os.path.split(result[0][1])[1]), result[2]) + directory = result[2][1] + for i, row in enumerate(result): + if row == ('end', directory): + return result[3:i], result[i+1:] + self.fail("Couldn't find end %s in result" % directory) + + def test_empty_tag_cache_has_header(self): + result = translator.tracks_to_tag_cache_format([]) + result = self.consume_headers(result) + + def test_empty_tag_cache_has_song_list(self): + result = translator.tracks_to_tag_cache_format([]) + result = self.consume_headers(result) + song_list, result = self.consume_song_list(result) + + self.assertEqual(len(song_list), 0) + self.assertEqual(len(result), 0) + + def test_tag_cache_has_header(self): + track = Track(uri='file:///dir/subdir/song.mp3') + result = translator.tracks_to_tag_cache_format([track]) + result = self.consume_headers(result) + + def test_tag_cache_has_song_list(self): + track = Track(uri='file:///dir/subdir/song.mp3') + result = translator.tracks_to_tag_cache_format([track]) + result = self.consume_headers(result) + song_list, result = self.consume_song_list(result) + + self.assert_(song_list) + self.assertEqual(len(result), 0) + + def test_tag_cache_has_formated_track(self): + track = Track(uri='file:///dir/subdir/song.mp3') + formated = self.translate(track) + result = translator.tracks_to_tag_cache_format([track]) + + result = self.consume_headers(result) + song_list, result = self.consume_song_list(result) + + self.assertEqual(song_list, formated) + self.assertEqual(len(result), 0) + + def test_tag_cache_has_formated_track_with_key_and_mtime(self): + track = Track(uri='file:///dir/subdir/song.mp3') + formated = self.translate(track) + result = translator.tracks_to_tag_cache_format([track]) + + result = self.consume_headers(result) + song_list, result = self.consume_song_list(result) + + self.assertEqual(song_list, formated) + self.assertEqual(len(result), 0) + + def test_tag_cache_suports_directories(self): + track = Track(uri='file:///dir/subdir/folder/song.mp3') + formated = self.translate(track) + result = translator.tracks_to_tag_cache_format([track]) + + result = self.consume_headers(result) + folder, result = self.consume_directory(result) + song_list, result = self.consume_song_list(result) + self.assertEqual(len(song_list), 0) + self.assertEqual(len(result), 0) + + song_list, result = self.consume_song_list(folder) + self.assertEqual(len(result), 0) + self.assertEqual(song_list, formated) + + def test_tag_cache_diretory_header_is_right(self): + track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') + formated = self.translate(track) + result = translator.tracks_to_tag_cache_format([track]) + + result = self.consume_headers(result) + folder, result = self.consume_directory(result) + + self.assertEqual(('directory', 'folder/sub'), folder[0]) + self.assertEqual(('mtime', mtime('.')), folder[1]) + self.assertEqual(('begin', 'sub'), folder[2]) + + def test_tag_cache_suports_sub_directories(self): + track = Track(uri='file:///dir/subdir/folder/sub/song.mp3') + formated = self.translate(track) + result = translator.tracks_to_tag_cache_format([track]) + + result = self.consume_headers(result) + + folder, result = self.consume_directory(result) + song_list, result = self.consume_song_list(result) + self.assertEqual(len(song_list), 0) + self.assertEqual(len(result), 0) + + folder, result = self.consume_directory(folder) + song_list, result = self.consume_song_list(result) + self.assertEqual(len(result), 0) + self.assertEqual(len(song_list), 0) + + song_list, result = self.consume_song_list(folder) + self.assertEqual(len(result), 0) + self.assertEqual(song_list, formated) + + def test_tag_cache_supports_multiple_tracks(self): + tracks = [ + Track(uri='file:///dir/subdir/song1.mp3'), + Track(uri='file:///dir/subdir/song2.mp3'), + ] + + formated = [] + formated.extend(self.translate(tracks[0])) + formated.extend(self.translate(tracks[1])) + + result = translator.tracks_to_tag_cache_format(tracks) + + result = self.consume_headers(result) + song_list, result = self.consume_song_list(result) + + self.assertEqual(song_list, formated) + self.assertEqual(len(result), 0) + + def test_tag_cache_supports_multiple_tracks_in_dirs(self): + tracks = [ + Track(uri='file:///dir/subdir/song1.mp3'), + Track(uri='file:///dir/subdir/folder/song2.mp3'), + ] + + formated = [] + formated.append(self.translate(tracks[0])) + formated.append(self.translate(tracks[1])) + + result = translator.tracks_to_tag_cache_format(tracks) + + result = self.consume_headers(result) + folder, result = self.consume_directory(result) + song_list, song_result = self.consume_song_list(folder) + + self.assertEqual(song_list, formated[1]) + self.assertEqual(len(song_result), 0) + + song_list, result = self.consume_song_list(result) + self.assertEqual(len(result), 0) + self.assertEqual(song_list, formated[0]) + + +class TracksToDirectoryTreeTest(unittest.TestCase): + def setUp(self): + settings.LOCAL_MUSIC_PATH = '/root/' + + def tearDown(self): + settings.runtime.clear() + + def test_no_tracks_gives_emtpy_tree(self): + tree = translator.tracks_to_directory_tree([]) + self.assertEqual(tree, ({}, [])) + + def test_top_level_files(self): + tracks = [ + Track(uri='file:///root/file1.mp3'), + Track(uri='file:///root/file2.mp3'), + Track(uri='file:///root/file3.mp3'), + ] + tree = translator.tracks_to_directory_tree(tracks) + self.assertEqual(tree, ({}, tracks)) + + def test_single_file_in_subdir(self): + tracks = [Track(uri='file:///root/dir/file1.mp3')] + tree = translator.tracks_to_directory_tree(tracks) + expected = ({'dir': ({}, tracks)}, []) + self.assertEqual(tree, expected) + + def test_single_file_in_sub_subdir(self): + tracks = [Track(uri='file:///root/dir1/dir2/file1.mp3')] + tree = translator.tracks_to_directory_tree(tracks) + expected = ({'dir1': ({'dir1/dir2': ({}, tracks)}, [])}, []) + self.assertEqual(tree, expected) + + def test_complex_file_structure(self): + tracks = [ + Track(uri='file:///root/file1.mp3'), + Track(uri='file:///root/dir1/file2.mp3'), + Track(uri='file:///root/dir1/file3.mp3'), + Track(uri='file:///root/dir2/file4.mp3'), + Track(uri='file:///root/dir2/sub/file5.mp3'), + ] + tree = translator.tracks_to_directory_tree(tracks) + expected = ( + { + 'dir1': ({}, [tracks[1], tracks[2]]), + 'dir2': ( + { + 'dir2/sub': ({}, [tracks[4]]) + }, + [tracks[3]] + ), + }, + [tracks[0]] + ) + self.assertEqual(tree, expected) diff --git a/tests/frontends/mpd/server_test.py b/tests/frontends/mpd/server_test.py index 9d006eb3..48c7e790 100644 --- a/tests/frontends/mpd/server_test.py +++ b/tests/frontends/mpd/server_test.py @@ -1,5 +1,6 @@ import unittest +from mopidy import settings from mopidy.frontends.mpd import server class MpdServerTest(unittest.TestCase): @@ -21,8 +22,60 @@ class MpdSessionTest(unittest.TestCase): def setUp(self): self.session = server.MpdSession(None, None, (None, None), None) + def tearDown(self): + settings.runtime.clear() + def test_found_terminator_catches_decode_error(self): # Pressing Ctrl+C in a telnet session sends a 0xff byte to the server. self.session.input_buffer = ['\xff'] self.session.found_terminator() self.assertEqual(len(self.session.input_buffer), 0) + + def test_authentication_with_valid_password_is_accepted(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + authed, response = self.session.check_password(u'password "topsecret"') + self.assertTrue(authed) + self.assertEqual(u'OK', response) + + def test_authentication_with_invalid_password_is_not_accepted(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + authed, response = self.session.check_password(u'password "secret"') + self.assertFalse(authed) + self.assertEqual(u'ACK [3@0] {password} incorrect password', response) + + def test_authentication_with_anything_when_password_check_turned_off(self): + settings.MPD_SERVER_PASSWORD = None + authed, response = self.session.check_password(u'any request at all') + self.assertTrue(authed) + self.assertEqual(None, response) + + def test_anything_when_not_authenticated_should_fail(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + authed, response = self.session.check_password(u'any request at all') + self.assertFalse(authed) + self.assertEqual( + u'ACK [4@0] {any} you don\'t have permission for "any"', response) + + def test_close_is_allowed_without_authentication(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + authed, response = self.session.check_password(u'close') + self.assertFalse(authed) + self.assertEqual(None, response) + + def test_commands_is_allowed_without_authentication(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + authed, response = self.session.check_password(u'commands') + self.assertFalse(authed) + self.assertEqual(None, response) + + def test_notcommands_is_allowed_without_authentication(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + authed, response = self.session.check_password(u'notcommands') + self.assertFalse(authed) + self.assertEqual(None, response) + + def test_ping_is_allowed_without_authentication(self): + settings.MPD_SERVER_PASSWORD = u'topsecret' + authed, response = self.session.check_password(u'ping') + self.assertFalse(authed) + self.assertEqual(None, response) diff --git a/tests/models_test.py b/tests/models_test.py index ab7bc793..0b44f337 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -5,6 +5,50 @@ from mopidy.models import Artist, Album, Track, Playlist from tests import SkipTest +class GenericCopyTets(unittest.TestCase): + def compare(self, orig, other): + self.assertEqual(orig, other) + self.assertNotEqual(id(orig), id(other)) + + def test_copying_track(self): + track = Track() + self.compare(track, track.copy()) + + def test_copying_artist(self): + artist = Artist() + self.compare(artist, artist.copy()) + + def test_copying_album(self): + album = Album() + self.compare(album, album.copy()) + + def test_copying_playlist(self): + playlist = Playlist() + self.compare(playlist, playlist.copy()) + + def test_copying_track_with_basic_values(self): + track = Track(name='foo', uri='bar') + copy = track.copy(name='baz') + self.assertEqual('baz', copy.name) + self.assertEqual('bar', copy.uri) + + def test_copying_track_with_missing_values(self): + track = Track(uri='bar') + copy = track.copy(name='baz') + self.assertEqual('baz', copy.name) + self.assertEqual('bar', copy.uri) + + def test_copying_track_with_private_internal_value(self): + artists1 = [Artist(name='foo')] + artists2 = [Artist(name='bar')] + track = Track(artists=artists1) + copy = track.copy(artists=artists2) + self.assertEqual(copy.artists, artists2) + + def test_copying_track_with_invalid_key(self): + test = lambda: Track().copy(invalid_key=True) + self.assertRaises(TypeError, test) + class ArtistTest(unittest.TestCase): def test_uri(self): uri = u'an_uri' @@ -18,6 +62,13 @@ class ArtistTest(unittest.TestCase): self.assertEqual(artist.name, name) self.assertRaises(AttributeError, setattr, artist, 'name', None) + def test_musicbrainz_id(self): + mb_id = u'mb-id' + artist = Artist(musicbrainz_id=mb_id) + self.assertEqual(artist.musicbrainz_id, mb_id) + self.assertRaises(AttributeError, setattr, artist, + 'musicbrainz_id', None) + def test_invalid_kwarg(self): test = lambda: Artist(foo='baz') self.assertRaises(TypeError, test) @@ -34,9 +85,15 @@ class ArtistTest(unittest.TestCase): self.assertEqual(artist1, artist2) self.assertEqual(hash(artist1), hash(artist2)) + def test_eq_musibrainz_id(self): + artist1 = Artist(musicbrainz_id=u'id') + artist2 = Artist(musicbrainz_id=u'id') + self.assertEqual(artist1, artist2) + self.assertEqual(hash(artist1), hash(artist2)) + def test_eq(self): - artist1 = Artist(uri=u'uri', name=u'name') - artist2 = Artist(uri=u'uri', name=u'name') + artist1 = Artist(uri=u'uri', name=u'name', musicbrainz_id='id') + artist2 = Artist(uri=u'uri', name=u'name', musicbrainz_id='id') self.assertEqual(artist1, artist2) self.assertEqual(hash(artist1), hash(artist2)) @@ -58,9 +115,15 @@ class ArtistTest(unittest.TestCase): self.assertNotEqual(artist1, artist2) self.assertNotEqual(hash(artist1), hash(artist2)) + def test_ne_musicbrainz_id(self): + artist1 = Artist(musicbrainz_id=u'id1') + artist2 = Artist(musicbrainz_id=u'id2') + self.assertNotEqual(artist1, artist2) + self.assertNotEqual(hash(artist1), hash(artist2)) + def test_ne(self): - artist1 = Artist(uri=u'uri1', name=u'name1') - artist2 = Artist(uri=u'uri2', name=u'name2') + artist1 = Artist(uri=u'uri1', name=u'name1', musicbrainz_id='id1') + artist2 = Artist(uri=u'uri2', name=u'name2', musicbrainz_id='id2') self.assertNotEqual(artist1, artist2) self.assertNotEqual(hash(artist1), hash(artist2)) @@ -90,6 +153,13 @@ class AlbumTest(unittest.TestCase): self.assertEqual(album.num_tracks, num_tracks) self.assertRaises(AttributeError, setattr, album, 'num_tracks', None) + def test_musicbrainz_id(self): + mb_id = u'mb-id' + album = Album(musicbrainz_id=mb_id) + self.assertEqual(album.musicbrainz_id, mb_id) + self.assertRaises(AttributeError, setattr, album, + 'musicbrainz_id', None) + def test_invalid_kwarg(self): test = lambda: Album(foo='baz') self.assertRaises(TypeError, test) @@ -127,10 +197,16 @@ class AlbumTest(unittest.TestCase): self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) + def test_eq_musibrainz_id(self): + album1 = Album(musicbrainz_id=u'id') + album2 = Album(musicbrainz_id=u'id') + self.assertEqual(album1, album2) + self.assertEqual(hash(album1), hash(album2)) + def test_eq(self): artists = [Artist()] - album1 = Album(name=u'name', uri=u'uri', artists=artists, num_tracks=2) - album2 = Album(name=u'name', uri=u'uri', artists=artists, num_tracks=2) + album1 = Album(name=u'name', uri=u'uri', artists=artists, num_tracks=2, musicbrainz_id='id') + album2 = Album(name=u'name', uri=u'uri', artists=artists, num_tracks=2, musicbrainz_id='id') self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) @@ -164,11 +240,19 @@ class AlbumTest(unittest.TestCase): self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) + def test_ne_musicbrainz_id(self): + album1 = Album(musicbrainz_id=u'id1') + album2 = Album(musicbrainz_id=u'id2') + self.assertNotEqual(album1, album2) + self.assertNotEqual(hash(album1), hash(album2)) + def test_ne(self): album1 = Album(name=u'name1', uri=u'uri1', - artists=[Artist(name=u'name1')], num_tracks=1) + artists=[Artist(name=u'name1')], num_tracks=1, + musicbrainz_id='id1') album2 = Album(name=u'name2', uri=u'uri2', - artists=[Artist(name=u'name2')], num_tracks=2) + artists=[Artist(name=u'name2')], num_tracks=2, + musicbrainz_id='id2') self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) @@ -222,6 +306,13 @@ class TrackTest(unittest.TestCase): self.assertEqual(track.bitrate, bitrate) self.assertRaises(AttributeError, setattr, track, 'bitrate', None) + def test_musicbrainz_id(self): + mb_id = u'mb-id' + track = Track(musicbrainz_id=mb_id) + self.assertEqual(track.musicbrainz_id, mb_id) + self.assertRaises(AttributeError, setattr, track, + 'musicbrainz_id', None) + def test_invalid_kwarg(self): test = lambda: Track(foo='baz') self.assertRaises(TypeError, test) @@ -285,14 +376,22 @@ class TrackTest(unittest.TestCase): self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) + def test_eq_musibrainz_id(self): + track1 = Track(musicbrainz_id=u'id') + track2 = Track(musicbrainz_id=u'id') + self.assertEqual(track1, track2) + self.assertEqual(hash(track1), hash(track2)) + def test_eq(self): date = dt.date.today() artists = [Artist()] album = Album() track1 = Track(uri=u'uri', name=u'name', artists=artists, album=album, - track_no=1, date=date, length=100, bitrate=100) + track_no=1, date=date, length=100, bitrate=100, + musicbrainz_id='id') track2 = Track(uri=u'uri', name=u'name', artists=artists, album=album, - track_no=1, date=date, length=100, bitrate=100) + track_no=1, date=date, length=100, bitrate=100, + musicbrainz_id='id') self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) @@ -350,14 +449,21 @@ class TrackTest(unittest.TestCase): self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) + def test_ne_musicbrainz_id(self): + track1 = Track(musicbrainz_id=u'id1') + track2 = Track(musicbrainz_id=u'id2') + self.assertNotEqual(track1, track2) + self.assertNotEqual(hash(track1), hash(track2)) + def test_ne(self): track1 = Track(uri=u'uri1', name=u'name1', artists=[Artist(name=u'name1')], album=Album(name=u'name1'), - track_no=1, date=dt.date.today(), length=100, bitrate=100) + track_no=1, date=dt.date.today(), length=100, bitrate=100, + musicbrainz_id='id1') track2 = Track(uri=u'uri2', name=u'name2', artists=[Artist(name=u'name2')], album=Album(name=u'name2'), track_no=2, date=dt.date.today()-dt.timedelta(days=1), - length=200, bitrate=200) + length=200, bitrate=200, musicbrainz_id='id2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) @@ -398,7 +504,7 @@ class PlaylistTest(unittest.TestCase): last_modified = dt.datetime.now() playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) - new_playlist = playlist.with_(uri=u'another uri') + new_playlist = playlist.copy(uri=u'another uri') self.assertEqual(new_playlist.uri, u'another uri') self.assertEqual(new_playlist.name, u'a name') self.assertEqual(new_playlist.tracks, tracks) @@ -409,7 +515,7 @@ class PlaylistTest(unittest.TestCase): last_modified = dt.datetime.now() playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) - new_playlist = playlist.with_(name=u'another name') + new_playlist = playlist.copy(name=u'another name') self.assertEqual(new_playlist.uri, u'an uri') self.assertEqual(new_playlist.name, u'another name') self.assertEqual(new_playlist.tracks, tracks) @@ -421,7 +527,7 @@ class PlaylistTest(unittest.TestCase): playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_tracks = [Track(), Track()] - new_playlist = playlist.with_(tracks=new_tracks) + new_playlist = playlist.copy(tracks=new_tracks) self.assertEqual(new_playlist.uri, u'an uri') self.assertEqual(new_playlist.name, u'a name') self.assertEqual(new_playlist.tracks, new_tracks) @@ -433,7 +539,7 @@ class PlaylistTest(unittest.TestCase): new_last_modified = last_modified + dt.timedelta(1) playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) - new_playlist = playlist.with_(last_modified=new_last_modified) + new_playlist = playlist.copy(last_modified=new_last_modified) self.assertEqual(new_playlist.uri, u'an uri') self.assertEqual(new_playlist.name, u'a name') self.assertEqual(new_playlist.tracks, tracks) diff --git a/tests/scanner_test.py b/tests/scanner_test.py new file mode 100644 index 00000000..a1b53bcf --- /dev/null +++ b/tests/scanner_test.py @@ -0,0 +1,186 @@ +import unittest +from datetime import date + +from mopidy.scanner import Scanner, translator +from mopidy.models import Track, Artist, Album + +from tests import data_folder + +class FakeGstDate(object): + def __init__(self, year, month, day): + self.year = year + self.month = month + self.day = day + +class TranslatorTest(unittest.TestCase): + def setUp(self): + self.data = { + 'uri': 'uri', + 'album': u'albumname', + 'track-number': 1, + 'artist': u'name', + 'album-artist': 'albumartistname', + 'title': u'trackname', + 'track-count': 2, + 'date': FakeGstDate(2006, 1, 1,), + 'container-format': u'ID3 tag', + 'duration': 4531, + 'musicbrainz-trackid': 'mbtrackid', + 'musicbrainz-albumid': 'mbalbumid', + 'musicbrainz-artistid': 'mbartistid', + 'musicbrainz-albumartistid': 'mbalbumartistid', + } + + self.album = { + 'name': 'albumname', + 'num_tracks': 2, + 'musicbrainz_id': 'mbalbumid', + } + + self.artist = { + 'name': 'name', + 'musicbrainz_id': 'mbartistid', + } + + self.albumartist = { + 'name': 'albumartistname', + 'musicbrainz_id': 'mbalbumartistid', + } + + self.track = { + 'uri': 'uri', + 'name': 'trackname', + 'date': date(2006, 1, 1), + 'track_no': 1, + 'length': 4531, + 'musicbrainz_id': 'mbtrackid', + } + + def build_track(self): + if self.albumartist: + self.album['artists'] = [Artist(**self.albumartist)] + self.track['album'] = Album(**self.album) + self.track['artists'] = [Artist(**self.artist)] + return Track(**self.track) + + def check(self): + expected = self.build_track() + actual = translator(self.data) + self.assertEqual(expected, actual) + + def test_basic_data(self): + self.check() + + def test_missing_track_number(self): + del self.data['track-number'] + del self.track['track_no'] + self.check() + + def test_missing_track_count(self): + del self.data['track-count'] + del self.album['num_tracks'] + self.check() + + def test_missing_track_name(self): + del self.data['title'] + del self.track['name'] + self.check() + + def test_missing_track_musicbrainz_id(self): + del self.data['musicbrainz-trackid'] + del self.track['musicbrainz_id'] + self.check() + + def test_missing_album_name(self): + del self.data['album'] + del self.album['name'] + self.check() + + def test_missing_album_musicbrainz_id(self): + del self.data['musicbrainz-albumid'] + del self.album['musicbrainz_id'] + self.check() + + def test_missing_artist_name(self): + del self.data['artist'] + del self.artist['name'] + self.check() + + def test_missing_artist_musicbrainz_id(self): + del self.data['musicbrainz-artistid'] + del self.artist['musicbrainz_id'] + self.check() + + def test_missing_album_artist(self): + del self.data['album-artist'] + del self.albumartist['name'] + self.check() + + def test_missing_album_artist_musicbrainz_id(self): + del self.data['musicbrainz-albumartistid'] + del self.albumartist['musicbrainz_id'] + self.check() + + def test_missing_date(self): + del self.data['date'] + del self.track['date'] + self.check() + +class ScannerTest(unittest.TestCase): + def setUp(self): + self.errors = {} + self.data = {} + + def scan(self, path): + scanner = Scanner(data_folder(path), + self.data_callback, self.error_callback) + scanner.start() + + def check(self, name, key, value): + name = data_folder(name) + self.assertEqual(self.data[name][key], value) + + def data_callback(self, data): + uri = data['uri'][len('file://'):] + self.data[uri] = data + + def error_callback(self, uri, errors): + uri = uri[len('file://'):] + self.errors[uri] = errors + + def test_data_is_set(self): + self.scan('scanner/simple') + self.assert_(self.data) + + def test_errors_is_not_set(self): + self.scan('scanner/simple') + self.assert_(not self.errors) + + def test_uri_is_set(self): + self.scan('scanner/simple') + self.check('scanner/simple/song1.mp3', 'uri', 'file://' + + data_folder('scanner/simple/song1.mp3')) + + def test_duration_is_set(self): + self.scan('scanner/simple') + self.check('scanner/simple/song1.mp3', 'duration', 4680) + + def test_artist_is_set(self): + self.scan('scanner/simple') + self.check('scanner/simple/song1.mp3', 'artist', 'name') + + def test_album_is_set(self): + self.scan('scanner/simple') + self.check('scanner/simple/song1.mp3', 'album', 'albumname') + + def test_track_is_set(self): + self.scan('scanner/simple') + self.check('scanner/simple/song1.mp3', 'title', 'trackname') + + def test_nonexistant_folder_does_not_fail(self): + self.scan('scanner/does-not-exist') + self.assert_(not self.errors) + + def test_other_media_is_ignored(self): + self.scan('scanner/image') + self.assert_(self.errors) diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index ae63d5c0..4366305c 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -6,9 +6,10 @@ import sys import tempfile import unittest -from mopidy.utils.path import get_or_create_folder, path_to_uri +from mopidy.utils.path import (get_or_create_folder, mtime, + path_to_uri, uri_to_path, split_path, find_files) -from tests import SkipTest +from tests import SkipTest, data_folder class GetOrCreateFolderTest(unittest.TestCase): def setUp(self): @@ -33,9 +34,6 @@ class GetOrCreateFolderTest(unittest.TestCase): self.assert_(os.path.isdir(self.parent)) self.assertEqual(created, self.parent) - def test_that_userfolder_is_expanded(self): - raise SkipTest # Not sure how to safely test this - class PathToFileURITest(unittest.TestCase): def test_simple_path(self): @@ -69,3 +67,84 @@ class PathToFileURITest(unittest.TestCase): else: result = path_to_uri(u'/tmp/æøå') self.assertEqual(result, u'file:///tmp/%C3%A6%C3%B8%C3%A5') + + +class UriToPathTest(unittest.TestCase): + def test_simple_uri(self): + if sys.platform == 'win32': + result = uri_to_path('file:///C://WINDOWS/clock.avi') + self.assertEqual(result, u'C:/WINDOWS/clock.avi') + else: + result = uri_to_path('file:///etc/fstab') + self.assertEqual(result, u'/etc/fstab') + + def test_space_in_uri(self): + if sys.platform == 'win32': + result = uri_to_path('file:///C://test%20this') + self.assertEqual(result, u'C:/test this') + else: + result = uri_to_path(u'file:///tmp/test%20this') + self.assertEqual(result, u'/tmp/test this') + + def test_unicode_in_uri(self): + if sys.platform == 'win32': + result = uri_to_path( 'file:///C://%C3%A6%C3%B8%C3%A5') + self.assertEqual(result, u'C:/æøå') + else: + result = uri_to_path(u'file:///tmp/%C3%A6%C3%B8%C3%A5') + self.assertEqual(result, u'/tmp/æøå') + + +class SplitPathTest(unittest.TestCase): + def test_empty_path(self): + self.assertEqual([], split_path('')) + + def test_single_folder(self): + self.assertEqual(['foo'], split_path('foo')) + + def test_folders(self): + self.assertEqual(['foo', 'bar', 'baz'], split_path('foo/bar/baz')) + + def test_folders(self): + self.assertEqual(['foo', 'bar', 'baz'], split_path('foo/bar/baz')) + + def test_initial_slash_is_ignored(self): + self.assertEqual(['foo', 'bar', 'baz'], split_path('/foo/bar/baz')) + + def test_only_slash(self): + self.assertEqual([], split_path('/')) + + +class FindFilesTest(unittest.TestCase): + def find(self, path): + return list(find_files(data_folder(path))) + + def test_basic_folder(self): + self.assert_(self.find('')) + + def test_nonexistant_folder(self): + self.assertEqual(self.find('does-not-exist'), []) + + def test_file(self): + files = self.find('blank.mp3') + self.assertEqual(len(files), 1) + self.assert_(files[0], data_folder('blank.mp3')) + + def test_names_are_unicode(self): + is_unicode = lambda f: isinstance(f, unicode) + for name in self.find(''): + self.assert_(is_unicode(name), + '%s is not unicode object' % repr(name)) + + +class MtimeTest(unittest.TestCase): + def tearDown(self): + mtime.undo_fake() + + def test_mtime_of_current_dir(self): + mtime_dir = int(os.stat('.').st_mtime) + self.assertEqual(mtime_dir, mtime('.')) + + def test_fake_time_is_returned(self): + mtime.set_fake_time(123456) + self.assertEqual(mtime('.'), 123456) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 0c06ae5c..8e2575b9 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -1,6 +1,7 @@ +import os import unittest -from mopidy import settings as default_settings_module +from mopidy import settings as default_settings_module, SettingsError from mopidy.utils.settings import validate_settings, SettingsProxy class ValidateSettingsTest(unittest.TestCase): @@ -54,6 +55,33 @@ class SettingsProxyTest(unittest.TestCase): self.settings.TEST = 'test' self.assertEqual(self.settings.TEST, 'test') + def test_getattr_raises_error_on_missing_setting(self): + try: + test = self.settings.TEST + self.fail(u'Should raise exception') + except SettingsError as e: + self.assertEqual(u'Setting "TEST" is not set.', e.message) + + def test_getattr_raises_error_on_empty_setting(self): + self.settings.TEST = u'' + try: + test = self.settings.TEST + self.fail(u'Should raise exception') + except SettingsError as e: + self.assertEqual(u'Setting "TEST" is empty.', e.message) + + def test_getattr_does_not_raise_error_if_setting_is_false(self): + self.settings.TEST = False + self.assertEqual(False, self.settings.TEST) + + def test_getattr_does_not_raise_error_if_setting_is_none(self): + self.settings.TEST = None + self.assertEqual(None, self.settings.TEST) + + def test_getattr_does_not_raise_error_if_setting_is_zero(self): + self.settings.TEST = 0 + self.assertEqual(0, self.settings.TEST) + def test_setattr_updates_runtime_settings(self): self.settings.TEST = 'test' self.assert_('TEST' in self.settings.runtime) @@ -65,3 +93,37 @@ class SettingsProxyTest(unittest.TestCase): def test_runtime_value_included_in_current(self): self.settings.TEST = 'test' self.assertEqual(self.settings.current['TEST'], 'test') + + def test_value_ending_in_path_is_expanded(self): + self.settings.TEST_PATH = '~/test' + actual = self.settings.TEST_PATH + expected = os.path.expanduser('~/test') + self.assertEqual(actual, expected) + + def test_value_ending_in_path_is_absolute(self): + self.settings.TEST_PATH = './test' + actual = self.settings.TEST_PATH + expected = os.path.abspath('./test') + self.assertEqual(actual, expected) + + def test_value_ending_in_file_is_expanded(self): + self.settings.TEST_FILE = '~/test' + actual = self.settings.TEST_FILE + expected = os.path.expanduser('~/test') + self.assertEqual(actual, expected) + + def test_value_ending_in_file_is_absolute(self): + self.settings.TEST_FILE = './test' + actual = self.settings.TEST_FILE + expected = os.path.abspath('./test') + self.assertEqual(actual, expected) + + def test_value_not_ending_in_path_or_file_is_not_expanded(self): + self.settings.TEST = '~/test' + actual = self.settings.TEST + self.assertEqual(actual, '~/test') + + def test_value_not_ending_in_path_or_file_is_not_absolute(self): + self.settings.TEST = './test' + actual = self.settings.TEST + self.assertEqual(actual, './test') diff --git a/tests/version_test.py b/tests/version_test.py index fcc95c4c..a8bc2955 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -12,6 +12,7 @@ class VersionTest(unittest.TestCase): self.assert_(SV('0.1.0a1') < SV('0.1.0a2')) self.assert_(SV('0.1.0a2') < SV('0.1.0a3')) self.assert_(SV('0.1.0a3') < SV('0.1.0')) - self.assert_(SV('0.1.0') < SV(get_version())) - self.assert_(SV(get_version()) < SV('0.2.1')) - self.assert_(SV('0.2.0') < SV('1.0.0')) + self.assert_(SV('0.1.0') < SV('0.2.0')) + self.assert_(SV('0.1.0') < SV('1.0.0')) + self.assert_(SV('0.2.0') < SV(get_version())) + self.assert_(SV(get_version()) < SV('0.3.1'))