diff --git a/.gitignore b/.gitignore index 65ed3f37..6f127051 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,14 @@ *.swp .coverage .idea -docs/_build -local_settings.py +.noseids +MANIFEST +build/ +cover/ +coverage.xml +dist/ +docs/_build/ +nosetests.xml pip-log.txt -spotify_appkey.key src/ tmp/ diff --git a/AUTHORS.rst b/AUTHORS.rst index 8e08456e..e8c7f224 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -3,5 +3,12 @@ Authors Contributors to Mopidy in the order of appearance: -* Stein Magnus Jodal -* Johannes Knutsen +- Stein Magnus Jodal +- Johannes Knutsen +- Thomas Adamcik +- Kristian Klette + +Also, we would like to thank: + +- Jørgen P. Tjernø for his work on the Python wrapper for Despotify. +- Doug Winter for his work on the Python wrapper for libspotify. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..6235b2c8 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include COPYING +include *.rst +include requirements*.txt +recursive-include docs *.rst diff --git a/README.rst b/README.rst index 9bca444e..0d9db0b8 100644 --- a/README.rst +++ b/README.rst @@ -7,10 +7,11 @@ Mopidy is an `MPD `_ server with a can search for music in Spotify's vast archive, manage Spotify play lists and play music from Spotify. -Mopidy is currently under development. Unless you want to contribute to the -development, you should probably wait for our first release before trying out -Mopidy. +To install Mopidy, check out +`the installation docs `_. -* `Source code `_ * `Documentation `_ +* `Source code `_ +* `Issue tracker `_ * IRC: ``#mopidy`` at `irc.freenode.net `_ +* `Presentation of Mopidy `_ diff --git a/bin/mopidy b/bin/mopidy new file mode 100644 index 00000000..0472518e --- /dev/null +++ b/bin/mopidy @@ -0,0 +1,5 @@ +#! /usr/bin/env python + +if __name__ == '__main__': + from mopidy.__main__ import main + main() diff --git a/docs/_static/thread_communication.png b/docs/_static/thread_communication.png new file mode 100644 index 00000000..95bf1892 Binary files /dev/null and b/docs/_static/thread_communication.png differ diff --git a/docs/_static/thread_communication.txt b/docs/_static/thread_communication.txt new file mode 100644 index 00000000..4119004e --- /dev/null +++ b/docs/_static/thread_communication.txt @@ -0,0 +1,37 @@ +Script for use with www.websequencediagrams.com +=============================================== + +Main -> Core: create +activate Core +note over Core: create NadMixer +Core -> NadTalker: create +activate NadTalker +note over NadTalker: calibrate device +note over Core: create DespotifyBackend +Core -> despotify: connect to Spotify +activate despotify +note over Core: create MpdFrontend +Main -> Server: create +activate Server +note over Server: open port +Client -> Server: connect +note over Server: open session +Client -> Server: play 1 +Server -> Core: play 1 +Core -> despotify: play first track +Client -> Server: setvol 50 +Server -> Core: setvol 50 +Core -> NadTalker: volume = 50 +Client -> Server: status +Server -> Core: status +Core -> NadTalker: volume? +NadTalker -> Core: volume = 50 +Core -> Server: status response +Server -> Client: status response +despotify -> Core: end of track callback +Core -> despotify: play second track +Client -> Server: stop +Server -> Core: stop +Core -> despotify: stop +Client -> Server: disconnect +note over Server: close session diff --git a/docs/_templates/layout.html b/docs/_templates/layout.html new file mode 100644 index 00000000..d6cb00e9 --- /dev/null +++ b/docs/_templates/layout.html @@ -0,0 +1,15 @@ +{% extends "!layout.html" %} + +{% block footer %} +{{ super() }} + + +{% endblock %} diff --git a/docs/api/backends.rst b/docs/api/backends.rst index e20578ea..e099fbf8 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -1,294 +1,69 @@ -************************************* -:mod:`mopidy.backends` -- Backend API -************************************* +********************** +:mod:`mopidy.backends` +********************** -.. warning:: - This is our *planned* backend API, and not the current API. +The backend and its controllers +=============================== -.. module:: mopidy.backends - :synopsis: Interface between Mopidy and its various backends. +.. graph:: backend_relations -.. class:: BaseBackend() + backend -- current_playlist + backend -- library + backend -- playback + backend -- stored_playlists - .. attribute:: current_playlist - The current playlist controller. An instance of - :class:`BaseCurrentPlaylistController`. +Backend API +=========== - .. attribute:: library +.. note:: - The library controller. An instance of :class:`BaseLibraryController`. + Currently this only documents the API that is available for use by + frontends like :class:`mopidy.mpd.handler`, and not what is required to + implement your own backend. :class:`mopidy.backends.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. - .. attribute:: playback +.. automodule:: mopidy.backends + :synopsis: Backend interface. + :members: + :undoc-members: - The playback controller. An instance of :class:`BasePlaybackController`. - .. attribute:: stored_playlists +Spotify backends +================ - The stored playlists controller. An instance of - :class:`BaseStoredPlaylistsController`. +:mod:`mopidy.backends.despotify` -- Despotify backend +----------------------------------------------------- - .. attribute:: uri_handlers +.. automodule:: mopidy.backends.despotify + :synopsis: Spotify backend using the despotify library. + :members: - List of URI prefixes this backend can handle. +:mod:`mopidy.backends.libspotify` -- Libspotify backend +------------------------------------------------------- -.. class:: BaseCurrentPlaylistController(backend) +.. automodule:: mopidy.backends.libspotify + :synopsis: Spotify backend using the libspotify library. + :members: - :param backend: backend the controller is a part of - :type backend: :class:`BaseBackend` - .. method:: add(track, at_position=None) +Other backends +============== - Add the track to the end of, or at the given position in the current - playlist. +:mod:`mopidy.backends.dummy` -- Dummy backend +--------------------------------------------- - :param track: track to add - :type track: :class:`mopidy.models.Track` - :param at_position: position in current playlist to add track - :type at_position: int or :class:`None` +.. automodule:: mopidy.backends.dummy + :synopsis: Dummy backend used for testing. + :members: - .. method:: clear() - Clear the current playlist. +GStreamer backend +----------------- - .. method:: load(playlist) - - Replace the current playlist with the given playlist. - - :param playlist: playlist to load - :type playlist: :class:`mopidy.models.Playlist` - - .. method:: move(start, end, to_position) - - Move the tracks in the slice ``[start:end]`` to ``to_position``. - - :param start: position of first track to move - :type start: int - :param end: position after last track to move - :type end: int - :param to_position: new position for the tracks - :type to_position: int - - .. attribute:: playlist - - The currently loaded :class:`mopidy.models.Playlist`. - - .. method:: remove(track) - - Remove the track from the current playlist. - - :param track: track to remove - :type track: :class:`mopidy.models.Track` - - .. method:: shuffle(start=None, end=None) - - Shuffles the entire playlist. If ``start`` and ``end`` is given only - shuffles the slice ``[start:end]``. - - :param start: position of first track to shuffle - :type start: int or :class:`None` - :param end: position after last track to shuffle - :type end: int or :class:`None` - - .. attribute:: version - - The current playlist version. Integer which is increased every time the - current playlist is changed. - - -.. class:: BasePlaybackController(backend) - - :param backend: backend the controller is a part of - :type backend: :class:`BaseBackend` - - .. attribute:: consume - - :class:`True` - Tracks are removed from the playlist when they have been played. - :class:`False` - Tracks are not removed from the playlist. - - .. attribute:: current_track - - The currently playing or selected :class:`mopidy.models.Track`. - - .. method:: next() - - Play the next track. - - .. method:: pause() - - Pause playblack. - - .. attribute:: PAUSED - - Constant representing the paused state. - - .. method:: play(track=None) - - Play the given track or the currently active track. - - :param track: track to play - :type track: :class:`mopidy.models.Track` or :class:`None` - - .. attribute:: PLAYING - - Constant representing the playing state. - - .. attribute:: playlist_position - - The position in the current playlist. - - .. method:: previous() - - Play the previous track. - - .. attribute:: random - - :class:`True` - Tracks are selected at random from the playlist. - :class:`False` - Tracks are played in the order of the playlist. - - .. attribute:: repeat - - :class:`True` - The current track is played repeatedly. - :class:`False` - The current track is played once. - - .. method:: resume() - - If paused, resume playing the current track. - - .. method:: seek(time_position) - - Seeks to time position given in milliseconds. - - :param time_position: time position in milliseconds - :type time_position: int - - .. attribute:: state - - The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or - :attr:`STOPPED`. - - .. method:: stop() - - Stop playing. - - .. attribute:: STOPPED - - Constant representing the stopped state. - - .. attribute:: time_position - - Time position in milliseconds. - - .. attribute:: volume - - The audio volume as an int in the range [0, 100]. :class:`None` if - unknown. - - -.. class:: BaseLibraryController(backend) - - :param backend: backend the controller is a part of - :type backend: :class:`BaseBackend` - - .. method:: find_exact(type, query) - - Find tracks in the library where ``type`` matches ``query`` exactly. - - :param type: 'title', 'artist', or 'album' - :type type: string - :param query: the search query - :type query: string - :rtype: list of :class:`mopidy.models.Track` - - .. method:: lookup(uri) - - Lookup track with given URI. - - :param uri: track URI - :type uri: string - :rtype: :class:`mopidy.models.Track` - - .. method:: refresh(uri=None) - - Refresh library. Limit to URI and below if an URI is given. - - :param uri: directory or track URI - :type uri: string - - .. method:: search(type, query) - - Search the library for tracks where ``type`` contains ``query``. - - :param type: 'title', 'artist', 'album', or 'uri' - :type type: string - :param query: the search query - :type query: string - :rtype: list of :class:`mopidy.models.Track` - - -.. class:: BaseStoredPlaylistsController(backend) - - :param backend: backend the controller is a part of - :type backend: :class:`BaseBackend` - - .. method:: add(uri) - - Add existing playlist with the given URI. - - :param uri: URI of existing playlist - :type uri: string - - .. method:: create(name) - - Create a new playlist. - - :param name: name of the new playlist - :type name: string - :rtype: :class:`mopidy.models.Playlist` - - .. attribute:: playlists - - List of :class:`mopidy.models.Playlist`. - - .. method:: delete(playlist) - - Delete playlist. - - :param playlist: the playlist to delete - :type playlist: :class:`mopidy.models.Playlist` - - .. method:: lookup(uri) - - Lookup playlist with given URI. - - :param uri: playlist URI - :type uri: string - :rtype: :class:`mopidy.models.Playlist` - - .. method:: refresh() - - Refresh stored playlists. - - .. method:: rename(playlist, new_name) - - Rename playlist. - - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - :param new_name: the new name - :type new_name: string - - .. method:: search(query) - - Search for playlists whose name contains ``query``. - - :param query: query to search for - :type query: string - :rtype: list of :class:`mopidy.models.Playlist` +``GstreamerBackend`` is pending merge from `adamcik/mopidy/gstreamer +`_. diff --git a/docs/api/index.rst b/docs/api/index.rst new file mode 100644 index 00000000..86f4e06e --- /dev/null +++ b/docs/api/index.rst @@ -0,0 +1,8 @@ +***************** +API documentation +***************** + +.. toctree:: + :glob: + + ** diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst new file mode 100644 index 00000000..aa5998d0 --- /dev/null +++ b/docs/api/mixers.rst @@ -0,0 +1,96 @@ +******************** +:mod:`mopidy.mixers` +******************** + +Mixers are responsible for controlling volume. Clients of the mixers will +simply instantiate a mixer and read/write to the ``volume`` attribute:: + + >>> from mopidy.mixers.alsa import AlsaMixer + >>> mixer = AlsaMixer() + >>> mixer.volume + 100 + >>> mixer.volume = 80 + >>> mixer.volume + 80 + + +Mixer API +========= + +All mixers should subclass :class:`mopidy.mixers.BaseMixer` and override +methods as described below. + +.. automodule:: mopidy.mixers + :synopsis: Sound mixer interface. + :members: + :undoc-members: + + +Internal mixers +=============== + +Most users will use one of these internal mixers which controls the volume on +the computer running Mopidy. If you do not specify which mixer you want to use +in the settings, Mopidy will choose one for you based upon what OS you run. See +:attr:`mopidy.settings.MIXER` for the defaults. + + +:mod:`mopidy.mixers.alsa` -- ALSA mixer +--------------------------------------- + +.. automodule:: mopidy.mixers.alsa + :synopsis: ALSA mixer + :members: + +.. inheritance-diagram:: mopidy.mixers.alsa.AlsaMixer + + +:mod:`mopidy.mixers.dummy` -- Dummy mixer +----------------------------------------- + +.. automodule:: mopidy.mixers.dummy + :synopsis: Dummy mixer + :members: + +.. inheritance-diagram:: mopidy.mixers.dummy + + +:mod:`mopidy.mixers.osa` -- Osa mixer +------------------------------------- + +.. automodule:: mopidy.mixers.osa + :synopsis: Osa mixer + :members: + +.. inheritance-diagram:: mopidy.mixers.osa + + +External device mixers +====================== + +Mopidy supports controlling volume on external devices instead of on the +computer running Mopidy through the use of custom mixer implementations. To +enable one of the following 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. + + +:mod:`mopidy.mixers.denon` -- Denon amplifier mixer +--------------------------------------------------- + +.. automodule:: mopidy.mixers.denon + :synopsis: Denon amplifier mixer + :members: + +.. inheritance-diagram:: mopidy.mixers.denon + + +:mod:`mopidy.mixers.nad` -- NAD amplifier mixer +----------------------------------------------- + +.. automodule:: mopidy.mixers.nad + :synopsis: NAD amplifier mixer + :members: + +.. inheritance-diagram:: mopidy.mixers.nad diff --git a/docs/api/models.rst b/docs/api/models.rst index 75f9ab02..8be375ef 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -1,6 +1,26 @@ -********************************************* -:mod:`mopidy.models` -- Immutable data models -********************************************* +******************** +:mod:`mopidy.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 +and immutable. In other words, they can only be set through the class +constructor during instance creation. + + +Data model relations +==================== + +.. digraph:: model_relations + + Playlist -> Track [ label="has 0..n" ] + Track -> Album [ label="has 0..1" ] + Track -> Artist [ label="has 0..n" ] + Album -> Artist [ label="has 0..n" ] + + +Data model API +============== .. automodule:: mopidy.models :synopsis: Immutable data models. diff --git a/docs/api/mpd.rst b/docs/api/mpd.rst new file mode 100644 index 00000000..021f5dcd --- /dev/null +++ b/docs/api/mpd.rst @@ -0,0 +1,22 @@ +***************** +:mod:`mopidy.mpd` +***************** + +MPD protocol implementation +=========================== + +.. automodule:: mopidy.mpd.frontend + :synopsis: Our MPD protocol implementation. + :members: + :undoc-members: + + +MPD server implementation +========================= + +.. automodule:: mopidy.mpd.server + :synopsis: Our MPD server implementation. + :members: + :undoc-members: + +.. inheritance-diagram:: mopidy.mpd.server diff --git a/docs/api/settings.rst b/docs/api/settings.rst new file mode 100644 index 00000000..4679a535 --- /dev/null +++ b/docs/api/settings.rst @@ -0,0 +1,27 @@ +********************** +: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:: + + SERVER_HOSTNAME = u'0.0.0.0' + SPOTIFY_USERNAME = u'alice' + SPOTIFY_USERNAME = 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 new file mode 100644 index 00000000..e122f914 --- /dev/null +++ b/docs/authors.rst @@ -0,0 +1 @@ +.. include:: ../AUTHORS.rst diff --git a/docs/autodoc_private_members.py b/docs/autodoc_private_members.py new file mode 100644 index 00000000..9cb2e49b --- /dev/null +++ b/docs/autodoc_private_members.py @@ -0,0 +1,10 @@ +def setup(app): + app.connect('autodoc-skip-member', autodoc_private_members_with_doc) + +def autodoc_private_members_with_doc(app, what, name, obj, skip, options): + if not skip: + return skip + if (name.startswith('_') and obj.__doc__ is not None + and not (name.startswith('__') and name.endswith('__'))): + return False + return skip diff --git a/docs/changes.rst b/docs/changes.rst index 3e4edadd..cd54336c 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -5,11 +5,19 @@ Changes This change log is used to track all major changes to Mopidy. -0.1 (unreleased) -================ +0.1.0a0 (2010-03-27) +==================== -Initial version. +"*Release early. Release often. Listen to your customers.*" wrote Eric S. +Raymond in *The Cathedral and the Bazaar*. -Features: +Three months of development should be more than enough. We have more to do, but +Mopidy is working and usable. 0.1.0a0 is an alpha release, which basicly means +we will still change APIs, add features, etc. before the final 0.1.0 release. +But the software is usable as is, so we release it. Please give it a try and +give us feedback, either at our IRC channel or through the `issue tracker +`_. Thanks! -* *TODO:* Fill out +**Changes** + +- Initial version. No changelog available. diff --git a/docs/conf.py b/docs/conf.py index 8cb63290..3b00883e 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,13 +16,17 @@ 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__) + '/../')) +import mopidy + # -- General configuration ----------------------------------------------------- # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc'] +extensions = ['sphinx.ext.autodoc', 'autodoc_private_members', + 'sphinx.ext.graphviz', 'sphinx.ext.inheritance_diagram'] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -44,10 +48,11 @@ copyright = u'2010, Stein Magnus Jodal' # |version| and |release|, also used in various other places throughout the # built documents. # -# The short X.Y version. -version = '0.1' # The full version, including alpha/beta/rc tags. -release = '0.1' +release = mopidy.get_version() +# The short X.Y version. +version = '.'.join(release.split('.')[:2]) + # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -84,7 +89,7 @@ exclude_trees = ['_build'] pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +modindex_common_prefix = ['mopidy.'] # -- Options for HTML output --------------------------------------------------- @@ -124,7 +129,7 @@ html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. @@ -192,3 +197,4 @@ latex_documents = [ # If false, no module index is generated. #latex_use_modindex = True + diff --git a/docs/development.rst b/docs/development.rst deleted file mode 100644 index 2a39b327..00000000 --- a/docs/development.rst +++ /dev/null @@ -1,94 +0,0 @@ -*********** -Development -*********** - -Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at -``irc.freenode.net`` and through `GitHub `_. - - -API documentation -================= - -.. toctree:: - :glob: - - api/* - - -Scope -===== - -To limit scope, we will start by implementing an MPD server which only -supports Spotify, and not playback of files from disk. We will make Mopidy -modular, so we can extend it with other backends in the future, like file -playback and other online music services such as Last.fm. - - -Running tests -============= - -To run tests, you need a couple of dependiencies. Some can be installed through Debian/Ubuntu package management:: - - sudo aptitude install python-coverage - -The rest can be installed using pip:: - - sudo aptitude install python-pip python-setuptools bzr - sudo pip install -r test-requirements.txt - -Then, to run all tests:: - - python tests - - -Music Player Daemon (MPD) -========================= - -The `MPD protocol documentation `_ is a -useful resource. It is rather incomplete with regards to data formats, both for -requests and responses. Thus we have to talk a great deal with the the original -`MPD server `_ using telnet to get the details we need -to implement our own MPD server which is compatible with the numerous existing -`MPD clients `_. - - -spytify -======= - -`spytify `_ -is the Python bindings for the open source `despotify `_ -library. It got no documentation to speak of, but a couple of examples are -available. - -Issues ------- - -A list of the issues we currently experience with spytify, both bugs and -features we wished was there. - -* r483: Sometimes segfaults when traversing stored playlists, their tracks, - artists, and albums. As it is not predictable, it may be a concurrency issue. - -* r503: Segfaults when looking up playlists, both your own lists and other - peoples shared lists. To reproduce:: - - >>> import spytify - >>> s = spytify.Spytify('alice', 'secret') - >>> s.lookup('spotify:user:klette:playlist:5rOGYPwwKqbAcVX8bW4k5V') - Segmentation fault - - -pyspotify -========= - -`pyspotify `_ is the Python bindings for -the official Spotify library, libspotify. It got no documentation to speak of, -but multiple examples are available. - -Issues ------- - -A list of the issues we currently experience with pyspotify, both bugs and -features we wished was there. - -* None at the moment. diff --git a/docs/development/contributing.rst b/docs/development/contributing.rst new file mode 100644 index 00000000..a2ef4a15 --- /dev/null +++ b/docs/development/contributing.rst @@ -0,0 +1,162 @@ +***************** +How to contribute +***************** + +Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at +``irc.freenode.net`` and through `GitHub `_. + + +Code style +========== + +- Follow :pep:`8` unless otherwise noted. `pep8.py + `_ can be used to check your code against + the guidelines, however remember that matching the style of the surrounding + code is also important. + +- Use four spaces for indentation, *never* tabs. + +- Use CamelCase with initial caps for class names:: + + ClassNameWithCamelCase + +- Use underscore to split variable, function and method names for + readability. Don't use CamelCase. + + :: + + lower_case_with_underscores + +- Use the fact that empty strings, lists and tuples are :class:`False` and + don't compare boolean values using ``==`` and ``!=``. + +- Follow whitespace rules as described in :pep:`8`. Good examples:: + + spam(ham[1], {eggs: 2}) + spam(1) + dict['key'] = list[index] + +- Limit lines to 80 characters and avoid trailing whitespace. However note that + wrapped lines should be *one* indentation level in from level above, except + for ``if``, ``for``, ``with``, and ``while`` lines which should have two + levels of indentation:: + + if (foo and bar ... + baz and foobar): + a = 1 + + from foobar import (foo, bar, ... + baz) + +- For consistency, prefer ``'`` over ``"`` for strings, unless the string + contains ``'``. + +- Take a look at :pep:`20` for a nice peek into a general mindset useful for + Python coding. + + +Commit guidelines +================= + +- Keep commits small and on topic. + +- If a commit looks too big you should be working in a feature branch not a + single commit. + +- Merge feature branches with ``--no-ff`` to keep track of the merge. + + +Running tests +============= + +To run tests, you need a couple of dependencies. They can be installed through +Debian/Ubuntu package management:: + + sudo aptitude install python-coverage python-nose + +Or, they can be installed using ``pip``:: + + sudo pip install -r requirements-tests.txt + +Then, to run all tests, go to the project directory and run:: + + nosetests + +For example:: + + $ nosetests + ...................................................................... + ...................................................................... + ...................................................................... + ....... + ---------------------------------------------------------------------- + Ran 217 tests in 0.267s + + OK + +To run tests with test coverage statistics:: + + nosetests --with-coverage + +For more documentation on testing, check out the `nose documentation +`_. + + +Continuous integration server +============================= + +We run a continuous integration server called Hudson at +http://hudson.mopidy.com/ that runs all test on multiple platforms (Ubuntu, OS +X, etc.) for every commit we push to GitHub. If the build is broken or fixed, +Hudson will issue notifications to our IRC channel. + +In addition to running tests, Hudson also does coverage statistics and uses +pylint to check for errors and possible improvements in our code. So, if you're +out of work, the code coverage and pylint data in Hudson should give you a +place to start. + + +Writing documentation +===================== + +To write documentation, we use `Sphinx `_. See their +site for lots of documentation on how to use Sphinx. To generate HTML or LaTeX +from the documentation files, you need some additional dependencies. + +You can install them through Debian/Ubuntu package management:: + + sudo aptitude install python-sphinx python-pygraphviz graphviz + +Then, to generate docs:: + + cd docs/ + make # For help on available targets + make html # To generate HTML docs + +.. note:: + + The documentation at http://www.mopidy.com/docs/ is automatically updated + within 10 minutes after a documentation update is pushed to + ``jodal/mopidy/master`` at GitHub. + + +Creating releases +================= + +1. Update changelog and commit it. + +2. Tag release:: + + git tag -a -m "Release v0.1.0a0" v0.1.0a0 + +3. Push to GitHub:: + + git push + git push --tags + +4. Build package and upload to PyPI:: + + rm MANIFEST # Will be regenerated by setup.py + python setup.py sdist upload + +5. Spread the word. diff --git a/docs/development/index.rst b/docs/development/index.rst new file mode 100644 index 00000000..14c49dbd --- /dev/null +++ b/docs/development/index.rst @@ -0,0 +1,10 @@ +*********** +Development +*********** + +.. toctree:: + :maxdepth: 3 + + roadmap + contributing + internals diff --git a/docs/development/internals.rst b/docs/development/internals.rst new file mode 100644 index 00000000..085b55ac --- /dev/null +++ b/docs/development/internals.rst @@ -0,0 +1,59 @@ +********* +Internals +********* + +Some of the following notes and details will hopefully be useful when you start +developing on Mopidy, while some may only be useful when you get deeper into +specific parts of Mopidy. + +In addition to what you'll find here, don't forget the :doc:`/api/index`. + + +Class instantiation and usage +============================= + +The following diagram shows how Mopidy with the despotify backend and ALSA +mixer is wired together. The gray nodes are part of external dependencies, and +not Mopidy. The red nodes lives in the ``main`` process (running an +:mod:`asyncore` loop), while the blue nodes lives in a secondary process named +``core`` (running a service loop in :class:`mopidy.core.CoreProcess`). + +.. digraph:: class_instantiation_and_usage + + "spytify" [ color="gray" ] + "despotify" [ color="gray" ] + "alsaaudio" [ color="gray" ] + "__main__" [ color="red" ] + "CoreProcess" [ color="blue" ] + "DespotifyBackend" [ color="blue" ] + "AlsaMixer" [ color="blue" ] + "MpdFrontend" [ color="blue" ] + "MpdServer" [ color="red" ] + "MpdSession" [ color="red" ] + "__main__" -> "CoreProcess" [ label="create" ] + "__main__" -> "MpdServer" [ label="create" ] + "CoreProcess" -> "DespotifyBackend" [ label="create" ] + "CoreProcess" -> "MpdFrontend" [ label="create" ] + "MpdServer" -> "MpdSession" [ label="create one per client" ] + "MpdSession" -> "MpdFrontend" [ label="pass MPD requests to" ] + "MpdFrontend" -> "DespotifyBackend" [ label="use backend API" ] + "DespotifyBackend" -> "AlsaMixer" [ label="create and use mixer API" ] + "DespotifyBackend" -> "spytify" [ label="use Python wrapper" ] + "spytify" -> "despotify" [ label="use C library" ] + "AlsaMixer" -> "alsaaudio" [ label="use Python library" ] + + +Thread/process communication +============================ + +- Everything starts with ``Main``. +- ``Main`` creates a ``Core`` process which runs the frontend, backend, and + mixer. +- Mixers *may* create an additional process for communication with external + devices, like ``NadTalker`` in this example. +- Backend libraries *may* have threads of their own, like ``despotify`` here + which has additional threads in the ``Core`` process. +- ``Server`` part currently runs in the same process and thread as ``Main``. +- ``Client`` is some external client talking to ``Server`` over a socket. + +.. image:: /_static/thread_communication.png diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst new file mode 100644 index 00000000..7e9cfc1a --- /dev/null +++ b/docs/development/roadmap.rst @@ -0,0 +1,58 @@ +******* +Roadmap +******* + +This is the current roadmap and collection of wild ideas for future Mopidy +development. + + +Scope for the first release +=========================== + +This was was the plan written down when we started developing Mopidy, and we +still keep quite close to it: + + To limit scope, we will start by implementing an MPD server which only + supports Spotify, and not playback of files from disk. We will make Mopidy + modular, so we can extend it with other backends in the future, like file + playback and other online music services such as Last.fm. + + +Stuff we really want to do, but just not right now +================================================== + +- Replace libspotify with `openspotify + `_ for the + ``LibspotifyBackend``. +- A backend for playback from local disk. Quite a bit of work on a `gstreamer + `_ backend has already been done by Thomas + Adamcik. +- Support multiple backends at the same time. It would be really nice to have + tracks from local disk and Spotify tracks in the same playlist. +- **[Done]** Package Mopidy as a `Python package + `_. +- **[Done]** Get a build server, i.e. `Hudson `_, up and + running which runs our test suite on all relevant platforms (Ubuntu, OS X, + etc.) and creates nightly packages (see next items). +- 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. +- Create `Homebrew `_ recipies for all our + dependencies and Mopidy itself to make OS X installation a breeze. + + +Crazy stuff we had to write down somewhere +========================================== + +- Add or create a new frontend protocol other than MPD. The MPD protocol got + quite a bit of legacy and it is badly documented. The amount of available + client implementations is MPD's big win. +- Add support for storing (Spotify) music to disk. +- Add support for serving the music as an `Icecast `_ + stream instead of playing it locally. +- Integrate with `Squeezebox `_ in some + way. +- AirPort Express support, like in + `PulseAudio `_. +- **[Done]** NAD/Denon amplifier mixer through their RS-232 connection. +- DNLA and/or UPnP support. diff --git a/docs/index.rst b/docs/index.rst index 7c618dc3..2a6dbfc5 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,9 +6,11 @@ Contents .. toctree:: :maxdepth: 3 + installation/index changes - installation - development + development/index + api/index + authors Indices and tables ================== diff --git a/docs/installation.rst b/docs/installation.rst deleted file mode 100644 index f6eb8326..00000000 --- a/docs/installation.rst +++ /dev/null @@ -1,135 +0,0 @@ -************ -Installation -************ - -Mopidy itself is a breeze to install, as it just requires a standard Python -installation. The libraries we depend on to connect to the Spotify service is -far more tricky to get working for the time being. Until installation of these -libraries are either well documented by their developers, or the libraries are -packaged for various Linux distributions, we will supply our own installation -guides here. - - -Dependencies -============ - -* Python >= 2.5 -* Dependencies for at least one Mopidy backend: - - * :ref:`despotify` - * :ref:`libspotify` - - -.. _despotify: - -despotify backend -================= - -To use the despotify backend, you first need to install despotify and spytify. - -*This backend requires a Spotify premium account.* - - -Installing despotify and spytify --------------------------------- - -Install despotify's dependencies. At Debian/Ubuntu systems:: - - sudo aptitude install libssl-dev zlib1g-dev libvorbis-dev \ - libtool libncursesw5-dev libao-dev - -Check out revision 503 of the despotify source code:: - - svn co https://despotify.svn.sourceforge.net/svnroot/despotify@503 despotify - -Build and install despotify:: - - cd despotify/src/ - make - sudo make install - -Build and install spytify:: - - cd despotify/src/bindings/python/ - make - sudo make install - -To validate that everything is working, run the ``test.py`` script which is -distributed with spytify:: - - python test.py - -The test script should ask for your username and password (which must be for a -Spotify Premium account), ask for a search query, list all your playlists with -tracks, play 10s from a random song from the search result, pause for two -seconds, play for five more seconds, and quit. - -.. _libspotify: - -libspotify backend -================== - -As an alternative to the despotify backend, we are working on a libspotify -backend. To use the libspotify backend you must install libspotify and -pyspotify. - -*This backend requires a Spotify premium account.* - -*This backend requires you to get an application key from Spotify before use.* - - -Installing libspotify and pyspotify ------------------------------------ - -As libspotify's installation script at the moment is somewhat broken (see this -`GetSatisfaction thread `_ -for details), it is easiest to use the libspotify files bundled with pyspotify. -The files bundled with pyspotify are for 64-bit, so if you run a 32-bit OS, you -must get libspotify from https://developer.spotify.com/en/libspotify/. - -Install pyspotify's dependencies. At Debian/Ubuntu systems:: - - sudo aptitude install python-alsaaudio - -Check out the pyspotify code, and install it:: - - git clone git://github.com/winjer/pyspotify.git - cd pyspotify - export LD_LIBRARY_PATH=$PWD/lib - sudo python setup.py develop - -Apply for an application key at -https://developer.spotify.com/en/libspotify/application-key, download the -binary version, and place the file at ``pyspotify/spotify_appkey.key``. - -Test your libspotify setup:: - - ./example1.py -u USERNAME -p PASSWORD - -Until Spotify fixes their installation script, you'll have to set -``LD_LIBRARY_PATH`` every time you are going to use libspotify (in other words -before starting Mopidy). - - -Running Mopidy -============== - -Create a file name ``local_settings.py`` in the same directory as -``settings.py``. Enter your Spotify Premium account's username and password -into the file, like this:: - - SPOTIFY_USERNAME = u'myusername' - SPOTIFY_PASSWORD = u'mysecret' - -Currently the despotify backend is the default. If you want to use the -libspotify backend, copy the Spotify application key to -``mopidy/spotify_appkey.key``, and add the following to -``mopidy/mopidy/local_settings.py``:: - - BACKEND=u'mopidy.backends.libspotify.LibspotifyBackend' - -To start Mopidy, go to the root of the Mopidy project, then simply run:: - - python mopidy - -To stop Mopidy, press ``CTRL+C``. diff --git a/docs/installation/despotify.rst b/docs/installation/despotify.rst new file mode 100644 index 00000000..f00a4dee --- /dev/null +++ b/docs/installation/despotify.rst @@ -0,0 +1,79 @@ +********************** +despotify installation +********************** + +To use the `despotify `_ backend, you first need to +install despotify and spytify. + +.. warning:: + + This backend requires a Spotify premium account. + + +Installing despotify +==================== + +*Linux:* Install despotify's dependencies. At Debian/Ubuntu systems:: + + sudo aptitude install libssl-dev zlib1g-dev libvorbis-dev \ + libtool libncursesw5-dev libao-dev + +*OS X:* In OS X you need to have `XCode +`_ installed, and either `MacPorts +`_ or `Homebrew `_. + +*OS X, Homebrew:* Install dependencies:: + + brew install libvorbis ncursesw libao pkg-config + +*OS X, MacPorts:* Install dependencies:: + + sudo port install libvorbis libtool ncursesw libao + +*All OS:* Check out revision 503 of the despotify source code:: + + svn checkout https://despotify.svn.sourceforge.net/svnroot/despotify@503 + +*OS X, MacPorts:* Copy ``despotify/src/Makefile.local.mk.dist`` to +``despotify/src/Makefile.local.mk`` and uncomment the last two lines of the new +file so that it reads:: + + ## If you're on Mac OS X and have installed libvorbisfile + ## via 'port install ..', try uncommenting these lines + CFLAGS += -I/opt/local/include + LDFLAGS += -L/opt/local/lib + +*All OS:* Build and install despotify:: + + cd despotify/src/ + make + sudo make install + + +Installing spytify +================== + +spytify's source comes bundled with despotify. + +Build and install spytify:: + + cd despotify/src/bindings/python/ + export PKG_CONFIG_PATH=../../lib # Needed on OS X + make + sudo make install + + +Testing the installation +======================== + +To validate that everything is working, run the ``test.py`` script which is +distributed with spytify:: + + python test.py + +The test script should ask for your username and password (which must be for a +Spotify Premium account), ask for a search query, list all your playlists with +tracks, play 10s from a random song from the search result, pause for two +seconds, play for five more seconds, and quit. +o stop Mopidy, press ``CTRL+C``. + diff --git a/docs/installation/index.rst b/docs/installation/index.rst new file mode 100644 index 00000000..bacefb77 --- /dev/null +++ b/docs/installation/index.rst @@ -0,0 +1,107 @@ +************ +Installation +************ + +Mopidy itself is a breeze to install, as it just requires a standard Python +2.6 or newer installation. The libraries we depend on to connect to the Spotify +service is far more tricky to get working for the time being. Until +installation of these libraries are either well documented by their developers, +or the libraries are packaged for various Linux distributions, we will supply +our own installation guides. + + +Dependencies +============ + +.. toctree:: + :hidden: + + despotify + libspotify + +- Python >= 2.6 +- Dependencies for at least one Mopidy mixer: + + - AlsaMixer (Linux only) + + - pyalsaaudio >= 0.2 (Debian/Ubuntu package: python-alsaaudio) + + - OsaMixer (OS X only) + + - Nothing needed. + +- Dependencies for at least one Mopidy backend: + + - DespotifyBackend (Linux and OS X) + + - see :doc:`despotify` + + - LibspotifyBackend (Linux only) + + - see :doc:`libspotify` + + +Install latest release +====================== + +To install the currently latest release of Mopidy using ``pip``:: + + sudo aptitude install python-pip # On Ubuntu/Debian + sudo brew install pip # On OS X + sudo pip install Mopidy + +To later upgrade to the latest release:: + + sudo pip install -U Mopidy + +If you for some reason can't use ``pip``, try ``easy_install``. + + +Install development version +=========================== + +If you want to follow Mopidy development closer, you may install the +development version of Mopidy:: + + sudo aptitude install git-core # On Ubuntu/Debian + sudo brew install git # On OS X + git clone git://github.com/jodal/mopidy.git + cd mopidy/ + sudo python setup.py install + +To later update to the very latest version:: + + cd mopidy/ + git pull + sudo python setup.py install + +For an introduction to ``git``, please visit `git-scm.com +`_. + + +Spotify settings +================ + +Create a file named ``settings.py`` in the directory ``~/.mopidy/``. Enter +your Spotify Premium account's username and password into the file, like this:: + + SPOTIFY_USERNAME = u'myusername' + SPOTIFY_PASSWORD = u'mysecret' + +For a full list of available settings, see :mod:`mopidy.settings`. + + +Running Mopidy +============== + +To start Mopidy, simply open a terminal and run:: + + mopidy + +When Mopidy says ``MPD server running at [localhost]:6600`` it's ready to +accept connections by any MPD client. You can find a list of tons of MPD +clients at http://mpd.wikia.com/wiki/Clients. We use Sonata, GMPC, ncmpc, and +ncmpcpp during development. The first two are GUI clients, while the last two +are terminal clients. + +To stop Mopidy, press ``CTRL+C``. diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst new file mode 100644 index 00000000..6fbe57f5 --- /dev/null +++ b/docs/installation/libspotify.rst @@ -0,0 +1,67 @@ +*********************** +libspotify installation +*********************** + +As an alternative to the despotify backend, we are working on a +`libspotify `_ backend. +To use the libspotify backend you must install libspotify and +`pyspotify `_. + +.. warning:: + + This backend requires a Spotify premium account, and it requires you to get + an application key from Spotify before use. + + +Installing libspotify +===================== + +As libspotify's installation script at the moment is somewhat broken (see this +`GetSatisfaction thread `_ +for details), it is easiest to use the libspotify files bundled with pyspotify. +The files bundled with pyspotify are for 64-bit, so if you run a 32-bit OS, you +must get libspotify from https://developer.spotify.com/en/libspotify/. + + +Installing pyspotify +==================== + +Install pyspotify's dependencies. At Debian/Ubuntu systems:: + + sudo aptitude install python-alsaaudio + +Check out the pyspotify code, and install it:: + + git clone git://github.com/winjer/pyspotify.git + cd pyspotify + export LD_LIBRARY_PATH=$PWD/lib + sudo python setup.py develop + +Apply for an application key at +https://developer.spotify.com/en/libspotify/application-key, download the +binary version, and place the file at ``pyspotify/spotify_appkey.key``. + + +Testing the installation +======================== + +Test your libspotify setup:: + + examples/example1.py -u USERNAME -p PASSWORD + +.. note:: + + Until Spotify fixes their installation script, you'll have to set + ``LD_LIBRARY_PATH`` every time you are going to use libspotify (in other + words before starting Mopidy). + + +Setting up Mopidy to use libspotify +=================================== + +Currently :mod:`mopidy.backends.despotify` is the default +backend. If you want to use :mod:`mopidy.backends.libspotify` +instead, copy the Spotify application key to ``~/.mopidy/spotify_appkey.key``, +and add the following to ``~/.mopidy/settings.py``:: + + BACKENDS = (u'mopidy.backends.libspotify.LibspotifyBackend',) diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 9cd068a6..d94913b0 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,21 +1,34 @@ -from mopidy import settings -from mopidy.exceptions import ConfigError +from mopidy import settings as raw_settings def get_version(): - return u'0' + return u'0.1.0a0' def get_mpd_protocol_version(): - return u'0.15.0' + return u'0.16.0' -class Config(object): +class MopidyException(Exception): + def __init__(self, message): + self.message = message + + @property + def message(self): + """Reimplement message field that was deprecated in Python 2.6""" + return self._message + + @message.setter + def message(self, message): + self._message = message + +class SettingsError(MopidyException): + pass + +class Settings(object): def __getattr__(self, attr): - if not hasattr(settings, attr): - raise ConfigError(u'Setting "%s" is not set.' % attr) - value = getattr(settings, attr) + if attr.isupper() and not hasattr(raw_settings, attr): + raise SettingsError(u'Setting "%s" is not set.' % attr) + value = getattr(raw_settings, attr) if type(value) != bool and not value: - raise ConfigError(u'Setting "%s" is empty.' % attr) - if type(value) == unicode: - value = value.encode('utf-8') + raise SettingsError(u'Setting "%s" is empty.' % attr) return value -config = Config() +settings = Settings() diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 0d02b416..aad44375 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -1,23 +1,39 @@ import asyncore import logging +import multiprocessing +import optparse import os import sys sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import config -from mopidy.exceptions import ConfigError -from mopidy.mpd.server import MpdServer +from mopidy import get_version, settings, SettingsError +from mopidy.process import CoreProcess +from mopidy.utils import get_class, get_or_create_dotdir -logger = logging.getLogger('mopidy') +logger = logging.getLogger('mopidy.main') def main(): - _setup_logging(2) - backend = _get_backend(config.BACKEND) - MpdServer(backend=backend) + options, args = _parse_options() + _setup_logging(options.verbosity_level) + get_or_create_dotdir('~/.mopidy/') + core_queue = multiprocessing.Queue() + get_class(settings.SERVER)(core_queue) + core = CoreProcess(core_queue) + core.start() asyncore.loop() +def _parse_options(): + parser = optparse.OptionParser(version='Mopidy %s' % get_version()) + parser.add_option('-q', '--quiet', + action='store_const', const=0, dest='verbosity_level', + help='less output (warning level)') + parser.add_option('-v', '--verbose', + action='store_const', const=2, dest='verbosity_level', + help='more output (debug level)') + return parser.parse_args() + def _setup_logging(verbosity_level): if verbosity_level == 0: level = logging.WARNING @@ -25,24 +41,17 @@ def _setup_logging(verbosity_level): level = logging.DEBUG else: level = logging.INFO - logging.basicConfig( - format=config.CONSOLE_LOG_FORMAT, - level=level, - ) - -def _get_backend(name): - module_name = name[:name.rindex('.')] - class_name = name[name.rindex('.') + 1:] - logger.info('Loading: %s from %s', class_name, module_name) - module = __import__(module_name, globals(), locals(), [class_name], -1) - class_object = getattr(module, class_name) - instance = class_object() - return instance + logging.basicConfig(format=settings.CONSOLE_LOG_FORMAT, level=level) if __name__ == '__main__': try: main() except KeyboardInterrupt: - sys.exit('\nInterrupted by user') - except ConfigError, e: - sys.exit('%s' % e) + logger.info(u'Interrupted by user') + sys.exit(0) + except SettingsError, e: + logger.error(e) + sys.exit(1) + except SystemExit, e: + logger.error(e) + sys.exit(1) diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index e50832b1..ce022bd1 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -1,205 +1,587 @@ +from copy import copy import logging import random import time -from mopidy.exceptions import MpdNotImplemented +from mopidy import settings from mopidy.models import Playlist +from mopidy.utils import get_class -logger = logging.getLogger('backends.base') +logger = logging.getLogger('mopidy.backends.base') class BaseBackend(object): + def __init__(self, core_queue=None, mixer=None): + self.core_queue = core_queue + if mixer is not None: + self.mixer = mixer + else: + self.mixer = get_class(settings.MIXER)() + + #: A :class:`multiprocessing.Queue` which can be used by e.g. library + #: callbacks to send messages to the core. + core_queue = None + + #: The current playlist controller. An instance of + #: :class:`BaseCurrentPlaylistController`. current_playlist = None + + #: The library controller. An instance of :class:`BaseLibraryController`. library = None + + #: The sound mixer. An instance of :class:`mopidy.mixers.BaseMixer`. + mixer = None + + #: The playback controller. An instance of :class:`BasePlaybackController`. playback = None + + #: The stored playlists controller. An instance of + #: :class:`BaseStoredPlaylistsController`. stored_playlists = None + + #: List of URI prefixes this backend can handle. uri_handlers = [] - def destroy(self): - self.playback.destroy() class BaseCurrentPlaylistController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`BaseBackend` + """ + + #: The current playlist version. Integer which is increased every time the + #: current playlist is changed. Is not reset before the MPD server is + #: restarted. + version = 0 + def __init__(self, backend): self.backend = backend - self.version = 0 self.playlist = Playlist() @property def playlist(self): - return self._playlist + """The currently loaded :class:`mopidy.models.Playlist`.""" + return copy(self._playlist) @playlist.setter - def playlist(self, playlist): - self._playlist = playlist + def playlist(self, new_playlist): + self._playlist = new_playlist self.version += 1 - self.backend.playback.new_playlist_loaded_callback() - def add(self, uri, at_position=None): - raise NotImplementedError + def add(self, track, at_position=None): + """ + Add the track to the end of, or at the given position in the current + playlist. + + :param track: track to add + :type track: :class:`mopidy.models.Track` + :param at_position: position in current playlist to add track + :type at_position: int or :class:`None` + """ + tracks = self.playlist.tracks + if at_position: + tracks.insert(at_position, track) + else: + tracks.append(track) + self.playlist = self.playlist.with_(tracks=tracks) def clear(self): + """Clear the current playlist.""" self.backend.playback.stop() + self.backend.playback.current_track = None self.playlist = Playlist() - def get_by_id(self, id): - matches = filter(lambda t: t.id == id, self.playlist.tracks) - if matches: - return matches[0] - else: - raise KeyError('Track with ID "%s" not found' % id) + def get(self, **criteria): + """ + Get track by given criterias from current playlist. - def get_by_url(self, uri): - matches = filter(lambda t: t.uri == uri, self.playlist.tracks) - if matches: + Raises :exc:`LookupError` if a unique match is not found. + + Examples:: + + get(id=1) # Returns track with ID 1 + get(uri='xyz') # Returns track with URI 'xyz' + get(id=1, uri='xyz') # Returns track with ID 1 and URI 'xyz' + + :param criteria: on or more criteria to match by + :type criteria: dict + :rtype: :class:`mopidy.models.Track` + """ + matches = self._playlist.tracks + for (key, value) in criteria.iteritems(): + matches = filter(lambda t: getattr(t, key) == value, matches) + if len(matches) == 1: return matches[0] + criteria_string = ', '.join( + ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) + if len(matches) == 0: + raise LookupError(u'"%s" match no tracks' % criteria_string) else: - raise KeyError('Track with URI "%s" not found' % uri) + raise LookupError(u'"%s" match multiple tracks' % criteria_string) def load(self, playlist): + """ + Replace the current playlist with the given playlist. + + :param playlist: playlist to load + :type playlist: :class:`mopidy.models.Playlist` + """ self.playlist = playlist def move(self, start, end, to_position): + """ + Move the tracks in the slice ``[start:end]`` to ``to_position``. + + :param start: position of first track to move + :type start: int + :param end: position after last track to move + :type end: int + :param to_position: new position for the tracks + :type to_position: int + """ tracks = self.playlist.tracks - - if start == end: - end += 1 - new_tracks = tracks[:start] + tracks[end:] - for track in tracks[start:end]: new_tracks.insert(to_position, track) to_position += 1 + self.playlist = self.playlist.with_(tracks=new_tracks) - self.playlist = Playlist(tracks=new_tracks) + def remove(self, track): + """ + Remove the track from the current playlist. - def remove(self,track): - tracks = filter(lambda t: t != track, self.playlist.tracks) - - self.playlist = Playlist(tracks=tracks) + :param track: track to remove + :type track: :class:`mopidy.models.Track` + """ + tracks = self.playlist.tracks + position = tracks.index(track) + del tracks[position] + self.playlist = self.playlist.with_(tracks=tracks) def shuffle(self, start=None, end=None): - tracks = self.playlist.tracks + """ + Shuffles the entire playlist. If ``start`` and ``end`` is given only + shuffles the slice ``[start:end]``. + :param start: position of first track to shuffle + :type start: int or :class:`None` + :param end: position after last track to shuffle + :type end: int or :class:`None` + """ + tracks = self.playlist.tracks before = tracks[:start or 0] shuffled = tracks[start:end] after = tracks[end or len(tracks):] - random.shuffle(shuffled) + self.playlist = self.playlist.with_(tracks=before+shuffled+after) - self.playlist = Playlist(tracks=before+shuffled+after) -class BasePlaybackController(object): - PAUSED = 'paused' - PLAYING = 'playing' - STOPPED = 'stopped' +class BaseLibraryController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`BaseBackend` + """ - state = STOPPED - repeat = False - random = False - consume = False - volume = None - def __init__(self, backend): self.backend = backend - self.current_track = None - self._shuffled = [] - self._first_shuffle = True - def play(self, id=None, position=None): + def find_exact(self, type, query): + """ + Find tracks in the library where ``type`` matches ``query`` exactly. + + :param type: 'track', 'artist', or 'album' + :type type: string + :param query: the search query + :type query: string + :rtype: :class:`mopidy.models.Playlist` + """ raise NotImplementedError - def stop(self): + def lookup(self, uri): + """ + Lookup track with given URI. + + :param uri: track URI + :type uri: string + :rtype: :class:`mopidy.models.Track` + """ raise NotImplementedError - def new_playlist_loaded_callback(self): - self.current_track = None + def refresh(self, uri=None): + """ + Refresh library. Limit to URI and below if an URI is given. - if self.state == self.PLAYING: - self.play() - elif self.state == self.PAUSED: - self.stop() - - def next(self): - current_track = self.current_track - - if not self.next_track: - self.stop() - else: - self.play(self.next_track) - - if self.consume: - self.backend.current_playlist.remove(current_track) - - def previous(self): - if self.previous_track: - self.play(self.previous_track) - - def pause(self): + :param uri: directory or track URI + :type uri: string + """ raise NotImplementedError - def resume(self): + def search(self, type, query): + """ + Search the library for tracks where ``type`` contains ``query``. + + :param type: 'track', 'artist', 'album', 'uri', and 'any' + :type type: string + :param query: the search query + :type query: string + :rtype: :class:`mopidy.models.Playlist` + """ raise NotImplementedError - def seek(self, time_position): - raise NotImplementedError + +class BasePlaybackController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`BaseBackend` + """ + + #: Constant representing the paused state. + PAUSED = u'paused' + + #: Constant representing the playing state. + PLAYING = u'playing' + + #: Constant representing the stopped state. + STOPPED = u'stopped' + + #: :class:`True` + #: Tracks are removed from the playlist when they have been played. + #: :class:`False` + #: Tracks are not removed from the playlist. + consume = False + + #: The currently playing or selected :class:`mopidy.models.Track`. + current_track = None + + #: :class:`True` + #: Tracks are selected at random from the playlist. + #: :class:`False` + #: Tracks are played in the order of the playlist. + random = False + + #: :class:`True` + #: The current track is played repeatedly. + #: :class:`False` + #: The current track is played once. + repeat = False + + #: :class:`True` + #: Playback is stopped after current song, unless in repeat mode. + #: :class:`False` + #: Playback continues after current song. + single = False + + def __init__(self, backend): + self.backend = backend + self._state = self.STOPPED @property def next_track(self): - playlist = self.backend.current_playlist.playlist - - if not playlist.tracks: - return None - - if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: - self._shuffled = playlist.tracks - random.shuffle(self._shuffled) - self._first_shuffle = self.repeat - - if self._shuffled: - return self._shuffled[0] - - if self.current_track is None: - return playlist.tracks[0] - - if self.repeat: - position = (self.playlist_position + 1) % len(playlist.tracks) - return playlist.tracks[position] - - try: - return playlist.tracks[self.playlist_position + 1] - except IndexError: - return None - - @property - def previous_track(self): - playlist = self.backend.current_playlist.playlist - - if self.repeat or self.consume or self.random: - return self.current_track + """ + The next :class:`mopidy.models.Track` in the playlist. + For normal playback this is the next track in the playlist. If repeat + is enabled the next track can loop around the playlist. When random is + enabled this should be a random track, all tracks should be played once + before the list repeats. + """ if self.current_track is None: return None - - if self.playlist_position - 1 < 0: - return None - try: - return playlist.tracks[self.playlist_position - 1] + return self.backend.current_playlist.playlist.tracks[ + self.playlist_position + 1] except IndexError: return None @property def playlist_position(self): - playlist = self.backend.current_playlist.playlist - + """The position in the current playlist.""" + if self.current_track is None: + return None try: - return playlist.tracks.index(self.current_track) + return self.backend.current_playlist.playlist.tracks.index( + self.current_track) except ValueError: return None + @property + def previous_track(self): + """ + The previous :class:`mopidy.models.Track` in the playlist. + + For normal playback this is the next track in the playlist. If random + and/or consume is enabled it should return the current track instead. + """ + if self.current_track is None: + return None + try: + return self.backend.current_playlist.playlist.tracks[ + self.playlist_position - 1] + except IndexError: + return None + + @property + def state(self): + """ + The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or + :attr:`STOPPED`. + + Possible states and transitions: + + .. digraph:: state_transitions + + "STOPPED" -> "PLAYING" [ label="play" ] + "PLAYING" -> "STOPPED" [ label="stop" ] + "PLAYING" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "PLAYING" [ label="play" ] + "PAUSED" -> "PLAYING" [ label="resume" ] + "PAUSED" -> "STOPPED" [ label="stop" ] + """ + return self._state + + @state.setter + def state(self, new_state): + (old_state, self._state) = (self.state, new_state) + logger.debug(u'Changing state: %s -> %s', old_state, new_state) + if (old_state in (self.PLAYING, self.STOPPED) + and new_state == self.PLAYING): + self._play_time_start() + elif old_state == self.PLAYING and new_state == self.PAUSED: + self._play_time_pause() + elif old_state == self.PAUSED and new_state == self.PLAYING: + self._play_time_resume() + @property def time_position(self): + """Time position in milliseconds.""" + if self.state == self.PLAYING: + time_since_started = (self._current_wall_time - + self._play_time_started) + return self._play_time_accumulated + time_since_started + elif self.state == self.PAUSED: + return self._play_time_accumulated + elif self.state == self.STOPPED: + return 0 + + def _play_time_start(self): + self._play_time_accumulated = 0 + self._play_time_started = self._current_wall_time + + def _play_time_pause(self): + time_since_started = self._current_wall_time - self._play_time_started + self._play_time_accumulated += time_since_started + + def _play_time_resume(self): + self._play_time_started = self._current_wall_time + + @property + def _current_wall_time(self): + return int(time.time() * 1000) + + @property + def volume(self): + """ + The audio volume as an int in the range [0, 100]. + + :class:`None` if unknown. + """ + return self.backend.mixer.volume + + @volume.setter + def volume(self, volume): + self.backend.mixer.volume = volume + + def end_of_track_callback(self): + """Tell the playback controller that end of track is reached.""" + if self.next_track is not None: + self.next() + else: + self.stop() + + def new_playlist_loaded_callback(self): + """Tell the playback controller that a new playlist has been loaded.""" + self.current_track = None + if self.state == self.PLAYING: + if self.backend.current_playlist.playlist.length > 0: + self.play(self.backend.current_playlist.playlist.tracks[0]) + else: + self.stop() + + def next(self): + """Play the next track.""" + if self.next_track is not None and self._next(self.next_track): + self.current_track = self.next_track + self.state = self.PLAYING + + def _next(self, track): + return self._play(track) + + def pause(self): + """Pause playback.""" + if self.state == self.PLAYING and self._pause(): + self.state = self.PAUSED + + def _pause(self): raise NotImplementedError - def destroy(self): - pass + def play(self, track=None): + """ + Play the given track or the currently active track. + + :param track: track to play + :type track: :class:`mopidy.models.Track` or :class:`None` + """ + if self.state == self.PAUSED and track is None: + return self.resume() + if track is not None and self._play(track): + self.current_track = track + self.state = self.PLAYING + + def _play(self, track): + raise NotImplementedError + + def previous(self): + """Play the previous track.""" + if (self.previous_track is not None + and self._previous(self.previous_track)): + self.current_track = self.previous_track + self.state = self.PLAYING + + def _previous(self, track): + return self._play(track) + + def resume(self): + """If paused, resume playing the current track.""" + if self.state == self.PAUSED and self._resume(): + self.state = self.PLAYING + + def _resume(self): + raise NotImplementedError + + def seek(self, time_position): + """ + Seeks to time position given in milliseconds. + + :param time_position: time position in milliseconds + :type time_position: int + """ + raise NotImplementedError + + def stop(self): + """Stop playing.""" + if self.state != self.STOPPED and self._stop(): + self.state = self.STOPPED + + def _stop(self): + raise NotImplementedError + + +class BaseStoredPlaylistsController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`BaseBackend` + """ + + def __init__(self, backend): + self.backend = backend + self._playlists = [] + + @property + def playlists(self): + """List of :class:`mopidy.models.Playlist`.""" + return copy(self._playlists) + + @playlists.setter + def playlists(self, playlists): + self._playlists = playlists + + def create(self, name): + """ + Create a new playlist. + + :param name: name of the new playlist + :type name: string + :rtype: :class:`mopidy.models.Playlist` + """ + raise NotImplementedError + + def delete(self, playlist): + """ + Delete playlist. + + :param playlist: the playlist to delete + :type playlist: :class:`mopidy.models.Playlist` + """ + raise NotImplementedError + + def get(self, **criteria): + """ + Get playlist by given criterias from the set of stored playlists. + + Raises :exc:`LookupError` if a unique match is not found. + + Examples:: + + 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' + + :param criteria: on or more criteria to match by + :type criteria: dict + :rtype: :class:`mopidy.models.Playlist` + """ + matches = self._playlists + for (key, value) in criteria.iteritems(): + matches = filter(lambda p: getattr(p, key) == value, matches) + if len(matches) == 1: + return matches[0] + criteria_string = ', '.join( + ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) + if len(matches) == 0: + raise LookupError('"%s" match no playlists' % criteria_string) + else: + raise LookupError('"%s" match multiple playlists' % criteria_string) + + def lookup(self, uri): + """ + Lookup playlist with given URI in both the set of stored playlists and + in any other playlist sources. + + :param uri: playlist URI + :type uri: string + :rtype: :class:`mopidy.models.Playlist` + """ + raise NotImplementedError + + def refresh(self): + """Refresh stored playlists.""" + raise NotImplementedError + + def rename(self, playlist, new_name): + """ + Rename playlist. + + :param playlist: the playlist + :type playlist: :class:`mopidy.models.Playlist` + :param new_name: the new name + :type new_name: string + """ + raise NotImplementedError + + def save(self, playlist): + """ + Save the playlist to the set of stored playlists. + + :param playlist: the playlist + :type playlist: :class:`mopidy.models.Playlist` + """ + raise NotImplementedError + + def search(self, query): + """ + Search for playlists whose name contains ``query``. + + :param query: query to search for + :type query: string + :rtype: list of :class:`mopidy.models.Playlist` + """ + return filter(lambda p: query in p.name, self._playlists) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify.py index a60e2aac..ea74de10 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify.py @@ -4,121 +4,178 @@ import sys import spytify -from mopidy import config -from mopidy.backends import BaseBackend +from mopidy import settings +from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, + BaseLibraryController, BasePlaybackController, + BaseStoredPlaylistsController) from mopidy.models import Artist, Album, Track, Playlist +from mopidy.utils import spotify_uri_to_int -logger = logging.getLogger(u'backends.despotify') +logger = logging.getLogger('mopidy.backends.despotify') ENCODING = 'utf-8' class DespotifyBackend(BaseBackend): + """ + A Spotify backend which uses the open source `despotify library + `_. + + `spytify `_ + is the Python bindings for the despotify library. It got litle + documentation, but a couple of examples are available. + + **Issues** + + - r503: Sometimes segfaults when traversing stored playlists, their tracks, + artists, and albums. As it is not predictable, it may be a concurrency + issue. + + - r503: Segfaults when looking up playlists, both your own lists and other + peoples shared lists. To reproduce:: + + >>> import spytify # doctest: +SKIP + >>> s = spytify.Spytify('alice', 'secret') # doctest: +SKIP + >>> s.lookup('spotify:user:klette:playlist:5rOGYPwwKqbAcVX8bW4k5V') + ... # doctest: +SKIP + Segmentation fault + + """ + def __init__(self, *args, **kwargs): super(DespotifyBackend, self).__init__(*args, **kwargs) - logger.info(u'Connecting to Spotify') - self.spotify = spytify.Spytify( - config.SPOTIFY_USERNAME, config.SPOTIFY_PASSWORD) - self.cache_stored_playlists() + self.current_playlist = DespotifyCurrentPlaylistController(backend=self) + self.library = DespotifyLibraryController(backend=self) + self.playback = DespotifyPlaybackController(backend=self) + self.stored_playlists = DespotifyStoredPlaylistsController(backend=self) + self.uri_handlers = [u'spotify:', u'http://open.spotify.com/'] + self.spotify = self._connect() + self.stored_playlists.refresh() - def cache_stored_playlists(self): + def _connect(self): + logger.info(u'Connecting to Spotify') + try: + return DespotifySessionManager( + settings.SPOTIFY_USERNAME.encode(ENCODING), + settings.SPOTIFY_PASSWORD.encode(ENCODING), + core_queue=self.core_queue) + except spytify.SpytifyError as e: + logger.exception(e) + sys.exit(1) + + +class DespotifyCurrentPlaylistController(BaseCurrentPlaylistController): + pass + + +class DespotifyLibraryController(BaseLibraryController): + def lookup(self, uri): + track = self.backend.spotify.lookup(uri.encode(ENCODING)) + return DespotifyTranslator.to_mopidy_track(track) + + def search(self, type, what): + if type == u'track': + type = u'title' + if type == u'any': + query = what + else: + query = u'%s:%s' % (type, what) + result = self.backend.spotify.search(query.encode(ENCODING)) + if (result is None or result.playlist.tracks[0].get_uri() == + 'spotify:track:0000000000000000000000'): + return Playlist() + return DespotifyTranslator.to_mopidy_playlist(result.playlist) + + find_exact = search + + +class DespotifyPlaybackController(BasePlaybackController): + def _pause(self): + self.backend.spotify.pause() + return True + + def _play(self, track): + self.backend.spotify.play(self.backend.spotify.lookup(track.uri)) + return True + + def _resume(self): + self.backend.spotify.resume() + return True + + def _stop(self): + self.backend.spotify.stop() + return True + + +class DespotifyStoredPlaylistsController(BaseStoredPlaylistsController): + def refresh(self): logger.info(u'Caching stored playlists') playlists = [] - for spotify_playlist in self.spotify.stored_playlists: - playlists.append(self._to_mopidy_playlist(spotify_playlist)) + for spotify_playlist in self.backend.spotify.stored_playlists: + playlists.append( + DespotifyTranslator.to_mopidy_playlist(spotify_playlist)) self._playlists = playlists logger.debug(u'Available playlists: %s', - u', '.join([u'<%s>' % p.name for p in self._playlists])) + u', '.join([u'<%s>' % p.name for p in self.playlists])) + logger.info(u'Done caching stored playlists') -# Model translation - def _to_mopidy_id(self, spotify_uri): - return 0 # TODO +class DespotifyTranslator(object): + @classmethod + def to_mopidy_id(cls, spotify_uri): + return spotify_uri_to_int(spotify_uri) - def _to_mopidy_artist(self, spotify_artist): + @classmethod + def to_mopidy_artist(cls, spotify_artist): return Artist( uri=spotify_artist.get_uri(), name=spotify_artist.name.decode(ENCODING) ) - def _to_mopidy_album(self, spotify_album_name): + @classmethod + def to_mopidy_album(cls, spotify_album_name): return Album(name=spotify_album_name.decode(ENCODING)) - def _to_mopidy_track(self, spotify_track): + @classmethod + def to_mopidy_track(cls, spotify_track): if dt.MINYEAR <= int(spotify_track.year) <= dt.MAXYEAR: date = dt.date(spotify_track.year, 1, 1) else: date = None return Track( uri=spotify_track.get_uri(), - title=spotify_track.title.decode(ENCODING), - artists=[self._to_mopidy_artist(a) for a in spotify_track.artists], - album=self._to_mopidy_album(spotify_track.album), + name=spotify_track.title.decode(ENCODING), + artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists], + album=cls.to_mopidy_album(spotify_track.album), track_no=spotify_track.tracknumber, date=date, length=spotify_track.length, - id=self._to_mopidy_id(spotify_track.get_uri()), + bitrate=320, + id=cls.to_mopidy_id(spotify_track.get_uri()), ) - def _to_mopidy_playlist(self, spotify_playlist): + @classmethod + def to_mopidy_playlist(cls, spotify_playlist): return Playlist( uri=spotify_playlist.get_uri(), name=spotify_playlist.name.decode(ENCODING), - tracks=[self._to_mopidy_track(t) for t in spotify_playlist.tracks], + tracks=[cls.to_mopidy_track(t) for t in spotify_playlist.tracks], ) -# Play control - def _next(self): - self._current_song_pos += 1 - self.spotify.play(self.spotify.lookup(self._current_track.uri)) - return True +class DespotifySessionManager(spytify.Spytify): + DESPOTIFY_NEW_TRACK = 1 + DESPOTIFY_TIME_TELL = 2 + DESPOTIFY_END_OF_PLAYLIST = 3 + DESPOTIFY_TRACK_PLAY_ERROR = 4 - def _pause(self): - self.spotify.pause() - return True + def __init__(self, *args, **kwargs): + kwargs['callback'] = self.callback + self.core_queue = kwargs.pop('core_queue') + super(DespotifySessionManager, self).__init__(*args, **kwargs) - def _play(self): - if self._current_track is not None: - self.spotify.play(self.spotify.lookup(self._current_track.uri)) - return True - else: - return False - - def _play_id(self, songid): - self._current_song_pos = songid # XXX - self.spotify.play(self.spotify.lookup(self._current_track.uri)) - return True - - def _play_pos(self, songpos): - self._current_song_pos = songpos - self.spotify.play(self.spotify.lookup(self._current_track.uri)) - return True - - def _previous(self): - self._current_song_pos -= 1 - self.spotify.play(self.spotify.lookup(self._current_track.uri)) - return True - - def _resume(self): - self.spotify.resume() - return True - - def _stop(self): - self.spotify.stop() - return True - -# Status querying - - def status_bitrate(self): - return 320 - - def url_handlers(self): - return [u'spotify:', u'http://open.spotify.com/'] - -# Music database - - def search(self, type, what): - query = u'%s:%s' % (type, what) - result = self.spotify.search(query.encode(ENCODING)) - if result is not None: - return self._to_mopidy_playlist(result.playlist).mpd_format() + def callback(self, signal, data): + if signal == self.DESPOTIFY_END_OF_PLAYLIST: + logger.debug('Despotify signalled end of playlist') + self.core_queue.put({'command': 'end_of_track'}) + elif signal == self.DESPOTIFY_TRACK_PLAY_ERROR: + logger.error('Despotify signalled track play error') diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy.py index 8ebf3ac2..0da24c44 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy.py @@ -1,25 +1,48 @@ -from mopidy.backends import BaseBackend +from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, + BasePlaybackController, BaseLibraryController, + BaseStoredPlaylistsController) +from mopidy.models import Playlist class DummyBackend(BaseBackend): + """ + A backend which implements the backend API in the simplest way possible. + Used in tests of the frontends. + + Handles URIs starting with ``dummy:``. + """ + 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.uri_handlers = [u'dummy:'] - def url_handlers(self): - return [u'dummy:'] +class DummyCurrentPlaylistController(BaseCurrentPlaylistController): + pass +class DummyLibraryController(BaseLibraryController): + _library = [] + + def lookup(self, uri): + matches = filter(lambda t: uri == t.uri, self._library) + if matches: + return matches[0] + + def search(self, type, query): + return Playlist() + + find_exact = search + +class DummyPlaybackController(BasePlaybackController): def _next(self): return True def _pause(self): return True - def _play(self): - return True - - def _play_id(self, songid): - return True - - def _play_pos(self, songpos): + def _play(self, track): return True def _previous(self): @@ -27,3 +50,7 @@ class DummyBackend(BaseBackend): def _resume(self): return True + +class DummyStoredPlaylistsController(BaseStoredPlaylistsController): + def search(self, query): + return [Playlist(name=query)] diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify.py index d33875c9..67b65318 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify.py @@ -1,121 +1,98 @@ -from copy import deepcopy import datetime as dt import logging +import os +import multiprocessing import threading from spotify import Link from spotify.manager import SpotifySessionManager from spotify.alsahelper import AlsaController -from mopidy import config -from mopidy.backends import BaseBackend +from mopidy import get_version, settings +from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, + BaseLibraryController, BasePlaybackController, + BaseStoredPlaylistsController) from mopidy.models import Artist, Album, Track, Playlist +from mopidy.utils import spotify_uri_to_int -logger = logging.getLogger(u'backends.libspotify') +logger = logging.getLogger('mopidy.backends.libspotify') ENCODING = 'utf-8' class LibspotifyBackend(BaseBackend): + """ + A Spotify backend which uses the official `libspotify library + `_. + + `pyspotify `_ is the Python bindings + for libspotify. It got no documentation, but multiple examples are + available. Like libspotify, pyspotify's calls are mostly asynchronous. + + This backend should also work with `openspotify + `_, but we haven't tested + that yet. + + **Issues** + + - libspotify is badly packaged. See + http://getsatisfaction.com/spotify/topics/libspotify_please_fix_the_installation_script. + """ + def __init__(self, *args, **kwargs): super(LibspotifyBackend, self).__init__(*args, **kwargs) - self._next_id = 0 - self._id_to_uri_map = {} - self._uri_to_id_map = {} + self.current_playlist = LibspotifyCurrentPlaylistController( + 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): logger.info(u'Connecting to Spotify') - self.spotify = LibspotifySessionManager( - config.SPOTIFY_USERNAME, config.SPOTIFY_PASSWORD, backend=self) - self.spotify.start() + spotify = LibspotifySessionManager( + settings.SPOTIFY_USERNAME, settings.SPOTIFY_PASSWORD, + core_queue=self.core_queue) + spotify.start() + return spotify - def update_stored_playlists(self): - logger.info(u'Updating stored playlists') - playlists = [] - for spotify_playlist in self.spotify.playlists: - playlists.append(self._to_mopidy_playlist(spotify_playlist)) - self._playlists = playlists - logger.debug(u'Available playlists: %s', - u', '.join([u'<%s>' % p.name for p in self._playlists])) -# Model translation +class LibspotifyCurrentPlaylistController(BaseCurrentPlaylistController): + pass - def _to_mopidy_id(self, spotify_uri): - if spotify_uri in self._uri_to_id_map: - return self._uri_to_id_map[spotify_uri] + +class LibspotifyLibraryController(BaseLibraryController): + def search(self, type, what): + if type is u'any': + query = what else: - id = self._next_id - self._next_id += 1 - self._id_to_uri_map[id] = spotify_uri - self._uri_to_id_map[spotify_uri] = id - return id + query = u'%s:%s' % (type, what) + my_end, other_end = multiprocessing.Pipe() + self.backend.spotify.search(query.encode(ENCODING), other_end) + my_end.poll(None) + logger.debug(u'In search method, receiving search results') + playlist = my_end.recv() + logger.debug(u'In search method, done receiving search results') + logger.debug(['%s' % t.name for t in playlist.tracks]) + return playlist - def _to_mopidy_artist(self, spotify_artist): - return Artist( - uri=str(Link.from_artist(spotify_artist)), - name=spotify_artist.name().decode(ENCODING), - ) + find_exact = search - def _to_mopidy_album(self, spotify_album): - # TODO pyspotify got much more data on albums than this - return Album(name=spotify_album.name().decode(ENCODING)) - - def _to_mopidy_track(self, spotify_track): - return Track( - uri=str(Link.from_track(spotify_track, 0)), - title=spotify_track.name().decode(ENCODING), - artists=[self._to_mopidy_artist(a) - for a in spotify_track.artists()], - album=self._to_mopidy_album(spotify_track.album()), - track_no=spotify_track.index(), - date=dt.date(spotify_track.album().year(), 1, 1), - length=spotify_track.duration(), - id=self._to_mopidy_id(str(Link.from_track(spotify_track, 0))), - ) - - def _to_mopidy_playlist(self, spotify_playlist): - return Playlist( - uri=str(Link.from_playlist(spotify_playlist)), - name=spotify_playlist.name().decode(ENCODING), - tracks=[self._to_mopidy_track(t) for t in spotify_playlist], - ) -# Playback control - - def _play_current_track(self): - self.spotify.session.load( - Link.from_string(self._current_track.uri).as_track()) - self.spotify.session.play(1) - - def _next(self): - self._current_song_pos += 1 - self._play_current_track() - return True +class LibspotifyPlaybackController(BasePlaybackController): def _pause(self): # TODO return False - def _play(self): - if self._current_track is not None: - self._play_current_track() - return True - else: + def _play(self, track): + if self.state == self.PLAYING: + self.stop() + if track.uri is None: return False - - def _play_id(self, songid): - matches = filter(lambda t: t.id == songid, self._current_playlist) - if matches: - self._current_song_pos = self._current_playlist.index(matches[0]) - self._play_current_track() - return True - else: - return False - - def _play_pos(self, songpos): - self._current_song_pos = songpos - self._play_current_track() - return True - - def _previous(self): - self._current_song_pos -= 1 - self._play_current_track() + self.backend.spotify.session.load( + Link.from_string(track.uri).as_track()) + self.backend.spotify.session.play(1) return True def _resume(self): @@ -123,62 +100,142 @@ class LibspotifyBackend(BaseBackend): return False def _stop(self): - self.spotify.session.play(0) + self.backend.spotify.session.play(0) return True -# Status querying - def status_bitrate(self): - return 320 +class LibspotifyStoredPlaylistsController(BaseStoredPlaylistsController): + pass - def url_handlers(self): - return [u'spotify:', u'http://open.spotify.com/'] + +class LibspotifyTranslator(object): + @classmethod + def to_mopidy_id(cls, spotify_uri): + return spotify_uri_to_int(spotify_uri) + + @classmethod + def to_mopidy_artist(cls, spotify_artist): + if not spotify_artist.is_loaded(): + return Artist(name=u'[loading...]') + return Artist( + uri=str(Link.from_artist(spotify_artist)), + name=spotify_artist.name().decode(ENCODING), + ) + + @classmethod + def to_mopidy_album(cls, spotify_album): + if not spotify_album.is_loaded(): + return Album(name=u'[loading...]') + # TODO pyspotify got much more data on albums than this + return Album(name=spotify_album.name().decode(ENCODING)) + + @classmethod + def to_mopidy_track(cls, spotify_track): + if not spotify_track.is_loaded(): + return Track(name=u'[loading...]') + uri = str(Link.from_track(spotify_track, 0)) + return Track( + uri=uri, + name=spotify_track.name().decode(ENCODING), + artists=[cls.to_mopidy_artist(a) for a in spotify_track.artists()], + album=cls.to_mopidy_album(spotify_track.album()), + track_no=spotify_track.index(), + date=dt.date(spotify_track.album().year(), 1, 1), + length=spotify_track.duration(), + bitrate=320, + id=cls.to_mopidy_id(uri), + ) + + @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], + ) class LibspotifySessionManager(SpotifySessionManager, threading.Thread): - def __init__(self, username, password, backend): + cache_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE) + settings_location = os.path.expanduser(settings.SPOTIFY_LIB_CACHE) + appkey_file = os.path.expanduser(settings.SPOTIFY_LIB_APPKEY) + user_agent = 'Mopidy %s' % get_version() + + def __init__(self, username, password, core_queue): SpotifySessionManager.__init__(self, username, password) threading.Thread.__init__(self) - self.backend = backend + self.core_queue = core_queue + self.connected = threading.Event() self.audio = AlsaController() def run(self): self.connect() def logged_in(self, session, error): + """Callback used by pyspotify""" logger.info('Logged in') self.session = session - try: - self.playlists = session.playlist_container() - logger.debug('Got playlist container') - except Exception, e: - logger.exception(e) + self.connected.set() def logged_out(self, session): + """Callback used by pyspotify""" logger.info('Logged out') def metadata_updated(self, session): - logger.debug('Metadata updated') - self.backend.update_stored_playlists() + """Callback used by pyspotify""" + logger.debug('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, + }) def connection_error(self, session, error): + """Callback used by pyspotify""" logger.error('Connection error: %s', error) def message_to_user(self, session, message): + """Callback used by pyspotify""" logger.info(message) def notify_main_thread(self, session): + """Callback used by pyspotify""" logger.debug('Notify main thread') def music_delivery(self, *args, **kwargs): + """Callback used by pyspotify""" self.audio.music_delivery(*args, **kwargs) def play_token_lost(self, session): + """Callback used by pyspotify""" logger.debug('Play token lost') + self.core_queue.put({'command': 'stop_playback'}) def log_message(self, session, data): + """Callback used by pyspotify""" logger.debug(data) def end_of_track(self, session): + """Callback used by pyspotify""" logger.debug('End of track') + self.core_queue.put({'command': 'end_of_track'}) + def search(self, query, connection): + """Search method used by Mopidy backend""" + self.connected.wait() + def callback(results, userdata): + logger.debug(u'In search callback, translating search results') + logger.debug(results.tracks()) + # TODO Include results from results.albums(), etc. too + playlist = Playlist(tracks=[ + LibspotifyTranslator.to_mopidy_track(t) + for t in results.tracks()]) + logger.debug(u'In search callback, sending search results') + logger.debug(['%s' % t.name for t in playlist.tracks]) + connection.send(playlist) + self.session.search(query, callback) diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py deleted file mode 100644 index c6b85845..00000000 --- a/mopidy/exceptions.py +++ /dev/null @@ -1,9 +0,0 @@ -class ConfigError(Exception): - pass - -class MpdAckError(Exception): - pass - -class MpdNotImplemented(MpdAckError): - def __init__(self, *args): - super(MpdNotImplemented, self).__init__(u'Not implemented', *args) diff --git a/mopidy/mixers/__init__.py b/mopidy/mixers/__init__.py new file mode 100644 index 00000000..786a32d0 --- /dev/null +++ b/mopidy/mixers/__init__.py @@ -0,0 +1,35 @@ +class BaseMixer(object): + @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. + """ + return self._get_volume() + + @volume.setter + def volume(self, volume): + volume = int(volume) + if volume < 0: + volume = 0 + elif volume > 100: + volume = 100 + self._set_volume(volume) + + 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 new file mode 100644 index 00000000..03133dbe --- /dev/null +++ b/mopidy/mixers/alsa.py @@ -0,0 +1,18 @@ +import alsaaudio + +from mopidy.mixers import BaseMixer + +class AlsaMixer(BaseMixer): + """ + Mixer which uses the Advanced Linux Sound Architecture (ALSA) to control + volume. + """ + + def __init__(self): + self._mixer = alsaaudio.Mixer() + + def _get_volume(self): + return self._mixer.getvolume()[0] + + def _set_volume(self, volume): + self._mixer.setvolume(volume) diff --git a/mopidy/mixers/denon.py b/mopidy/mixers/denon.py new file mode 100644 index 00000000..ae5871d9 --- /dev/null +++ b/mopidy/mixers/denon.py @@ -0,0 +1,62 @@ +import logging +from threading import Lock + +from serial import Serial + +from mopidy.mixers import BaseMixer +from mopidy.settings import MIXER_EXT_PORT + +logger = logging.getLogger(u'mopidy.mixers.denon') + +class DenonMixer(BaseMixer): + """ + Mixer for controlling Denon amplifiers and receivers using the RS-232 + protocol. + + The external mixer is the authoritative source for the current volume. + This allows the user to use his remote control the volume without Mopidy + cancelling the volume setting. + + **Dependencies** + + - pyserial (python-serial on Debian/Ubuntu) + + **Settings** + + - :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0`` + """ + + def __init__(self): + """ + Connects using the serial specifications from Denon's RS-232 Protocol + specification: 9600bps 8N1. + """ + self._device = Serial(port=MIXER_EXT_PORT, timeout=0.2) + self._levels = ['99'] + ["%(#)02d" % {'#': v} for v in range(0, 99)] + self._volume = 0 + self._lock = Lock() + + def _get_volume(self): + self._lock.acquire(); + self.ensure_open_device() + self._device.write('MV?\r') + vol = str(self._device.readline()[2:4]) + self._lock.release() + logger.debug(u'_get_volume() = %s' % vol) + return self._levels.index(vol) + + def _set_volume(self, volume): + # Clamp according to Denon-spec + if volume > 99: + volume = 99 + self._lock.acquire() + self.ensure_open_device() + self._device.write('MV%s\r'% self._levels[volume]) + vol = self._device.readline()[2:4] + self._lock.release() + self._volume = self._levels.index(vol) + + def ensure_open_device(self): + if not self._device.isOpen(): + logger.debug(u'(re)connecting to Denon device') + self._device.open() diff --git a/mopidy/mixers/dummy.py b/mopidy/mixers/dummy.py new file mode 100644 index 00000000..38af7186 --- /dev/null +++ b/mopidy/mixers/dummy.py @@ -0,0 +1,13 @@ +from mopidy.mixers import BaseMixer + +class DummyMixer(BaseMixer): + """Mixer which just stores and reports the chosen volume.""" + + def __init__(self): + self._volume = None + + def _get_volume(self): + return self._volume + + def _set_volume(self, volume): + self._volume = volume diff --git a/mopidy/mixers/nad.py b/mopidy/mixers/nad.py new file mode 100644 index 00000000..d82a19a3 --- /dev/null +++ b/mopidy/mixers/nad.py @@ -0,0 +1,200 @@ +import logging +from serial import Serial +from multiprocessing import Pipe + +from mopidy.mixers import BaseMixer +from mopidy.process import BaseProcess +from mopidy.settings import (MIXER_EXT_PORT, MIXER_EXT_SOURCE, + MIXER_EXT_SPEAKERS_A, MIXER_EXT_SPEAKERS_B) + +logger = logging.getLogger('mopidy.mixers.nad') + +class NadMixer(BaseMixer): + """ + Mixer for controlling NAD amplifiers and receivers using the NAD RS-232 + protocol. + + The NAD mixer was created using a NAD C 355BEE amplifier, but should also + work with other NAD amplifiers supporting the same RS-232 protocol (v2.x). + The C 355BEE does not give you access to the current volume. It only + supports increasing or decreasing the volume one step at the time. Other + NAD amplifiers may support more advanced volume adjustment than what is + currently used by this mixer. + + Sadly, this means that if you use the remote control to change the volume + on the amplifier, Mopidy will no longer report the correct volume. To + recalibrate the mixer, set the volume to 0 through Mopidy. This will reset + the amplifier to a known state, including powering on the device, selecting + the configured speakers and input sources. + + **Dependencies** + + - pyserial (python-serial on Debian/Ubuntu) + + **Settings** + + - :attr:`mopidy.settings.MIXER_EXT_PORT` -- Example: ``/dev/ttyUSB0`` + - :attr:`mopidy.settings.MIXER_EXT_SOURCE` -- Example: ``Aux`` + - :attr:`mopidy.settings.MIXER_EXT_SPEAKERS_A` -- Example: ``On`` + - :attr:`mopidy.settings.MIXER_EXT_SPEAKERS_B` -- Example: ``Off`` + + """ + + def __init__(self): + self._volume = None + self._pipe, other_end = Pipe() + NadTalker(pipe=other_end).start() + + def _get_volume(self): + return self._volume + + def _set_volume(self, volume): + self._volume = volume + if volume == 0: + self._pipe.send({'command': 'reset_device'}) + self._pipe.send({'command': 'set_volume', 'volume': volume}) + + +class NadTalker(BaseProcess): + """ + Independent process which does the communication with the NAD device. + + Since the communication is done in an independent process, Mopidy won't + block other requests while doing rather time consuming work like + calibrating the NAD device's volume. + """ + + # Timeout in seconds used for read/write operations. + # If you set the timeout too low, the reads will never get complete + # confirmations and calibration will decrease volume forever. If you set + # the timeout too high, stuff takes more time. 0.2s seems like a good value + # for NAD C 355BEE. + TIMEOUT = 0.2 + + # Number of volume levels the device supports. 40 for NAD C 355BEE. + VOLUME_LEVELS = 40 + + # Volume in range 0..VOLUME_LEVELS. :class:`None` before calibration. + _nad_volume = None + + def __init__(self, pipe=None): + super(NadTalker, self).__init__() + self.pipe = pipe + + def _run(self): + self._open_connection() + self._set_device_to_known_state() + while self.pipe.poll(None): + message = self.pipe.recv() + if message['command'] == 'set_volume': + self._set_volume(message['volume']) + elif message['command'] == 'reset_device': + self._set_device_to_known_state() + + def _open_connection(self): + # Opens serial connection to the device. + # Communication settings: 115200 bps 8N1 + logger.info(u'Connecting to serial device "%s"', MIXER_EXT_PORT) + self._device = Serial(port=MIXER_EXT_PORT, baudrate=115200, + timeout=self.TIMEOUT) + self._get_device_model() + + def _set_device_to_known_state(self): + self._power_device_on() + self._select_speakers() + self._select_input_source() + self._unmute() + self._calibrate_volume() + + def _get_device_model(self): + model = self._ask_device('Main.Model') + logger.info(u'Connected to device of model "%s"', model) + return model + + def _power_device_on(self): + while self._ask_device('Main.Power') != 'On': + logger.info(u'Powering device on') + self._command_device('Main.Power', 'On') + + def _select_speakers(self): + if MIXER_EXT_SPEAKERS_A is not None: + while self._ask_device('Main.SpeakerA') != MIXER_EXT_SPEAKERS_A: + logger.info(u'Setting speakers A "%s"', MIXER_EXT_SPEAKERS_A) + self._command_device('Main.SpeakerA', MIXER_EXT_SPEAKERS_A) + if MIXER_EXT_SPEAKERS_B is not None: + while self._ask_device('Main.SpeakerB') != MIXER_EXT_SPEAKERS_B: + logger.info(u'Setting speakers B "%s"', MIXER_EXT_SPEAKERS_B) + self._command_device('Main.SpeakerB', MIXER_EXT_SPEAKERS_B) + + def _select_input_source(self): + if MIXER_EXT_SOURCE is not None: + while self._ask_device('Main.Source') != MIXER_EXT_SOURCE: + logger.info(u'Selecting input source "%s"', MIXER_EXT_SOURCE) + self._command_device('Main.Source', MIXER_EXT_SOURCE) + + def _unmute(self): + while self._ask_device('Main.Mute') != 'Off': + logger.info(u'Unmuting device') + self._command_device('Main.Mute', 'Off') + + def _ask_device(self, key): + self._write('%s?' % key) + return self._readline().replace('%s=' % key, '') + + def _command_device(self, key, value): + self._write('%s=%s' % (key, value)) + self._readline() + + def _calibrate_volume(self): + # The NAD C 355BEE amplifier has 40 different volume levels. We have no + # way of asking on which level we are. Thus, we must calibrate the + # mixer by decreasing the volume 39 times. + logger.info(u'Calibrating NAD amplifier') + steps_left = self.VOLUME_LEVELS - 1 + while steps_left: + if self._decrease_volume(): + steps_left -= 1 + self._nad_volume = 0 + logger.info(u'Done calibrating NAD amplifier') + + def _set_volume(self, volume): + # Increase or decrease the amplifier volume until it matches the given + # target volume. + logger.debug(u'Setting volume to %d' % volume) + target_nad_volume = int(round(volume * self.VOLUME_LEVELS / 100.0)) + if self._nad_volume is None: + return # Calibration needed + while target_nad_volume > self._nad_volume: + if self._increase_volume(): + self._nad_volume += 1 + while target_nad_volume < self._nad_volume: + if self._decrease_volume(): + self._nad_volume -= 1 + + def _increase_volume(self): + # Increase volume. Returns :class:`True` if confirmed by device. + self._write('Main.Volume+') + return self._readline() == 'Main.Volume+' + + def _decrease_volume(self): + # Decrease volume. Returns :class:`True` if confirmed by device. + self._write('Main.Volume-') + return self._readline() == 'Main.Volume-' + + def _write(self, data): + # Write data to device. Prepends and appends a newline to the data, as + # recommended by the NAD documentation. + if not self._device.isOpen(): + self._device.open() + self._device.write('\n%s\n' % data) + logger.debug('Write: %s', data) + + def _readline(self): + # Read line from device. The result is stripped for leading and + # trailing whitespace. + if not self._device.isOpen(): + self._device.open() + result = self._device.readline(eol='\n').strip() + if result: + logger.debug('Read: %s', result) + return result diff --git a/mopidy/mixers/osa.py b/mopidy/mixers/osa.py new file mode 100644 index 00000000..6291cac1 --- /dev/null +++ b/mopidy/mixers/osa.py @@ -0,0 +1,34 @@ +from subprocess import Popen, PIPE +import time + +from mopidy.mixers import BaseMixer + +class OsaMixer(BaseMixer): + """Mixer which uses ``osascript`` on OS X to control volume.""" + + CACHE_TTL = 30 + + _cache = None + _last_update = None + + def _valid_cache(self): + return (self._cache is not None + and self._last_update is not None + and (int(time.time() - self._last_update) < self.CACHE_TTL)) + + def _get_volume(self): + if not self._valid_cache(): + try: + self._cache = int(Popen( + ['osascript', '-e', + 'output volume of (get volume settings)'], + stdout=PIPE).communicate()[0]) + except ValueError: + self._cache = None + self._last_update = int(time.time()) + return self._cache + + def _set_volume(self, volume): + Popen(['osascript', '-e', 'set volume output volume %d' % volume]) + self._cache = volume + self._last_update = int(time.time()) diff --git a/mopidy/models.py b/mopidy/models.py index 39212c8e..6d0b0dee 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,6 +1,24 @@ from copy import copy -class Artist(object): +class ImmutableObject(object): + """ + Superclass for immutable objects whose fields can only be modified via the + constructor. + + :param kwargs: kwargs to set as fields on the object + :type kwargs: any + """ + + def __init__(self, *args, **kwargs): + self.__dict__.update(kwargs) + + def __setattr__(self, name, value): + if name.startswith('_'): + return super(ImmutableObject, self).__setattr__(name, value) + raise AttributeError('Object is immutable.') + + +class Artist(ImmutableObject): """ :param uri: artist URI :type uri: string @@ -8,22 +26,14 @@ class Artist(object): :type name: string """ - def __init__(self, uri=None, name=None): - self._uri = None - self._name = name + #: The artist URI. Read-only. + uri = None - @property - def uri(self): - """The artist URI. Read-only.""" - return self._uri - - @property - def name(self): - """The artist name. Read-only.""" - return self._name + #: The artist name. Read-only. + name = None -class Album(object): +class Album(ImmutableObject): """ :param uri: album URI :type uri: string @@ -35,39 +45,31 @@ class Album(object): :type num_tracks: integer """ - def __init__(self, uri=None, name=None, artists=None, num_tracks=0): - self._uri = uri - self._name = name - self._artists = artists or [] - self._num_tracks = num_tracks + #: The album URI. Read-only. + uri = None - @property - def uri(self): - """The album URI. Read-only.""" - return self._uri + #: The album name. Read-only. + name = None - @property - def name(self): - """The album name. Read-only.""" - return self._name + #: The number of tracks in the album. Read-only. + num_tracks = 0 + + def __init__(self, *args, **kwargs): + self._artists = kwargs.pop('artists', []) + super(Album, self).__init__(*args, **kwargs) @property def artists(self): """List of :class:`Artist` elements. Read-only.""" return copy(self._artists) - @property - def num_tracks(self): - """The number of tracks in the album. Read-only.""" - return self._num_tracks - -class Track(object): +class Track(ImmutableObject): """ :param uri: track URI :type uri: string - :param title: track title - :type title: string + :param name: track name + :type name: string :param artists: track artists :type artists: list of :class:`Artist` :param album: track album @@ -84,64 +86,40 @@ class Track(object): :type id: integer """ - def __init__(self, uri=None, title=None, artists=None, album=None, - track_no=0, date=None, length=None, bitrate=None, id=None): - self._uri = uri - self._title = title - self._artists = artists or [] - self._album = album - self._track_no = track_no - self._date = date - self._length = length - self._bitrate = bitrate - self._id = id + #: The track URI. Read-only. + uri = None - @property - def uri(self): - """The track URI. Read-only.""" - return self._uri + #: The track name. Read-only. + name = None - @property - def title(self): - """The track title. Read-only.""" - return self._title + #: The track :class:`Album`. Read-only. + album = None + + #: The track number in album. Read-only. + track_no = 0 + + #: The track release date. Read-only. + date = None + + #: The track length in milliseconds. Read-only. + length = None + + #: The track's bitrate in kbit/s. Read-only. + bitrate = None + + #: The track ID. Read-only. + id = None + + def __init__(self, *args, **kwargs): + self._artists = kwargs.pop('artists', []) + super(Track, self).__init__(*args, **kwargs) @property def artists(self): """List of :class:`Artist`. Read-only.""" return copy(self._artists) - @property - def album(self): - """The track :class:`Album`. Read-only.""" - return self._album - - @property - def track_no(self): - """The track number in album. Read-only.""" - return self._track_no - - @property - def date(self): - """The track release date. Read-only.""" - return self._date - - @property - def length(self): - """The track length in milliseconds. Read-only.""" - return self._length - - @property - def bitrate(self): - """The track's bitrate in kbit/s. Read-only.""" - return self._bitrate - - @property - def id(self): - """The track ID. Read-only.""" - return self._id - - def mpd_format(self, position=0): + def mpd_format(self, position=0, search_result=False): """ Format track for output to MPD client. @@ -149,17 +127,23 @@ class Track(object): :type position: integer :rtype: list of two-tuples """ - return [ - ('file', self.uri), - ('Time', self.length // 1000), + result = [ + ('file', self.uri or ''), + ('Time', self.length and (self.length // 1000) or 0), ('Artist', self.mpd_format_artists()), - ('Title', self.title), - ('Album', self.album.name), - ('Track', '%d/%d' % (self.track_no, self.album.num_tracks)), - ('Date', self.date), - ('Pos', position), - ('Id', self.id), + ('Title', self.name or ''), + ('Album', self.album and self.album.name or ''), + ('Date', self.date or ''), ] + if self.album is not None and self.album.num_tracks != 0: + result.append(('Track', '%d/%d' % ( + self.track_no, self.album.num_tracks))) + else: + result.append(('Track', self.track_no)) + if not search_result: + result.append(('Pos', position)) + result.append(('Id', self.id or position)) + return result def mpd_format_artists(self): """ @@ -170,7 +154,7 @@ class Track(object): return u', '.join([a.name for a in self.artists]) -class Playlist(object): +class Playlist(ImmutableObject): """ :param uri: playlist URI :type uri: string @@ -180,20 +164,20 @@ class Playlist(object): :type tracks: list of :class:`Track` elements """ - def __init__(self, uri=None, name=None, tracks=None): - self._uri = uri - self._name = name - self._tracks = tracks or [] + #: The playlist URI. Read-only. + uri = None - @property - def uri(self): - """The playlist URI. Read-only.""" - return self._uri + #: The playlist name. Read-only. + name = None - @property - def name(self): - """The playlist name. Read-only.""" - return self._name + #: The playlist modification time. Read-only. + #: + #: :class:`datetime.datetime`, or :class:`None` if unknown. + last_modified = None + + def __init__(self, *args, **kwargs): + self._tracks = kwargs.pop('tracks', []) + super(Playlist, self).__init__(*args, **kwargs) @property def tracks(self): @@ -205,15 +189,56 @@ class Playlist(object): """The number of tracks in the playlist. Read-only.""" return len(self._tracks) - def mpd_format(self, start=0, end=None): + def mpd_format(self, start=0, end=None, search_result=False): """ Format playlist for output to MPD client. + Optionally limit output to the slice ``[start:end]`` of the playlist. + + :param start: position of first track to include in output + :type start: int (positive or negative) + :param end: position after last track to include in output + :type end: int (positive or negative) or :class:`None` for end of list :rtype: list of lists of two-tuples """ - if end is None: - end = self.length + if start < 0: + range_start = self.length + start + else: + range_start = start + if end is not None and end < 0: + range_end = self.length - end + elif end is not None and end >= 0: + range_end = end + else: + range_end = self.length tracks = [] - for track, position in zip(self.tracks, range(start, end)): - tracks.append(track.mpd_format(position)) + for track, position in zip(self.tracks[start:end], + range(range_start, range_end)): + tracks.append(track.mpd_format(position, search_result)) return tracks + + 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 + :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/mpd/__init__.py b/mopidy/mpd/__init__.py index e69de29b..c0685891 100644 --- a/mopidy/mpd/__init__.py +++ b/mopidy/mpd/__init__.py @@ -0,0 +1,8 @@ +from mopidy import MopidyException + +class MpdAckError(MopidyException): + pass + +class MpdNotImplemented(MpdAckError): + def __init__(self): + super(MpdNotImplemented, self).__init__(u'Not implemented') diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py new file mode 100644 index 00000000..c2ac84ea --- /dev/null +++ b/mopidy/mpd/frontend.py @@ -0,0 +1,1493 @@ +""" +This is our MPD protocol implementation. + +This is partly based upon the `MPD protocol documentation +`_, which is a useful resource, but it is +rather incomplete with regards to data formats, both for requests and +responses. Thus, we have had to talk a great deal with the the original `MPD +server `_ using telnet to get the details we need to +implement our own MPD server which is compatible with the numerous existing +`MPD clients `_. +""" + +import datetime as dt +import logging +import re + +from mopidy.mpd import MpdAckError, MpdNotImplemented +from mopidy.utils import flatten + +logger = logging.getLogger('mopidy.mpd.frontend') + +_request_handlers = {} + +def handle_pattern(pattern): + """ + Decorator for connecting command handlers to command patterns. + + If you use named groups in the pattern, the decorated method will get the + groups as keyword arguments. If the group is optional, remember to give the + argument a default value. + + For example, if the command is ``do that thing`` the ``what`` argument will + be ``this thing``:: + + @handle_pattern('^do (?P.+)$') + def do(what): + ... + + :param pattern: regexp pattern for matching commands + :type pattern: string + """ + def decorator(func): + if pattern in _request_handlers: + raise ValueError(u'Tried to redefine handler for %s with %s' % ( + pattern, func)) + _request_handlers[pattern] = func + func.__doc__ = ' - **Pattern:** ``%s``\n\n%s' % ( + pattern, func.__doc__ or '') + return func + return decorator + +class MpdFrontend(object): + def __init__(self, backend=None): + self.backend = backend + self.command_list = False + + def handle_request(self, request, add_ok=True): + if self.command_list is not False and request != u'command_list_end': + self.command_list.append(request) + return None + for pattern in _request_handlers: + matches = re.match(pattern, request) + if matches is not None: + groups = matches.groupdict() + try: + result = _request_handlers[pattern](self, **groups) + except MpdAckError as e: + return self.handle_response(u'ACK %s' % e.message, + add_ok=False) + if self.command_list is not False: + return None + else: + return self.handle_response(result, add_ok) + return self.handle_response(u'ACK Unknown command: %s' % request) + + def handle_response(self, result, add_ok=True): + response = [] + if result is None: + result = [] + elif not isinstance(result, list): + result = [result] + for line in flatten(result): + if isinstance(line, dict): + for (key, value) in line.items(): + response.append(u'%s: %s' % (key, value)) + elif isinstance(line, tuple): + (key, value) = line + response.append(u'%s: %s' % (key, value)) + else: + response.append(line) + if add_ok and (not response or not response[-1].startswith(u'ACK')): + response.append(u'OK') + return response + + @handle_pattern(r'^ack$') + def _ack(self): + """ + Always returns an 'ACK'. Not a part of the MPD protocol. + """ + raise MpdNotImplemented + + @handle_pattern(r'^disableoutput "(?P\d+)"$') + def _audio_output_disableoutput(self, outputid): + """ + *musicpd.org, audio output section:* + + ``disableoutput`` + + Turns an output off. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^enableoutput "(?P\d+)"$') + def _audio_output_enableoutput(self, outputid): + """ + *musicpd.org, audio output section:* + + ``enableoutput`` + + Turns an output on. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^outputs$') + def _audio_output_outputs(self): + """ + *musicpd.org, audio output section:* + + ``outputs`` + + Shows information about all outputs. + """ + return [ + ('outputid', 0), + ('outputname', self.backend.__class__.__name__), + ('outputenabled', 1), + ] + + @handle_pattern(r'^command_list_begin$') + def _command_list_begin(self): + """ + *musicpd.org, command list section:* + + To facilitate faster adding of files etc. you can pass a list of + commands all at once using a command list. The command list begins + with ``command_list_begin`` or ``command_list_ok_begin`` and ends + with ``command_list_end``. + + It does not execute any commands until the list has ended. The + return value is whatever the return for a list of commands is. On + success for all commands, ``OK`` is returned. If a command fails, + no more commands are executed and the appropriate ``ACK`` error is + returned. If ``command_list_ok_begin`` is used, ``list_OK`` is + returned for each successful command executed in the command list. + """ + self.command_list = [] + self.command_list_ok = False + + @handle_pattern(r'^command_list_end$') + def _command_list_end(self): + """See :meth:`_command_list_begin`.""" + (command_list, self.command_list) = (self.command_list, False) + (command_list_ok, self.command_list_ok) = (self.command_list_ok, False) + result = [] + for command in command_list: + response = self.handle_request(command, add_ok=False) + if response is not None: + result.append(response) + if response and response[-1].startswith(u'ACK'): + return result + if command_list_ok: + response.append(u'list_OK') + return result + + @handle_pattern(r'^command_list_ok_begin$') + def _command_list_ok_begin(self): + """See :meth:`_command_list_begin`.""" + self.command_list = [] + self.command_list_ok = True + + @handle_pattern(r'^close$') + def _connection_close(self): + """ + *musicpd.org, connection section:* + + ``close`` + + Closes the connection to MPD. + """ + # TODO Does not work after multiprocessing branch merge + #self.session.do_close() + + @handle_pattern(r'^kill$') + def _connection_kill(self): + """ + *musicpd.org, connection section:* + + ``kill`` + + Kills MPD. + """ + # TODO Does not work after multiprocessing branch merge + #self.session.do_kill() + + @handle_pattern(r'^password "(?P[^"]+)"$') + def _connection_password(self, password): + """ + *musicpd.org, connection section:* + + ``password {PASSWORD}`` + + This is used for authentication with the server. ``PASSWORD`` is + simply the plaintext password. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^ping$') + def _connection_ping(self): + """ + *musicpd.org, connection section:* + + ``ping`` + + Does nothing but return ``OK``. + """ + pass + + @handle_pattern(r'^add "(?P[^"]*)"$') + def _current_playlist_add(self, uri): + """ + *musicpd.org, current playlist section:* + + ``add {URI}`` + + Adds the file ``URI`` to the playlist (directories add recursively). + ``URI`` can also be a single file. + """ + self._current_playlist_addid(uri) + + @handle_pattern(r'^addid "(?P[^"]*)"( "(?P\d+)")*$') + def _current_playlist_addid(self, uri, songpos=None): + """ + *musicpd.org, current playlist section:* + + ``addid {URI} [POSITION]`` + + Adds a song to the playlist (non-recursive) and returns the song id. + + ``URI`` is always a single file or URL. For example:: + + addid "foo.mp3" + Id: 999 + OK + """ + if songpos is not None: + songpos = int(songpos) + track = self.backend.library.lookup(uri) + if track is not None: + self.backend.current_playlist.add(track, at_position=songpos) + return ('Id', track.id) + + @handle_pattern(r'^delete "(?P\d+):(?P\d+)*"$') + def _current_playlist_delete_range(self, start, end=None): + """ + *musicpd.org, current playlist section:* + + ``delete [{POS} | {START:END}]`` + + Deletes a song from the playlist. + """ + start = int(start) + if end is not None: + end = int(end) + else: + end = self.backend.current_playlist.playlist.length + tracks = self.backend.current_playlist.playlist.tracks[start:end] + if not tracks: + raise MpdAckError(u'Position out of bounds') + for track in tracks: + self.backend.current_playlist.remove(track) + + @handle_pattern(r'^delete "(?P\d+)"$') + def _current_playlist_delete_songpos(self, songpos): + """See :meth:`_current_playlist_delete_range`""" + try: + songpos = int(songpos) + track = self.backend.current_playlist.playlist.tracks[songpos] + self.backend.current_playlist.remove(track) + except IndexError as e: + raise MpdAckError(u'Position out of bounds') + + @handle_pattern(r'^deleteid "(?P\d+)"$') + def _current_playlist_deleteid(self, songid): + """ + *musicpd.org, current playlist section:* + + ``deleteid {SONGID}`` + + Deletes the song ``SONGID`` from the playlist + """ + songid = int(songid) + try: + track = self.backend.current_playlist.get(id=songid) + return self.backend.current_playlist.remove(track) + except LookupError as e: + raise MpdAckError(e[0]) + + @handle_pattern(r'^clear$') + def _current_playlist_clear(self): + """ + *musicpd.org, current playlist section:* + + ``clear`` + + Clears the current playlist. + """ + self.backend.current_playlist.clear() + + @handle_pattern(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') + def _current_playlist_move_range(self, start, to, end=None): + """ + *musicpd.org, current playlist section:* + + ``move [{FROM} | {START:END}] {TO}`` + + Moves the song at ``FROM`` or range of songs at ``START:END`` to + ``TO`` in the playlist. + """ + if end is None: + end = self.backend.current_playlist.playlist.length + start = int(start) + end = int(end) + to = int(to) + self.backend.current_playlist.move(start, end, to) + + @handle_pattern(r'^move "(?P\d+)" "(?P\d+)"$') + def _current_playlist_move_songpos(self, songpos, to): + """See :meth:`_current_playlist_move_range`.""" + songpos = int(songpos) + to = int(to) + self.backend.current_playlist.move(songpos, songpos + 1, to) + + @handle_pattern(r'^moveid "(?P\d+)" "(?P\d+)"$') + def _current_playlist_moveid(self, songid, to): + """ + *musicpd.org, current playlist section:* + + ``moveid {FROM} {TO}`` + + Moves the song with ``FROM`` (songid) to ``TO`` (playlist index) in + the playlist. If ``TO`` is negative, it is relative to the current + song in the playlist (if there is one). + """ + songid = int(songid) + to = int(to) + track = self.backend.current_playlist.get(id=songid) + position = self.backend.current_playlist.playlist.tracks.index(track) + self.backend.current_playlist.move(position, position + 1, to) + + @handle_pattern(r'^playlist$') + def _current_playlist_playlist(self): + """ + *musicpd.org, current playlist section:* + + ``playlist`` + + Displays the current playlist. + + .. note:: + + Do not use this, instead use ``playlistinfo``. + """ + return self._current_playlist_playlistinfo() + + @handle_pattern(r'^playlistfind (?P[^"]+) "(?P[^"]+)"$') + @handle_pattern(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') + def _current_playlist_playlistfind(self, tag, needle): + """ + *musicpd.org, current playlist section:* + + ``playlistfind {TAG} {NEEDLE}`` + + Finds songs in the current playlist with strict matching. + + *GMPC:* + + - does not add quotes around the tag. + """ + if tag == 'filename': + try: + track = self.backend.current_playlist.get(uri=needle) + return track.mpd_format() + except LookupError: + return None + raise MpdNotImplemented # TODO + + @handle_pattern(r'^playlistid( "(?P\d+)")*$') + def _current_playlist_playlistid(self, songid=None): + """ + *musicpd.org, current playlist section:* + + ``playlistid {SONGID}`` + + Displays a list of songs in the playlist. ``SONGID`` is optional + and specifies a single song to display info for. + """ + if songid is not None: + try: + songid = int(songid) + track = self.backend.current_playlist.get(id=songid) + return track.mpd_format() + except LookupError as e: + raise MpdAckError(e[0]) + else: + return self.backend.current_playlist.playlist.mpd_format() + + @handle_pattern(r'^playlistinfo$') + @handle_pattern(r'^playlistinfo "(?P-?\d+)"$') + @handle_pattern(r'^playlistinfo "(?P\d+):(?P\d+)*"$') + def _current_playlist_playlistinfo(self, songpos=None, + start=None, end=None): + """ + *musicpd.org, current playlist section:* + + ``playlistinfo [[SONGPOS] | [START:END]]`` + + Displays a list of all songs in the playlist, or if the optional + argument is given, displays information only for the song + ``SONGPOS`` or the range of songs ``START:END``. + + *ncmpc:* + + - uses negative indexes, like ``playlistinfo "-1"``, to request + information on the last track in the playlist. + """ + if songpos is not None: + songpos = int(songpos) + start = songpos + end = songpos + 1 + if start == -1: + end = None + return self.backend.current_playlist.playlist.mpd_format( + start, end) + else: + if start is None: + start = 0 + start = int(start) + if end is not None: + end = int(end) + return self.backend.current_playlist.playlist.mpd_format(start, end) + + @handle_pattern(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') + def _current_playlist_playlistsearch(self, tag, needle): + """ + *musicpd.org, current playlist section:* + + ``playlistsearch {TAG} {NEEDLE}`` + + Searches case-sensitively for partial matches in the current + playlist. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^plchanges "(?P\d+)"$') + def _current_playlist_plchanges(self, version): + """ + *musicpd.org, current playlist section:* + + ``plchanges {VERSION}`` + + Displays changed songs currently in the playlist since ``VERSION``. + + To detect songs that were deleted at the end of the playlist, use + ``playlistlength`` returned by status command. + """ + # XXX Naive implementation that returns all tracks as changed + if int(version) < self.backend.current_playlist.version: + return self.backend.current_playlist.playlist.mpd_format() + + @handle_pattern(r'^plchangesposid "(?P\d+)"$') + def _current_playlist_plchangesposid(self, version): + """ + *musicpd.org, current playlist section:* + + ``plchangesposid {VERSION}`` + + Displays changed songs currently in the playlist since ``VERSION``. + This function only returns the position and the id of the changed + song, not the complete metadata. This is more bandwidth efficient. + + To detect songs that were deleted at the end of the playlist, use + ``playlistlength`` returned by status command. + """ + # XXX Naive implementation that returns all tracks as changed + if int(version) != self.backend.current_playlist.version: + result = [] + for position, track in enumerate( + self.backend.current_playlist.playlist.tracks): + result.append((u'cpos', position)) + result.append((u'Id', track.id)) + return result + + @handle_pattern(r'^shuffle$') + @handle_pattern(r'^shuffle "(?P\d+):(?P\d+)*"$') + def _current_playlist_shuffle(self, start=None, end=None): + """ + *musicpd.org, current playlist section:* + + ``shuffle [START:END]`` + + Shuffles the current playlist. ``START:END`` is optional and + specifies a range of songs. + """ + if start is not None: + start = int(start) + if end is not None: + end = int(end) + self.backend.current_playlist.shuffle(start, end) + + @handle_pattern(r'^swap "(?P\d+)" "(?P\d+)"$') + def _current_playlist_swap(self, songpos1, songpos2): + """ + *musicpd.org, current playlist section:* + + ``swap {SONG1} {SONG2}`` + + Swaps the positions of ``SONG1`` and ``SONG2``. + """ + songpos1 = int(songpos1) + songpos2 = int(songpos2) + playlist = self.backend.current_playlist.playlist + tracks = playlist.tracks + song1 = tracks[songpos1] + song2 = tracks[songpos2] + del tracks[songpos1] + tracks.insert(songpos1, song2) + del tracks[songpos2] + tracks.insert(songpos2, song1) + self.backend.current_playlist.load(playlist.with_(tracks=tracks)) + + @handle_pattern(r'^swapid "(?P\d+)" "(?P\d+)"$') + def _current_playlist_swapid(self, songid1, songid2): + """ + *musicpd.org, current playlist section:* + + ``swapid {SONG1} {SONG2}`` + + Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). + """ + songid1 = int(songid1) + songid2 = int(songid2) + song1 = self.backend.current_playlist.get(id=songid1) + song2 = self.backend.current_playlist.get(id=songid2) + songpos1 = self.backend.current_playlist.playlist.tracks.index(song1) + songpos2 = self.backend.current_playlist.playlist.tracks.index(song2) + self._current_playlist_swap(songpos1, songpos2) + + @handle_pattern(r'^$') + def _empty(self): + """The original MPD server returns ``OK`` on an empty request.``""" + pass + + @handle_pattern(r'^count "(?P[^"]+)" "(?P[^"]*)"$') + def _music_db_count(self, tag, needle): + """ + *musicpd.org, music database section:* + + ``count {TAG} {NEEDLE}`` + + Counts the number of songs and their total playtime in the db + matching ``TAG`` exactly. + """ + return [('songs', 0), ('playtime', 0)] # TODO + + @handle_pattern(r'^find (?P(album|artist|title)) ' + r'"(?P[^"]+)"$') + @handle_pattern(r'^find (?P(Album|Artist|Title)) ' + r'"(?P[^"]+)"$') + @handle_pattern(r'^find "(?P(album|artist|title))" ' + r'"(?P[^"]+)"$') + @handle_pattern(r'^find (?P(album)) ' + r'"(?P[^"]+)" artist "([^"]+)"$') + def _music_db_find(self, type, what): + """ + *musicpd.org, music database section:* + + ``find {TYPE} {WHAT}`` + + Finds songs in the db that are exactly ``WHAT``. ``TYPE`` should be + ``album``, ``artist``, or ``title``. ``WHAT`` is what to find. + + *GMPC:* + + - does not add quotes around the type argument. + - also uses ``find album "[ALBUM]" artist "[ARTIST]"`` to list album + tracks. + + *ncmpc:* + + - does not add quotes around the type argument. + - capitalizes the type argument. + """ + type = type.lower() + if type == u'title': + type = u'track' + return self.backend.library.find_exact(type, what).mpd_format( + search_result=True) + + @handle_pattern(r'^findadd "(?P(album|artist|title))" ' + r'"(?P[^"]+)"$') + def _music_db_findadd(self, type, what): + """ + *musicpd.org, music database section:* + + ``findadd {TYPE} {WHAT}`` + + Finds songs in the db that are exactly ``WHAT`` and adds them to + current playlist. ``TYPE`` can be any tag supported by MPD. + ``WHAT`` is what to find. + """ + result = self._music_db_find(type, what) + # TODO Add result to current playlist + #return result + + @handle_pattern(r'^list "(?Partist)"$') + @handle_pattern(r'^list (?PArtist)$') + @handle_pattern(r'^list "(?Palbum)"( "(?P[^"]+)")*$') + def _music_db_list(self, type, artist=None): + """ + *musicpd.org, music database section:* + + ``list {TYPE} [ARTIST]`` + + Lists all tags of the specified type. ``TYPE`` should be ``album`` + or ``artist``. + + ``ARTIST`` is an optional parameter when type is ``album``, this + specifies to list albums by an artist. + + *ncmpc:* + + - does not add quotes around the type argument. + - capitalizes the type argument. + """ + type = type.lower() + pass # TODO + + @handle_pattern(r'^listall "(?P[^"]+)"') + def _music_db_listall(self, uri): + """ + *musicpd.org, music database section:* + + ``listall [URI]`` + + Lists all songs and directories in ``URI``. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^listallinfo "(?P[^"]+)"') + def _music_db_listallinfo(self, uri): + """ + *musicpd.org, music database section:* + + ``listallinfo [URI]`` + + Same as ``listall``, except it also returns metadata info in the + same format as ``lsinfo``. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^lsinfo$') + @handle_pattern(r'^lsinfo "(?P[^"]*)"$') + def _music_db_lsinfo(self, uri=None): + """ + *musicpd.org, music database section:* + + ``lsinfo [URI]`` + + Lists the contents of the directory ``URI``. + + When listing the root directory, this currently returns the list of + stored playlists. This behavior is deprecated; use + ``listplaylists`` instead. + """ + if uri == u'/' or uri is None: + return self._stored_playlists_listplaylists() + raise MpdNotImplemented # TODO + + @handle_pattern(r'^rescan( "(?P[^"]+)")*$') + def _music_db_rescan(self, uri=None): + """ + *musicpd.org, music database section:* + + ``rescan [URI]`` + + Same as ``update``, but also rescans unmodified files. + """ + return self._music_db_update(uri, rescan_unmodified_files=True) + + @handle_pattern(r'^search (?P(album|artist|filename|title|any)) ' + r'"(?P[^"]+)"$') + @handle_pattern(r'^search (?P(Album|Artist|Filename|Title|Any)) ' + r'"(?P[^"]+)"$') + @handle_pattern(r'^search "(?P(album|artist|filename|title|any))" ' + r'"(?P[^"]+)"$') + def _music_db_search(self, type, what): + """ + *musicpd.org, music database section:* + + ``search {TYPE} {WHAT}`` + + Searches for any song that contains ``WHAT``. ``TYPE`` can be + ``title``, ``artist``, ``album`` or ``filename``. Search is not + case sensitive. + + *GMPC:* + + - does not add quotes around the type argument. + - uses the undocumented type ``any``. + - searches for multiple words like this:: + + search any "foo" any "bar" any "baz" + + *ncmpc:* + + - does not add quotes around the type argument. + - capitalizes the type argument. + """ + # TODO Support GMPC multi-word search + type = type.lower() + if type == u'title': + type = u'track' + return self.backend.library.search(type, what).mpd_format( + search_result=True) + + @handle_pattern(r'^update( "(?P[^"]+)")*$') + def _music_db_update(self, uri=None, rescan_unmodified_files=False): + """ + *musicpd.org, music database section:* + + ``update [URI]`` + + Updates the music database: find new files, remove deleted files, + update modified files. + + ``URI`` is a particular directory or song/file to update. If you do + not specify it, everything is updated. + + Prints ``updating_db: JOBID`` where ``JOBID`` is a positive number + identifying the update job. You can read the current job id in the + ``status`` response. + """ + return {'updating_db': 0} # TODO + + @handle_pattern(r'^consume "(?P[01])"$') + def _playback_consume(self, state): + """ + *musicpd.org, playback section:* + + ``consume {STATE}`` + + Sets consume state to ``STATE``, ``STATE`` should be 0 or + 1. When consume is activated, each song played is removed from + playlist. + """ + if int(state): + self.backend.playback.consume = True + else: + self.backend.playback.consume = False + + @handle_pattern(r'^crossfade "(?P\d+)"$') + def _playback_crossfade(self, seconds): + """ + *musicpd.org, playback section:* + + ``crossfade {SECONDS}`` + + Sets crossfading between songs. + """ + seconds = int(seconds) + raise MpdNotImplemented # TODO + + @handle_pattern(r'^next$') + def _playback_next(self): + """ + *musicpd.org, playback section:* + + ``next`` + + Plays next song in the playlist. + """ + return self.backend.playback.next() + + @handle_pattern(r'^pause "(?P[01])"$') + def _playback_pause(self, state): + """ + *musicpd.org, playback section:* + + ``pause {PAUSE}`` + + Toggles pause/resumes playing, ``PAUSE`` is 0 or 1. + """ + if int(state): + self.backend.playback.pause() + else: + self.backend.playback.resume() + + @handle_pattern(r'^play$') + def _playback_play(self): + """ + The original MPD server resumes from the paused state on ``play`` + without arguments. + """ + return self.backend.playback.play() + + @handle_pattern(r'^playid "(?P\d+)"$') + @handle_pattern(r'^playid "(?P-1)"$') + def _playback_playid(self, songid): + """ + *musicpd.org, playback section:* + + ``playid [SONGID]`` + + Begins playing the playlist at song ``SONGID``. + + *GMPC:* + + - issues ``playid "-1"`` after playlist replacement. + """ + songid = int(songid) + try: + if songid == -1: + track = self.backend.current_playlist.playlist.tracks[0] + else: + track = self.backend.current_playlist.get(id=songid) + return self.backend.playback.play(track) + except LookupError as e: + raise MpdAckError(e[0]) + + @handle_pattern(r'^play "(?P\d+)"$') + def _playback_playpos(self, songpos): + """ + *musicpd.org, playback section:* + + ``play [SONGPOS]`` + + Begins playing the playlist at song number ``SONGPOS``. + """ + songpos = int(songpos) + try: + track = self.backend.current_playlist.playlist.tracks[songpos] + return self.backend.playback.play(track) + except IndexError: + raise MpdAckError(u'Position out of bounds') + + @handle_pattern(r'^previous$') + def _playback_previous(self): + """ + *musicpd.org, playback section:* + + ``previous`` + + Plays previous song in the playlist. + """ + return self.backend.playback.previous() + + @handle_pattern(r'^random "(?P[01])"$') + def _playback_random(self, state): + """ + *musicpd.org, playback section:* + + ``random {STATE}`` + + Sets random state to ``STATE``, ``STATE`` should be 0 or 1. + """ + if int(state): + self.backend.playback.random = True + else: + self.backend.playback.random = False + + @handle_pattern(r'^repeat "(?P[01])"$') + def _playback_repeat(self, state): + """ + *musicpd.org, playback section:* + + ``repeat {STATE}`` + + Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. + """ + if int(state): + self.backend.playback.repeat = True + else: + self.backend.playback.repeat = False + + @handle_pattern(r'^replay_gain_mode "(?P(off|track|album))"$') + def _playback_replay_gain_mode(self, mode): + """ + *musicpd.org, playback section:* + + ``replay_gain_mode {MODE}`` + + Sets the replay gain mode. One of ``off``, ``track``, ``album``. + + Changing the mode during playback may take several seconds, because + the new settings does not affect the buffered data. + + This command triggers the options idle event. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^replay_gain_status$') + def _playback_replay_gain_status(self): + """ + *musicpd.org, playback section:* + + ``replay_gain_status`` + + Prints replay gain options. Currently, only the variable + ``replay_gain_mode`` is returned. + """ + return u'off' # TODO + + @handle_pattern(r'^seek "(?P\d+)" "(?P\d+)"$') + def _playback_seek(self, songpos, seconds): + """ + *musicpd.org, playback section:* + + ``seek {SONGPOS} {TIME}`` + + Seeks to the position ``TIME`` (in seconds) of entry ``SONGPOS`` in + the playlist. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^seekid "(?P\d+)" "(?P\d+)"$') + def _playback_seekid(self, songid, seconds): + """ + *musicpd.org, playback section:* + + ``seekid {SONGID} {TIME}`` + + Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^setvol "(?P[-+]*\d+)"$') + def _playback_setvol(self, volume): + """ + *musicpd.org, playback section:* + + ``setvol {VOL}`` + + Sets volume to ``VOL``, the range of volume is 0-100. + """ + volume = int(volume) + if volume < 0: + volume = 0 + if volume > 100: + volume = 100 + self.backend.playback.volume = volume + + @handle_pattern(r'^single "(?P[01])"$') + def _playback_single(self, state): + """ + *musicpd.org, playback section:* + + ``single {STATE}`` + + Sets single state to ``STATE``, ``STATE`` should be 0 or 1. When + single is activated, playback is stopped after current song, or + song is repeated if the ``repeat`` mode is enabled. + """ + if int(state): + self.backend.playback.single = True + else: + self.backend.playback.single = False + + @handle_pattern(r'^stop$') + def _playback_stop(self): + """ + *musicpd.org, playback section:* + + ``stop`` + + Stops playing. + """ + self.backend.playback.stop() + + @handle_pattern(r'^commands$') + def _reflection_commands(self): + """ + *musicpd.org, reflection section:* + + ``commands`` + + Shows which commands the current user has access to. + """ + pass # TODO + + @handle_pattern(r'^decoders$') + def _reflection_decoders(self): + """ + *musicpd.org, reflection section:* + + ``decoders`` + + Print a list of decoder plugins, followed by their supported + suffixes and MIME types. Example response:: + + plugin: mad + suffix: mp3 + suffix: mp2 + mime_type: audio/mpeg + plugin: mpcdec + suffix: mpc + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^notcommands$') + def _reflection_notcommands(self): + """ + *musicpd.org, reflection section:* + + ``notcommands`` + + Shows which commands the current user does not have access to. + """ + pass # TODO + + @handle_pattern(r'^tagtypes$') + def _reflection_tagtypes(self): + """ + *musicpd.org, reflection section:* + + ``tagtypes`` + + Shows a list of available song metadata. + """ + pass # TODO + + @handle_pattern(r'^urlhandlers$') + def _reflection_urlhandlers(self): + """ + *musicpd.org, reflection section:* + + ``urlhandlers`` + + Gets a list of available URL handlers. + """ + return [(u'handler', uri) for uri in self.backend.uri_handlers] + + @handle_pattern(r'^clearerror$') + def _status_clearerror(self): + """ + *musicpd.org, status section:* + + ``clearerror`` + + Clears the current error message in status (this is also + accomplished by any command that starts playback). + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^currentsong$') + def _status_currentsong(self): + """ + *musicpd.org, status section:* + + ``currentsong`` + + Displays the song info of the current song (same song that is + identified in status). + """ + if self.backend.playback.current_track is not None: + return self.backend.playback.current_track.mpd_format( + position=self.backend.playback.playlist_position) + + @handle_pattern(r'^idle$') + @handle_pattern(r'^idle (?P.+)$') + def _status_idle(self, subsystems=None): + """ + *musicpd.org, status section:* + + ``idle [SUBSYSTEMS...]`` + + Waits until there is a noteworthy change in one or more of MPD's + subsystems. As soon as there is one, it lists all changed systems + in a line in the format ``changed: SUBSYSTEM``, where ``SUBSYSTEM`` + is one of the following: + + - ``database``: the song database has been modified after update. + - ``update``: a database update has started or finished. If the + database was modified during the update, the database event is + also emitted. + - ``stored_playlist``: a stored playlist has been modified, + renamed, created or deleted + - ``playlist``: the current playlist has been modified + - ``player``: the player has been started, stopped or seeked + - ``mixer``: the volume has been changed + - ``output``: an audio output has been enabled or disabled + - ``options``: options like repeat, random, crossfade, replay gain + + While a client is waiting for idle results, the server disables + timeouts, allowing a client to wait for events as long as MPD runs. + The idle command can be canceled by sending the command ``noidle`` + (no other commands are allowed). MPD will then leave idle mode and + print results immediately; might be empty at this time. + + If the optional ``SUBSYSTEMS`` argument is used, MPD will only send + notifications when something changed in one of the specified + subsystems. + """ + pass # TODO + + @handle_pattern(r'^noidle$') + def _status_noidle(self): + """See :meth:`_idle`.""" + raise MpdNotImplemented # TODO + + @handle_pattern(r'^stats$') + def _status_stats(self): + """ + *musicpd.org, status section:* + + ``stats`` + + Displays statistics. + + - ``artists``: number of artists + - ``songs``: number of albums + - ``uptime``: daemon uptime in seconds + - ``db_playtime``: sum of all song times in the db + - ``db_update``: last db update in UNIX time + - ``playtime``: time length of music played + """ + return { + 'artists': 0, # TODO + 'albums': 0, # TODO + 'songs': 0, # TODO + # TODO Does not work after multiprocessing branch merge + 'uptime': 0, # self.session.stats_uptime(), + 'db_playtime': 0, # TODO + 'db_update': 0, # TODO + 'playtime': 0, # TODO + } + + @handle_pattern(r'^status$') + def _status_status(self): + """ + *musicpd.org, status section:* + + ``status`` + + Reports the current status of the player and the volume level. + + - ``volume``: 0-100 + - ``repeat``: 0 or 1 + - ``single``: 0 or 1 + - ``consume``: 0 or 1 + - ``playlist``: 31-bit unsigned integer, the playlist version + number + - ``playlistlength``: integer, the length of the playlist + - ``state``: play, stop, or pause + - ``song``: playlist song number of the current song stopped on or + playing + - ``songid``: playlist songid of the current song stopped on or + playing + - ``nextsong``: playlist song number of the next song to be played + - ``nextsongid``: playlist songid of the next song to be played + - ``time``: total time elapsed (of current playing/paused song) + - ``elapsed``: Total time elapsed within the current song, but with + higher resolution. + - ``bitrate``: instantaneous bitrate in kbps + - ``xfade``: crossfade in seconds + - ``audio``: sampleRate``:bits``:channels + - ``updatings_db``: job id + - ``error``: if there is an error, returns message here + """ + result = [ + ('volume', self.__status_status_volume()), + ('repeat', self.__status_status_repeat()), + ('random', self.__status_status_random()), + ('single', self.__status_status_single()), + ('consume', self.__status_status_consume()), + ('playlist', self.__status_status_playlist_version()), + ('playlistlength', self.__status_status_playlist_length()), + ('xfade', self.__status_status_xfade()), + ('state', self.__status_status_state()), + ] + if self.backend.playback.current_track is not None: + result.append(('song', self.__status_status_songpos())) + result.append(('songid', self.__status_status_songid())) + if self.backend.playback.state in ( + self.backend.playback.PLAYING, self.backend.playback.PAUSED): + result.append(('time', self.__status_status_time())) + result.append(('elapsed', self.__status_status_time_elapsed())) + result.append(('bitrate', self.__status_status_bitrate())) + return result + + def __status_status_bitrate(self): + if self.backend.playback.current_track is not None: + return self.backend.playback.current_track.bitrate + + def __status_status_consume(self): + if self.backend.playback.consume: + return 1 + else: + return 0 + + def __status_status_playlist_length(self): + return self.backend.current_playlist.playlist.length + + def __status_status_playlist_version(self): + return self.backend.current_playlist.version + + def __status_status_random(self): + return int(self.backend.playback.random) + + def __status_status_repeat(self): + return int(self.backend.playback.repeat) + + def __status_status_single(self): + return int(self.backend.playback.single) + + def __status_status_songid(self): + if self.backend.playback.current_track.id is not None: + return self.backend.playback.current_track.id + else: + return self.__status_status_songpos() + + def __status_status_songpos(self): + return self.backend.playback.playlist_position + + def __status_status_state(self): + if self.backend.playback.state == self.backend.playback.PLAYING: + return u'play' + elif self.backend.playback.state == self.backend.playback.STOPPED: + return u'stop' + elif self.backend.playback.state == self.backend.playback.PAUSED: + return u'pause' + + def __status_status_time(self): + return u'%s:%s' % (self.__status_status_time_elapsed() // 1000, + self.__status_status_time_total() // 1000) + + def __status_status_time_elapsed(self): + return self.backend.playback.time_position + + def __status_status_time_total(self): + if self.backend.playback.current_track is None: + return 0 + elif self.backend.playback.current_track.length is None: + return 0 + else: + return self.backend.playback.current_track.length + + def __status_status_volume(self): + if self.backend.playback.volume is not None: + return self.backend.playback.volume + else: + return 0 + + def __status_status_xfade(self): + return 0 # TODO + + @handle_pattern(r'^sticker delete "(?P[^"]+)" ' + r'"(?P[^"]+)"( "(?P[^"]+)")*$') + def _sticker_delete(self, type, uri, name=None): + """ + *musicpd.org, sticker section:* + + ``sticker delete {TYPE} {URI} [NAME]`` + + Deletes a sticker value from the specified object. If you do not + specify a sticker name, all sticker values are deleted. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" ' + r'"(?P[^"]+)"$') + def _sticker_find(self, type, uri, name): + """ + *musicpd.org, sticker section:* + + ``sticker find {TYPE} {URI} {NAME}`` + + Searches the sticker database for stickers with the specified name, + below the specified directory (``URI``). For each matching song, it + prints the ``URI`` and that one sticker's value. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" ' + r'"(?P[^"]+)"$') + def _sticker_get(self, type, uri, name): + """ + *musicpd.org, sticker section:* + + ``sticker get {TYPE} {URI} {NAME}`` + + Reads a sticker value for the specified object. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^sticker list "(?P[^"]+)" "(?P[^"]+)"$') + def _sticker_list(self, type, uri): + """ + *musicpd.org, sticker section:* + + ``sticker list {TYPE} {URI}`` + + Lists the stickers for the specified object. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" ' + r'"(?P[^"]+)" "(?P[^"]+)"$') + def _sticker_set(self, type, uri, name, value): + """ + *musicpd.org, sticker section:* + + ``sticker set {TYPE} {URI} {NAME} {VALUE}`` + + Adds a sticker value to the specified object. If a sticker item + with that name already exists, it is replaced. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^listplaylist "(?P[^"]+)"$') + def _stored_playlists_listplaylist(self, name): + """ + *musicpd.org, stored playlists section:* + + ``listplaylist {NAME}`` + + Lists the files in the playlist ``NAME.m3u``. + + Output format:: + + file: relative/path/to/file1.flac + file: relative/path/to/file2.ogg + file: relative/path/to/file3.mp3 + """ + try: + return ['file: %s' % t.uri + for t in self.backend.stored_playlists.get(name=name).tracks] + except LookupError as e: + raise MpdAckError(e[0]) + + @handle_pattern(r'^listplaylistinfo "(?P[^"]+)"$') + def _stored_playlists_listplaylistinfo(self, name): + """ + *musicpd.org, stored playlists section:* + + ``listplaylistinfo {NAME}`` + + Lists songs in the playlist ``NAME.m3u``. + + Output format: + + Standard track listing, with fields: file, Time, Title, Date, + Album, Artist, Track + """ + try: + return self.backend.stored_playlists.get(name=name).mpd_format( + search_result=True) + except LookupError as e: + raise MpdAckError(e[0]) + + @handle_pattern(r'^listplaylists$') + def _stored_playlists_listplaylists(self): + """ + *musicpd.org, stored playlists section:* + + ``listplaylists`` + + Prints a list of the playlist directory. + + After each playlist name the server sends its last modification + time as attribute ``Last-Modified`` in ISO 8601 format. To avoid + problems due to clock differences between clients and the server, + clients should not compare this value with their local clock. + + Output format:: + + playlist: a + Last-Modified: 2010-02-06T02:10:25Z + playlist: b + Last-Modified: 2010-02-06T02:11:08Z + """ + result = [] + for playlist in self.backend.stored_playlists.playlists: + result.append((u'playlist', playlist.name)) + # TODO Remove microseconds and add time zone information + result.append((u'Last-Modified', + (playlist.last_modified or dt.datetime.now()).isoformat())) + return result + + @handle_pattern(r'^load "(?P[^"]+)"$') + def _stored_playlists_load(self, name): + """ + *musicpd.org, stored playlists section:* + + ``load {NAME}`` + + Loads the playlist ``NAME.m3u`` from the playlist directory. + """ + matches = self.backend.stored_playlists.search(name) + if matches: + self.backend.current_playlist.load(matches[0]) + self.backend.playback.new_playlist_loaded_callback() + + @handle_pattern(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') + def _stored_playlist_playlistadd(self, name, uri): + """ + *musicpd.org, stored playlists section:* + + ``playlistadd {NAME} {URI}`` + + Adds ``URI`` to the playlist ``NAME.m3u``. + + ``NAME.m3u`` will be created if it does not exist. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^playlistclear "(?P[^"]+)"$') + def _stored_playlist_playlistclear(self, name): + """ + *musicpd.org, stored playlists section:* + + ``playlistclear {NAME}`` + + Clears the playlist ``NAME.m3u``. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') + def _stored_playlist_playlistdelete(self, name, songpos): + """ + *musicpd.org, stored playlists section:* + + ``playlistdelete {NAME} {SONGPOS}`` + + Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^playlistmove "(?P[^"]+)" ' + r'"(?P\d+)" "(?P\d+)"$') + def _stored_playlist_playlistmove(self, name, songid, songpos): + """ + *musicpd.org, stored playlists section:* + + ``playlistmove {NAME} {SONGID} {SONGPOS}`` + + Moves ``SONGID`` in the playlist ``NAME.m3u`` to the position + ``SONGPOS``. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') + def _stored_playlists_rename(self, old_name, new_name): + """ + *musicpd.org, stored playlists section:* + + ``rename {NAME} {NEW_NAME}`` + + Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^rm "(?P[^"]+)"$') + def _stored_playlists_rm(self, name): + """ + *musicpd.org, stored playlists section:* + + ``rm {NAME}`` + + Removes the playlist ``NAME.m3u`` from the playlist directory. + """ + raise MpdNotImplemented # TODO + + @handle_pattern(r'^save "(?P[^"]+)"$') + def _stored_playlists_save(self, name): + """ + *musicpd.org, stored playlists section:* + + ``save {NAME}`` + + Saves the current playlist to ``NAME.m3u`` in the playlist + directory. + """ + raise MpdNotImplemented # TODO diff --git a/mopidy/mpd/handler.py b/mopidy/mpd/handler.py deleted file mode 100644 index 9769f7af..00000000 --- a/mopidy/mpd/handler.py +++ /dev/null @@ -1,421 +0,0 @@ -import logging -import re -import sys - -from mopidy import settings -from mopidy.exceptions import MpdAckError, MpdNotImplemented - -logger = logging.getLogger('mpd.handler') - -_request_handlers = {} - -def register(pattern): - def decorator(func): - if pattern in _request_handlers: - raise ValueError(u'Tried to redefine handler for %s with %s' % ( - pattern, func)) - _request_handlers[pattern] = func - return func - return decorator - -def flatten(the_list): - result = [] - for element in the_list: - if isinstance(element, list): - result.extend(flatten(element)) - else: - result.append(element) - return result - -class MpdHandler(object): - def __init__(self, session=None, backend=None): - self.session = session - self.backend = backend - self.command_list = False - - def handle_request(self, request, add_ok=True): - if self.command_list is not False and request != u'command_list_end': - self.command_list.append(request) - return None - for pattern in _request_handlers: - matches = re.match(pattern, request) - if matches is not None: - groups = matches.groupdict() - try: - result = _request_handlers[pattern](self, **groups) - except MpdAckError, e: - return self.handle_response(u'ACK %s' % e, add_ok=False) - if self.command_list is not False: - return None - else: - return self.handle_response(result, add_ok) - raise MpdAckError(u'Unknown command: %s' % request) - - def handle_response(self, result, add_ok=True): - response = [] - if result is None: - result = [] - elif not isinstance(result, list): - result = [result] - for line in flatten(result): - if isinstance(line, dict): - for (key, value) in line.items(): - response.append(u'%s: %s' % (key, value)) - elif isinstance(line, tuple): - (key, value) = line - response.append(u'%s: %s' % (key, value)) - else: - response.append(line) - if add_ok: - response.append(u'OK') - return response - - @register(r'^add "(?P[^"]*)"$') - def _add(self, uri): - self.backend.playlist_add_track(uri) - - @register(r'^addid "(?P[^"]*)"( (?P\d+))*$') - def _add(self, uri, songpos=None): - self.backend.playlist_add_track(uri, int(songpos)) - - @register(r'^clear$') - def _clear(self): - raise MpdNotImplemented # TODO - - @register(r'^clearerror$') - def _clearerror(self): - raise MpdNotImplemented # TODO - - @register(r'^close$') - def _close(self): - self.session.do_close() - - @register(r'^command_list_begin$') - def _command_list_begin(self): - self.command_list = [] - self.command_list_ok = False - - @register(r'^command_list_ok_begin$') - def _command_list_ok_begin(self): - self.command_list = [] - self.command_list_ok = True - - @register(r'^command_list_end$') - def _command_list_end(self): - (command_list, self.command_list) = (self.command_list, False) - (command_list_ok, self.command_list_ok) = (self.command_list_ok, False) - result = [] - for command in command_list: - response = self.handle_request(command, add_ok=False) - if response is not None: - result.append(response) - if command_list_ok: - response.append(u'list_OK') - return result - - @register(r'^consume "(?P[01])"$') - def _consume(self, state): - state = int(state) - if state: - raise MpdNotImplemented # TODO - else: - raise MpdNotImplemented # TODO - - @register(r'^count "(?P[^"]+)" "(?P[^"]+)"$') - def _count(self, tag, needle): - raise MpdNotImplemented # TODO - - @register(r'^crossfade "(?P\d+)"$') - def _crossfade(self, seconds): - seconds = int(seconds) - raise MpdNotImplemented # TODO - - @register(r'^currentsong$') - def _currentsong(self): - return self.backend.current_song() - - @register(r'^delete "(?P\d+)"$') - @register(r'^delete "(?P\d+):(?P\d+)*"$') - def _delete(self, songpos=None, start=None, end=None): - raise MpdNotImplemented # TODO - - @register(r'^deleteid "(?P\d+)"$') - def _deleteid(self, songid): - raise MpdNotImplemented # TODO - - @register(r'^$') - def _empty(self): - pass - - @register(r'^find "(?P(album|artist|title))" "(?P[^"]+)"$') - def _find(self, type, what): - raise MpdNotImplemented # TODO - - @register(r'^findadd "(?P(album|artist|title))" "(?P[^"]+)"$') - def _findadd(self, type, what): - result = self._find(type, what) - # TODO Add result to current playlist - #return result - - @register(r'^idle$') - @register(r'^idle (?P.+)$') - def _idle(self, subsystems=None): - raise MpdNotImplemented # TODO - - @register(r'^kill$') - def _kill(self): - self.session.do_kill() - - @register(r'^list "(?Partist)"$') - @register(r'^list "(?Palbum)"( "(?P[^"]+)")*$') - def _list(self, type, artist=None): - raise MpdNotImplemented # TODO - - @register(r'^listall "(?P[^"]+)"') - def _listall(self, uri): - raise MpdNotImplemented # TODO - - @register(r'^listallinfo "(?P[^"]+)"') - def _listallinfo(self, uri): - raise MpdNotImplemented # TODO - - @register(r'^listplaylist "(?P[^"]+)"$') - def _listplaylist(self, name): - raise MpdNotImplemented # TODO - - @register(r'^listplaylistinfo "(?P[^"]+)"$') - def _listplaylistinfo(self, name): - raise MpdNotImplemented # TODO - - @register(r'^listplaylists$') - def _listplaylists(self): - return self.backend.playlists_list() - - @register(r'^load "(?P[^"]+)"$') - def _load(self, name): - return self.backend.playlist_load(name) - - @register(r'^lsinfo$') - @register(r'^lsinfo "(?P[^"]*)"$') - def _lsinfo(self, uri=None): - if uri == u'/' or uri is None: - return self._listplaylists() - raise MpdNotImplemented # TODO - - @register(r'^move "(?P\d+)" "(?P\d+)"$') - @register(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') - def _move(self, songpos=None, start=None, end=None, to=None): - raise MpdNotImplemented # TODO - - @register(r'^moveid "(?P\d+)" "(?P\d+)"$') - def _moveid(self, songid, to): - raise MpdNotImplemented # TODO - - @register(r'^next$') - def _next(self): - return self.backend.next() - - @register(r'^password "(?P[^"]+)"$') - def _password(self, password): - raise MpdNotImplemented # TODO - - @register(r'^pause "(?P[01])"$') - def _pause(self, state): - if int(state): - self.backend.pause() - else: - self.backend.resume() - - @register(r'^ping$') - def _ping(self): - pass - - @register(r'^play$') - def _play(self): - return self.backend.play() - - @register(r'^play "(?P\d+)"$') - def _playpos(self, songpos): - return self.backend.play(songpos=int(songpos)) - - @register(r'^playid "(?P\d+)"$') - def _playid(self, songid): - return self.backend.play(songid=int(songid)) - - @register(r'^playlist$') - def _playlist(self): - return self._playlistinfo() - - @register(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') - def _playlistadd(self, name, uri): - raise MpdNotImplemented # TODO - - @register(r'^playlistclear "(?P[^"]+)"$') - def _playlistclear(self, name): - raise MpdNotImplemented # TODO - - @register(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') - def _playlistdelete(self, name, songpos): - raise MpdNotImplemented # TODO - - @register(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') - def _playlistfind(self, tag, needle): - raise MpdNotImplemented # TODO - - @register(r'^playlistid( "(?P\S+)")*$') - def _playlistid(self, songid=None): - return self.backend.playlist_info(songid, None, None) - - @register(r'^playlistinfo$') - @register(r'^playlistinfo "(?P\d+)"$') - @register(r'^playlistinfo "(?P\d+):(?P\d+)*"$') - def _playlistinfo(self, songpos=None, start=None, end=None): - return self.backend.playlist_info(songpos, start, end) - - @register(r'^playlistmove "(?P[^"]+)" "(?P\d+)" "(?P\d+)"$') - def _playlistdelete(self, name, songid, songpos): - raise MpdNotImplemented # TODO - - @register(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') - def _playlistsearch(self, tag, needle): - raise MpdNotImplemented # TODO - - @register(r'^plchanges "(?P\d+)"$') - def _plchanges(self, version): - return self.backend.playlist_changes_since(version) - - @register(r'^plchangesposid "(?P\d+)"$') - def _plchangesposid(self, version): - raise MpdNotImplemented # TODO - - @register(r'^previous$') - def _previous(self): - return self.backend.previous() - - @register(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') - def _rename(self, old_name, new_name): - raise MpdNotImplemented # TODO - - @register(r'^random "(?P[01])"$') - def _random(self, state): - state = int(state) - if state: - raise MpdNotImplemented # TODO - else: - raise MpdNotImplemented # TODO - - @register(r'^repeat "(?P[01])"$') - def _repeat(self, state): - state = int(state) - if state: - raise MpdNotImplemented # TODO - else: - raise MpdNotImplemented # TODO - - @register(r'^replay_gain_mode "(?P(off|track|album))"$') - def _replay_gain_mode(self, mode): - raise MpdNotImplemented # TODO - - @register(r'^replay_gain_status$') - def _replay_gain_status(self): - return u'off' # TODO - - @register(r'^rescan( "(?P[^"]+)")*$') - def _update(self, uri=None): - return self._update(uri, rescan_unmodified_files=True) - - @register(r'^rm "(?P[^"]+)"$') - def _rm(self, name): - raise MpdNotImplemented # TODO - - @register(r'^save "(?P[^"]+)"$') - def _save(self, name): - raise MpdNotImplemented # TODO - - @register(r'^search "(?P(album|artist|filename|title))" "(?P[^"]+)"$') - def _search(self, type, what): - return self.backend.search(type, what) - - @register(r'^seek "(?P\d+)" "(?P\d+)"$') - def _seek(self, songpos, seconds): - raise MpdNotImplemented # TODO - - @register(r'^seekid "(?P\d+)" "(?P\d+)"$') - def _seekid(self, songid, seconds): - raise MpdNotImplemented # TODO - - @register(r'^setvol "(?P-*\d+)"$') - def _setvol(self, volume): - volume = int(volume) - if volume < 0: - volume = 0 - if volume > 100: - volume = 100 - raise MpdNotImplemented # TODO - - @register(r'^shuffle$') - @register(r'^shuffle "(?P\d+):(?P\d+)*"$') - def _shuffle(self, start=None, end=None): - raise MpdNotImplemented # TODO - - @register(r'^single "(?P[01])"$') - def _single(self, state): - state = int(state) - if state: - raise MpdNotImplemented # TODO - else: - raise MpdNotImplemented # TODO - - @register(r'^stats$') - def _stats(self): - pass # TODO - return { - 'artists': 0, - 'albums': 0, - 'songs': 0, - 'uptime': self.session.stats_uptime(), - 'db_playtime': 0, - 'db_update': 0, - 'playtime': 0, - } - - @register(r'^stop$') - def _stop(self): - self.backend.stop() - - @register(r'^status$') - def _status(self): - result = [ - ('volume', self.backend.status_volume()), - ('repeat', self.backend.status_repeat()), - ('random', self.backend.status_random()), - ('single', self.backend.status_single()), - ('consume', self.backend.status_consume()), - ('playlist', self.backend.status_playlist()), - ('playlistlength', self.backend.status_playlist_length()), - ('xfade', self.backend.status_xfade()), - ('state', self.backend.status_state()), - ] - if self.backend.status_playlist_length() > 0: - result.append(('song', self.backend.status_song_id())) - result.append(('songid', self.backend.status_song_id())) - if self.backend.state in (self.backend.PLAY, self.backend.PAUSE): - result.append(('time', self.backend.status_time())) - result.append(('bitrate', self.backend.status_bitrate())) - return result - - @register(r'^swap "(?P\d+)" "(?P\d+)"$') - def _swap(self, songpos1, songpos2): - raise MpdNotImplemented # TODO - - @register(r'^swapid "(?P\d+)" "(?P\d+)"$') - def _swapid(self, songid1, songid2): - raise MpdNotImplemented # TODO - - @register(r'^update( "(?P[^"]+)")*$') - def _update(self, uri=None, rescan_unmodified_files=False): - return {'updating_db': 0} # TODO - - @register(r'^urlhandlers$') - def _urlhandlers(self): - return self.backend.url_handlers() diff --git a/mopidy/mpd/server.py b/mopidy/mpd/server.py index dff365ec..e6abb633 100644 --- a/mopidy/mpd/server.py +++ b/mopidy/mpd/server.py @@ -1,41 +1,93 @@ +""" +This is our MPD server implementation. +""" + +import asynchat import asyncore import logging +import multiprocessing import socket import sys -import time -from mopidy import config -from mopidy.mpd.session import MpdSession +from mopidy import get_mpd_protocol_version, settings +from mopidy.utils import indent, pickle_connection -logger = logging.getLogger(u'mpd.server') +logger = logging.getLogger('mopidy.mpd.server') + +#: The MPD protocol uses UTF-8 for encoding all data. +ENCODING = u'utf-8' + +#: The MPD protocol uses ``\n`` as line terminator. +LINE_TERMINATOR = u'\n' class MpdServer(asyncore.dispatcher): - def __init__(self, session_class=MpdSession, backend=None): + """ + The MPD server. Creates a :class:`MpdSession` for each client connection. + """ + + def __init__(self, core_queue): asyncore.dispatcher.__init__(self) - self.session_class = session_class - self.backend = backend - self.create_socket(socket.AF_INET, socket.SOCK_STREAM) - self.set_reuse_addr() - self.bind((config.MPD_SERVER_HOSTNAME, config.MPD_SERVER_PORT)) - self.listen(1) - self.started_at = int(time.time()) - logger.info(u'Please connect to %s port %s using an MPD client.', - config.MPD_SERVER_HOSTNAME, config.MPD_SERVER_PORT) + try: + self.core_queue = core_queue + self.create_socket(socket.AF_INET, socket.SOCK_STREAM) + self.set_reuse_addr() + self.bind((settings.SERVER_HOSTNAME, settings.SERVER_PORT)) + self.listen(1) + logger.info(u'MPD server running at [%s]:%s', + settings.SERVER_HOSTNAME, settings.SERVER_PORT) + except IOError, e: + sys.exit('MPD server startup failed: %s' % e) def handle_accept(self): (client_socket, client_address) = self.accept() - logger.info(u'Connection from: [%s]:%s', *client_address) - self.session_class(self, client_socket, client_address, - backend=self.backend) + logger.info(u'MPD client connection from [%s]:%s', *client_address) + MpdSession(self, client_socket, client_address, self.core_queue) def handle_close(self): self.close() - def do_kill(self): - logger.info(u'Received "kill". Shutting down.') - self.handle_close() - sys.exit(0) - @property - def uptime(self): - return int(time.time()) - self.started_at +class MpdSession(asynchat.async_chat): + """ + The MPD client session. Dispatches MPD requests to the frontend. + """ + + def __init__(self, server, client_socket, client_address, core_queue): + asynchat.async_chat.__init__(self, sock=client_socket) + self.server = server + self.client_address = client_address + self.core_queue = core_queue + self.input_buffer = [] + self.set_terminator(LINE_TERMINATOR.encode(ENCODING)) + self.send_response(u'OK MPD %s' % get_mpd_protocol_version()) + + def collect_incoming_data(self, data): + self.input_buffer.append(data) + + def found_terminator(self): + data = ''.join(self.input_buffer).strip() + self.input_buffer = [] + input = data.decode(ENCODING) + logger.debug(u'Input: %s', indent(input)) + self.handle_request(input) + + def handle_request(self, input): + my_end, other_end = multiprocessing.Pipe() + self.core_queue.put({ + 'command': 'mpd_request', + 'request': input, + 'reply_to': pickle_connection(other_end), + }) + my_end.poll(None) + response = my_end.recv() + if response is not None: + self.handle_response(response) + + def handle_response(self, response): + self.send_response(LINE_TERMINATOR.join(response)) + + def send_response(self, output): + logger.debug(u'Output: %s', indent(output)) + output = u'%s%s' % (output, LINE_TERMINATOR) + data = output.encode(ENCODING) + self.push(data) diff --git a/mopidy/mpd/session.py b/mopidy/mpd/session.py deleted file mode 100644 index edd2a95c..00000000 --- a/mopidy/mpd/session.py +++ /dev/null @@ -1,66 +0,0 @@ -import asynchat -import logging - -from mopidy import get_mpd_protocol_version, config -from mopidy.exceptions import MpdAckError -from mopidy.mpd.handler import MpdHandler - -logger = logging.getLogger(u'mpd.session') - -def indent(string, places=4, linebreak=config.MPD_LINE_TERMINATOR): - lines = string.split(linebreak) - if len(lines) == 1: - return string - result = u'' - for line in lines: - result += linebreak + ' ' * places + line - return result - -class MpdSession(asynchat.async_chat): - def __init__(self, server, client_socket, client_address, backend, - handler_class=MpdHandler): - asynchat.async_chat.__init__(self, sock=client_socket) - self.server = server - self.client_address = client_address - self.input_buffer = [] - self.set_terminator(config.MPD_LINE_TERMINATOR.encode( - config.MPD_LINE_ENCODING)) - self.handler = handler_class(session=self, backend=backend) - self.send_response(u'OK MPD %s' % get_mpd_protocol_version()) - - def do_close(self): - logger.info(u'Closing connection with [%s]:%s', *self.client_address) - self.close_when_done() - - def do_kill(self): - self.server.do_kill() - - def collect_incoming_data(self, data): - self.input_buffer.append(data) - - def found_terminator(self): - data = ''.join(self.input_buffer).strip() - self.input_buffer = [] - input = data.decode(config.MPD_LINE_ENCODING) - logger.debug(u'Input: %s', indent(input)) - self.handle_request(input) - - def handle_request(self, input): - try: - response = self.handler.handle_request(input) - self.handle_response(response) - except MpdAckError, e: - logger.warning(e) - return self.send_response(u'ACK %s' % e) - - def handle_response(self, response): - self.send_response(config.MPD_LINE_TERMINATOR.join(response)) - - def send_response(self, output): - logger.debug(u'Output: %s', indent(output)) - output = u'%s%s' % (output, config.MPD_LINE_TERMINATOR) - data = output.encode(config.MPD_LINE_ENCODING) - self.push(data) - - def stats_uptime(self): - return self.server.uptime diff --git a/mopidy/process.py b/mopidy/process.py new file mode 100644 index 00000000..9459f816 --- /dev/null +++ b/mopidy/process.py @@ -0,0 +1,53 @@ +import logging +import multiprocessing +import sys + +from mopidy import settings, SettingsError +from mopidy.utils import get_class, unpickle_connection + +logger = logging.getLogger('mopidy.process') + +class BaseProcess(multiprocessing.Process): + def run(self): + try: + self._run() + except KeyboardInterrupt: + logger.info(u'Interrupted by user') + sys.exit(0) + except SettingsError, e: + logger.error(e) + sys.exit(1) + + def _run(self): + raise NotImplementedError + + +class CoreProcess(BaseProcess): + def __init__(self, core_queue): + super(CoreProcess, self).__init__() + self.core_queue = core_queue + + def _run(self): + self._setup() + while True: + message = self.core_queue.get() + self._process_message(message) + + def _setup(self): + self._backend = get_class(settings.BACKENDS[0])( + core_queue=self.core_queue) + self._frontend = get_class(settings.FRONTEND)(backend=self._backend) + + def _process_message(self, message): + if message['command'] == 'mpd_request': + response = self._frontend.handle_request(message['request']) + connection = unpickle_connection(message['reply_to']) + connection.send(response) + elif message['command'] == 'end_of_track': + self._backend.playback.end_of_track_callback() + elif message['command'] == 'stop_playback': + self._backend.playback.stop() + elif message['command'] == 'set_stored_playlists': + self._backend.stored_playlists.playlists = message['playlists'] + else: + logger.warning(u'Cannot handle message: %s', message) diff --git a/mopidy/settings.py b/mopidy/settings.py index aa513c81..6cb2928e 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -1,17 +1,109 @@ -CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(threadName)s] %(name)s\n %(message)s' -MPD_LINE_ENCODING = u'utf-8' -MPD_LINE_TERMINATOR = u'\n' -MPD_SERVER_HOSTNAME = u'localhost' -MPD_SERVER_PORT = 6600 +""" +Available settings and their default values. -BACKEND=u'mopidy.backends.despotify.DespotifyBackend' -#BACKEND=u'mopidy.backends.libspotify.LibspotifyBackend' +.. warning:: + Do *not* change settings in ``mopidy/settings.py``. Instead, add a file + called ``~/.mopidy/settings.py`` and redefine settings there. +""" + +from __future__ import absolute_import +import os +import sys + +#: List of playback backends to use. See :mod:`mopidy.backends` for all +#: available backends. Default:: +#: +#: BACKENDS = (u'mopidy.backends.despotify.DespotifyBackend',) +#: +#: .. note:: +#: Currently only the first backend in the list is used. +BACKENDS = ( + u'mopidy.backends.despotify.DespotifyBackend', + #u'mopidy.backends.libspotify.LibspotifyBackend', +) + +#: The log format used on the console. See +#: http://docs.python.org/library/logging.html#formatter-objects for details on +#: the format. +CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(asctime)s [%(process)d:%(threadName)s] %(name)s\n %(message)s' + +#: Protocol frontend to use. Default:: +#: +#: FRONTEND = u'mopidy.mpd.frontend.MpdFrontend' +FRONTEND = u'mopidy.mpd.frontend.MpdFrontend' + +#: Sound mixer to use. See :mod:`mopidy.mixers` for all available mixers. +#: +#: Default on Linux:: +#: +#: MIXER = u'mopidy.mixers.alsa.AlsaMixer' +#: +#: Default on OS X:: +#: +#: MIXER = u'mopidy.mixers.osa.OsaMixer' +#: +#: Default on other operating systems:: +#: +#: MIXER = u'mopidy.mixers.dummy.DummyMixer' +MIXER = u'mopidy.mixers.dummy.DummyMixer' +if sys.platform == 'linux2': + MIXER = u'mopidy.mixers.alsa.AlsaMixer' +elif sys.platform == 'darwin': + MIXER = u'mopidy.mixers.osa.OsaMixer' + +#: External mixers only. Which port the mixer is connected to. +#: +#: This must point to the device port like ``/dev/ttyUSB0``. +#: *Default:* :class:`None` +MIXER_EXT_PORT = None + +#: External mixers only. What input source the external mixer should use. +#: +#: Example: ``Aux``. *Default:* :class:`None` +MIXER_EXT_SOURCE = None + +#: External mixers only. What state Speakers A should be in. +#: +#: *Default:* :class:`None`. +MIXER_EXT_SPEAKERS_A = None + +#: External mixers only. What state Speakers B should be in. +#: +#: *Default:* :class:`None`. +MIXER_EXT_SPEAKERS_B = None + +#: Server to use. Default:: +#: +#: SERVER = u'mopidy.mpd.server.MpdServer' +SERVER = u'mopidy.mpd.server.MpdServer' + +#: Which address Mopidy should bind to. Examples: +#: +#: ``localhost`` +#: Listens only on the loopback interface. *Default.* +#: ``0.0.0.0`` +#: Listens on all interfaces. +SERVER_HOSTNAME = u'localhost' + +#: Which TCP port Mopidy should listen to. *Default: 6600* +SERVER_PORT = 6600 + +#: Your Spotify Premium username. Used by all Spotify backends. SPOTIFY_USERNAME = u'' + +#: Your Spotify Premium password. Used by all Spotify backends. SPOTIFY_PASSWORD = u'' -try: - from mopidy.local_settings import * -except ImportError: - pass +#: Path to your libspotify application key. Used by LibspotifyBackend. +SPOTIFY_LIB_APPKEY = u'~/.mopidy/spotify_appkey.key' +#: Path to the libspotify cache. Used by LibspotifyBackend. +SPOTIFY_LIB_CACHE = u'~/.mopidy/libspotify_cache' + +# Import user specific settings +dotdir = os.path.expanduser(u'~/.mopidy/') +settings_file = os.path.join(dotdir, u'settings.py') +if os.path.isfile(settings_file): + sys.path.insert(0, dotdir) + from settings import * diff --git a/mopidy/utils.py b/mopidy/utils.py new file mode 100644 index 00000000..0142e15c --- /dev/null +++ b/mopidy/utils.py @@ -0,0 +1,93 @@ +import logging +from multiprocessing.reduction import reduce_connection +import os +import pickle + +logger = logging.getLogger('mopidy.utils') + +def flatten(the_list): + result = [] + for element in the_list: + if isinstance(element, list): + result.extend(flatten(element)) + else: + result.append(element) + return result + +def get_class(name): + module_name = name[:name.rindex('.')] + class_name = name[name.rindex('.') + 1:] + logger.debug('Loading: %s', name) + module = __import__(module_name, globals(), locals(), [class_name], -1) + class_object = getattr(module, class_name) + return class_object + +def get_or_create_dotdir(dotdir): + dotdir = os.path.expanduser(dotdir) + if not os.path.isdir(dotdir): + logger.info(u'Creating %s', dotdir) + os.mkdir(dotdir, 0755) + return dotdir + +def indent(string, places=4, linebreak='\n'): + lines = string.split(linebreak) + if len(lines) == 1: + return string + result = u'' + for line in lines: + result += linebreak + ' ' * places + line + return result + +def pickle_connection(connection): + return pickle.dumps(reduce_connection(connection)) + +def unpickle_connection(pickled_connection): + # From http://stackoverflow.com/questions/1446004 + (func, args) = pickle.loads(pickled_connection) + return func(*args) + +def spotify_uri_to_int(uri, output_bits=31): + """ + Stable one-way translation from Spotify URI to 31-bit integer. + + Spotify track URIs has 62^22 possible values, which requires 131 bits of + storage. The original MPD server uses 32-bit unsigned integers for track + IDs. GMPC seems to think the track ID is a signed integer, thus we use 31 + output bits. + + In other words, this function throws away 100 bits of information. Since we + only use the track IDs to identify a track within a single Mopidy instance, + this information loss is acceptable. The chances of getting two different + tracks with the same track ID loaded in the same Mopidy instance is still + rather slim. 1 to 2,147,483,648 to be exact. + + Normal usage, with data loss:: + + >>> spotify_uri_to_int('spotify:track:5KRRcT67VNIZUygEbMoIC1') + 624351954 + + No data loss, may be converted back into a Spotify URI:: + + >>> spotify_uri_to_int('spotify:track:5KRRcT67VNIZUygEbMoIC1', + ... output_bits=131) + 101411513484007705241035418492696638725L + + :param uri: Spotify URI on the format ``spotify:track:*`` + :type uri: string + :param output_bits: number of bits of information kept in the return value + :type output_bits: int + :rtype: int + """ + + CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' + BITS_PER_CHAR = 6 # int(math.ceil(math.log(len(CHARS), 2))) + + key = uri.split(':')[-1] + full_id = 0 + for i, char in enumerate(key): + full_id ^= CHARS.index(char) << BITS_PER_CHAR * i + compressed_id = 0 + while full_id != 0: + compressed_id ^= (full_id & (2 ** output_bits - 1)) + full_id >>= output_bits + return int(compressed_id) diff --git a/requirements-docs.txt b/requirements-docs.txt new file mode 100644 index 00000000..0d593422 --- /dev/null +++ b/requirements-docs.txt @@ -0,0 +1,2 @@ +Sphinx +pygraphviz diff --git a/requirements-external-mixers.txt b/requirements-external-mixers.txt new file mode 100644 index 00000000..f6c1a1f5 --- /dev/null +++ b/requirements-external-mixers.txt @@ -0,0 +1 @@ +pyserial diff --git a/requirements-tests.txt b/requirements-tests.txt new file mode 100644 index 00000000..33f49451 --- /dev/null +++ b/requirements-tests.txt @@ -0,0 +1,2 @@ +coverage +nose diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..e09a7b15 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,8 @@ +[nosetests] +verbosity = 1 +#with-doctest = 1 +#with-coverage = 1 +cover-package = mopidy +cover-inclusive = 1 +cover-html = 1 +with-xunit = 1 diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..bbf300f7 --- /dev/null +++ b/setup.py @@ -0,0 +1,69 @@ +from distutils.core import setup +from distutils.command.install import INSTALL_SCHEMES +import os + +from mopidy import get_version + +def fullsplit(path, result=None): + """ + Split a pathname into components (the opposite of os.path.join) in a + platform-neutral way. + """ + if result is None: + result = [] + head, tail = os.path.split(path) + if head == '': + return [tail] + result + if head == path: + return result + return fullsplit(head, [tail] + result) + +# Tell distutils to put the data_files in platform-specific installation +# locations. See here for an explanation: +# http://groups.google.com/group/comp.lang.python/browse_thread/thread/35ec7b2fed36eaec/2105ee4d9e8042cb +for scheme in INSTALL_SCHEMES.values(): + scheme['data'] = scheme['purelib'] + +# Compile the list of packages available, because distutils doesn't have +# an easy way to do this. +packages, data_files = [], [] +root_dir = os.path.dirname(__file__) +if root_dir != '': + os.chdir(root_dir) +project_dir = 'mopidy' + +for dirpath, dirnames, filenames in os.walk(project_dir): + # Ignore dirnames that start with '.' + for i, dirname in enumerate(dirnames): + if dirname.startswith('.'): + del dirnames[i] + if '__init__.py' in filenames: + packages.append('.'.join(fullsplit(dirpath))) + elif filenames: + data_files.append([dirpath, + [os.path.join(dirpath, f) for f in filenames]]) + +setup( + name='Mopidy', + version=get_version(), + author='Stein Magnus Jodal', + author_email='stein.magnus@jodal.no', + packages=packages, + data_files=data_files, + scripts=['bin/mopidy'], + url='http://www.mopidy.com/', + license='GPLv2', + description='MPD server with Spotify support', + long_description=open('README.rst').read(), + classifiers=[ + 'Development Status :: 3 - Alpha', + 'Environment :: No Input/Output (Daemon)', + 'Intended Audience :: End Users/Desktop', + 'License :: OSI Approved :: GNU General Public License (GPL)', + 'Operating System :: MacOS :: MacOS X', + 'Operating System :: POSIX :: Linux', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Topic :: Multimedia :: Sound/Audio :: Players', + ], +) diff --git a/test-requirements.txt b/test-requirements.txt deleted file mode 100644 index 459f434a..00000000 --- a/test-requirements.txt +++ /dev/null @@ -1 +0,0 @@ --e bzr+http://liw.iki.fi/bzr/coverage-test-runner/trunk/#egg=CoverageTestRunner diff --git a/tests/__main__.py b/tests/__main__.py index 86c814ac..e2bb3e72 100644 --- a/tests/__main__.py +++ b/tests/__main__.py @@ -1,16 +1,4 @@ -import logging -import os -import sys - -from CoverageTestRunner import CoverageTestRunner - -def main(): - logging.basicConfig(level=logging.CRITICAL) - sys.path.insert(0, - os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) - r = CoverageTestRunner() - r.add_pair('mopidy/mpd/handler.py', 'tests/mpd/handlertest.py') - r.run() +import nose if __name__ == '__main__': - main() + nose.main() diff --git a/tests/backends/base_test.py b/tests/backends/base_test.py new file mode 100644 index 00000000..d91467dd --- /dev/null +++ b/tests/backends/base_test.py @@ -0,0 +1,46 @@ +class BaseCurrentPlaylistControllerTest(object): + uris = [] + backend_class = None + + def setUp(self): + self.backend = self.backend_class() + + def test_add(self): + playlist = self.backend.current_playlist + + for uri in self.uris: + playlist.add(uri) + self.assertEqual(uri, playlist.tracks[-1].uri) + + def test_add_at_position(self): + playlist = self.backend.current_playlist + + for uri in self.uris: + playlist.add(uri, 0) + self.assertEqual(uri, playlist.tracks[0].uri) + + # FIXME test other placements + +class BasePlaybackControllerTest(object): + backend_class = None + + def setUp(self): + self.backend = self.backend_class() + + def test_play(self): + playback = self.backend.playback + + self.assertEqual(playback.state, playback.STOPPED) + + playback.play() + + self.assertEqual(playback.state, playback.PLAYING) + + def test_next(self): + playback = self.backend.playback + + current_song = playback.playlist_position + + playback.next() + + self.assertEqual(playback.playlist_position, current_song+1) diff --git a/tests/backends/get_test.py b/tests/backends/get_test.py new file mode 100644 index 00000000..c2ed5fe9 --- /dev/null +++ b/tests/backends/get_test.py @@ -0,0 +1,99 @@ +import unittest + +from mopidy.backends.dummy import DummyBackend, DummyCurrentPlaylistController +from mopidy.mixers.dummy import DummyMixer +from mopidy.models import Playlist, Track + +class CurrentPlaylistGetTest(unittest.TestCase): + def setUp(self): + self.b = DummyBackend(mixer=DummyMixer()) + self.c = self.b.current_playlist + + def test_get_by_id_returns_unique_match(self): + track = Track(id=1) + self.c.playlist = Playlist(tracks=[Track(id=13), track, Track(id=17)]) + self.assertEqual(track, self.c.get(id=1)) + + def test_get_by_id_raises_error_if_multiple_matches(self): + track = Track(id=1) + self.c.playlist = Playlist(tracks=[Track(id=13), track, track]) + try: + self.c.get(id=1) + self.fail(u'Should raise LookupError if multiple matches') + except LookupError as e: + self.assertEqual(u'"id=1" match multiple tracks', e[0]) + + def test_get_by_id_raises_error_if_no_match(self): + self.c.playlist = Playlist(tracks=[Track(id=13), Track(id=17)]) + try: + self.c.get(id=1) + self.fail(u'Should raise LookupError if no match') + except LookupError as e: + self.assertEqual(u'"id=1" match no tracks', e[0]) + + def test_get_by_uri_returns_unique_match(self): + track = Track(uri='a') + self.c.playlist = Playlist( + tracks=[Track(uri='z'), track, Track(uri='y')]) + self.assertEqual(track, self.c.get(uri='a')) + + def test_get_by_uri_raises_error_if_multiple_matches(self): + track = Track(uri='a') + self.c.playlist = Playlist(tracks=[Track(uri='z'), track, track]) + try: + self.c.get(uri='a') + self.fail(u'Should raise LookupError if multiple matches') + except LookupError as e: + self.assertEqual(u'"uri=a" match multiple tracks', e[0]) + + def test_get_by_uri_raises_error_if_no_match(self): + self.c.playlist = Playlist(tracks=[Track(uri='z'), Track(uri='y')]) + try: + self.c.get(uri='a') + self.fail(u'Should raise LookupError if no match') + except LookupError as e: + self.assertEqual(u'"uri=a" match no tracks', e[0]) + + def test_get_by_multiple_criteria_returns_elements_matching_all(self): + track1 = Track(id=1, uri='a') + track2 = Track(id=1, uri='b') + track3 = Track(id=2, uri='b') + self.c.playlist = Playlist(tracks=[track1, track2, track3]) + self.assertEqual(track1, self.c.get(id=1, uri='a')) + self.assertEqual(track2, self.c.get(id=1, uri='b')) + self.assertEqual(track3, self.c.get(id=2, uri='b')) + + def test_get_by_criteria_that_is_not_present_in_all_elements(self): + track1 = Track(id=1) + track2 = Track(uri='b') + track3 = Track(id=2) + self.c.playlist = Playlist(tracks=[track1, track2, track3]) + self.assertEqual(track1, self.c.get(id=1)) + + +class StoredPlaylistsGetTest(unittest.TestCase): + def setUp(self): + self.b = DummyBackend(mixer=DummyMixer()) + self.s = self.b.stored_playlists + + def test_get_by_name_returns_unique_match(self): + playlist = Playlist(name='b') + self.s.playlists = [Playlist(name='a'), playlist] + self.assertEqual(playlist, self.s.get(name='b')) + + def test_get_by_name_returns_first_of_multiple_matches(self): + playlist = Playlist(name='b') + self.s.playlists = [playlist, Playlist(name='a'), Playlist(name='b')] + try: + self.s.get(name='b') + self.fail(u'Should raise LookupError if multiple matches') + except LookupError as e: + self.assertEqual(u'"name=b" match multiple playlists', e[0]) + + def test_get_by_id_raises_keyerror_if_no_match(self): + self.s.playlists = [Playlist(name='a'), Playlist(name='b')] + try: + self.s.get(name='c') + self.fail(u'Should raise LookupError if no match') + except LookupError as e: + self.assertEqual(u'"name=c" match no playlists', e[0]) diff --git a/docs/_static/.placeholder b/tests/mixers/__init__.py similarity index 100% rename from docs/_static/.placeholder rename to tests/mixers/__init__.py diff --git a/tests/mixers/denon_test.py b/tests/mixers/denon_test.py new file mode 100644 index 00000000..d0fb9a46 --- /dev/null +++ b/tests/mixers/denon_test.py @@ -0,0 +1,50 @@ +import unittest + +from mopidy.mixers.denon import DenonMixer + +class DenonMixerDeviceMock(object): + def __init__(self): + self._open = True + self.ret_val = bytes('MV00\r') + + def write(self, x): + if x[2] != '?': + self.ret_val = bytes(x) + + def read(self, x): + return self.ret_val + + def readline(self): + return self.ret_val + + def isOpen(self): + return self._open + + def open(self): + self._open = True + +class DenonMixerTest(unittest.TestCase): + def setUp(self): + self.m = DenonMixer() + self.m._device = DenonMixerDeviceMock() + + def test_volume_set_to_min(self): + self.m.volume = 0 + self.assertEqual(self.m.volume, 0) + + def test_volume_set_to_max(self): + self.m.volume = 100 + self.assertEqual(self.m.volume, 99) + + def test_volume_set_to_below_min_results_in_min(self): + self.m.volume = -10 + self.assertEqual(self.m.volume, 0) + + def test_volume_set_to_above_max_results_in_max(self): + self.m.volume = 110 + self.assertEqual(self.m.volume, 99) + + def test_reopen_device(self): + self.m._device._open = False + self.m.volume = 10 + self.assertTrue(self.m._device._open) diff --git a/tests/mixers/dummy_test.py b/tests/mixers/dummy_test.py new file mode 100644 index 00000000..00d748fe --- /dev/null +++ b/tests/mixers/dummy_test.py @@ -0,0 +1,26 @@ +import unittest + +from mopidy.mixers.dummy import DummyMixer + +class BaseMixerTest(unittest.TestCase): + def setUp(self): + self.m = DummyMixer() + + def test_volume_is_None_initially(self): + self.assertEqual(self.m.volume, None) + + def test_volume_set_to_min(self): + self.m.volume = 0 + self.assertEqual(self.m.volume, 0) + + def test_volume_set_to_max(self): + self.m.volume = 100 + self.assertEqual(self.m.volume, 100) + + def test_volume_set_to_below_min_results_in_min(self): + self.m.volume = -10 + self.assertEqual(self.m.volume, 0) + + def test_volume_set_to_above_max_results_in_max(self): + self.m.volume = 110 + self.assertEqual(self.m.volume, 100) diff --git a/tests/models_test.py b/tests/models_test.py new file mode 100644 index 00000000..43d6ca53 --- /dev/null +++ b/tests/models_test.py @@ -0,0 +1,243 @@ +import datetime as dt +import unittest + +from mopidy.models import Artist, Album, Track, Playlist + +class ArtistTest(unittest.TestCase): + def test_uri(self): + uri = u'an_uri' + artist = Artist(uri=uri) + self.assertEqual(artist.uri, uri) + self.assertRaises(AttributeError, setattr, artist, 'uri', None) + + def test_name(self): + name = u'a name' + artist = Artist(name=name) + self.assertEqual(artist.name, name) + self.assertRaises(AttributeError, setattr, artist, 'name', None) + + +class AlbumTest(unittest.TestCase): + def test_uri(self): + uri = u'an_uri' + album = Album(uri=uri) + self.assertEqual(album.uri, uri) + self.assertRaises(AttributeError, setattr, album, 'uri', None) + + def test_name(self): + name = u'a name' + album = Album(name=name) + self.assertEqual(album.name, name) + self.assertRaises(AttributeError, setattr, album, 'name', None) + + def test_artists(self): + artists = [Artist()] + album = Album(artists=artists) + self.assertEqual(album.artists, artists) + self.assertRaises(AttributeError, setattr, album, 'artists', None) + + def test_num_tracks(self): + num_tracks = 11 + album = Album(num_tracks=11) + self.assertEqual(album.num_tracks, num_tracks) + self.assertRaises(AttributeError, setattr, album, 'num_tracks', None) + + +class TrackTest(unittest.TestCase): + def test_uri(self): + uri = u'an_uri' + track = Track(uri=uri) + self.assertEqual(track.uri, uri) + self.assertRaises(AttributeError, setattr, track, 'uri', None) + + def test_name(self): + name = u'a name' + track = Track(name=name) + self.assertEqual(track.name, name) + self.assertRaises(AttributeError, setattr, track, 'name', None) + + def test_artists(self): + artists = [Artist(), Artist()] + track = Track(artists=artists) + self.assertEqual(track.artists, artists) + self.assertRaises(AttributeError, setattr, track, 'artists', None) + + def test_album(self): + album = Album() + track = Track(album=album) + self.assertEqual(track.album, album) + self.assertRaises(AttributeError, setattr, track, 'album', None) + + def test_track_no(self): + track_no = 7 + track = Track(track_no=track_no) + self.assertEqual(track.track_no, track_no) + self.assertRaises(AttributeError, setattr, track, 'track_no', None) + + def test_date(self): + date = dt.date(1977, 1, 1) + track = Track(date=date) + self.assertEqual(track.date, date) + self.assertRaises(AttributeError, setattr, track, 'date', None) + + def test_length(self): + length = 137000 + track = Track(length=length) + self.assertEqual(track.length, length) + self.assertRaises(AttributeError, setattr, track, 'length', None) + + def test_bitrate(self): + bitrate = 160 + track = Track(bitrate=bitrate) + self.assertEqual(track.bitrate, bitrate) + self.assertRaises(AttributeError, setattr, track, 'bitrate', None) + + def test_id(self): + id = 17 + track = Track(id=id) + self.assertEqual(track.id, id) + self.assertRaises(AttributeError, setattr, track, 'id', None) + + def test_mpd_format_for_empty_track(self): + track = Track() + result = track.mpd_format() + self.assert_(('file', '') in result) + self.assert_(('Time', 0) in result) + self.assert_(('Artist', '') in result) + self.assert_(('Title', '') in result) + self.assert_(('Album', '') in result) + self.assert_(('Track', 0) in result) + self.assert_(('Date', '') in result) + self.assert_(('Pos', 0) in result) + self.assert_(('Id', 0) in result) + + 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, + id=122, + ) + result = track.mpd_format(position=9) + 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_(('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) + + def test_mpd_format_artists(self): + track = Track(artists=[Artist(name=u'ABBA'), Artist(name=u'Beatles')]) + self.assertEqual(track.mpd_format_artists(), u'ABBA, Beatles') + + +class PlaylistTest(unittest.TestCase): + def test_uri(self): + uri = u'an_uri' + playlist = Playlist(uri=uri) + self.assertEqual(playlist.uri, uri) + self.assertRaises(AttributeError, setattr, playlist, 'uri', None) + + def test_name(self): + name = u'a name' + playlist = Playlist(name=name) + self.assertEqual(playlist.name, name) + self.assertRaises(AttributeError, setattr, playlist, 'name', None) + + def test_tracks(self): + tracks = [Track(), Track(), Track()] + playlist = Playlist(tracks=tracks) + self.assertEqual(playlist.tracks, tracks) + self.assertRaises(AttributeError, setattr, playlist, 'tracks', None) + + def test_length(self): + tracks = [Track(), Track(), Track()] + playlist = Playlist(tracks=tracks) + self.assertEqual(playlist.length, 3) + + def test_last_modified(self): + last_modified = dt.datetime.now() + playlist = Playlist(last_modified=last_modified) + self.assertEqual(playlist.last_modified, last_modified) + self.assertRaises(AttributeError, setattr, playlist, 'last_modified', + None) + + def test_mpd_format(self): + playlist = Playlist(tracks=[ + Track(track_no=1), Track(track_no=2), Track(track_no=3)]) + result = playlist.mpd_format() + self.assertEqual(len(result), 3) + + def test_mpd_format_with_range(self): + playlist = Playlist(tracks=[ + Track(track_no=1), Track(track_no=2), Track(track_no=3)]) + result = playlist.mpd_format(1, 2) + self.assertEqual(len(result), 1) + self.assertEqual(dict(result[0])['Track'], 2) + + def test_mpd_format_with_negative_start_and_no_end(self): + playlist = Playlist(tracks=[ + Track(track_no=1), Track(track_no=2), Track(track_no=3)]) + result = playlist.mpd_format(-1, None) + self.assertEqual(len(result), 1) + self.assertEqual(dict(result[0])['Track'], 3) + + def test_mpd_format_with_negative_start_and_end(self): + playlist = Playlist(tracks=[ + Track(track_no=1), Track(track_no=2), Track(track_no=3)]) + result = playlist.mpd_format(-2, -1) + self.assertEqual(len(result), 1) + self.assertEqual(dict(result[0])['Track'], 2) + + def test_with_new_uri(self): + tracks = [Track()] + 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') + self.assertEqual(new_playlist.uri, u'another uri') + self.assertEqual(new_playlist.name, u'a name') + self.assertEqual(new_playlist.tracks, tracks) + self.assertEqual(new_playlist.last_modified, last_modified) + + def test_with_new_name(self): + tracks = [Track()] + 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') + self.assertEqual(new_playlist.uri, u'an uri') + self.assertEqual(new_playlist.name, u'another name') + self.assertEqual(new_playlist.tracks, tracks) + self.assertEqual(new_playlist.last_modified, last_modified) + + def test_with_new_tracks(self): + tracks = [Track()] + last_modified = dt.datetime.now() + 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) + self.assertEqual(new_playlist.uri, u'an uri') + self.assertEqual(new_playlist.name, u'a name') + self.assertEqual(new_playlist.tracks, new_tracks) + self.assertEqual(new_playlist.last_modified, last_modified) + + def test_with_new_last_modified(self): + tracks = [Track()] + last_modified = dt.datetime.now() + 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) + self.assertEqual(new_playlist.uri, u'an uri') + self.assertEqual(new_playlist.name, u'a name') + self.assertEqual(new_playlist.tracks, tracks) + self.assertEqual(new_playlist.last_modified, new_last_modified) diff --git a/tests/mpd/__init__.py b/tests/mpd/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/mpd/exception_test.py b/tests/mpd/exception_test.py new file mode 100644 index 00000000..029063e8 --- /dev/null +++ b/tests/mpd/exception_test.py @@ -0,0 +1,19 @@ +import unittest + +from mopidy.mpd import MpdAckError, MpdNotImplemented + +class MpdExceptionsTest(unittest.TestCase): + def test_key_error_wrapped_in_mpd_ack_error(self): + try: + try: + raise KeyError(u'Track X not found') + except KeyError as e: + raise MpdAckError(e[0]) + except MpdAckError as e: + self.assertEqual(e.message, u'Track X not found') + + def test_mpd_not_implemented_is_a_mpd_ack_error(self): + try: + raise MpdNotImplemented + except MpdAckError as e: + self.assertEqual(e.message, u'Not implemented') diff --git a/tests/mpd/frontend_test.py b/tests/mpd/frontend_test.py new file mode 100644 index 00000000..4fb829ba --- /dev/null +++ b/tests/mpd/frontend_test.py @@ -0,0 +1,1147 @@ +import datetime as dt +import unittest + +from mopidy.backends.dummy import DummyBackend +from mopidy.mixers.dummy import DummyMixer +from mopidy.models import Track, Playlist +from mopidy.mpd import frontend + +class RequestHandlerTest(unittest.TestCase): + def setUp(self): + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = frontend.MpdFrontend(backend=self.b) + + def test_register_same_pattern_twice_fails(self): + func = lambda: None + try: + frontend.handle_pattern('a pattern')(func) + frontend.handle_pattern('a pattern')(func) + self.fail('Registering a pattern twice shoulde raise ValueError') + except ValueError: + pass + + def test_handling_unknown_request_raises_exception(self): + result = self.h.handle_request('an unhandled request') + self.assert_(u'ACK Unknown command' in result[0]) + + def test_handling_known_request(self): + expected = 'magic' + frontend._request_handlers['known request'] = lambda x: expected + result = self.h.handle_request('known request') + self.assert_(u'OK' in result) + self.assert_(expected in result) + +class CommandListsTest(unittest.TestCase): + def setUp(self): + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = frontend.MpdFrontend(backend=self.b) + + def test_command_list_begin(self): + result = self.h.handle_request(u'command_list_begin') + self.assert_(result is None) + + def test_command_list_end(self): + self.h.handle_request(u'command_list_begin') + result = self.h.handle_request(u'command_list_end') + self.assert_(u'OK' in result) + + def test_command_list_with_ping(self): + self.h.handle_request(u'command_list_begin') + self.assertEquals([], self.h.command_list) + self.assertEquals(False, self.h.command_list_ok) + self.h.handle_request(u'ping') + self.assert_(u'ping' in self.h.command_list) + result = self.h.handle_request(u'command_list_end') + self.assert_(u'OK' in result) + self.assertEquals(False, self.h.command_list) + + def test_command_list_with_error(self): + self.h.handle_request(u'command_list_begin') + self.h.handle_request(u'ack') + result = self.h.handle_request(u'command_list_end') + self.assert_(u'ACK' in result[-1]) + + def test_command_list_ok_begin(self): + result = self.h.handle_request(u'command_list_ok_begin') + self.assert_(result is None) + + def test_command_list_ok_with_ping(self): + self.h.handle_request(u'command_list_ok_begin') + self.assertEquals([], self.h.command_list) + self.assertEquals(True, self.h.command_list_ok) + self.h.handle_request(u'ping') + self.assert_(u'ping' in self.h.command_list) + result = self.h.handle_request(u'command_list_end') + self.assert_(u'list_OK' in result) + self.assert_(u'OK' in result) + self.assertEquals(False, self.h.command_list) + self.assertEquals(False, self.h.command_list_ok) + + +class StatusHandlerTest(unittest.TestCase): + def setUp(self): + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = frontend.MpdFrontend(backend=self.b) + + def test_clearerror(self): + result = self.h.handle_request(u'clearerror') + self.assert_(u'ACK Not implemented' in result) + + def test_currentsong(self): + track = Track() + self.b.current_playlist.playlist = Playlist(tracks=[track]) + self.b.playback.current_track = track + result = self.h.handle_request(u'currentsong') + self.assert_(u'file: ' in result) + self.assert_(u'Time: 0' in result) + self.assert_(u'Artist: ' in result) + self.assert_(u'Title: ' in result) + self.assert_(u'Album: ' in result) + self.assert_(u'Track: 0' in result) + self.assert_(u'Date: ' in result) + self.assert_(u'Pos: 0' in result) + self.assert_(u'Id: 0' in result) + self.assert_(u'OK' in result) + + def test_currentsong_without_song(self): + result = self.h.handle_request(u'currentsong') + self.assert_(u'OK' in result) + + def test_idle_without_subsystems(self): + result = self.h.handle_request(u'idle') + self.assert_(u'OK' in result) + + def test_idle_with_subsystems(self): + result = self.h.handle_request(u'idle database playlist') + self.assert_(u'OK' in result) + + def test_noidle(self): + result = self.h.handle_request(u'noidle') + self.assert_(u'ACK Not implemented' in result) + + def test_stats_command(self): + result = self.h.handle_request(u'stats') + self.assert_(u'OK' in result) + + def test_stats_method(self): + result = self.h._status_stats() + self.assert_('artists' in result) + self.assert_(int(result['artists']) >= 0) + self.assert_('albums' in result) + self.assert_(int(result['albums']) >= 0) + self.assert_('songs' in result) + self.assert_(int(result['songs']) >= 0) + self.assert_('uptime' in result) + self.assert_(int(result['uptime']) >= 0) + self.assert_('db_playtime' in result) + self.assert_(int(result['db_playtime']) >= 0) + self.assert_('db_update' in result) + self.assert_(int(result['db_update']) >= 0) + self.assert_('playtime' in result) + self.assert_(int(result['playtime']) >= 0) + + def test_status_command(self): + result = self.h.handle_request(u'status') + self.assert_(u'OK' in result) + + def test_status_method_contains_volume_which_defaults_to_0(self): + result = dict(self.h._status_status()) + self.assert_('volume' in result) + self.assertEquals(int(result['volume']), 0) + + def test_status_method_contains_volume(self): + self.b.playback.volume = 17 + result = dict(self.h._status_status()) + self.assert_('volume' in result) + self.assertEquals(int(result['volume']), 17) + + def test_status_method_contains_repeat_is_0(self): + result = dict(self.h._status_status()) + self.assert_('repeat' in result) + self.assertEquals(int(result['repeat']), 0) + + def test_status_method_contains_repeat_is_1(self): + self.b.playback.repeat = 1 + result = dict(self.h._status_status()) + self.assert_('repeat' in result) + self.assertEquals(int(result['repeat']), 1) + + def test_status_method_contains_random_is_0(self): + result = dict(self.h._status_status()) + self.assert_('random' in result) + self.assertEquals(int(result['random']), 0) + + def test_status_method_contains_random_is_1(self): + self.b.playback.random = 1 + result = dict(self.h._status_status()) + self.assert_('random' in result) + self.assertEquals(int(result['random']), 1) + + def test_status_method_contains_single(self): + result = dict(self.h._status_status()) + self.assert_('single' in result) + self.assert_(int(result['single']) in (0, 1)) + + def test_status_method_contains_consume_is_0(self): + result = dict(self.h._status_status()) + self.assert_('consume' in result) + self.assertEquals(int(result['consume']), 0) + + def test_status_method_contains_consume_is_1(self): + self.b.playback.consume = 1 + result = dict(self.h._status_status()) + self.assert_('consume' in result) + self.assertEquals(int(result['consume']), 1) + + def test_status_method_contains_playlist(self): + result = dict(self.h._status_status()) + self.assert_('playlist' in result) + self.assert_(int(result['playlist']) in xrange(0, 2**31 - 1)) + + def test_status_method_contains_playlistlength(self): + result = dict(self.h._status_status()) + self.assert_('playlistlength' in result) + self.assert_(int(result['playlistlength']) >= 0) + + def test_status_method_contains_xfade(self): + result = dict(self.h._status_status()) + self.assert_('xfade' in result) + self.assert_(int(result['xfade']) >= 0) + + def test_status_method_contains_state_is_play(self): + self.b.playback.state = self.b.playback.PLAYING + result = dict(self.h._status_status()) + self.assert_('state' in result) + self.assertEquals(result['state'], 'play') + + def test_status_method_contains_state_is_stop(self): + self.b.playback.state = self.b.playback.STOPPED + result = dict(self.h._status_status()) + self.assert_('state' in result) + self.assertEquals(result['state'], 'stop') + + def test_status_method_contains_state_is_pause(self): + self.b.playback.state = self.b.playback.PLAYING + self.b.playback.state = self.b.playback.PAUSED + result = dict(self.h._status_status()) + self.assert_('state' in result) + self.assertEquals(result['state'], 'pause') + + def test_status_method_when_playlist_loaded_contains_song(self): + track = Track() + self.b.current_playlist.load(Playlist(tracks=[track])) + self.b.playback.current_track = track + result = dict(self.h._status_status()) + self.assert_('song' in result) + self.assert_(int(result['song']) >= 0) + + def test_status_method_when_playlist_loaded_contains_pos_as_songid(self): + track = Track() + self.b.current_playlist.load(Playlist(tracks=[track])) + self.b.playback.current_track = track + result = dict(self.h._status_status()) + self.assert_('songid' in result) + self.assert_(int(result['songid']) >= 0) + + def test_status_method_when_playlist_loaded_contains_id_as_songid(self): + track = Track(id=1) + self.b.current_playlist.load(Playlist(tracks=[track])) + self.b.playback.current_track = track + result = dict(self.h._status_status()) + self.assert_('songid' in result) + self.assertEquals(int(result['songid']), 1) + + def test_status_method_when_playing_contains_time_with_no_length(self): + self.b.playback.current_track = Track(length=None) + self.b.playback.state = self.b.playback.PLAYING + result = dict(self.h._status_status()) + self.assert_('time' in result) + (position, total) = result['time'].split(':') + position = int(position) + total = int(total) + self.assert_(position <= total) + + def test_status_method_when_playing_contains_time_with_length(self): + self.b.playback.current_track = Track(length=10000) + self.b.playback.state = self.b.playback.PLAYING + result = dict(self.h._status_status()) + self.assert_('time' in result) + (position, total) = result['time'].split(':') + position = int(position) + total = int(total) + self.assert_(position <= total) + + def test_status_method_when_playing_contains_elapsed(self): + self.b.playback.state = self.b.playback.PAUSED + self.b.playback._play_time_accumulated = 59123 + result = dict(self.h._status_status()) + self.assert_('elapsed' in result) + self.assertEquals(int(result['elapsed']), 59123) + + def test_status_method_when_playing_contains_bitrate(self): + self.b.playback.state = self.b.playback.PLAYING + self.b.playback.current_track = Track(bitrate=320) + result = dict(self.h._status_status()) + self.assert_('bitrate' in result) + self.assertEquals(int(result['bitrate']), 320) + + +class PlaybackOptionsHandlerTest(unittest.TestCase): + def setUp(self): + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = frontend.MpdFrontend(backend=self.b) + + def test_consume_off(self): + result = self.h.handle_request(u'consume "0"') + self.assertFalse(self.b.playback.consume) + self.assert_(u'OK' in result) + + def test_consume_on(self): + result = self.h.handle_request(u'consume "1"') + self.assertTrue(self.b.playback.consume) + self.assert_(u'OK' in result) + + def test_crossfade(self): + result = self.h.handle_request(u'crossfade "10"') + self.assert_(u'ACK Not implemented' in result) + + def test_random_off(self): + result = self.h.handle_request(u'random "0"') + self.assertFalse(self.b.playback.random) + self.assert_(u'OK' in result) + + def test_random_on(self): + result = self.h.handle_request(u'random "1"') + self.assertTrue(self.b.playback.random) + self.assert_(u'OK' in result) + + def test_repeat_off(self): + result = self.h.handle_request(u'repeat "0"') + self.assertFalse(self.b.playback.repeat) + self.assert_(u'OK' in result) + + def test_repeat_on(self): + result = self.h.handle_request(u'repeat "1"') + self.assertTrue(self.b.playback.repeat) + self.assert_(u'OK' in result) + + def test_setvol_below_min(self): + result = self.h.handle_request(u'setvol "-10"') + self.assert_(u'OK' in result) + self.assertEqual(0, self.b.playback.volume) + + def test_setvol_min(self): + result = self.h.handle_request(u'setvol "0"') + self.assert_(u'OK' in result) + self.assertEqual(0, self.b.playback.volume) + + def test_setvol_middle(self): + result = self.h.handle_request(u'setvol "50"') + self.assert_(u'OK' in result) + self.assertEqual(50, self.b.playback.volume) + + def test_setvol_max(self): + result = self.h.handle_request(u'setvol "100"') + self.assert_(u'OK' in result) + self.assertEqual(100, self.b.playback.volume) + + def test_setvol_above_max(self): + result = self.h.handle_request(u'setvol "110"') + self.assert_(u'OK' in result) + self.assertEqual(100, self.b.playback.volume) + + def test_setvol_plus_is_ignored(self): + result = self.h.handle_request(u'setvol "+10"') + self.assert_(u'OK' in result) + self.assertEqual(10, self.b.playback.volume) + + def test_single_off(self): + result = self.h.handle_request(u'single "0"') + self.assertFalse(self.b.playback.single) + self.assert_(u'OK' in result) + + def test_single_on(self): + result = self.h.handle_request(u'single "1"') + self.assertTrue(self.b.playback.single) + self.assert_(u'OK' in result) + + def test_replay_gain_mode_off(self): + result = self.h.handle_request(u'replay_gain_mode "off"') + self.assert_(u'ACK Not implemented' in result) + + def test_replay_gain_mode_track(self): + result = self.h.handle_request(u'replay_gain_mode "track"') + self.assert_(u'ACK Not implemented' in result) + + def test_replay_gain_mode_album(self): + result = self.h.handle_request(u'replay_gain_mode "album"') + self.assert_(u'ACK Not implemented' in result) + + def test_replay_gain_status_default(self): + expected = u'off' + result = self.h.handle_request(u'replay_gain_status') + self.assert_(u'OK' in result) + self.assert_(expected in result) + + #def test_replay_gain_status_off(self): + # expected = u'off' + # self.h._replay_gain_mode(expected) + # result = self.h.handle_request(u'replay_gain_status') + # self.assert_(u'OK' in result) + # self.assert_(expected in result) + + #def test_replay_gain_status_track(self): + # expected = u'track' + # self.h._replay_gain_mode(expected) + # result = self.h.handle_request(u'replay_gain_status') + # self.assert_(u'OK' in result) + # self.assert_(expected in result) + + #def test_replay_gain_status_album(self): + # expected = u'album' + # self.h._replay_gain_mode(expected) + # result = self.h.handle_request(u'replay_gain_status') + # self.assert_(u'OK' in result) + # self.assert_(expected in result) + + +class PlaybackControlHandlerTest(unittest.TestCase): + def setUp(self): + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = frontend.MpdFrontend(backend=self.b) + + def test_next(self): + result = self.h.handle_request(u'next') + self.assert_(u'OK' in result) + + def test_pause_off(self): + track = Track() + self.b.current_playlist.playlist = Playlist(tracks=[track]) + self.b.playback.current_track = track + self.h.handle_request(u'play "0"') + self.h.handle_request(u'pause "1"') + result = self.h.handle_request(u'pause "0"') + self.assert_(u'OK' in result) + self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) + + def test_pause_on(self): + track = Track() + self.b.current_playlist.playlist = Playlist(tracks=[track]) + self.b.playback.current_track = track + self.h.handle_request(u'play "0"') + result = self.h.handle_request(u'pause "1"') + self.assert_(u'OK' in result) + self.assertEquals(self.b.playback.PAUSED, self.b.playback.state) + + def test_play_without_pos(self): + track = Track() + self.b.current_playlist.playlist = Playlist(tracks=[track]) + self.b.playback.current_track = track + self.b.playback.state = self.b.playback.PAUSED + result = self.h.handle_request(u'play') + self.assert_(u'OK' in result) + self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) + + def test_play_with_pos(self): + self.b.current_playlist.load(Playlist(tracks=[Track()])) + result = self.h.handle_request(u'play "0"') + self.assert_(u'OK' in result) + self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) + + def test_play_with_pos_out_of_bounds(self): + self.b.current_playlist.load(Playlist()) + result = self.h.handle_request(u'play "0"') + self.assert_(u'ACK Position out of bounds' in result) + self.assertEquals(self.b.playback.STOPPED, self.b.playback.state) + + def test_playid(self): + self.b.current_playlist.load(Playlist(tracks=[Track(id=0)])) + result = self.h.handle_request(u'playid "0"') + self.assert_(u'OK' in result) + self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) + + def test_playid_minus_one_plays_first_in_playlist(self): + track = Track(id=0) + self.b.current_playlist.load(Playlist(tracks=[track])) + result = self.h.handle_request(u'playid "-1"') + self.assert_(u'OK' in result) + self.assertEquals(self.b.playback.PLAYING, self.b.playback.state) + self.assertEquals(self.b.playback.current_track, track) + + def test_playid_which_does_not_exist(self): + self.b.current_playlist.load(Playlist(tracks=[Track(id=0)])) + result = self.h.handle_request(u'playid "1"') + self.assert_(u'ACK "id=1" match no tracks' in result) + + def test_previous(self): + result = self.h.handle_request(u'previous') + self.assert_(u'OK' in result) + + def test_seek(self): + result = self.h.handle_request(u'seek "0" "30"') + self.assert_(u'ACK Not implemented' in result) + + def test_seekid(self): + result = self.h.handle_request(u'seekid "0" "30"') + self.assert_(u'ACK Not implemented' in result) + + def test_stop(self): + result = self.h.handle_request(u'stop') + self.assert_(u'OK' in result) + self.assertEquals(self.b.playback.STOPPED, self.b.playback.state) + + +class CurrentPlaylistHandlerTest(unittest.TestCase): + def setUp(self): + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = frontend.MpdFrontend(backend=self.b) + + def test_add(self): + needle = Track(uri='dummy://foo') + self.b.library._library = [Track(), Track(), needle, Track()] + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'add "dummy://foo"') + self.assertEquals(self.b.current_playlist.playlist.length, 6) + self.assertEquals(self.b.current_playlist.playlist.tracks[5], needle) + self.assert_(u'OK' in result) + + def test_addid_without_songpos(self): + needle = Track(uri='dummy://foo', id=137) + self.b.library._library = [Track(), Track(), needle, Track()] + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'addid "dummy://foo"') + self.assertEquals(self.b.current_playlist.playlist.length, 6) + self.assertEquals(self.b.current_playlist.playlist.tracks[5], needle) + self.assert_(u'Id: 137' in result) + self.assert_(u'OK' in result) + + def test_addid_with_songpos(self): + needle = Track(uri='dummy://foo', id=137) + self.b.library._library = [Track(), Track(), needle, Track()] + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'addid "dummy://foo" "3"') + self.assertEquals(self.b.current_playlist.playlist.length, 6) + self.assertEquals(self.b.current_playlist.playlist.tracks[3], needle) + self.assert_(u'Id: 137' in result) + self.assert_(u'OK' in result) + + def test_clear(self): + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'clear') + self.assertEquals(self.b.current_playlist.playlist.length, 0) + self.assertEquals(self.b.playback.current_track, None) + self.assert_(u'OK' in result) + + def test_delete_songpos(self): + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'delete "2"') + self.assertEquals(self.b.current_playlist.playlist.length, 4) + self.assert_(u'OK' in result) + + def test_delete_songpos_out_of_bounds(self): + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'delete "5"') + self.assertEquals(self.b.current_playlist.playlist.length, 5) + self.assert_(u'ACK Position out of bounds' in result) + + def test_delete_open_range(self): + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'delete "1:"') + self.assertEquals(self.b.current_playlist.playlist.length, 1) + self.assert_(u'OK' in result) + + def test_delete_closed_range(self): + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'delete "1:3"') + self.assertEquals(self.b.current_playlist.playlist.length, 3) + self.assert_(u'OK' in result) + + def test_delete_range_out_of_bounds(self): + self.b.current_playlist.playlist = Playlist( + tracks=[Track(), Track(), Track(), Track(), Track()]) + self.assertEquals(self.b.current_playlist.playlist.length, 5) + result = self.h.handle_request(u'delete "5:7"') + self.assertEquals(self.b.current_playlist.playlist.length, 5) + self.assert_(u'ACK Position out of bounds' in result) + + def test_deleteid(self): + self.b.current_playlist.load(Playlist(tracks=[Track(id=0), Track()])) + self.assertEquals(self.b.current_playlist.playlist.length, 2) + result = self.h.handle_request(u'deleteid "0"') + self.assertEquals(self.b.current_playlist.playlist.length, 1) + self.assert_(u'OK' in result) + + def test_deleteid_does_not_exist(self): + self.b.current_playlist.load(Playlist(tracks=[Track(id=1), Track()])) + self.assertEquals(self.b.current_playlist.playlist.length, 2) + result = self.h.handle_request(u'deleteid "0"') + self.assertEquals(self.b.current_playlist.playlist.length, 2) + self.assert_(u'ACK "id=0" match no tracks' in result) + + def test_move_songpos(self): + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e'), Track(name='f')])) + result = self.h.handle_request(u'move "1" "0"') + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'b') + self.assertEquals(self.b.current_playlist.playlist.tracks[1].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[2].name, 'c') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'd') + self.assertEquals(self.b.current_playlist.playlist.tracks[4].name, 'e') + self.assertEquals(self.b.current_playlist.playlist.tracks[5].name, 'f') + self.assert_(u'OK' in result) + + def test_move_open_range(self): + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e'), Track(name='f')])) + result = self.h.handle_request(u'move "2:" "0"') + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'c') + self.assertEquals(self.b.current_playlist.playlist.tracks[1].name, 'd') + self.assertEquals(self.b.current_playlist.playlist.tracks[2].name, 'e') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'f') + self.assertEquals(self.b.current_playlist.playlist.tracks[4].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[5].name, 'b') + self.assert_(u'OK' in result) + + def test_move_closed_range(self): + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e'), Track(name='f')])) + result = self.h.handle_request(u'move "1:3" "0"') + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'b') + self.assertEquals(self.b.current_playlist.playlist.tracks[1].name, 'c') + self.assertEquals(self.b.current_playlist.playlist.tracks[2].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'd') + self.assertEquals(self.b.current_playlist.playlist.tracks[4].name, 'e') + self.assertEquals(self.b.current_playlist.playlist.tracks[5].name, 'f') + self.assert_(u'OK' in result) + + def test_moveid(self): + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e', id=137), Track(name='f')])) + result = self.h.handle_request(u'moveid "137" "2"') + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[1].name, 'b') + self.assertEquals(self.b.current_playlist.playlist.tracks[2].name, 'e') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'c') + self.assertEquals(self.b.current_playlist.playlist.tracks[4].name, 'd') + self.assertEquals(self.b.current_playlist.playlist.tracks[5].name, 'f') + self.assert_(u'OK' in result) + + def test_playlist_returns_same_as_playlistinfo(self): + playlist_result = self.h.handle_request(u'playlist') + playlistinfo_result = self.h.handle_request(u'playlistinfo') + self.assertEquals(playlist_result, playlistinfo_result) + + def test_playlistfind(self): + result = self.h.handle_request(u'playlistfind "tag" "needle"') + self.assert_(u'ACK Not implemented' in result) + + def test_playlistfind_by_filename(self): + result = self.h.handle_request(u'playlistfind "filename" "file:///dev/null"') + self.assert_(u'OK' in result) + + def test_playlistfind_by_filename_without_quotes(self): + result = self.h.handle_request(u'playlistfind filename "file:///dev/null"') + self.assert_(u'OK' in result) + + def test_playlistfind_by_filename_in_current_playlist(self): + self.b.current_playlist.playlist = Playlist(tracks=[ + Track(uri='file:///exists')]) + result = self.h.handle_request(u'playlistfind filename "file:///exists"') + self.assert_(u'file: file:///exists' in result) + self.assert_(u'OK' in result) + + def test_playlistid_without_songid(self): + self.b.current_playlist.load(Playlist( + tracks=[Track(name='a', id=33), Track(name='b', id=38)])) + result = self.h.handle_request(u'playlistid') + self.assert_(u'Title: a' in result) + self.assert_(u'Id: 33' in result) + self.assert_(u'Title: b' in result) + self.assert_(u'Id: 38' in result) + self.assert_(u'OK' in result) + + def test_playlistid_with_songid(self): + self.b.current_playlist.load(Playlist( + tracks=[Track(name='a', id=33), Track(name='b', id=38)])) + result = self.h.handle_request(u'playlistid "38"') + self.assert_(u'Title: a' not in result) + self.assert_(u'Id: 33' not in result) + self.assert_(u'Title: b' in result) + self.assert_(u'Id: 38' in result) + self.assert_(u'OK' in result) + + def test_playlistid_with_not_existing_songid_fails(self): + self.b.current_playlist.load(Playlist( + tracks=[Track(name='a', id=33), Track(name='b', id=38)])) + result = self.h.handle_request(u'playlistid "25"') + self.assert_(u'ACK "id=25" match no tracks' in result) + + def test_playlistinfo_without_songpos_or_range(self): + result = self.h.handle_request(u'playlistinfo') + self.assert_(u'OK' in result) + + def test_playlistinfo_with_songpos(self): + result = self.h.handle_request(u'playlistinfo "5"') + self.assert_(u'OK' in result) + + def test_playlistinfo_with_negative_songpos(self): + result = self.h.handle_request(u'playlistinfo "-1"') + self.assert_(u'OK' in result) + + def test_playlistinfo_with_open_range(self): + result = self.h.handle_request(u'playlistinfo "10:"') + self.assert_(u'OK' in result) + + def test_playlistinfo_with_closed_range(self): + result = self.h.handle_request(u'playlistinfo "10:20"') + self.assert_(u'OK' in result) + + def test_playlistsearch(self): + result = self.h.handle_request(u'playlistsearch "tag" "needle"') + self.assert_(u'ACK Not implemented' in result) + + def test_plchanges(self): + self.b.current_playlist.load(Playlist( + tracks=[Track(name='a'), Track(name='b'), Track(name='c')])) + result = self.h.handle_request(u'plchanges "0"') + self.assert_(u'Title: a' in result) + self.assert_(u'Title: b' in result) + self.assert_(u'Title: c' in result) + self.assert_(u'OK' in result) + + def test_plchangesposid(self): + self.b.current_playlist.load(Playlist( + tracks=[Track(id=11), Track(id=12), Track(id=13)])) + result = self.h.handle_request(u'plchangesposid "0"') + self.assert_(u'cpos: 0' in result) + self.assert_(u'Id: 11' in result) + self.assert_(u'cpos: 2' in result) + self.assert_(u'Id: 12' in result) + self.assert_(u'cpos: 2' in result) + self.assert_(u'Id: 13' in result) + self.assert_(u'OK' in result) + + def test_shuffle_without_range(self): + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e'), Track(name='f')])) + self.assertEquals(self.b.current_playlist.version, 2) + result = self.h.handle_request(u'shuffle') + self.assertEquals(self.b.current_playlist.version, 3) + self.assert_(u'OK' in result) + + def test_shuffle_with_open_range(self): + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e'), Track(name='f')])) + self.assertEquals(self.b.current_playlist.version, 2) + result = self.h.handle_request(u'shuffle "4:"') + self.assertEquals(self.b.current_playlist.version, 3) + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[1].name, 'b') + self.assertEquals(self.b.current_playlist.playlist.tracks[2].name, 'c') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'd') + self.assert_(u'OK' in result) + + def test_shuffle_with_closed_range(self): + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e'), Track(name='f')])) + self.assertEquals(self.b.current_playlist.version, 2) + result = self.h.handle_request(u'shuffle "1:3"') + self.assertEquals(self.b.current_playlist.version, 3) + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'd') + self.assertEquals(self.b.current_playlist.playlist.tracks[4].name, 'e') + self.assertEquals(self.b.current_playlist.playlist.tracks[5].name, 'f') + self.assert_(u'OK' in result) + + def test_swap(self): + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b'), Track(name='c'), + Track(name='d'), Track(name='e'), Track(name='f')])) + result = self.h.handle_request(u'swap "1" "4"') + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[1].name, 'e') + self.assertEquals(self.b.current_playlist.playlist.tracks[2].name, 'c') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'd') + self.assertEquals(self.b.current_playlist.playlist.tracks[4].name, 'b') + self.assertEquals(self.b.current_playlist.playlist.tracks[5].name, 'f') + self.assert_(u'OK' in result) + + def test_swapid(self): + self.b.current_playlist.load(Playlist(tracks=[ + Track(name='a'), Track(name='b', id=13), Track(name='c'), + Track(name='d'), Track(name='e', id=29), Track(name='f')])) + result = self.h.handle_request(u'swapid "13" "29"') + self.assertEquals(self.b.current_playlist.playlist.tracks[0].name, 'a') + self.assertEquals(self.b.current_playlist.playlist.tracks[1].name, 'e') + self.assertEquals(self.b.current_playlist.playlist.tracks[2].name, 'c') + self.assertEquals(self.b.current_playlist.playlist.tracks[3].name, 'd') + self.assertEquals(self.b.current_playlist.playlist.tracks[4].name, 'b') + self.assertEquals(self.b.current_playlist.playlist.tracks[5].name, 'f') + self.assert_(u'OK' in result) + + +class StoredPlaylistsHandlerTest(unittest.TestCase): + def setUp(self): + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = frontend.MpdFrontend(backend=self.b) + + def test_listplaylist(self): + self.b.stored_playlists.playlists = [ + Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + result = self.h.handle_request(u'listplaylist "name"') + self.assert_(u'file: file:///dev/urandom' in result) + self.assert_(u'OK' in result) + + def test_listplaylist_fails_if_no_playlist_is_found(self): + result = self.h.handle_request(u'listplaylist "name"') + self.assert_(u'ACK "name=name" match no playlists' in result) + + def test_listplaylistinfo(self): + self.b.stored_playlists.playlists = [ + Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + result = self.h.handle_request(u'listplaylistinfo "name"') + self.assert_(u'file: file:///dev/urandom' in result) + self.assert_(u'Track: 0' in result) + self.assert_(u'Pos: 0' not in result) + self.assert_(u'OK' in result) + + def test_listplaylistinfo_fails_if_no_playlist_is_found(self): + result = self.h.handle_request(u'listplaylistinfo "name"') + self.assert_(u'ACK "name=name" match no playlists' in result) + + def test_listplaylists(self): + last_modified = dt.datetime(2001, 3, 17, 13, 41, 17) + self.b.stored_playlists.playlists = [Playlist(name='a', + last_modified=last_modified)] + result = self.h.handle_request(u'listplaylists') + self.assert_(u'playlist: a' in result) + self.assert_(u'Last-Modified: 2001-03-17T13:41:17' in result) + self.assert_(u'OK' in result) + + def test_load(self): + result = self.h.handle_request(u'load "name"') + self.assert_(u'OK' in result) + + def test_playlistadd(self): + result = self.h.handle_request( + u'playlistadd "name" "file:///dev/urandom"') + self.assert_(u'ACK Not implemented' in result) + + def test_playlistclear(self): + result = self.h.handle_request(u'playlistclear "name"') + self.assert_(u'ACK Not implemented' in result) + + def test_playlistdelete(self): + result = self.h.handle_request(u'playlistdelete "name" "5"') + self.assert_(u'ACK Not implemented' in result) + + def test_playlistmove(self): + result = self.h.handle_request(u'playlistmove "name" "5" "10"') + self.assert_(u'ACK Not implemented' in result) + + def test_rename(self): + result = self.h.handle_request(u'rename "old_name" "new_name"') + self.assert_(u'ACK Not implemented' in result) + + def test_rm(self): + result = self.h.handle_request(u'rm "name"') + self.assert_(u'ACK Not implemented' in result) + + def test_save(self): + result = self.h.handle_request(u'save "name"') + self.assert_(u'ACK Not implemented' in result) + + +class MusicDatabaseHandlerTest(unittest.TestCase): + def setUp(self): + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = frontend.MpdFrontend(backend=self.b) + + def test_count(self): + result = self.h.handle_request(u'count "tag" "needle"') + self.assert_(u'songs: 0' in result) + self.assert_(u'playtime: 0' in result) + self.assert_(u'OK' in result) + + def test_find_album(self): + result = self.h.handle_request(u'find "album" "what"') + self.assert_(u'OK' in result) + + def test_find_album_without_quotes(self): + result = self.h.handle_request(u'find album "what"') + self.assert_(u'OK' in result) + + def test_find_artist(self): + result = self.h.handle_request(u'find "artist" "what"') + self.assert_(u'OK' in result) + + def test_find_artist_without_quotes(self): + result = self.h.handle_request(u'find artist "what"') + self.assert_(u'OK' in result) + + def test_find_title(self): + result = self.h.handle_request(u'find "title" "what"') + self.assert_(u'OK' in result) + + def test_find_title_without_quotes(self): + result = self.h.handle_request(u'find title "what"') + self.assert_(u'OK' in result) + + def test_find_else_should_fail(self): + result = self.h.handle_request(u'find "somethingelse" "what"') + self.assert_(u'ACK Unknown command' in result[0]) + + def test_find_album_and_artist(self): + result = self.h.handle_request(u'find album "album_what" artist "artist_what"') + self.assert_(u'OK' in result) + + def test_findadd(self): + result = self.h.handle_request(u'findadd "album" "what"') + self.assert_(u'OK' in result) + + def test_list_artist(self): + result = self.h.handle_request(u'list "artist"') + self.assert_(u'OK' in result) + + def test_list_artist_with_artist_should_fail(self): + result = self.h.handle_request(u'list "artist" "anartist"') + self.assert_(u'ACK Unknown command' in result[0]) + + def test_list_album_without_artist(self): + result = self.h.handle_request(u'list "album"') + self.assert_(u'OK' in result) + + def test_list_album_with_artist(self): + result = self.h.handle_request(u'list "album" "anartist"') + self.assert_(u'OK' in result) + + def test_listall(self): + result = self.h.handle_request(u'listall "file:///dev/urandom"') + self.assert_(u'ACK Not implemented' in result) + + def test_listallinfo(self): + result = self.h.handle_request(u'listallinfo "file:///dev/urandom"') + self.assert_(u'ACK Not implemented' in result) + + def test_lsinfo_without_path_returns_same_as_listplaylists(self): + lsinfo_result = self.h.handle_request(u'lsinfo') + listplaylists_result = self.h.handle_request(u'listplaylists') + self.assertEquals(lsinfo_result, listplaylists_result) + + def test_lsinfo_with_path(self): + result = self.h.handle_request(u'lsinfo ""') + self.assert_(u'ACK Not implemented' in result) + + def test_lsinfo_for_root_returns_same_as_listplaylists(self): + lsinfo_result = self.h.handle_request(u'lsinfo "/"') + listplaylists_result = self.h.handle_request(u'listplaylists') + self.assertEquals(lsinfo_result, listplaylists_result) + + def test_search_album(self): + result = self.h.handle_request(u'search "album" "analbum"') + self.assert_(u'OK' in result) + + def test_search_album_without_quotes(self): + result = self.h.handle_request(u'search album "analbum"') + self.assert_(u'OK' in result) + + def test_search_artist(self): + result = self.h.handle_request(u'search "artist" "anartist"') + self.assert_(u'OK' in result) + + def test_search_artist_without_quotes(self): + result = self.h.handle_request(u'search artist "anartist"') + self.assert_(u'OK' in result) + + def test_search_filename(self): + result = self.h.handle_request(u'search "filename" "afilename"') + self.assert_(u'OK' in result) + + def test_search_filename_without_quotes(self): + result = self.h.handle_request(u'search filename "afilename"') + self.assert_(u'OK' in result) + + def test_search_title(self): + result = self.h.handle_request(u'search "title" "atitle"') + self.assert_(u'OK' in result) + + def test_search_title_without_quotes(self): + result = self.h.handle_request(u'search title "atitle"') + self.assert_(u'OK' in result) + + def test_search_any(self): + result = self.h.handle_request(u'search "any" "anything"') + self.assert_(u'OK' in result) + + def test_search_any_without_quotes(self): + result = self.h.handle_request(u'search any "anything"') + self.assert_(u'OK' in result) + + def test_search_else_should_fail(self): + result = self.h.handle_request(u'search "sometype" "something"') + self.assert_(u'ACK Unknown command' in result[0]) + + def test_update_without_uri(self): + result = self.h.handle_request(u'update') + self.assert_(u'OK' in result) + self.assert_(u'updating_db: 0' in result) + + def test_update_with_uri(self): + result = self.h.handle_request(u'update "file:///dev/urandom"') + self.assert_(u'OK' in result) + self.assert_(u'updating_db: 0' in result) + + def test_rescan_without_uri(self): + result = self.h.handle_request(u'rescan') + self.assert_(u'OK' in result) + self.assert_(u'updating_db: 0' in result) + + def test_rescan_with_uri(self): + result = self.h.handle_request(u'rescan "file:///dev/urandom"') + self.assert_(u'OK' in result) + self.assert_(u'updating_db: 0' in result) + + +class StickersHandlerTest(unittest.TestCase): + def setUp(self): + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = frontend.MpdFrontend(backend=self.b) + + def test_sticker_get(self): + result = self.h.handle_request( + u'sticker get "song" "file:///dev/urandom" "a_name"') + self.assert_(u'ACK Not implemented' in result) + + def test_sticker_set(self): + result = self.h.handle_request( + u'sticker set "song" "file:///dev/urandom" "a_name" "a_value"') + self.assert_(u'ACK Not implemented' in result) + + def test_sticker_delete_with_name(self): + result = self.h.handle_request( + u'sticker delete "song" "file:///dev/urandom" "a_name"') + self.assert_(u'ACK Not implemented' in result) + + def test_sticker_delete_without_name(self): + result = self.h.handle_request( + u'sticker delete "song" "file:///dev/urandom"') + self.assert_(u'ACK Not implemented' in result) + + def test_sticker_list(self): + result = self.h.handle_request( + u'sticker list "song" "file:///dev/urandom"') + self.assert_(u'ACK Not implemented' in result) + + def test_sticker_find(self): + result = self.h.handle_request( + u'sticker find "song" "file:///dev/urandom" "a_name"') + self.assert_(u'ACK Not implemented' in result) + + +class ConnectionHandlerTest(unittest.TestCase): + def setUp(self): + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = frontend.MpdFrontend(backend=self.b) + + def test_close(self): + result = self.h.handle_request(u'close') + self.assert_(u'OK' in result) + + def test_empty_request(self): + result = self.h.handle_request(u'') + self.assert_(u'OK' in result) + + def test_kill(self): + result = self.h.handle_request(u'kill') + self.assert_(u'OK' in result) + + def test_password(self): + result = self.h.handle_request(u'password "secret"') + self.assert_(u'ACK Not implemented' in result) + + def test_ping(self): + result = self.h.handle_request(u'ping') + self.assert_(u'OK' in result) + + +class AudioOutputHandlerTest(unittest.TestCase): + def setUp(self): + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = frontend.MpdFrontend(backend=self.b) + + def test_enableoutput(self): + result = self.h.handle_request(u'enableoutput "0"') + self.assert_(u'ACK Not implemented' in result) + + def test_disableoutput(self): + result = self.h.handle_request(u'disableoutput "0"') + self.assert_(u'ACK Not implemented' in result) + + def test_outputs(self): + result = self.h.handle_request(u'outputs') + self.assert_(u'outputid: 0' in result) + self.assert_(u'outputname: DummyBackend' in result) + self.assert_(u'outputenabled: 1' in result) + self.assert_(u'OK' in result) + + +class ReflectionHandlerTest(unittest.TestCase): + def setUp(self): + self.m = DummyMixer() + self.b = DummyBackend(mixer=self.m) + self.h = frontend.MpdFrontend(backend=self.b) + + def test_commands(self): + result = self.h.handle_request(u'commands') + self.assert_(u'OK' in result) + + def test_decoders(self): + result = self.h.handle_request(u'decoders') + self.assert_(u'ACK Not implemented' in result) + + def test_notcommands(self): + result = self.h.handle_request(u'notcommands') + self.assert_(u'OK' in result) + + def test_tagtypes(self): + result = self.h.handle_request(u'tagtypes') + self.assert_(u'OK' in result) + + def test_urlhandlers(self): + result = self.h.handle_request(u'urlhandlers') + self.assert_(u'OK' in result) + self.assert_(u'handler: dummy:' in result) diff --git a/tests/mpd/handlertest.py b/tests/mpd/handlertest.py deleted file mode 100644 index 653044db..00000000 --- a/tests/mpd/handlertest.py +++ /dev/null @@ -1,660 +0,0 @@ -import unittest - -from mopidy.backends.dummy import DummyBackend -from mopidy.exceptions import MpdAckError -from mopidy.models import Track, Playlist -from mopidy.mpd import handler - -class DummySession(object): - def do_close(self): - pass - - def do_kill(self): - pass - - def stats_uptime(self): - return 0 - - -class RequestHandlerTest(unittest.TestCase): - def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) - - def test_register_same_pattern_twice_fails(self): - func = lambda: None - try: - handler.register('a pattern')(func) - handler.register('a pattern')(func) - self.fail('Registering a pattern twice shoulde raise ValueError') - except ValueError: - pass - - def test_handling_unknown_request_raises_exception(self): - try: - result = self.h.handle_request('an unhandled request') - self.fail(u'An unknown request should raise an exception') - except MpdAckError: - pass - - def test_handling_known_request(self): - expected = 'magic' - handler._request_handlers['known request'] = lambda x: expected - result = self.h.handle_request('known request') - self.assert_(u'OK' in result) - self.assert_(expected in result) - -class CommandListsTest(unittest.TestCase): - def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) - - def test_command_list_begin(self): - result = self.h.handle_request(u'command_list_begin') - self.assert_(result is None) - - def test_command_list_end(self): - self.h.handle_request(u'command_list_begin') - result = self.h.handle_request(u'command_list_end') - self.assert_(u'OK' in result) - - def test_command_list_with_ping(self): - self.h.handle_request(u'command_list_begin') - self.assertEquals([], self.h.command_list) - self.assertEquals(False, self.h.command_list_ok) - self.h.handle_request(u'ping') - self.assert_(u'ping' in self.h.command_list) - result = self.h.handle_request(u'command_list_end') - self.assert_(u'OK' in result) - self.assertEquals(False, self.h.command_list) - - def test_command_list_ok_begin(self): - result = self.h.handle_request(u'command_list_ok_begin') - self.assert_(result is None) - - def test_command_list_ok_with_ping(self): - self.h.handle_request(u'command_list_ok_begin') - self.assertEquals([], self.h.command_list) - self.assertEquals(True, self.h.command_list_ok) - self.h.handle_request(u'ping') - self.assert_(u'ping' in self.h.command_list) - result = self.h.handle_request(u'command_list_end') - self.assert_(u'list_OK' in result) - self.assert_(u'OK' in result) - self.assertEquals(False, self.h.command_list) - self.assertEquals(False, self.h.command_list_ok) - - -class StatusHandlerTest(unittest.TestCase): - def setUp(self): - self.b = DummyBackend() - self.s = DummySession() - self.h = handler.MpdHandler(backend=self.b, session=self.s) - - def test_clearerror(self): - result = self.h.handle_request(u'clearerror') - self.assert_(u'ACK Not implemented' in result) - - def test_currentsong(self): - result = self.h.handle_request(u'currentsong') - self.assert_(u'OK' in result) - - def test_idle_without_subsystems(self): - result = self.h.handle_request(u'idle') - self.assert_(u'ACK Not implemented' in result) - - def test_idle_with_subsystems(self): - result = self.h.handle_request(u'idle database playlist') - self.assert_(u'ACK Not implemented' in result) - - def test_stats_command(self): - result = self.h.handle_request(u'stats') - self.assert_(u'OK' in result) - - def test_stats_method(self): - result = self.h._stats() - self.assert_('artists' in result) - self.assert_(int(result['artists']) >= 0) - self.assert_('albums' in result) - self.assert_(int(result['albums']) >= 0) - self.assert_('songs' in result) - self.assert_(int(result['songs']) >= 0) - self.assert_('uptime' in result) - self.assert_(int(result['uptime']) >= 0) - self.assert_('db_playtime' in result) - self.assert_(int(result['db_playtime']) >= 0) - self.assert_('db_update' in result) - self.assert_(int(result['db_update']) >= 0) - self.assert_('playtime' in result) - self.assert_(int(result['playtime']) >= 0) - - def test_status_command(self): - result = self.h.handle_request(u'status') - self.assert_(u'OK' in result) - - def test_status_method(self): - result = dict(self.h._status()) - self.assert_('volume' in result) - self.assert_(int(result['volume']) in xrange(0, 101)) - self.assert_('repeat' in result) - self.assert_(int(result['repeat']) in (0, 1)) - self.assert_('random' in result) - self.assert_(int(result['random']) in (0, 1)) - self.assert_('single' in result) - self.assert_(int(result['single']) in (0, 1)) - self.assert_('consume' in result) - self.assert_(int(result['consume']) in (0, 1)) - self.assert_('playlist' in result) - self.assert_(int(result['playlist']) in xrange(0, 2**31)) - self.assert_('playlistlength' in result) - self.assert_(int(result['playlistlength']) >= 0) - self.assert_('xfade' in result) - self.assert_(int(result['xfade']) >= 0) - self.assert_('state' in result) - self.assert_(result['state'] in ('play', 'stop', 'pause')) - - def test_status_method_when_playlist_loaded(self): - self.b._current_playlist = Playlist(tracks=[Track()]) - result = dict(self.h._status()) - self.assert_('song' in result) - self.assert_(int(result['song']) >= 0) - self.assert_('songid' in result) - self.assert_(int(result['songid']) >= 0) - - def test_status_method_when_playing(self): - self.b.state = self.b.PLAY - result = dict(self.h._status()) - self.assert_('time' in result) - (position, total) = result['time'].split(':') - position = int(position) - total = int(total) - self.assert_(position <= total) - - -class PlaybackOptionsHandlerTest(unittest.TestCase): - def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) - - def test_consume_off(self): - result = self.h.handle_request(u'consume "0"') - self.assert_(u'ACK Not implemented' in result) - - def test_consume_on(self): - result = self.h.handle_request(u'consume "1"') - self.assert_(u'ACK Not implemented' in result) - - def test_crossfade(self): - result = self.h.handle_request(u'crossfade "10"') - self.assert_(u'ACK Not implemented' in result) - - def test_random_off(self): - result = self.h.handle_request(u'random "0"') - self.assert_(u'ACK Not implemented' in result) - - def test_random_on(self): - result = self.h.handle_request(u'random "1"') - self.assert_(u'ACK Not implemented' in result) - - def test_repeat_off(self): - result = self.h.handle_request(u'repeat "0"') - self.assert_(u'ACK Not implemented' in result) - - def test_repeat_on(self): - result = self.h.handle_request(u'repeat "1"') - self.assert_(u'ACK Not implemented' in result) - - def test_setvol_below_min(self): - result = self.h.handle_request(u'setvol "-10"') - self.assert_(u'ACK Not implemented' in result) - - def test_setvol_min(self): - result = self.h.handle_request(u'setvol "0"') - self.assert_(u'ACK Not implemented' in result) - - def test_setvol_middle(self): - result = self.h.handle_request(u'setvol "50"') - self.assert_(u'ACK Not implemented' in result) - - def test_setvol_max(self): - result = self.h.handle_request(u'setvol "100"') - self.assert_(u'ACK Not implemented' in result) - - def test_setvol_above_max(self): - result = self.h.handle_request(u'setvol "110"') - self.assert_(u'ACK Not implemented' in result) - - def test_single_off(self): - result = self.h.handle_request(u'single "0"') - self.assert_(u'ACK Not implemented' in result) - - def test_single_on(self): - result = self.h.handle_request(u'single "1"') - self.assert_(u'ACK Not implemented' in result) - - def test_replay_gain_mode_off(self): - result = self.h.handle_request(u'replay_gain_mode "off"') - self.assert_(u'ACK Not implemented' in result) - - def test_replay_gain_mode_track(self): - result = self.h.handle_request(u'replay_gain_mode "track"') - self.assert_(u'ACK Not implemented' in result) - - def test_replay_gain_mode_album(self): - result = self.h.handle_request(u'replay_gain_mode "album"') - self.assert_(u'ACK Not implemented' in result) - - def test_replay_gain_status_default(self): - expected = u'off' - result = self.h.handle_request(u'replay_gain_status') - self.assert_(u'OK' in result) - self.assert_(expected in result) - - #def test_replay_gain_status_off(self): - # expected = u'off' - # self.h._replay_gain_mode(expected) - # result = self.h.handle_request(u'replay_gain_status') - # self.assert_(u'OK' in result) - # self.assert_(expected in result) - - #def test_replay_gain_status_track(self): - # expected = u'track' - # self.h._replay_gain_mode(expected) - # result = self.h.handle_request(u'replay_gain_status') - # self.assert_(u'OK' in result) - # self.assert_(expected in result) - - #def test_replay_gain_status_album(self): - # expected = u'album' - # self.h._replay_gain_mode(expected) - # result = self.h.handle_request(u'replay_gain_status') - # self.assert_(u'OK' in result) - # self.assert_(expected in result) - - -class PlaybackControlHandlerTest(unittest.TestCase): - def setUp(self): - self.b = DummyBackend() - self.h = handler.MpdHandler(backend=self.b) - - def test_next(self): - result = self.h.handle_request(u'next') - self.assert_(u'OK' in result) - - def test_pause_off(self): - self.h.handle_request(u'play') - self.h.handle_request(u'pause "1"') - result = self.h.handle_request(u'pause "0"') - self.assert_(u'OK' in result) - self.assertEquals(self.b.PLAY, self.b.state) - - def test_pause_on(self): - self.h.handle_request(u'play') - result = self.h.handle_request(u'pause "1"') - self.assert_(u'OK' in result) - self.assertEquals(self.b.PAUSE, self.b.state) - - def test_play_without_pos(self): - result = self.h.handle_request(u'play') - self.assert_(u'OK' in result) - self.assertEquals(self.b.PLAY, self.b.state) - - def test_play_with_pos(self): - result = self.h.handle_request(u'play "0"') - self.assert_(u'OK' in result) - self.assertEquals(self.b.PLAY, self.b.state) - - def test_playid(self): - result = self.h.handle_request(u'playid "0"') - self.assert_(u'OK' in result) - self.assertEquals(self.b.PLAY, self.b.state) - - def test_previous(self): - result = self.h.handle_request(u'previous') - self.assert_(u'OK' in result) - - def test_seek(self): - result = self.h.handle_request(u'seek "0" "30"') - self.assert_(u'ACK Not implemented' in result) - - def test_seekid(self): - result = self.h.handle_request(u'seekid "0" "30"') - self.assert_(u'ACK Not implemented' in result) - - def test_stop(self): - result = self.h.handle_request(u'stop') - self.assert_(u'OK' in result) - self.assertEquals(self.b.STOP, self.b.state) - - -class CurrentPlaylistHandlerTest(unittest.TestCase): - def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) - - def test_add(self): - result = self.h.handle_request(u'add "file:///dev/urandom"') - self.assert_(u'ACK Not implemented' in result) - - def test_addid_without_songpos(self): - result = self.h.handle_request(u'addid "file:///dev/urandom"') - self.assert_(u'ACK Not implemented' in result) - - def test_addid_with_songpos(self): - result = self.h.handle_request(u'addid "file:///dev/urandom" 0') - self.assert_(u'ACK Not implemented' in result) - - def test_clear(self): - result = self.h.handle_request(u'clear') - self.assert_(u'ACK Not implemented' in result) - - def test_delete_songpos(self): - result = self.h.handle_request(u'delete "5"') - self.assert_(u'ACK Not implemented' in result) - - def test_delete_open_range(self): - result = self.h.handle_request(u'delete "10:"') - self.assert_(u'ACK Not implemented' in result) - - def test_delete_closed_range(self): - result = self.h.handle_request(u'delete "10:20"') - self.assert_(u'ACK Not implemented' in result) - - def test_deleteid(self): - result = self.h.handle_request(u'deleteid "0"') - self.assert_(u'ACK Not implemented' in result) - - def test_move_songpos(self): - result = self.h.handle_request(u'move "5" "0"') - self.assert_(u'ACK Not implemented' in result) - - def test_move_open_range(self): - result = self.h.handle_request(u'move "10:" "0"') - self.assert_(u'ACK Not implemented' in result) - - def test_move_closed_range(self): - result = self.h.handle_request(u'move "10:20" "0"') - self.assert_(u'ACK Not implemented' in result) - - def test_moveid(self): - result = self.h.handle_request(u'moveid "0" "10"') - self.assert_(u'ACK Not implemented' in result) - - def test_playlist_returns_same_as_playlistinfo(self): - playlist_result = self.h.handle_request(u'playlist') - playlistinfo_result = self.h.handle_request(u'playlistinfo') - self.assertEquals(playlist_result, playlistinfo_result) - - def test_playlistfind(self): - result = self.h.handle_request(u'playlistfind "tag" "needle"') - self.assert_(u'ACK Not implemented' in result) - - def test_playlistid_without_songid(self): - result = self.h.handle_request(u'playlistid') - self.assert_(u'OK' in result) - - def test_playlistid_with_songid(self): - result = self.h.handle_request(u'playlistid "10"') - self.assert_(u'OK' in result) - - def test_playlistinfo_without_songpos_or_range(self): - result = self.h.handle_request(u'playlistinfo') - self.assert_(u'OK' in result) - - def test_playlistinfo_with_songpos(self): - result = self.h.handle_request(u'playlistinfo "5"') - self.assert_(u'OK' in result) - - def test_playlistinfo_with_open_range(self): - result = self.h.handle_request(u'playlistinfo "10:"') - self.assert_(u'OK' in result) - - def test_playlistinfo_with_closed_range(self): - result = self.h.handle_request(u'playlistinfo "10:20"') - self.assert_(u'OK' in result) - - def test_playlistsearch(self): - result = self.h.handle_request(u'playlistsearch "tag" "needle"') - self.assert_(u'ACK Not implemented' in result) - - def test_plchanges(self): - result = self.h.handle_request(u'plchanges "0"') - self.assert_(u'OK' in result) - - def test_plchangesposid(self): - result = self.h.handle_request(u'plchangesposid "0"') - self.assert_(u'ACK Not implemented' in result) - - def test_shuffle_without_range(self): - result = self.h.handle_request(u'shuffle') - self.assert_(u'ACK Not implemented' in result) - - def test_shuffle_with_open_range(self): - result = self.h.handle_request(u'shuffle "10:"') - self.assert_(u'ACK Not implemented' in result) - - def test_shuffle_with_closed_range(self): - result = self.h.handle_request(u'shuffle "10:20"') - self.assert_(u'ACK Not implemented' in result) - - def test_swap(self): - result = self.h.handle_request(u'swap "10" "20"') - self.assert_(u'ACK Not implemented' in result) - - def test_swapid(self): - result = self.h.handle_request(u'swapid "10" "20"') - self.assert_(u'ACK Not implemented' in result) - - -class StoredPlaylistsHandlerTest(unittest.TestCase): - def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) - - def test_listplaylist(self): - result = self.h.handle_request(u'listplaylist "name"') - self.assert_(u'ACK Not implemented' in result) - - def test_listplaylistinfo(self): - result = self.h.handle_request(u'listplaylistinfo "name"') - self.assert_(u'ACK Not implemented' in result) - - def test_listplaylists(self): - result = self.h.handle_request(u'listplaylists') - self.assert_(u'OK' in result) - - def test_load(self): - result = self.h.handle_request(u'load "name"') - self.assert_(u'OK' in result) - - def test_playlistadd(self): - result = self.h.handle_request( - u'playlistadd "name" "file:///dev/urandom"') - self.assert_(u'ACK Not implemented' in result) - - def test_playlistclear(self): - result = self.h.handle_request(u'playlistclear "name"') - self.assert_(u'ACK Not implemented' in result) - - def test_playlistdelete(self): - result = self.h.handle_request(u'playlistdelete "name" "5"') - self.assert_(u'ACK Not implemented' in result) - - def test_playlistmove(self): - result = self.h.handle_request(u'playlistmove "name" "5" "10"') - self.assert_(u'ACK Not implemented' in result) - - def test_rename(self): - result = self.h.handle_request(u'rename "old_name" "new_name"') - self.assert_(u'ACK Not implemented' in result) - - def test_rm(self): - result = self.h.handle_request(u'rm "name"') - self.assert_(u'ACK Not implemented' in result) - - def test_save(self): - result = self.h.handle_request(u'save "name"') - self.assert_(u'ACK Not implemented' in result) - - -class MusicDatabaseHandlerTest(unittest.TestCase): - def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) - - def test_count(self): - result = self.h.handle_request(u'count "tag" "needle"') - self.assert_(u'ACK Not implemented' in result) - - def test_find_album(self): - result = self.h.handle_request(u'find "album" "what"') - self.assert_(u'ACK Not implemented' in result) - - def test_find_artist(self): - result = self.h.handle_request(u'find "artist" "what"') - self.assert_(u'ACK Not implemented' in result) - - def test_find_title(self): - result = self.h.handle_request(u'find "title" "what"') - self.assert_(u'ACK Not implemented' in result) - - def test_find_else_should_fail(self): - try: - result = self.h.handle_request(u'find "somethingelse" "what"') - self.fail('Find with unknown type should fail') - except MpdAckError: - pass - - def test_findadd(self): - result = self.h.handle_request(u'findadd "album" "what"') - self.assert_(u'ACK Not implemented' in result) - - def test_list_artist(self): - result = self.h.handle_request(u'list "artist"') - self.assert_(u'ACK Not implemented' in result) - - def test_list_artist_with_artist_should_fail(self): - try: - result = self.h.handle_request(u'list "artist" "anartist"') - self.fail(u'Listing artists filtered by an artist should fail') - except MpdAckError: - pass - - def test_list_album_without_artist(self): - result = self.h.handle_request(u'list "album"') - self.assert_(u'ACK Not implemented' in result) - - def test_list_album_with_artist(self): - result = self.h.handle_request(u'list "album" "anartist"') - self.assert_(u'ACK Not implemented' in result) - - def test_listall(self): - result = self.h.handle_request(u'listall "file:///dev/urandom"') - self.assert_(u'ACK Not implemented' in result) - - def test_listallinfo(self): - result = self.h.handle_request(u'listallinfo "file:///dev/urandom"') - self.assert_(u'ACK Not implemented' in result) - - def test_lsinfo_without_path_returns_same_as_listplaylists(self): - lsinfo_result = self.h.handle_request(u'lsinfo') - listplaylists_result = self.h.handle_request(u'listplaylists') - self.assertEquals(lsinfo_result, listplaylists_result) - - def test_lsinfo_with_path(self): - result = self.h.handle_request(u'lsinfo ""') - self.assert_(u'ACK Not implemented' in result) - - def test_lsinfo_for_root_returns_same_as_listplaylists(self): - lsinfo_result = self.h.handle_request(u'lsinfo "/"') - listplaylists_result = self.h.handle_request(u'listplaylists') - self.assertEquals(lsinfo_result, listplaylists_result) - - def test_search_album(self): - result = self.h.handle_request(u'search "album" "analbum"') - self.assert_(u'OK' in result) - - def test_search_artist(self): - result = self.h.handle_request(u'search "artist" "anartist"') - self.assert_(u'OK' in result) - - def test_search_filename(self): - result = self.h.handle_request(u'search "filename" "afilename"') - self.assert_(u'OK' in result) - - def test_search_title(self): - result = self.h.handle_request(u'search "title" "atitle"') - self.assert_(u'OK' in result) - - def test_search_else_should_fail(self): - try: - result = self.h.handle_request(u'search "sometype" "something"') - self.fail(u'Search with unknown type should fail') - except MpdAckError: - pass - - def test_update_without_uri(self): - result = self.h.handle_request(u'update') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - - def test_update_with_uri(self): - result = self.h.handle_request(u'update "file:///dev/urandom"') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - - def test_rescan_without_uri(self): - result = self.h.handle_request(u'rescan') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - - def test_rescan_with_uri(self): - result = self.h.handle_request(u'rescan "file:///dev/urandom"') - self.assert_(u'OK' in result) - self.assert_(u'updating_db: 0' in result) - - -class StickersHandlerTest(unittest.TestCase): - def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) - - pass # TODO - - -class ConnectionHandlerTest(unittest.TestCase): - def setUp(self): - self.h = handler.MpdHandler(session=DummySession(), - backend=DummyBackend()) - - def test_close(self): - result = self.h.handle_request(u'close') - self.assert_(u'OK' in result) - - def test_empty_request(self): - result = self.h.handle_request(u'') - self.assert_(u'OK' in result) - - def test_kill(self): - result = self.h.handle_request(u'kill') - self.assert_(u'OK' in result) - - def test_password(self): - result = self.h.handle_request(u'password "secret"') - self.assert_(u'ACK Not implemented' in result) - - def test_ping(self): - result = self.h.handle_request(u'ping') - self.assert_(u'OK' in result) - -class AudioOutputHandlerTest(unittest.TestCase): - def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) - - pass # TODO - - -class ReflectionHandlerTest(unittest.TestCase): - def setUp(self): - self.h = handler.MpdHandler(backend=DummyBackend()) - - def test_urlhandlers(self): - result = self.h.handle_request(u'urlhandlers') - self.assert_(u'OK' in result) - result = result[0] - self.assert_('dummy:' in result) - - pass # TODO diff --git a/tests/version_test.py b/tests/version_test.py new file mode 100644 index 00000000..4a9b948d --- /dev/null +++ b/tests/version_test.py @@ -0,0 +1,15 @@ +from distutils.version import StrictVersion as SV +import unittest + +from mopidy import get_version + +class VersionTest(unittest.TestCase): + def test_current_version_is_parsable_as_a_strict_version_number(self): + SV(get_version()) + + def test_versions_can_be_strictly_ordered(self): + self.assert_(SV(get_version()) < SV('0.1.0a1')) + self.assert_(SV('0.1.0a1') < SV('0.1.0')) + self.assert_(SV('0.1.0') < SV('0.1.1')) + self.assert_(SV('0.1.1') < SV('0.2.0')) + self.assert_(SV('0.2.0') < SV('1.0.0'))