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'))