diff --git a/.travis.yml b/.travis.yml index a57f7474..bbba0a94 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,3 +10,12 @@ before_script: - "rm $VIRTUAL_ENV/lib/python$TRAVIS_PYTHON_VERSION/no-global-site-packages.txt" script: nosetests + +notifications: + irc: + channels: + - "irc.freenode.org#mopidy" + on_success: change + on_failure: change + use_notice: true + skip_join: true diff --git a/README.rst b/README.rst index 0b0f6965..c7eea228 100644 --- a/README.rst +++ b/README.rst @@ -4,17 +4,22 @@ Mopidy .. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop -Mopidy is a music server which can play music from `Spotify -`_ or from your local hard drive. To search for music -in Spotify's vast archive, manage playlists, and play music, you can use most -`MPD clients `_. MPD clients are available for most +Mopidy is a music server which can play music both from your local hard drive +and from Spotify. Searches returns results from both your local hard drive and +from Spotify, and you can mix tracks from both sources in your play queue. Your +Spotify playlists are also available for use, though we don't support modifying +them yet. + +To control your music server, you can use the Ubuntu Sound Menu on the machine +running Mopidy, any device on the same network which can control UPnP +MediaRenderers, or any MPD client. MPD clients are available for most platforms, including Windows, Mac OS X, Linux, Android and iOS. -To install Mopidy, check out -`the installation docs `_. +To get started with Mopidy, check out `the docs `_. - `Documentation `_ - `Source code `_ - `Issue tracker `_ +- `CI server `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ - `Download development snapshot `_ diff --git a/docs/_static/mpd-client-gmpc.png b/docs/_static/mpd-client-gmpc.png new file mode 100644 index 00000000..aa85c273 Binary files /dev/null and b/docs/_static/mpd-client-gmpc.png differ diff --git a/docs/_static/mpd-client-mpad.jpg b/docs/_static/mpd-client-mpad.jpg new file mode 100644 index 00000000..6a3357dc Binary files /dev/null and b/docs/_static/mpd-client-mpad.jpg differ diff --git a/docs/_static/mpd-client-mpdroid.jpg b/docs/_static/mpd-client-mpdroid.jpg new file mode 100644 index 00000000..cd8bfc62 Binary files /dev/null and b/docs/_static/mpd-client-mpdroid.jpg differ diff --git a/docs/_static/mpd-client-mpod.jpg b/docs/_static/mpd-client-mpod.jpg new file mode 100644 index 00000000..a1704464 Binary files /dev/null and b/docs/_static/mpd-client-mpod.jpg differ diff --git a/docs/_static/mpd-client-ncmpcpp.png b/docs/_static/mpd-client-ncmpcpp.png new file mode 100644 index 00000000..975639c6 Binary files /dev/null and b/docs/_static/mpd-client-ncmpcpp.png differ diff --git a/docs/_static/mpd-client-sonata.png b/docs/_static/mpd-client-sonata.png new file mode 100644 index 00000000..5fb02049 Binary files /dev/null and b/docs/_static/mpd-client-sonata.png differ diff --git a/docs/_static/raspberry-pi-by-jwrodgers.jpg b/docs/_static/raspberry-pi-by-jwrodgers.jpg new file mode 100644 index 00000000..d093bb88 Binary files /dev/null and b/docs/_static/raspberry-pi-by-jwrodgers.jpg differ diff --git a/docs/_static/ubuntu-sound-menu.png b/docs/_static/ubuntu-sound-menu.png new file mode 100644 index 00000000..9362f6f4 Binary files /dev/null and b/docs/_static/ubuntu-sound-menu.png differ diff --git a/docs/api/audio.rst b/docs/api/audio.rst index d5fb5dd9..2b9f6cc5 100644 --- a/docs/api/audio.rst +++ b/docs/api/audio.rst @@ -4,16 +4,27 @@ Audio API ********* +.. module:: mopidy.audio + :synopsis: Thin wrapper around the parts of GStreamer we use + + The audio API is the interface we have built around GStreamer to support our specific use cases. Most backends should be able to get by with simply setting the URI of the resource they want to play, for these cases the default playback provider should be used. For more advanced cases such as when the raw audio data is delivered outside of -GStreamer or the backend needs to add metadata to the currently playing resource, -developers should sub-class the base playback provider and implement the extra -behaviour that is needed through the following API: +GStreamer or the backend needs to add metadata to the currently playing +resource, developers should sub-class the base playback provider and implement +the extra behaviour that is needed through the following API: .. autoclass:: mopidy.audio.Audio :members: + + +Audio listener +============== + +.. autoclass:: mopidy.audio.AudioListener + :members: diff --git a/docs/api/backends.rst b/docs/api/backends.rst index 781723d6..c296fb78 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -4,6 +4,9 @@ Backend API *********** +.. module:: mopidy.backends.base + :synopsis: The API implemented by backends + The backend API is the interface that must be implemented when you create a backend. If you are working on a frontend and need to access the backend, see the :ref:`core-api`. @@ -30,6 +33,8 @@ Library provider :members: +.. _backend-implementations: + Backend implementations ======================= diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index ae959237..203418de 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -1,29 +1,99 @@ .. _concepts: -********************************************** -The backend, controller, and provider concepts -********************************************** +************************* +Architecture and concepts +************************* -Backend: - The backend is mostly for convenience. It is a container that holds - references to all the controllers. -Controllers: - Each controller has responsibility for a given part of the backend - functionality. Most, but not all, controllers delegates some work to one or - more providers. The controllers are responsible for choosing the right - provider for any given task based upon i.e. the track's URI. See - :ref:`core-api` for more details. -Providers: - Anything specific to i.e. Spotify integration or local storage is contained - in the providers. To integrate with new music sources, you just add new - providers. See :ref:`backend-api` for more details. +The overall architecture of Mopidy is organized around multiple frontends and +backends. The frontends use the core API. The core actor makes multiple backends +work as one. The backends connect to various music sources. Both the core actor +and the backends use the audio actor to play audio and control audio volume. -.. digraph:: backend_relations +.. digraph:: overall_architecture - Backend -> "Current\nplaylist\ncontroller" - Backend -> "Library\ncontroller" - "Library\ncontroller" -> "Library\nproviders" - Backend -> "Playback\ncontroller" - "Playback\ncontroller" -> "Playback\nproviders" - Backend -> "Stored\nplaylists\ncontroller" - "Stored\nplaylists\ncontroller" -> "Stored\nplaylist\nproviders" + "Multiple frontends" -> Core + Core -> "Multiple backends" + Core -> Audio + "Multiple backends" -> Audio + + +Frontends +========= + +Frontends expose Mopidy to the external world. They can implement servers for +protocols like MPD and MPRIS, and they can be used to update other services +when something happens in Mopidy, like the Last.fm scrobbler frontend does. See +:ref:`frontend-api` for more details. + +.. digraph:: frontend_architecture + + "MPD\nfrontend" -> Core + "MPRIS\nfrontend" -> Core + "Last.fm\nfrontend" -> Core + + +Core +==== + +The core is organized as a set of controllers with responsiblity for separate +sets of functionality. + +The core is the single actor that the frontends send their requests to. For +every request from a frontend it calls out to one or more backends which does +the real work, and when the backends respond, the core actor is responsible for +combining the responses into a single response to the requesting frontend. + +The core actor also keeps track of the current playlist, since it doesn't +belong to a specific backend. + +See :ref:`core-api` for more details. + +.. digraph:: core_architecture + + Core -> "Current\nplaylist\ncontroller" + Core -> "Library\ncontroller" + Core -> "Playback\ncontroller" + Core -> "Stored\nplaylists\ncontroller" + + "Library\ncontroller" -> "Local backend" + "Library\ncontroller" -> "Spotify backend" + + "Playback\ncontroller" -> "Local backend" + "Playback\ncontroller" -> "Spotify backend" + "Playback\ncontroller" -> Audio + + "Stored\nplaylists\ncontroller" -> "Local backend" + "Stored\nplaylists\ncontroller" -> "Spotify backend" + + +Backends +======== + +The backends are organized as a set of providers with responsiblity for +separate sets of functionality, similar to the core actor. + +Anything specific to i.e. Spotify integration or local storage is contained in +the backends. To integrate with new music sources, you just add a new backend. +See :ref:`backend-api` for more details. + +.. digraph:: backend_architecture + + "Local backend" -> "Local\nlibrary\nprovider" -> "Local disk" + "Local backend" -> "Local\nplayback\nprovider" -> "Local disk" + "Local backend" -> "Local\nstored\nplaylists\nprovider" -> "Local disk" + "Local\nplayback\nprovider" -> Audio + + "Spotify backend" -> "Spotify\nlibrary\nprovider" -> "Spotify service" + "Spotify backend" -> "Spotify\nplayback\nprovider" -> "Spotify service" + "Spotify backend" -> "Spotify\nstored\nplaylists\nprovider" -> "Spotify service" + "Spotify\nplayback\nprovider" -> Audio + + +Audio +===== + +The audio actor is a thin wrapper around the parts of the GStreamer library we +use. In addition to playback, it's responsible for volume control through both +GStreamer's own volume mixers, and mixers we've created ourselves. If you +implement an advanced backend, you may need to implement your own playback +provider using the :ref:`audio-api`. diff --git a/docs/api/core.rst b/docs/api/core.rst index e74d9f45..eb1b9683 100644 --- a/docs/api/core.rst +++ b/docs/api/core.rst @@ -4,6 +4,9 @@ Core API ******** +.. module:: mopidy.core + :synopsis: Core API for use by frontends + The core API is the interface that is used by frontends like :mod:`mopidy.frontends.mpd`. The core layer is inbetween the frontends and the @@ -48,3 +51,10 @@ Manages the music library, e.g. searching for tracks to be added to a playlist. .. autoclass:: mopidy.core.LibraryController :members: + + +Core listener +============= + +.. autoclass:: mopidy.core.CoreListener + :members: diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index af0cc991..2237b4e7 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -1,3 +1,5 @@ +.. _frontend-api: + ************ Frontend API ************ @@ -6,22 +8,38 @@ The following requirements applies to any frontend implementation: - A frontend MAY do mostly whatever it wants to, including creating threads, opening TCP ports and exposing Mopidy for a group of clients. + - A frontend MUST implement at least one `Pykka `_ actor, called the "main actor" from here on. + +- The main actor MUST accept a constructor argument ``core``, which will be an + :class:`ActorProxy ` for the core actor. This object + gives access to the full :ref:`core-api`. + - It MAY use additional actors to implement whatever it does, and using actors in frontend implementations is encouraged. + - The frontend is activated by including its main actor in the :attr:`mopidy.settings.FRONTENDS` setting. + - The main actor MUST be able to start and stop the frontend when the main actor is started and stopped. + - The frontend MAY require additional settings to be set for it to work. + - Such settings MUST be documented. + - The main actor MUST stop itself if the defined settings are not adequate for the frontend to work properly. -- Any actor which is part of the frontend MAY implement any listener interface - from :mod:`mopidy.listeners` to receive notification of the specified events. + +- Any actor which is part of the frontend MAY implement the + :class:`mopidy.core.CoreListener` interface to receive notification of the + specified events. + + +.. _frontend-implementations: Frontend implementations ======================== diff --git a/docs/api/index.rst b/docs/api/index.rst index 618096ee..5a210812 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -11,4 +11,3 @@ API reference core audio frontends - listeners diff --git a/docs/api/listeners.rst b/docs/api/listeners.rst deleted file mode 100644 index 609dc3c7..00000000 --- a/docs/api/listeners.rst +++ /dev/null @@ -1,7 +0,0 @@ -************ -Listener API -************ - -.. automodule:: mopidy.listeners - :synopsis: Listener API - :members: diff --git a/docs/changes.rst b/docs/changes.rst index 43b930b8..a01eb1c7 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -4,8 +4,127 @@ Changes This change log is used to track all major changes to Mopidy. -v0.8 (in development) -===================== + +v0.9.0 (in development) +======================= + +**Multiple backends support** + +Support for using the local and Spotify backends simultaneously have for a very +long time been our most requested feature. Finally, it's here! + +- Both the local backend and the Spotify backend are now turned on by default. + The local backend is listed first in the :attr:`mopidy.settings.BACKENDS` + setting, and are thus given the highest priority in e.g. search results, + meaning that we're listing search hits from the local backend first. If you + want to prioritize the backends in another way, simply set ``BACKENDS`` in + your own settings file and reorder the backends. + + There are no other setting changes related to the local and Spotify backends. + As always, see :mod:`mopidy.settings` for the full list of available + settings. + +Internally, Mopidy have seen a lot of changes to pave the way for multiple +backends: + +- A new layer and actor, "core", has been added to our stack, inbetween the + frontends and the backends. The responsibility of the core layer and actor is + to take requests from the frontends, pass them on to one or more backends, + and combining the response from the backends into a single response to the + requesting frontend. + + Frontends no longer know anything about the backends. They just use the + :ref:`core-api`. + +- The base playback provider has been updated with sane default behavior + instead of empty functions. By default, the playback provider now lets + GStreamer keep track of the current track's time position. The local backend + simply uses the base playback provider without any changes. The same applies + to any future backend that just needs GStreamer to play an URI for it. + +- The dependency graph between the core controllers and the backend providers + have been straightened out, so that we don't have any circular dependencies. + The frontend, core, backend, and audio layers are now strictly separate. The + frontend layer calls on the core layer, and the core layer calls on the + backend layer. Both the core layer and the backends are allowed to call on + the audio layer. Any data flow in the opposite direction is done by + broadcasting of events to listeners, through e.g. + :class:`mopidy.core.CoreListener` and :class:`mopidy.audio.AudioListener`. + +- All dependencies are now explicitly passed to the constructors of the + frontends, core, and the backends. This makes testing each layer with + dummy/mocked lower layers easier than with the old variant, where + dependencies where looked up in Pykka's actor registry. + +- The stored playlists part of the core API has been revised to be more focused + around the playlist URI, and some redundant functionality has been removed: + + - :attr:`mopidy.core.StoredPlaylistsController.playlists` no longer supports + assignment to it. The `playlists` property on the backend layer still does, + and all functionality is maintained by assigning to the playlists + collections at the backend level. + + - :meth:`mopidy.core.StoredPlaylistsController.delete` now accepts an URI, + and not a playlist object. + + - :meth:`mopidy.core.StoredPlaylistsController.save` now returns the saved + playlist. The returned playlist may differ from the saved playlist, and + should thus be used instead of the playlist passed to ``save()``. + + - :meth:`mopidy.core.StoredPlaylistsController.rename` has been removed, + since renaming can be done with ``save()``. + +**Changes** + +- Made the :mod:`NAD mixer ` responsive to interrupts + during amplifier calibration. It will now quit immediately, while previously + it completed the calibration first, and then quit, which could take more than + 15 seconds. + +- Added :attr:`mopidy.models.Album.date` attribute. It has the same format as + the existing :attr:`mopidy.models.Track.date`. + +- The Spotify backend now includes release year and artist on albums. + +- Added support for search by filename to local backend. + +**Bug fixes** + +- :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now + accepts unquotes playlist names if they don't contain spaces. + +- The MPD command ``plchanges`` always returned the entire playlist. It now + returns an empty response when the client has seen the latest version. + + +v0.8.1 (2012-10-30) +=================== + +A small maintenance release to fix a bug introduced in 0.8.0 and update Mopidy +to work with Pykka 1.0. + +**Dependencies** + +- Pykka >= 1.0 is now required. + +**Bug fixes** + +- :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors + observed by some users on some Spotify tracks due to a change introduced in + 0.8.0. See the issue for a patch that applies to 0.8.0. + +- :issue:`216`: Volume returned by the MPD command `status` contained a + floating point ``.0`` suffix. This bug was introduced with the large audio + output and mixer changes in v0.8.0 and broke the MPDroid Android client. It + now returns an integer again. + + +v0.8.0 (2012-09-20) +=================== + +This release does not include any major new features. We've done a major +cleanup of how audio outputs and audio mixers work, and on the way we've +resolved a bunch of related issues. **Audio output and mixer changes** @@ -23,7 +142,7 @@ v0.8 (in development) mixer that will work on your system. If this picks the wrong mixer you can of course override it. Setting the mixer to :class:`None` is also supported. MPD protocol support for volume has also been updated to return -1 when we have - no mixer set. + no mixer set. ``software`` can be used to force software mixing. - Removed the Denon hardware mixer, as it is not maintained. @@ -63,23 +182,44 @@ v0.8 (in development) - Support tracks with only release year, and not a full release date, like e.g. Spotify tracks. +- Default value of ``LOCAL_MUSIC_PATH`` has been updated to be + ``$XDG_MUSIC_DIR``, which on most systems this is set to ``$HOME``. Users of + local backend that relied on the old default ``~/music`` need to update their + settings. Note that the code responsible for finding this music now also + ignores UNIX hidden files and folders. + +- File and path settings now support ``$XDG_CACHE_DIR``, ``$XDG_DATA_DIR`` and + ``$XDG_MUSIC_DIR`` substitution. Defaults for such settings have been updated + to use this instead of hidden away defaults. + +- Playback is now done using ``playbin2`` from GStreamer instead of rolling our + own. This is the first step towards resolving :issue:`171`. + **Bug fixes** - :issue:`72`: Created a Spotify track proxy that will switch to using loaded data as soon as it becomes available. -- :issue:`162`: Fixed bug when the MPD command ``playlistinfo`` is used with a - track position. Track position and CPID was intermixed, so it would cause a - crash if a CPID matching the track position didn't exist. - - :issue:`150`: Fix bug which caused some clients to block Mopidy completely. The bug was caused by some clients sending ``close`` and then shutting down the connection right away. This trigged a situation in which the connection cleanup code would wait for an response that would never come inside the event loop, blocking everything else. +- :issue:`162`: Fixed bug when the MPD command ``playlistinfo`` is used with a + track position. Track position and CPID was intermixed, so it would cause a + crash if a CPID matching the track position didn't exist. + - Fixed crash on lookup of unknown path when using local backend. +- :issue:`189`: ``LOCAL_MUSIC_PATH`` and path handling in rest of settings has + been updated so all of the code now uses the correct value. + +- Fixed incorrect track URIs generated by M3U playlist parsing code. Generated + tracks are now relative to ``LOCAL_MUSIC_PATH``. + +- :issue:`203`: Re-add support for software mixing. + v0.7.3 (2012-08-11) =================== @@ -266,7 +406,7 @@ Please note that 0.5.0 requires some updated dependencies, as listed under - If you use the Spotify backend, you *must* upgrade to libspotify 0.0.8 and pyspotify 1.3. If you install from APT, libspotify and pyspotify will automatically be upgraded. If you are not installing from APT, follow the - instructions at :doc:`/installation/libspotify/`. + instructions at :ref:`installation`. - If you have explicitly set the :attr:`mopidy.settings.SPOTIFY_HIGH_BITRATE` setting, you must update your settings file. The new setting is named @@ -407,8 +547,7 @@ loading from Mopidy 0.3.0 is still present. - If you use the Spotify backend, you *should* upgrade to libspotify 0.0.7 and the latest pyspotify from the Mopidy developers. If you install from APT, libspotify and pyspotify will automatically be upgraded. If you are not - installing from APT, follow the instructions at - :doc:`/installation/libspotify/`. + installing from APT, follow the instructions at :ref:`installation`. **Changes** @@ -520,7 +659,7 @@ to this problem. - If you use the Spotify backend, you need to upgrade to libspotify 0.0.6 and the latest pyspotify from the Mopidy developers. Follow the instructions at - :doc:`/installation/libspotify/`. + :ref:`installation`. - If you use the Last.fm frontend, you need to upgrade to pylast 0.5.7. Run ``sudo pip install --upgrade pylast`` or install Mopidy from APT. @@ -547,7 +686,7 @@ to this problem. - Local backend: - Add :command:`mopidy-scan` command to generate ``tag_cache`` files without - any help from the original MPD server. See :ref:`generating_a_tag_cache` + any help from the original MPD server. See :ref:`generating-a-tag-cache` for instructions on how to use it. - Fix support for UTF-8 encoding in tag caches. @@ -556,7 +695,7 @@ to this problem. - Add support for password authentication. See :attr:`mopidy.settings.MPD_SERVER_PASSWORD` and - :ref:`use_mpd_on_a_network` for details on how to use it. (Fixes: + :ref:`use-mpd-on-a-network` for details on how to use it. (Fixes: :issue:`41`) - Support ``setvol 50`` without quotes around the argument. Fixes volume @@ -675,10 +814,10 @@ We've worked a bit on OS X support, but not all issues are completely solved yet. :issue:`25` is the one that is currently blocking OS X support. Any help solving it will be greatly appreciated! -Finally, please :ref:`update your pyspotify installation -` when upgrading to Mopidy 0.2.0. The latest pyspotify -got a fix for the segmentation fault that occurred when playing music and -searching at the same time, thanks to Valentin David. +Finally, please :ref:`update your pyspotify installation ` when +upgrading to Mopidy 0.2.0. The latest pyspotify got a fix for the segmentation +fault that occurred when playing music and searching at the same time, thanks +to Valentin David. **Important changes** @@ -743,12 +882,11 @@ fixing the OS X issues for a future release. You can track the progress at **Important changes** - License changed from GPLv2 to Apache License, version 2.0. -- GStreamer is now a required dependency. See our :doc:`GStreamer installation - docs `. +- GStreamer is now a required dependency. See our :ref:`GStreamer installation + docs `. - :mod:`mopidy.backends.libspotify` is now the default backend. :mod:`mopidy.backends.despotify` is no longer available. This means that you - need to install the :doc:`dependencies for libspotify - `. + need to install the :ref:`dependencies for libspotify `. - If you used :mod:`mopidy.backends.libspotify` previously, pyspotify must be updated when updating to this release, to get working seek functionality. - :attr:`mopidy.settings.SERVER_HOSTNAME` and @@ -1003,7 +1141,7 @@ 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! +`_. Thanks! **Changes** diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index c7dc3799..a8cae367 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -1,92 +1,25 @@ -************************ -MPD client compatability -************************ +.. _mpd-clients: + +*********** +MPD clients +*********** This is a list of MPD clients we either know works well with Mopidy, or that we know won't work well. For a more exhaustive list of MPD clients, see http://mpd.wikia.com/wiki/Clients. - -Console clients -=============== - -mpc ---- - -A command line client. Version 0.14 had some issues with Mopidy (see -:issue:`5`), but 0.16 seems to work nicely. +.. contents:: Contents + :local: -ncmpc ------ +Test procedure +============== -A console client. Works with Mopidy 0.6 and upwards. Uses the ``idle`` MPD -command, but in a resource inefficient way. - - -ncmpcpp -------- - -A console client that generally works well with Mopidy, and is regularly used -by Mopidy developers. - -Search only works in two of the three search modes: - -- "Match if tag contains search phrase (regexes supported)" -- Does not work. - The client tries to fetch all known metadata and do the search client side. -- "Match if tag contains searched phrase (no regexes)" -- Works. -- "Match only if both values are the same" -- Works. - - -Graphical clients -================= - -GMPC ----- - -`GMPC `_ is a graphical MPD client (GTK+) which works -well with Mopidy, and is regularly used by Mopidy developers. - -GMPC may sometimes requests a lot of meta data of related albums, artists, etc. -This takes more time with Mopidy, which needs to query Spotify for the data, -than with a normal MPD server, which has a local cache of meta data. Thus, GMPC -may sometimes feel frozen, but usually you just need to give it a bit of slack -before it will catch up. - - -Sonata ------- - -`Sonata `_ is a graphical MPD client (GTK+). -It generally works well with Mopidy, except for search. - -When you search in Sonata, it only sends the first to letters of the search -query to Mopidy, and then does the rest of the filtering itself on the client -side. Since Spotify has a collection of millions of tracks and they only return -the first 100 hits for any search query, searching for two-letter combinations -seldom returns any useful results. See :issue:`1` and the matching `Sonata -bug`_ for details. - -.. _Sonata bug: http://developer.berlios.de/feature/?func=detailfeature&feature_id=5038&group_id=7323 - - -Theremin --------- - -`Theremin `_ is a graphical MPD client for OS X. -It generally works well with Mopidy. - - -.. _android_mpd_clients: - -Android clients -=============== - -We've tested all four MPD clients we could find for Android with Mopidy 0.7.3 on -a Samsung Galaxy Nexus with Android 4.1.1, using the following test procedure: +In some cases, we've used the following test procedure to compare the feature +completeness of clients: #. Connect to Mopidy -#. Search for ``foo``, with search type "any" if it can be selected +#. Search for "foo", with search type "any" if it can be selected #. Add "The Pretender" from the search results to the current playlist #. Start playback #. Pause and resume playback @@ -107,38 +40,138 @@ a Samsung Galaxy Nexus with Android 4.1.1, using the following test procedure: #. Check if the app got support for single mode and consume mode #. Kill Mopidy and confirm that the app handles it without crashing -We found that all four apps crashed on Android 4.1.1. -Combining what we managed to find before the apps crashed with our experience -from an older version of this review, using Android 2.1, we can say that: -- PMix can be ignored, because it is unmaintained and its fork MPDroid is - better on all fronts. +Console clients +=============== -- Droid MPD Client was to buggy to get an impression from. Unclear if the bugs - are due to the app or that it hasn't been updated for Android 4.x. +ncmpcpp +------- -- BitMPC is in our experience feature complete, but ugly. +A console client that works well with Mopidy, and is regularly used by Mopidy +developers. -- MPDroid, now that search is in place, is probably feature complete as well, - and looks nicer than BitMPC. +.. image:: /_static/mpd-client-ncmpcpp.png + :width: 575 + :height: 426 -In conclusion: MPD clients on Android 4.x is a sad affair. If you want to try -anyway, try BitMPC and MPDroid. +Search does not work in the "Match if tag contains search phrase (regexes +supported)" mode because the client tries to fetch all known metadata and do +the search on the client side. The two other search modes works nicely, so this +is not a problem. + + +ncmpc +----- + +A console client. Works with Mopidy 0.6 and upwards. Uses the ``idle`` MPD +command, but in a resource inefficient way. + + +mpc +--- + +A command line client. Version 0.16 and upwards seems to work nicely with +Mopidy. + + +Graphical clients +================= + +GMPC +---- + +`GMPC `_ is a graphical MPD client (GTK+) which works +well with Mopidy. + +.. image:: /_static/mpd-client-gmpc.png + :width: 1000 + :height: 565 + +GMPC may sometimes requests a lot of meta data of related albums, artists, etc. +This takes more time with Mopidy, which needs to query Spotify for the data, +than with a normal MPD server, which has a local cache of meta data. Thus, GMPC +may sometimes feel frozen, but usually you just need to give it a bit of slack +before it will catch up. + + +Sonata +------ + +`Sonata `_ is a graphical MPD client (GTK+). +It generally works well with Mopidy, except for search. + +.. image:: /_static/mpd-client-sonata.png + :width: 475 + :height: 424 + +When you search in Sonata, it only sends the first to letters of the search +query to Mopidy, and then does the rest of the filtering itself on the client +side. Since Spotify has a collection of millions of tracks and they only return +the first 100 hits for any search query, searching for two-letter combinations +seldom returns any useful results. See :issue:`1` and the closed `Sonata bug`_ +for details. + +.. _Sonata bug: http://developer.berlios.de/feature/?func=detailfeature&feature_id=5038&group_id=7323 + + +Theremin +-------- + +`Theremin `_ is a graphical MPD +client for OS X. It is unmaintained, but generally works well with Mopidy. + + +.. _android_mpd_clients: + +Android clients +=============== + +We've tested all five MPD clients we could find for Android with Mopidy 0.8.1 +on a Samsung Galaxy Nexus with Android 4.1.2, using our standard test +procedure. + + +MPDroid +------- + +Test date: + 2012-11-06 +Tested version: + 1.03.1 (released 2012-10-16) + +.. image:: /_static/mpd-client-mpdroid.jpg + :width: 288 + :height: 512 + +You can get `MPDroid from Google Play +`_. + +- MPDroid started out as a fork of PMix, and is now much better. + +- MPDroid's user interface looks nice. + +- Everything in the test procedure works. + +- In contrast to all other Android clients, MPDroid does support single mode or + consume mode. + +- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to + try to reconnect. + +MPDroid is a good MPD client, and really the only one we can recommend. BitMPC ------ Test date: - 2012-09-12 + 2012-11-06 Tested version: 1.0.0 (released 2010-04-12) -Downloads: - 5,000+ -Rating: - 3.7 stars from about 100 ratings +You can get `BitMPC from Google Play +`_. - The user interface lacks some finishing touches. E.g. you can't enter a hostname for the server. Only IPv4 addresses are allowed. @@ -152,8 +185,8 @@ Rating: - BitMPC crashed if Mopidy was killed or crashed. - When we tried to test using Android 4.1.1, BitMPC started and connected to - Mopidy without problems, but the app crashed as soon as fire off our search, - and continued to crash on startup after that. + Mopidy without problems, but the app crashed as soon as we fired off our + search, and continued to crash on startup after that. In conclusion, BitMPC is usable if you got an older Android phone and don't care about looks. For newer Android versions, BitMPC will probably not work as @@ -164,13 +197,12 @@ Droid MPD Client ---------------- Test date: - 2012-09-12 + 2012-11-06 Tested version: 1.4.0 (released 2011-12-20) -Downloads: - 10,000+ -Rating: - 4.2 stars from 400+ ratings + +You can get `Droid MPD Client from Google Play +`_. - No intutive way to ask the app to connect to the server after adding the server hostname to the settings. @@ -187,11 +219,6 @@ Rating: - Searching for "foo" did nothing. No request was sent to the server. -- Once, I managed to get a list of stored playlists in the "Search" tab, but I - never managed to reproduce this. Opening the stored playlists doesn't work, - because Mopidy haven't implemented ``lsinfo "Playlist name"`` (see - :issue:`193`). - - Droid MPD client does not support single mode or consume mode. - Not able to complete the test procedure, due to the above problems. @@ -199,71 +226,34 @@ Rating: In conclusion, not a client we can recommend. -MPDroid -------- - -Test date: - 2012-09-12 -Tested version: - 0.7 (released 2011-06-19) -Downloads: - 10,000+ -Rating: - 4.5 stars from ~500 ratings - -- MPDroid started out as a fork of PMix. - -- First of all, MPDroid's user interface looks nice. - -- Last time we tested MPDroid (v0.6.9), we couldn't find any search - functionality. Now we found it, and it worked. - -- Last time we tested MPDroid (v0.6.9) everything in the test procedure worked - out flawlessly. - -- Like all other Android clients, MPDroid does not support single mode or - consume mode. - -- When Mopidy is killed, MPDroid handles it gracefully and asks if you want to - try to reconnect. - -- When using Android 4.1.1, MPDroid crashes here and there, e.g. when having an - empty current playlist and pressing play. - -Disregarding Android 4.x problems, MPDroid is a good MPD client. - - PMix ---- Test date: - 2012-09-12 + 2012-11-06 Tested version: 0.4.0 (released 2010-03-06) -Downloads: - 10,000+ -Rating: - 3.8 stars from >200 ratings -- Using Android 4.1.1, PMix, which haven't been updated for 2.5 years, crashes - as soon as it connects to Mopidy. +You can get `PMix from Google Play +`_. -- Last time we tested the same version of PMix using Android 2.1, we found - that: +PMix haven't been updated for 2.5 years, and has less working features than +it's fork MPDroid. Ignore PMix and use MPDroid instead. - - PMix does not support search. - - I could not find stored playlists. +MPD Remote +---------- - - Other than that, I was able to complete the test procedure. +Test date: + 2012-11-06 +Tested version: + 1.0 (released 2012-05-01) - - PMix crashed once during testing. +You can get `MPD Remote from Google Play +`_. - - PMix handled the killing of Mopidy just as nicely as MPDroid. - - - It does not support single mode or consume mode. - -All in all, PMix works but can do less than MPDroid. Use MPDroid instead. +This app looks terrible in the screen shots, got just 100+ downloads, and got a +terrible rating. I honestly didn't take the time to test it. .. _ios_mpd_clients: @@ -271,63 +261,60 @@ All in all, PMix works but can do less than MPDroid. Use MPDroid instead. iOS clients =========== -MPod +MPoD ---- Test date: - 2011-01-19 + 2012-11-06 Tested version: - 1.5.1 + 1.7.1 + +.. image:: /_static/mpd-client-mpod.jpg + :width: 320 + :height: 480 The `MPoD `_ iPhone/iPod Touch -app can be installed from the `iTunes Store -`_. +app can be installed from `MPoD at iTunes Store +`_. -Users have reported varying success in using MPoD together with Mopidy. Thus, -we've tested a fresh install of MPoD 1.5.1 with Mopidy as of revision e7ed28d -(pre-0.3) on an iPod Touch 3rd generation. The following are our findings: +- The user interface looks nice. -- **Works:** Playback control generally works, including stop, play, pause, - previous, next, repeat, random, seek, and volume control. +- All features exercised in the test procedure worked with MPaD, except seek, + which I didn't figure out to do. -- **Bug:** Search does not work, neither in the artist, album, or song - tabs. Mopidy gets no requests at all from MPoD when executing searches. Seems - like MPoD only searches in local cache, even if "Use local cache" is turned - off in MPoD's settings. Until this is fixed by the MPoD developer, MPoD will - be much less useful with Mopidy. +- Search only works in the "Browse" tab, and not under in the "Artist", + "Album", or "Song" tabs. For the tabs where search doesn't work, no queries + are sent to Mopidy when searching. -- **Bug:** When adding another playlist to the current playlist in MPoD, - the currently playing track restarts at the beginning. I do not currently - know enough about this bug, because I'm not sure if MPoD was in the "add to - active playlist" or "replace active playlist" mode when I tested it. I only - later learned what that button was for. Anyway, what I experienced was: - - #. I play a track - #. I select a new playlist - #. MPoD reconnects to Mopidy for unknown reason - #. MPoD issues MPD command ``load "a playlist name"`` - #. MPoD issues MPD command ``play "-1"`` - #. MPoD issues MPD command ``playlistinfo "-1"`` - #. I hear that the currently playing tracks restarts playback - -- **Tips:** MPoD seems to cache stored playlists, but they won't work if the - server hasn't loaded stored playlists from e.g. Spotify yet. A trick to force - refetching of playlists from Mopidy is to add a new empty playlist in MPoD. - -- **Wishlist:** Modifying the current playlists is not supported by MPoD it - seems. - -- **Wishlist:** MPoD supports playback of Last.fm radio streams through the MPD - server. Mopidy does not currently support this, but there is a wishlist bug - at :issue:`38`. - -- **Wishlist:** MPoD supports autodetection/-configuration of MPD servers - through the use of Bonjour. Mopidy does not currently support this, but there - is a wishlist bug at :issue:`39`. +- Single mode and consume mode is supported. MPaD ---- -The `MPaD `_ iPad app works -with Mopidy. A complete review may appear here in the future. +Test date: + 2012-11-06 +Tested version: + 1.7.1 + +.. image:: /_static/mpd-client-mpad.jpg + :width: 480 + :height: 360 + +The `MPaD `_ iPad app can be +purchased from `MPaD at iTunes Store +`_ + +- The user interface looks nice, though I would like to be able to view the + current playlist in the large part of the split view. + +- All features exercised in the test procedure worked with MPaD. + +- Search only works in the "Browse" tab, and not under in the "Artist", + "Album", or "Song" tabs. For the tabs where search doesn't work, no queries + are sent to Mopidy when searching. + +- Single mode and consume mode is supported. + +- The server menu can be very slow top open, and there is no visible feedback + when waiting for the connection to a server to succeed. diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst new file mode 100644 index 00000000..95866089 --- /dev/null +++ b/docs/clients/mpris.rst @@ -0,0 +1,66 @@ +.. _mpris-clients: + +************* +MPRIS clients +************* + +`MPRIS `_ is short for Media Player Remote Interfacing +Specification. It's a spec that describes a standard D-Bus interface for making +media players available to other applications on the same system. + +Mopidy's :ref:`MPRIS frontend ` currently implements all +required parts of the MPRIS spec, but not the optional playlist interface. For +tracking the development of the playlist interface, see :issue:`229`. + + +.. _ubuntu-sound-menu: + +Ubuntu Sound Menu +================= + +The `Ubuntu Sound Menu `_ is the default +sound menu in Ubuntu since 10.10 or 11.04. By default, it only includes the +Rhytmbox music player, but many other players can integrate with the sound +menu, including the official Spotify player and Mopidy. + +.. image:: /_static/ubuntu-sound-menu.png + :height: 480 + :width: 955 + +If you install Mopidy from apt.mopidy.com, the sound menu should work out of +the box. If you install Mopidy in any other way, you need to make sure that the +file located at ``data/mopidy.desktop`` in the Mopidy git repo is installed as +``/usr/share/applications/mopidy.desktop``, and that the properties ``TryExec`` +and ``Exec`` in the file points to an existing executable file, preferably your +Mopidy executable. If this isn't in place, the sound menu will not detect that +Mopidy is running. + +Next, Mopidy's MPRIS frontend must be running for the sound menu to be able to +control Mopidy. The frontend is activated by default, so unless you've changed +the :attr:`mopidy.settings.FRONTENDS` setting, you should be good to go. Keep +an eye out for warnings or errors from the MPRIS frontend when you start +Mopidy, since it may fail because of missing dependencies or because Mopidy is +started outside of X; the frontend won't work if ``$DISPLAY`` isn't set when +Mopidy is started. + +Under normal use, if Mopidy isn't running and you open the menu and click on +"Mopidy Music Server", a terminal window will open and automatically start +Mopidy. If Mopidy is already running, you'll see that Mopidy is marked with an +arrow to the left of its name, like in the screen shot above, and the player +controls will be visible. Mopidy doesn't support the MPRIS spec's optional +playlist interface yet, so you'll not be able to select what track to play from +the sound menu. If you use an MPD client to queue a playlist, you can use the +sound menu to check what you're currently playing, pause, resume, and skip to +the next and previous track. + +In summary, Mopidy's sound menu integration is currently not a full featured +client, but it's a convenient addition to an MPD client since it's always +easily available on Unity's menu bar. + + +Rygel +===== + +Rygel is an application that will translate between Mopidy's MPRIS interface +and UPnP, and thus make Mopidy controllable from devices compatible with UPnP +and/or DLNA. To read more about this, see :ref:`upnp-clients`. diff --git a/docs/clients/upnp.rst b/docs/clients/upnp.rst new file mode 100644 index 00000000..567fb04f --- /dev/null +++ b/docs/clients/upnp.rst @@ -0,0 +1,117 @@ +.. _upnp-clients: + +************ +UPnP clients +************ + +`UPnP `_ is a set of +specifications for media sharing, playing, remote control, etc, across a home +network. The specs are supported by a lot of consumer devices (like +smartphones, TVs, Xbox, and PlayStation) that are often labeled as being `DLNA +`_ compatible or certified. + +The DLNA guidelines and UPnP specifications defines several device roles, of +which Mopidy may play two: + +DLNA Digital Media Server (DMS) / UPnP AV MediaServer: + + A MediaServer provides a library of media and is capable of streaming that + media to a MediaRenderer. If Mopidy was a MediaServer, you could browse and + play Mopidy's music on a TV, smartphone, or tablet supporting UPnP. Mopidy + does not currently support this, but we may in the future. :issue:`52` is + the relevant wishlist issue. + +DLNA Digital Media Renderer (DMR) / UPnP AV MediaRenderer: + + A MediaRenderer is asked by some remote controller to play some + given media, typically served by a MediaServer. If Mopidy was a + MediaRenderer, you could use e.g. your smartphone or tablet to make Mopidy + play media. Mopidy *does already* have experimental support for being a + MediaRenderer with the help of Rygel, as you can read more about below. + + +.. _rygel: + +How to make Mopidy available as an UPnP MediaRenderer +===================================================== + +With the help of `the Rygel project `_ Mopidy can +be made available as an UPnP MediaRenderer. Rygel will interface with Mopidy's +:ref:`MPRIS frontend `, and make Mopidy available as a +MediaRenderer on the local network. Since this depends on the MPRIS frontend, +which again depends on D-Bus being available, this will only work on Linux, and +not OS X. MPRIS/D-Bus is only available to other applications on the same host, +so Rygel must be running on the same machine as Mopidy. + +1. Start Mopidy and make sure the :ref:`MPRIS frontend ` is + working. It is activated by default, but you may miss dependencies or be + using OS X, in which case it will not work. Check the console output when + Mopidy is started for any errors related to the MPRIS frontend. If you're + unsure it is working, there are instructions for how to test it on the + :ref:`MPRIS frontend ` page. + +2. Install Rygel. On Debian/Ubuntu:: + + sudo apt-get install rygel + +3. Enable Rygel's MPRIS plugin. On Debian/Ubuntu, edit ``/etc/rygel.conf``, + find the ``[MPRIS]`` section, and change ``enabled=false`` to + ``enabled=true``. + +4. Start Rygel by running:: + + rygel + + Example output:: + + $ rygel + Rygel-Message: New plugin 'MediaExport' available + Rygel-Message: New plugin 'org.mpris.MediaPlayer2.spotify' available + Rygel-Message: New plugin 'org.mpris.MediaPlayer2.mopidy' available + + Note that in the above example, both the official Spotify client and Mopidy + is running and made available through Rygel. + + +The UPnP-Inspector client +========================= + +`UPnP-Inspector `_ is a +graphical analyzer and debugging tool for UPnP services. It will detect any +UPnP devices on your network, and show these in a tree structure. This is not a +tool for your everyday music listening while relaxing on the couch, but it may +be of use for testing that your setup works correctly. + +1. Install UPnP-Inspector. On Debian/Ubuntu:: + + sudo apt-get install upnp-inspector + +2. Run it:: + + upnp-inspector + +3. Assuming that Mopidy is running with a working MPRIS frontend, and that + Rygel is running on the same machine, Mopidy should now appear in + UPnP-Inspector's device list. + +4. If you expand the tree item saying ``Mopidy + (MediaRenderer:2)`` or similiar, and then the sub element named + ``AVTransport:2`` or similar, you'll find a list of commands you can invoke. + E.g. if you double-click the ``Pause`` command, you'll get a new window + where you can press an ``Invoke`` button, and then Mopidy should be paused. + +Note that if you have a firewall on the host running Mopidy and Rygel, and you +want this to be exposed to the rest of your local network, you need to open up +your firewall for UPnP traffic. UPnP use UDP port 1900 as well as some +dynamically assigned ports. I've only verified that this procedure works across +the network by temporarily disabling the firewall on the the two hosts +involved, so I'll leave any firewall configuration as an exercise to the +reader. + + +Other clients +============= + +For a long list of UPnP clients for all possible platforms, see Wikipedia's +`List of UPnP AV media servers and clients +`_. diff --git a/docs/conf.py b/docs/conf.py index 8129adec..d02303df 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -3,7 +3,8 @@ # Mopidy documentation build configuration file, created by # sphinx-quickstart on Fri Feb 5 22:19:08 2010. # -# This file is execfile()d with the current directory set to its containing dir. +# This file is execfile()d with the current directory set to its containing +# dir. # # Note that not all possible configuration values are present in this # autogenerated file. @@ -12,9 +13,9 @@ # serve to show the default. import os -import re import sys + class Mock(object): def __init__(self, *args, **kwargs): pass @@ -34,6 +35,7 @@ class Mock(object): else: return Mock() + MOCK_MODULES = [ 'dbus', 'dbus.mainloop', @@ -63,12 +65,16 @@ sys.path.insert(0, os.path.abspath(os.path.dirname(__file__) + '/../')) # the string True. on_rtd = os.environ.get('READTHEDOCS', None) == 'True' -# -- General configuration ----------------------------------------------------- +# -- 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', 'sphinx.ext.graphviz', - 'sphinx.ext.extlinks', 'sphinx.ext.viewcode'] +# 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', + 'sphinx.ext.extlinks', + 'sphinx.ext.graphviz', + 'sphinx.ext.viewcode', +] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -91,7 +97,7 @@ copyright = u'2010-2012, Stein Magnus Jodal and contributors' # built documents. # # The full version, including alpha/beta/rc tags. -from mopidy import get_version +from mopidy.utils.versioning import get_version release = get_version() # The short X.Y version. version = '.'.join(release.split('.')[:2]) @@ -114,7 +120,8 @@ version = '.'.join(release.split('.')[:2]) # for source files. exclude_trees = ['_build'] -# The reST default role (used for this markup: `text`) to use for all documents. +# The reST default role (used for this markup: `text`) to use for all +# documents. #default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. @@ -135,7 +142,7 @@ pygments_style = 'sphinx' modindex_common_prefix = ['mopidy.'] -# -- Options for HTML output --------------------------------------------------- +# -- Options for HTML output -------------------------------------------------- # The theme to use for HTML and HTML Help pages. Major themes that come with # Sphinx are currently 'default' and 'sphinxdoc'. @@ -210,7 +217,7 @@ html_static_path = ['_static'] htmlhelp_basename = 'Mopidydoc' -# -- Options for LaTeX output -------------------------------------------------- +# -- Options for LaTeX output ------------------------------------------------- # The paper size ('letter' or 'a4'). #latex_paper_size = 'letter' @@ -218,11 +225,16 @@ htmlhelp_basename = 'Mopidydoc' # The font size ('10pt', '11pt' or '12pt'). #latex_font_size = '10pt' -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, author, documentclass [howto/manual]). +# Grouping the document tree into LaTeX files. List of tuples (source start +# file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'Mopidy.tex', u'Mopidy Documentation', - u'Stein Magnus Jodal', 'manual'), + ( + 'index', + 'Mopidy.tex', + u'Mopidy Documentation', + u'Stein Magnus Jodal', + 'manual' + ), ] # The name of an image file (relative to this directory) to place at the top of diff --git a/docs/development.rst b/docs/development.rst index c5020bd9..6cab7bf1 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -3,7 +3,7 @@ Development *********** Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at -``irc.freenode.net`` and through `GitHub `_. +``irc.freenode.net`` and through `GitHub `_. Release schedule @@ -37,13 +37,58 @@ implemented, and you may add new wishlist issues if your ideas are not already represented. +.. _run-from-git: + +Run Mopidy from Git repo +======================== + +If you want to contribute to the development of Mopidy, you should run Mopidy +directly from the Git repo. + +#. First of all, install Mopidy in the recommended way for your OS and/or + distribution, like described at :ref:`installation`. You can have a + system-wide installation of the last Mopidy release in addition to the Git + repo which you run from when you code on Mopidy. + +#. Then install Git, if haven't already. For Ubuntu/Debian:: + + sudo apt-get install git-core + + On OS X using Homebrew:: + + sudo brew install git + +#. Clone the official Mopidy repository:: + + git clone git://github.com/mopidy/mopidy.git + + or your own fork of it:: + + git clone git@github.com:mygithubuser/mopidy.git + +#. You can then run Mopidy directly from the Git repository:: + + cd mopidy/ # Move into the Git repo dir + python mopidy # Run python on the mopidy source code dir + +How you update your clone depends on whether you cloned the official Mopidy +repository or your own fork, whether you have made any changes to the clone +or not, and whether you are currently working on a feature branch or not. In +other words, you'll need to learn Git. + +For an introduction to Git, please visit `git-scm.com `_. +Also, please read the rest of our developer documentation before you start +contributing. + + 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. + `_ or `flake8 + `_ 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. @@ -89,7 +134,8 @@ Code style Commit guidelines ================= -- We follow the development process described at http://nvie.com/git-model. +- We follow the development process described at + `nvie.com `_. - Keep commits small and on topic. @@ -118,27 +164,35 @@ Then, to run all tests, go to the project directory and run:: For example:: $ nosetests - ...................................................................... - ...................................................................... - ...................................................................... - ....... - ---------------------------------------------------------------------- - Ran 217 tests in 0.267s + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................................. + ............................................................. + ----------------------------------------------------------------------------- + 1062 tests run in 7.4 seconds (1062 tests passed) - OK +To run tests with test coverage statistics, remember to specify the tests dir:: -To run tests with test coverage statistics:: - - nosetests --with-coverage + nosetests --with-coverage tests/ For more documentation on testing, check out the `nose documentation -`_. +`_. Continuous integration ====================== -Mopidy uses the free service `Travis CI `_ +Mopidy uses the free service `Travis CI `_ for automatically running the test suite when code is pushed to GitHub. This works both for the main Mopidy repo, but also for any forks. This way, any contributions to Mopidy through GitHub will automatically be tested by Travis @@ -196,14 +250,44 @@ of writing. See ``--help`` for available options. Sample session:: +ACK [2@0] {listallinfo} incorrect arguments To ensure that Mopidy and MPD have comparable state it is suggested you setup -both to use ``tests/data/library_tag_cache`` for their tag cache and -``tests/data`` for music/playlist folders. +both to use ``tests/data/advanced_tag_cache`` for their tag cache and +``tests/data/scanner/advanced/`` for the music folder and ``tests/data`` for +playlists. + + +Setting profiles during development +=================================== + +While developing Mopidy switching settings back and forth can become an all too +frequent occurrence. As a quick hack to get around this you can structure your +settings file in the following way:: + + import os + profile = os.environ.get('PROFILE', '').split(',') + + if 'spotify' in profile: + BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) + elif 'local' in profile: + BACKENDS = (u'mopidy.backends.local.LocalBackend',) + LOCAL_MUSIC_PATH = u'~/music' + + if 'shoutcast' in profile: + OUTPUT = u'lame ! shout2send mount="/stream"' + elif 'silent' in profile: + OUTPUT = u'fakesink' + MIXER = None + + SPOTIFY_USERNAME = u'xxxxx' + SPOTIFY_PASSWORD = u'xxxxx' + +Using this setup you can now run Mopidy with ``PROFILE=silent,spotify mopidy`` +if you for instance want to test Spotify without any actual audio output. Writing documentation ===================== -To write documentation, we use `Sphinx `_. See their +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. diff --git a/docs/index.rst b/docs/index.rst index 0af510d0..bce84b5a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,26 +2,33 @@ Mopidy ****** -Mopidy is a music server which can play music from `Spotify -`_ or from your local hard drive. To search for music -in Spotify's vast archive, manage playlists, and play music, you can use most -`MPD clients `_. MPD clients are available for most -platforms, including Windows, Mac OS X, Linux, Android, and iOS. +Mopidy is a music server which can play music both from your :ref:`local hard +drive ` and from :ref:`Spotify `. Searches +returns results from both your local hard drive and from Spotify, and you can +mix tracks from both sources in your play queue. Your Spotify playlists are +also available for use, though we don't support modifying them yet. -To install Mopidy, start out by reading :ref:`installation`. +To control your music server, you can use the :ref:`Ubuntu Sound Menu +` on the machine running Mopidy, any device on the same +network which can control UPnP MediaRenderers (see :ref:`upnp-clients`), or any +:ref:`MPD client `. MPD clients are available for most platforms, +including Windows, Mac OS X, Linux, Android, and iOS. + +To install Mopidy, start by reading :ref:`installation`. If you get stuck, we usually hang around at ``#mopidy`` at `irc.freenode.net `_. If you stumble into a bug or got a feature request, please create an issue in the `issue tracker -`_. +`_. Project resources ================= - `Documentation `_ -- `Source code `_ -- `Issue tracker `_ +- `Source code `_ +- `Issue tracker `_ +- `CI server `_ - IRC: ``#mopidy`` at `irc.freenode.net `_ @@ -32,6 +39,7 @@ User documentation :maxdepth: 3 installation/index + installation/raspberrypi settings running clients/index @@ -39,6 +47,7 @@ User documentation licenses changes + Reference documentation ======================= @@ -48,6 +57,7 @@ Reference documentation api/index modules/index + Development documentation ========================= @@ -56,10 +66,10 @@ Development documentation development + Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` - diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst deleted file mode 100644 index 42685ad0..00000000 --- a/docs/installation/gstreamer.rst +++ /dev/null @@ -1,113 +0,0 @@ -********************** -GStreamer installation -********************** - -To use Mopidy, you first need to install GStreamer and the GStreamer Python -bindings. - - -Installing GStreamer on Linux -============================= - -GStreamer is packaged for most popular Linux distributions. Search for -GStreamer in your package manager, and make sure to install the Python -bindings, and the "good" and "ugly" plugin sets. - - -Debian/Ubuntu -------------- - -If you use Debian/Ubuntu you can install GStreamer like this:: - - sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ - gstreamer0.10-plugins-ugly - -If you install Mopidy from our APT archive, you don't need to install GStreamer -yourself. The Mopidy Debian package will handle it for you. - - -Arch Linux ----------- - -If you use Arch Linux, install the following packages from the official -repository:: - - sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \ - gstreamer0.10-ugly-plugins - - -Installing GStreamer on OS X -============================ - -.. note:: - - We have been working with `Homebrew `_ to - make all the GStreamer packages easily installable on OS X using Homebrew. - We've gotten most of our packages included, but the Homebrew guys aren't - very happy to include Python specific packages into Homebrew, even though - they are not installable by pip. If you're interested, see the discussion - in `Homebrew's issue #1612 - `_ for details. - -The following is currently the shortest path to installing GStreamer with -Python bindings on OS X using Homebrew. - -#. Install `Homebrew `_. - -#. Download our Homebrew formula for ``gst-python``:: - - curl -o $(brew --prefix)/Library/Formula/gst-python.rb \ - https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/gst-python.rb - -#. Install the required packages:: - - brew install gst-python gst-plugins-good gst-plugins-ugly - -#. Make sure to include Homebrew's Python ``site-packages`` directory in your - ``PYTHONPATH``. If you don't include this, Mopidy will not find GStreamer - and crash. - - You can either amend your ``PYTHONPATH`` permanently, by adding the - following statement to your shell's init file, e.g. ``~/.bashrc``:: - - export PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages:$PYTHONPATH - - Or, you can prefix the Mopidy command every time you run it:: - - PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy - - Note that you need to replace ``python2.7`` with ``python2.6`` if that's - the Python version you are using. To find your Python version, run:: - - python --version - - -Testing the installation -======================== - -If you now run the ``gst-inspect-0.10`` command (the version number may vary), -you should see a long listing of installed plugins, ending in a summary line:: - - $ gst-inspect-0.10 - ... long list of installed plugins ... - Total count: 218 plugins (1 blacklist entry not shown), 1031 features - -You should be able to produce a audible tone by running:: - - gst-launch-0.10 audiotestsrc ! autoaudiosink - -If you cannot hear any sound when running this command, you won't hear any -sound from Mopidy either, as Mopidy uses GStreamer's ``autoaudiosink`` to play -audio. Thus, make this work before you continue installing Mopidy. - - -Using a custom audio sink -========================= - -If you for some reason want to use some other GStreamer audio sink than -``autoaudiosink``, you can set :attr:`mopidy.settings.OUTPUT` to a partial -GStreamer pipeline description describing the GStreamer sink you want to use. - -Example of ``settings.py`` for OSS4:: - - OUTPUT = u'oss4sink' diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 66b920f8..4ae04c40 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -4,60 +4,21 @@ Installation ************ -There are several ways to install Mopidy. What way is best depends upon your -setup and whether you want to use stable releases or less stable development -versions. +There are several ways to install Mopidy. What way is best depends upon your OS +and/or distribution. If you want to contribute to the development of Mopidy, +you should first read this page, then have a look at :ref:`run-from-git`. + +.. contents:: Installation guides + :local: -Requirements -============ - -.. toctree:: - :hidden: - - gstreamer - libspotify - -If you install Mopidy from the APT archive, as described below, APT will take -care of all the dependencies for you. Otherwise, make sure you got the required -dependencies installed. - -- Hard dependencies: - - - Python >= 2.6, < 3 - - - Pykka >= 0.12.3:: - - sudo pip install -U pykka - - - GStreamer 0.10.x, with Python bindings. See :doc:`gstreamer`. - -- Optional dependencies: - - - For Spotify support, you need libspotify and pyspotify. See - :doc:`libspotify`. - - - To scrobble your played tracks to Last.fm, you need pylast:: - - sudo pip install -U pylast - - - To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound Menu, you - need some additional requirements:: - - sudo apt-get install python-dbus python-indicate - - -Install latest stable release -============================= - - -From APT archive ----------------- +Debian/Ubuntu: Install from apt.mopidy.com +========================================== If you run a Debian based Linux distribution, like Ubuntu, the easiest way to -install Mopidy is from the Mopidy APT archive. When installing from the APT -archive, you will automatically get updates to Mopidy in the same way as you -get updates to the rest of your distribution. +install Mopidy is from the `Mopidy APT archive `_. When +installing from the APT archive, you will automatically get updates to Mopidy +in the same way as you get updates to the rest of your distribution. #. Add the archive's GPG key:: @@ -71,119 +32,48 @@ get updates to the rest of your distribution. deb http://apt.mopidy.com/ stable main contrib non-free deb-src http://apt.mopidy.com/ stable main contrib non-free + For the lazy, you can simply run the following command to create + ``/etc/apt/sources.list.d/mopidy.list``:: + + sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list + #. Install Mopidy and all dependencies:: sudo apt-get update sudo apt-get install mopidy -#. Next, you need to set a couple of :doc:`settings `, and then +#. Finally, you need to set a couple of :doc:`settings `, and then you're ready to :doc:`run Mopidy `. -When a new release is out, and you can't wait for you system to figure it out -for itself, run the following to force an upgrade:: +When a new release of Mopidy is out, and you can't wait for you system to +figure it out for itself, run the following to upgrade right away:: sudo apt-get update sudo apt-get dist-upgrade -From PyPI using Pip -------------------- +Raspberry Pi running Debian +--------------------------- -If you are on OS X or on Linux, but can't install from the APT archive, you can -install Mopidy from PyPI using Pip. - -#. When you install using Pip, you first need to ensure that all of Mopidy's - dependencies have been installed. See the section on dependencies above. - -#. Then, you need to install Pip:: - - sudo apt-get install python-setuptools python-pip # On Ubuntu/Debian - sudo easy_install pip # On OS X - -#. To install the currently latest stable release of Mopidy:: - - sudo pip install -U Mopidy - - To upgrade Mopidy to future releases, just rerun this command. - -#. Next, you need to set a couple of :doc:`settings `, and then - you're ready to :doc:`run Mopidy `. +Fred Hatfull has created a guide for installing a Raspberry Pi from scratch +with Debian and Mopidy. See :ref:`raspberrypi-installation`. -Install development version -=========================== +Vagrant virtual machine running Ubuntu +-------------------------------------- -If you want to follow the development of Mopidy closer, you may install a -development version of Mopidy. These are not as stable as the releases, but -you'll get access to new features earlier and may help us by reporting issues. +Paul Sturgess has created a Vagrant and Chef setup that automatically creates +and sets up a virtual machine which runs Mopidy. Check out +https://github.com/paulsturgess/mopidy-vagrant if you're interested in trying +it out. -From snapshot using Pip ------------------------ +Arch Linux: Install from AUR +============================ -If you want to follow Mopidy development closer, you may install a snapshot of -Mopidy's ``develop`` branch. - -#. When you install using Pip, you first need to ensure that all of Mopidy's - dependencies have been installed. See the section on dependencies above. - -#. Then, you need to install Pip:: - - sudo apt-get install python-setuptools python-pip # On Ubuntu/Debian - sudo easy_install pip # On OS X - -#. To install the latest snapshot of Mopidy, run:: - - sudo pip install mopidy==dev - - To upgrade Mopidy to future releases, just rerun this command. - -#. Next, you need to set a couple of :doc:`settings `, and then - you're ready to :doc:`run Mopidy `. - - -From Git --------- - -If you want to contribute to Mopidy, you should install Mopidy using Git. - -#. When you install from Git, you first need to ensure that all of Mopidy's - dependencies have been installed. See the section on dependencies above. - -#. Then install Git, if haven't already:: - - sudo apt-get install git-core # On Ubuntu/Debian - sudo brew install git # On OS X using Homebrew - -#. Clone the official Mopidy repository, or your own fork of it:: - - git clone git://github.com/mopidy/mopidy.git - -#. Next, you need to set a couple of :doc:`settings `. - -#. You can then run Mopidy directly from the Git repository:: - - cd mopidy/ # Move into the Git repo dir - python mopidy # Run python on the mopidy source code dir - -#. Later, to get the latest changes to Mopidy:: - - cd mopidy/ - git pull - -For an introduction to ``git``, please visit `git-scm.com -`_. Also, please read our :doc:`developer documentation -`. - - -From AUR on ArchLinux ---------------------- - -If you are running ArchLinux, you can install a development snapshot of Mopidy -using the package found at http://aur.archlinux.org/packages.php?ID=44026. - -#. First, you should consider installing any optional dependencies not included - by the AUR package, like required for e.g. Last.fm scrobbling. +If you are running Arch Linux, you can install a development snapshot of Mopidy +using the `mopidy-git `_ +package found in AUR. #. To install Mopidy with GStreamer, libspotify and pyspotify, you can use ``packer``, ``yaourt``, or do it by hand like this:: @@ -195,5 +85,161 @@ using the package found at http://aur.archlinux.org/packages.php?ID=44026. To upgrade Mopidy to future releases, just rerun ``makepkg``. -#. Next, you need to set a couple of :doc:`settings `, and then +#. Optional: If you want to scrobble your played tracks to Last.fm, you need to + install `python2-pylast + `_ from AUR. + +#. Finally, you need to set a couple of :doc:`settings `, and then + you're ready to :doc:`run Mopidy `. + + +OS X: Install from Homebrew and Pip +=================================== + +If you are running OS X, you can install everything needed with Homebrew and +Pip. + +#. Install `Homebrew `_. + + If you are already using Homebrew, make sure your installation is up to + date before you continue:: + + brew update + brew upgrade + +#. Install the required packages from Homebrew:: + + brew install gst-python gst-plugins-good gst-plugins-ugly libspotify + +#. Make sure to include Homebrew's Python ``site-packages`` directory in your + ``PYTHONPATH``. If you don't include this, Mopidy will not find GStreamer + and crash. + + You can either amend your ``PYTHONPATH`` permanently, by adding the + following statement to your shell's init file, e.g. ``~/.bashrc``:: + + export PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages:$PYTHONPATH + + Or, you can prefix the Mopidy command every time you run it:: + + PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy + + Note that you need to replace ``python2.7`` with ``python2.6`` in the above + ``PYTHONPATH`` examples if you are using Python 2.6. To find your Python + version, run:: + + python --version + +#. Next up, you need to install some Python packages. To do so, we use Pip. If + you don't have the ``pip`` command, you can install it now:: + + sudo easy_install pip + +#. Then get, build, and install the latest releast of pyspotify, pylast, pykka, + and Mopidy using Pip:: + + sudo pip install -U pyspotify pylast pykka mopidy + +#. Finally, you need to set a couple of :doc:`settings `, and then + you're ready to :doc:`run Mopidy `. + + +Otherwise: Install from source using Pip +======================================== + +If you are on on Linux, but can't install from the APT archive or from AUR, you +can install Mopidy from PyPI using Pip. + +#. First of all, you need Python >= 2.6, < 3. Check if you have Python and what + version by running:: + + python --version + +#. When you install using Pip, you need to make sure you have Pip. You'll also + need a C compiler and the Python development headers to build pyspotify + later. + + This is how you install it on Debian/Ubuntu:: + + sudo apt-get install build-essential python-dev python-pip + + And on Arch Linux from the official repository:: + + sudo pacman -S base-devel python2-pip + +#. Then you'll need to install all of Mopidy's hard dependencies: + + - Pykka >= 1.0:: + + sudo pip install -U pykka + + - GStreamer 0.10.x, with Python bindings. GStreamer is packaged for most + popular Linux distributions. Search for GStreamer in your package manager, + and make sure to install the Python bindings, and the "good" and "ugly" + plugin sets. + + If you use Debian/Ubuntu you can install GStreamer like this:: + + sudo apt-get install python-gst0.10 gstreamer0.10-plugins-good \ + gstreamer0.10-plugins-ugly gstreamer0.10-tools + + If you use Arch Linux, install the following packages from the official + repository:: + + sudo pacman -S gstreamer0.10-python gstreamer0.10-good-plugins \ + gstreamer0.10-ugly-plugins + +#. Optional: If you want Spotify support in Mopidy, you'll need to install + libspotify and the Python bindings, pyspotify. + + #. First, check `pyspotify's changelog `_ to + see what's the latest version of libspotify which it supports. The + versions of libspotify and pyspotify are tightly coupled, so you'll need + to get this right. + + #. Download and install the appropriate version of libspotify for your OS and + CPU architecture from `Spotify + `_. + + For libspotify 12.1.51 for 64-bit Linux the process is as follows:: + + wget https://developer.spotify.com/download/libspotify/libspotify-12.1.51-Linux-x86_64-release.tar.gz + tar zxfv libspotify-12.1.51-Linux-x86_64-release.tar.gz + cd libspotify-12.1.51-Linux-x86_64-release/ + sudo make install prefix=/usr/local + sudo ldconfig + + Remember to adjust the above example for the latest libspotify version + supported by pyspotify, your OS, and your CPU architecture. + + #. Then get, build, and install the latest release of pyspotify using Pip:: + + sudo pip install -U pyspotify + +#. Optional: If you want to scrobble your played tracks to Last.fm, you need + pylast:: + + sudo pip install -U pylast + +#. Optional: To use MPRIS, e.g. for controlling Mopidy from the Ubuntu Sound + Menu or from an UPnP client via Rygel, you need some additional + dependencies: the Python bindings for libindicate, and the Python bindings + for libdbus, the reference D-Bus library. + + On Debian/Ubuntu:: + + sudo apt-get install python-dbus python-indicate + +#. Then, to install the latest release of Mopidy:: + + sudo pip install -U mopidy + + To upgrade Mopidy to future releases, just rerun this command. + + Alternatively, if you want to track Mopidy development closer, you may + install a snapshot of Mopidy's ``develop`` Git branch using Pip:: + + sudo pip install mopidy==dev + +#. Finally, you need to set a couple of :doc:`settings `, and then you're ready to :doc:`run Mopidy `. diff --git a/docs/installation/libspotify.rst b/docs/installation/libspotify.rst deleted file mode 100644 index 223e4ed7..00000000 --- a/docs/installation/libspotify.rst +++ /dev/null @@ -1,112 +0,0 @@ -*********************** -libspotify installation -*********************** - -Mopidy uses `libspotify -`_ for playing music from -the Spotify music service. To use :mod:`mopidy.backends.spotify` you must -install libspotify and `pyspotify `_. - -.. note:: - - This backend requires a paid `Spotify premium account - `_. - - -Installing libspotify -===================== - - -On Linux from APT archive -------------------------- - -If you install from APT, jump directly to :ref:`pyspotify_installation` below. - - -On Linux from source --------------------- - -First, check pyspotify's changelog to see what's the latest version of -libspotify which is supported. The versions of libspotify and pyspotify are -tightly coupled. - -Download and install the appropriate version of libspotify for your OS and CPU -architecture from https://developer.spotify.com/en/libspotify/. - -For libspotify 0.0.8 for 64-bit Linux the process is as follows:: - - wget http://developer.spotify.com/download/libspotify/libspotify-0.0.8-linux6-x86_64.tar.gz - tar zxfv libspotify-0.0.8-linux6-x86_64.tar.gz - cd libspotify-0.0.8-linux6-x86_64/ - sudo make install prefix=/usr/local - sudo ldconfig - -Remember to adjust for the latest libspotify version supported by pyspotify, -your OS and your CPU architecture. - -When libspotify has been installed, continue with -:ref:`pyspotify_installation`. - - -On OS X from Homebrew ---------------------- - -In OS X you need to have `XCode `_ and -`Homebrew `_ installed. Then, to install -libspotify:: - - brew install libspotify - -To update your existing libspotify installation using Homebrew:: - - brew update - brew upgrade - -When libspotify has been installed, continue with -:ref:`pyspotify_installation`. - - -.. _pyspotify_installation: - -Installing pyspotify -==================== - -When you've installed libspotify, it's time for making it available from Python -by installing pyspotify. - - -On Linux from APT archive -------------------------- - -If you run a Debian based Linux distribution, like Ubuntu, see -http://apt.mopidy.com/ for how to use the Mopidy APT archive as a software -source on your system. Then, simply run:: - - sudo apt-get install python-spotify - -This command will install both libspotify and pyspotify for you. - - -On Linux from source -------------------------- - -If you have have already installed libspotify, you can continue with installing -the libspotify Python bindings, called pyspotify. - -On Linux, you need to get the Python development files installed. On -Debian/Ubuntu systems run:: - - sudo apt-get install python-dev - -Then get, build, and install the latest releast of pyspotify using ``pip``:: - - sudo pip install -U pyspotify - - -On OS X from source -------------------- - -If you have already installed libspotify, you can get, build, and install the -latest releast of pyspotify using ``pip``:: - - sudo pip install -U pyspotify diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst new file mode 100644 index 00000000..fbb07364 --- /dev/null +++ b/docs/installation/raspberrypi.rst @@ -0,0 +1,264 @@ +.. _raspberrypi-installation: + +**************************** +Installation on Raspberry Pi +**************************** + +As of early August, 2012, running Mopidy on a `Raspberry Pi +`_ is possible, although there are a few +significant drawbacks to doing so. This document is intended to help you get +Mopidy running on your Raspberry Pi and to document the progress made and +issues surrounding running Mopidy on the Raspberry Pi. + +Mopidy will not currently run with Spotify support on the foundation-provided +`Raspbian `_ distribution. See :ref:`not-raspbian` for +details. However, Mopidy should run with Spotify support on any ARM Debian +image that has hardware floating-point support **disabled**. + +.. image:: /_static/raspberry-pi-by-jwrodgers.jpg + :width: 640 + :height: 427 + + +.. _raspi-squeeze: + +How to for Debian 6 (Squeeze) +============================= + +The following guide illustrates how to get Mopidy running on a minimal Debian +squeeze distribution. + +1. The image used can be downloaded at + http://www.linuxsystems.it/2012/06/debian-wheezy-raspberry-pi-minimal-image/. + This image is a very minimal distribution and does not include many common + packages you might be used to having access to. If you find yourself trying + to complete instructions here and getting ``command not found``, try using + ``apt-get`` to install the relevant packages! + +2. Flash the OS image to your SD card. See + http://elinux.org/RPi_Easy_SD_Card_Setup for help. + +3. If you have an SD card that's >2 GB, resize the disk image to use some more + space (we'll need a bit more to install some packages and stuff). See + http://elinux.org/RPi_Resize_Flash_Partitions#Manually_resizing_the_SD_card_on_Raspberry_Pi + for help. + +4. To even get to the point where we can start installing software let's create + a new user and give it sudo access. + + - Install ``sudo``:: + + apt-get install sudo + + - Create a user account:: + + adduser + + - Give the user sudo access by adding it to the ``sudo`` group so we don't + have to do everything on the ``root`` account:: + + adduser sudo + + - While we're at it, give your user access to the sound card by adding it to + the audio group:: + + adduser audio + + - Log in to your Raspberry Pi again with your new user account instead of + the ``root`` account. + +5. Enable the Raspberry Pi's sound drivers: + + - To enable the Raspberry Pi's sound driver:: + + sudo modprobe snd_bcm2835 + + - To load the sound driver at boot time:: + + echo "snd_bcm2835" | sudo tee /etc/modules + +6. Let's get the Raspberry Pi up-to-date: + + - Get some tools that we need to download and run the ``rpi-update`` + script:: + + sudo apt-get install ca-certificates git-core binutils + + - Download ``rpi-update`` from Github:: + + sudo wget https://raw.github.com/Hexxeh/rpi-update/master/rpi-update + + - Move ``rpi-update`` to an appropriate location:: + + sudo mv rpi-update /usr/local/bin/rpi-update + + - Make ``rpi-update`` executable:: + + sudo chmod +x /usr/local/bin/rpi-update + + - Finally! Update your firmware:: + + sudo rpi-update + + - After firmware updating finishes, reboot your Raspberry Pi:: + + sudo reboot + +7. To avoid a couple of potential problems with Mopidy, turn on IPv6 support: + + - Load the IPv6 kernel module now:: + + sudo modprobe ipv6 + + - Add ``ipv6`` to ``/etc/modules`` to ensure the IPv6 kernel module is + loaded on boot:: + + echo ipv6 | sudo tee /etc/modules + +8. Installing Mopidy and its dependencies from `apt.mopidy.com + `_, as described in :ref:`installation`. In short:: + + wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add - + sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list + sudo apt-get update + sudo apt-get install mopidy + +9. jackd2, which should be installed at this point, seems to cause some + problems. Let's install jackd1, as it seems to work a little bit better:: + + sudo apt-get install jackd1 + +You may encounter some issues with your audio configuration where sound does +not play. If that happens, edit your ``/etc/asound.conf`` to read something +like:: + + pcm.mmap0 { + type mmap_emul; + slave { + pcm "hw:0,0"; + } + } + + pcm.!default { + type plug; + slave { + pcm mmap0; + } + } + + +.. _raspi-wheezy: + +How to for Debian 7 (Wheezy) +============================ + +This is a very similar system to Debian 6.0 above, but with a bit newer +software packages, as Wheezy is going to be the next release of Debian. + +1. Download the latest wheezy disk image from + http://downloads.raspberrypi.org/images/debian/7/. I used the one dated + 2012-08-08. + +2. Flash the OS image to your SD card. See + http://elinux.org/RPi_Easy_SD_Card_Setup for help. + +3. If you have an SD card that's >2 GB, you don't have to resize the file + systems on another computer. Just boot up your Raspberry Pi with the + unaltered partions, and it will boot right into the ``raspi-config`` tool, + which will let you grow the root file system to fill the SD card. This tool + will also allow you do other useful stuff, like turning on the SSH server. + +4. As opposed to on Squeeze, ``sudo`` comes preinstalled. You can login to the + default user using username ``pi`` and password ``raspberry``. To become + root, just enter ``sudo -i``. + + Opposed to on Squeeze, there is no need to add your user to the ``audio`` + group, as the ``pi`` user already is a member of that group. + +5. As opposed to on Squeeze, the correct sound driver comes preinstalled. + +6. As opposed to on Squeeze, your kernel and GPU firmware is rather up to date + when running Wheezy. + +7. To avoid a couple of potential problems with Mopidy, turn on IPv6 support: + + - Load the IPv6 kernel module now:: + + sudo modprobe ipv6 + + - Add ``ipv6`` to ``/etc/modules`` to ensure the IPv6 kernel module is + loaded on boot:: + + echo ipv6 | sudo tee /etc/modules + +8. Installing Mopidy and its dependencies from `apt.mopidy.com + `_, as described in :ref:`installation`. In short:: + + wget -q -O - http://apt.mopidy.com/mopidy.gpg | sudo apt-key add - + sudo wget -q -O /etc/apt/sources.list.d/mopidy.list http://apt.mopidy.com/mopidy.list + sudo apt-get update + sudo apt-get install mopidy + +9. Since I have a HDMI cable connected, but want the sound on the analog sound + connector, I have to run:: + + amixer cset numid=3 1 + + to force it to use analog output. ``1`` means analog, ``0`` means auto, and + is the default, while ``2`` means HDMI. You can test sound output + independent of Mopidy by running:: + + aplay /usr/share/sounds/alsa/Front_Center.wav + + To make the change to analog output stick, you can add the ``amixer`` command + to e.g. ``/etc/rc.local``, which will be executed when the system is + booting. + + +Known Issues +============ + +Audio Quality +------------- + +The Raspberry Pi's audio quality can be sub-par through the analog output. This +is known and unlikely to be fixed as including any higher-quality hardware +would increase the cost of the board. If you experience crackling/hissing or +skipping audio, you may want to try a USB sound card. Additionally, you could +lower your default ALSA sampling rate to 22KHz, though this will lead to a +substantial decrease in sound quality. + + +.. _not-raspbian: + +Why Not Raspbian? +----------------- + +Mopidy with Spotify support is currently unavailable on the recommended +`Raspbian `_ Debian distribution that the Raspberry Pi +foundation has made available. This is due to Raspbian's hardware +floating-point support. The Raspberry Pi comes with a co-processor designed +specifically for floating-point computations (commonly called an FPU). Taking +advantage of the FPU can speed up many computations significantly over +software-emulated floating point routines. Most of Mopidy's dependencies are +open-source and have been (or can be) compiled to support the ``armhf`` +architecture. However, there is one component of Mopidy's stack which is +closed-source and crucial to Mopidy's Spotify support: libspotify. + +The ARM distributions of libspotify available on `Spotify's developer website +`_ are compiled for the ``armel`` architecture, +which has software floating-point support. ``armel`` and ``armhf`` software +cannot be mixed, and pyspotify links with libspotify as C extensions. Thus, +Mopidy will not run with Spotify support on ``armhf`` distributions. + +If the Spotify folks ever release builds of libspotify with ``armhf`` support, +Mopidy *should* work on Raspbian. + + +Support +======= + +If you had trouble with the above or got Mopidy working a different way on +Raspberry Pi, please send us a pull request to update this page with your new +information. As usual, the folks at ``#mopidy`` on ``irc.freenode.net`` may be +able to help with any problems encountered. diff --git a/docs/modules/audio/mixers/auto.rst b/docs/modules/audio/mixers/auto.rst new file mode 100644 index 00000000..caf6e3ab --- /dev/null +++ b/docs/modules/audio/mixers/auto.rst @@ -0,0 +1,6 @@ +********************************************* +:mod:`mopidy.audio.mixers.auto` -- Auto mixer +********************************************* + +.. automodule:: mopidy.audio.mixers.auto + :synopsis: Mixer element which automatically selects the real mixer to use diff --git a/docs/modules/audio/mixers/fake.rst b/docs/modules/audio/mixers/fake.rst new file mode 100644 index 00000000..dcab7767 --- /dev/null +++ b/docs/modules/audio/mixers/fake.rst @@ -0,0 +1,6 @@ +********************************************* +:mod:`mopidy.audio.mixers.fake` -- Fake mixer +********************************************* + +.. automodule:: mopidy.audio.mixers.fake + :synopsis: Fake mixer for use in tests diff --git a/docs/modules/audio/mixers/nad.rst b/docs/modules/audio/mixers/nad.rst new file mode 100644 index 00000000..661dc723 --- /dev/null +++ b/docs/modules/audio/mixers/nad.rst @@ -0,0 +1,6 @@ +********************************************* +:mod:`mopidy.audio.mixers.nad` -- NAD mixer +********************************************* + +.. automodule:: mopidy.audio.mixers.nad + :synopsis: Mixer element for controlling volume on NAD amplifiers diff --git a/docs/modules/backends/local.rst b/docs/modules/backends/local.rst index 892f5a87..9ac93bc8 100644 --- a/docs/modules/backends/local.rst +++ b/docs/modules/backends/local.rst @@ -1,7 +1,8 @@ +.. _local-backend: + ********************************************* :mod:`mopidy.backends.local` -- Local backend ********************************************* .. automodule:: mopidy.backends.local :synopsis: Backend for playing music files on local storage - :members: diff --git a/docs/modules/backends/spotify.rst b/docs/modules/backends/spotify.rst index 938d6337..b410f272 100644 --- a/docs/modules/backends/spotify.rst +++ b/docs/modules/backends/spotify.rst @@ -1,7 +1,8 @@ +.. _spotify-backend: + ************************************************* :mod:`mopidy.backends.spotify` -- Spotify backend ************************************************* .. automodule:: mopidy.backends.spotify :synopsis: Backend for the Spotify music streaming service - :members: diff --git a/docs/modules/frontends/lastfm.rst b/docs/modules/frontends/lastfm.rst index a726f4a2..0dba922f 100644 --- a/docs/modules/frontends/lastfm.rst +++ b/docs/modules/frontends/lastfm.rst @@ -4,4 +4,3 @@ .. automodule:: mopidy.frontends.lastfm :synopsis: Last.fm scrobbler frontend - :members: diff --git a/docs/modules/frontends/mpd.rst b/docs/modules/frontends/mpd.rst index 0ce138a2..090ca5cd 100644 --- a/docs/modules/frontends/mpd.rst +++ b/docs/modules/frontends/mpd.rst @@ -4,7 +4,6 @@ .. automodule:: mopidy.frontends.mpd :synopsis: MPD server frontend - :members: MPD dispatcher @@ -27,6 +26,7 @@ Audio output ------------ .. automodule:: mopidy.frontends.mpd.protocol.audio_output + :synopsis: MPD protocol: audio output :members: @@ -34,6 +34,7 @@ Command list ------------ .. automodule:: mopidy.frontends.mpd.protocol.command_list + :synopsis: MPD protocol: command list :members: @@ -41,6 +42,7 @@ Connection ---------- .. automodule:: mopidy.frontends.mpd.protocol.connection + :synopsis: MPD protocol: connection :members: @@ -48,12 +50,15 @@ Current playlist ---------------- .. automodule:: mopidy.frontends.mpd.protocol.current_playlist + :synopsis: MPD protocol: current playlist :members: + Music database -------------- .. automodule:: mopidy.frontends.mpd.protocol.music_db + :synopsis: MPD protocol: music database :members: @@ -61,6 +66,7 @@ Playback -------- .. automodule:: mopidy.frontends.mpd.protocol.playback + :synopsis: MPD protocol: playback :members: @@ -68,6 +74,7 @@ Reflection ---------- .. automodule:: mopidy.frontends.mpd.protocol.reflection + :synopsis: MPD protocol: reflection :members: @@ -75,6 +82,7 @@ Status ------ .. automodule:: mopidy.frontends.mpd.protocol.status + :synopsis: MPD protocol: status :members: @@ -82,6 +90,7 @@ Stickers -------- .. automodule:: mopidy.frontends.mpd.protocol.stickers + :synopsis: MPD protocol: stickers :members: @@ -89,4 +98,5 @@ Stored playlists ---------------- .. automodule:: mopidy.frontends.mpd.protocol.stored_playlists + :synopsis: MPD protocol: stored playlists :members: diff --git a/docs/modules/frontends/mpris.rst b/docs/modules/frontends/mpris.rst index 05a6e287..e0ec63da 100644 --- a/docs/modules/frontends/mpris.rst +++ b/docs/modules/frontends/mpris.rst @@ -1,7 +1,8 @@ +.. _mpris-frontend: + *********************************************** :mod:`mopidy.frontends.mpris` -- MPRIS frontend *********************************************** .. automodule:: mopidy.frontends.mpris :synopsis: MPRIS frontend - :members: diff --git a/docs/settings.rst b/docs/settings.rst index 0c1a3c7e..5bc63d7f 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -19,8 +19,8 @@ You can either create the settings file yourself, or run the ``mopidy`` command, and it will create an empty settings file for you. When you have created the settings file, open it in a text editor, and add -settings you want to change. If you want to keep the default value for setting, -you should *not* redefine it in your own settings file. +settings you want to change. If you want to keep the default value for a +setting, you should *not* redefine it in your own settings file. A complete ``~/.config/mopidy/settings.py`` may look as simple as this:: @@ -29,6 +29,8 @@ A complete ``~/.config/mopidy/settings.py`` may look as simple as this:: SPOTIFY_PASSWORD = u'mysecret' +.. _music-from-spotify: + Music from Spotify ================== @@ -39,34 +41,26 @@ Premium account's username and password into the file, like this:: SPOTIFY_PASSWORD = u'mysecret' +.. _music-from-local-storage: + Music from local storage ======================== If you want use Mopidy to play music you have locally at your machine instead -of using Spotify, you need to change the backend from the default to -:mod:`mopidy.backends.local` by adding the following line to your settings -file:: - - BACKENDS = (u'mopidy.backends.local.LocalBackend',) - -You may also want to change some of the ``LOCAL_*`` settings. See -:mod:`mopidy.settings`, for a full list of available settings. - -.. note:: - - Currently, Mopidy supports using Spotify *or* local storage as a music - source. We're working on using both sources simultaneously, and will - hopefully have support for this in the 0.6 release. +of or in addition to using Spotify, you need to review and maybe change some of +the ``LOCAL_*`` settings. See :mod:`mopidy.settings`, for a full list of +available settings. Then you need to generate a tag cache for your local +music... -.. _generating_a_tag_cache: +.. _generating-a-tag-cache: Generating a tag cache ---------------------- Before Mopidy 0.3 the local storage backend relied purely on ``tag_cache`` files generated by the original MPD server. To remedy this the command -:command:`mopidy-scan` has been created. The program will scan your current +:command:`mopidy-scan` was created. The program will scan your current :attr:`mopidy.settings.LOCAL_MUSIC_PATH` and build a MPD compatible ``tag_cache``. @@ -90,7 +84,7 @@ To make a ``tag_cache`` of your local music available for Mopidy: #. Start Mopidy, find the music library in a client, and play some local music! -.. _use_mpd_on_a_network: +.. _use-mpd-on-a-network: Connecting from other machines on the network ============================================= @@ -119,7 +113,7 @@ file:: LASTFM_PASSWORD = u'mysecret' -.. _install_desktop_file: +.. _install-desktop-file: Controlling Mopidy through the Ubuntu Sound Menu ================================================ @@ -146,6 +140,41 @@ requirements of the `MPRIS specification `_. The ``TrackList`` and the ``Playlists`` interfaces of the spec are not supported. +Using a custom audio sink +========================= + +If you have successfully installed GStreamer, and then run the ``gst-inspect`` +or ``gst-inspect-0.10`` command, you should see a long listing of installed +plugins, ending in a summary line:: + + $ gst-inspect-0.10 + ... long list of installed plugins ... + Total count: 254 plugins (1 blacklist entry not shown), 1156 features + +Next, you should be able to produce a audible tone by running:: + + gst-launch-0.10 audiotestsrc ! sudioresample ! autoaudiosink + +If you cannot hear any sound when running this command, you won't hear any +sound from Mopidy either, as Mopidy by default uses GStreamer's +``autoaudiosink`` to play audio. Thus, make this work before you file a bug +against Mopidy. + +If you for some reason want to use some other GStreamer audio sink than +``autoaudiosink``, you can set the setting :attr:`mopidy.settings.OUTPUT` to a +partial GStreamer pipeline description describing the GStreamer sink you want +to use. + +Example of ``settings.py`` for using OSS4:: + + OUTPUT = u'oss4sink' + +Again, this is the equivalent of the following ``gst-inspect`` command, so make +this work first:: + + gst-launch-0.10 audiotestsrc ! audioresample ! oss4sink + + Streaming audio through a SHOUTcast/Icecast server ================================================== diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 11293446..3010acf4 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -1,62 +1,28 @@ +# pylint: disable = E0611,F0401 +from distutils.version import StrictVersion as SV +# pylint: enable = E0611,F0401 import sys +import warnings + +import pykka + + if not (2, 6) <= sys.version_info < (3,): - sys.exit(u'Mopidy requires Python >= 2.6, < 3') + sys.exit( + u'Mopidy requires Python >= 2.6, < 3, but found %s' % + '.'.join(map(str, sys.version_info[:3]))) -import os -import platform -from subprocess import PIPE, Popen +if (isinstance(pykka.__version__, basestring) + and not SV('1.0') <= SV(pykka.__version__) < SV('2.0')): + sys.exit( + u'Mopidy requires Pykka >= 1.0, < 2, but found %s' % pykka.__version__) -import glib -__version__ = '0.7.3' +warnings.filterwarnings('ignore', 'could not open display') -DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') -CACHE_PATH = os.path.join(str(glib.get_user_cache_dir()), 'mopidy') -SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy') -SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') -def get_version(): - try: - return get_git_version() - except EnvironmentError: - return __version__ +__version__ = '0.8.1' -def get_git_version(): - process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE) - if process.wait() != 0: - raise EnvironmentError('Execution of "git describe" failed') - version = process.stdout.read().strip() - if version.startswith('v'): - version = version[1:] - return version - -def get_platform(): - return platform.platform() - -def get_python(): - implementation = platform.python_implementation() - version = platform.python_version() - return u' '.join([implementation, version]) - -class MopidyException(Exception): - def __init__(self, message, *args, **kwargs): - super(MopidyException, self).__init__(message, *args, **kwargs) - 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 OptionalDependencyError(MopidyException): - pass from mopidy import settings as default_settings_module from mopidy.utils.settings import SettingsProxy diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 49793752..b312840e 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -24,78 +24,85 @@ sys.argv[1:] = gstreamer_args # Add ../ to the path so we can run Mopidy from a Git checkout without # installing it on the system. -sys.path.insert(0, - os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) +sys.path.insert( + 0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) -from mopidy import (get_version, settings, OptionalDependencyError, - SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) +from mopidy import exceptions, settings from mopidy.audio import Audio -from mopidy.utils import get_class -from mopidy.utils.deps import list_deps_optparse_callback -from mopidy.utils.log import setup_logging -from mopidy.utils.path import get_or_create_folder, get_or_create_file -from mopidy.utils.process import (exit_handler, stop_remaining_actors, - stop_actors_by_class, DebugThread) -from mopidy.utils.settings import list_settings_optparse_callback +from mopidy.core import Core +from mopidy.utils import ( + deps, importing, log, path, process, settings as settings_utils, + versioning) logger = logging.getLogger('mopidy.main') def main(): - debug_thread = DebugThread() + debug_thread = process.DebugThread() debug_thread.start() signal.signal(signal.SIGUSR1, debug_thread.handler) - signal.signal(signal.SIGTERM, exit_handler) + signal.signal(signal.SIGTERM, process.exit_handler) loop = gobject.MainLoop() + options = parse_options() try: - options = parse_options() - setup_logging(options.verbosity_level, options.save_debug_log) + log.setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) - setup_audio() - setup_backend() - setup_frontends() + audio = setup_audio() + backends = setup_backends(audio) + core = setup_core(audio, backends) + setup_frontends(core) loop.run() - except SettingsError as e: - logger.error(e.message) + except exceptions.SettingsError as ex: + logger.error(ex.message) except KeyboardInterrupt: logger.info(u'Interrupted. Exiting...') - except Exception as e: - logger.exception(e) + except Exception as ex: + logger.exception(ex) finally: loop.quit() stop_frontends() - stop_backend() + stop_core() + stop_backends() stop_audio() - stop_remaining_actors() + process.stop_remaining_actors() def parse_options(): - parser = optparse.OptionParser(version=u'Mopidy %s' % get_version()) - parser.add_option('--help-gst', + parser = optparse.OptionParser( + version=u'Mopidy %s' % versioning.get_version()) + parser.add_option( + '--help-gst', action='store_true', dest='help_gst', help='show GStreamer help options') - parser.add_option('-i', '--interactive', + parser.add_option( + '-i', '--interactive', action='store_true', dest='interactive', help='ask interactively for required settings which are missing') - parser.add_option('-q', '--quiet', + parser.add_option( + '-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') - parser.add_option('-v', '--verbose', + parser.add_option( + '-v', '--verbose', action='count', default=1, dest='verbosity_level', help='more output (debug level)') - parser.add_option('--save-debug-log', + parser.add_option( + '--save-debug-log', action='store_true', dest='save_debug_log', help='save debug log to "./mopidy.log"') - parser.add_option('--list-settings', - action='callback', callback=list_settings_optparse_callback, + parser.add_option( + '--list-settings', + action='callback', + callback=settings_utils.list_settings_optparse_callback, help='list current settings') - parser.add_option('--list-deps', - action='callback', callback=list_deps_optparse_callback, + parser.add_option( + '--list-deps', + action='callback', callback=deps.list_deps_optparse_callback, help='list dependencies and their versions') return parser.parse_args(args=mopidy_args)[0] @@ -106,50 +113,67 @@ def check_old_folders(): if not os.path.isdir(old_settings_folder): return - logger.warning(u'Old settings folder found at %s, settings.py should be ' - 'moved to %s, any cache data should be deleted. See release notes ' - 'for further instructions.', old_settings_folder, SETTINGS_PATH) + logger.warning( + u'Old settings folder found at %s, settings.py should be moved ' + u'to %s, any cache data should be deleted. See release notes for ' + u'further instructions.', old_settings_folder, path.SETTINGS_PATH) def setup_settings(interactive): - get_or_create_folder(SETTINGS_PATH) - get_or_create_folder(DATA_PATH) - get_or_create_file(SETTINGS_FILE) + path.get_or_create_folder(path.SETTINGS_PATH) + path.get_or_create_folder(path.DATA_PATH) + path.get_or_create_file(path.SETTINGS_FILE) try: settings.validate(interactive) - except SettingsError, e: - logger.error(e.message) + except exceptions.SettingsError as ex: + logger.error(ex.message) sys.exit(1) def setup_audio(): - Audio.start() + return Audio.start().proxy() def stop_audio(): - stop_actors_by_class(Audio) - -def setup_backend(): - get_class(settings.BACKENDS[0]).start() + process.stop_actors_by_class(Audio) -def stop_backend(): - stop_actors_by_class(get_class(settings.BACKENDS[0])) +def setup_backends(audio): + backends = [] + for backend_class_name in settings.BACKENDS: + backend_class = importing.get_class(backend_class_name) + backend = backend_class.start(audio=audio).proxy() + backends.append(backend) + return backends -def setup_frontends(): +def stop_backends(): + for backend_class_name in settings.BACKENDS: + process.stop_actors_by_class(importing.get_class(backend_class_name)) + + +def setup_core(audio, backends): + return Core.start(audio=audio, backends=backends).proxy() + + +def stop_core(): + process.stop_actors_by_class(Core) + + +def setup_frontends(core): for frontend_class_name in settings.FRONTENDS: try: - get_class(frontend_class_name).start() - except OptionalDependencyError as e: - logger.info(u'Disabled: %s (%s)', frontend_class_name, e) + importing.get_class(frontend_class_name).start(core=core) + except exceptions.OptionalDependencyError as ex: + logger.info(u'Disabled: %s (%s)', frontend_class_name, ex) def stop_frontends(): for frontend_class_name in settings.FRONTENDS: try: - stop_actors_by_class(get_class(frontend_class_name)) - except OptionalDependencyError: + frontend_class = importing.get_class(frontend_class_name) + process.stop_actors_by_class(frontend_class) + except exceptions.OptionalDependencyError: pass diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index dd98dfa8..ba76bd84 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -1,412 +1,3 @@ -import pygst -pygst.require('0.10') -import gst -import gobject - -import logging - -from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry - -from mopidy import settings, utils -from mopidy.backends.base import Backend -from mopidy.utils import process - -# Trigger install of gst mixer plugins -from mopidy.audio import mixers - -logger = logging.getLogger('mopidy.audio') - - -class Audio(ThreadingActor): - """ - Audio output through `GStreamer `_. - - **Settings:** - - - :attr:`mopidy.settings.OUTPUT` - - :attr:`mopidy.settings.MIXER` - - :attr:`mopidy.settings.MIXER_TRACK` - - """ - - def __init__(self): - super(Audio, self).__init__() - - self._default_caps = gst.Caps(""" - audio/x-raw-int, - endianness=(int)1234, - channels=(int)2, - width=(int)16, - depth=(int)16, - signed=(boolean)true, - rate=(int)44100""") - - self._pipeline = None - self._source = None - self._uridecodebin = None - self._output = None - self._mixer = None - - self._message_processor_set_up = False - - def on_start(self): - try: - self._setup_pipeline() - self._setup_output() - self._setup_mixer() - self._setup_message_processor() - except gobject.GError as ex: - logger.exception(ex) - process.exit_process() - - def on_stop(self): - self._teardown_message_processor() - self._teardown_mixer() - self._teardown_pipeline() - - def _setup_pipeline(self): - # TODO: replace with and input bin so we simply have an input bin we - # connect to an output bin with a mixer on the side. set_uri on bin? - description = ' ! '.join([ - 'uridecodebin name=uri', - 'audioconvert name=convert', - 'audioresample name=resample', - 'queue name=queue']) - - logger.debug(u'Setting up base GStreamer pipeline: %s', description) - - self._pipeline = gst.parse_launch(description) - self._uridecodebin = self._pipeline.get_by_name('uri') - - self._uridecodebin.connect('notify::source', self._on_new_source) - self._uridecodebin.connect('pad-added', self._on_new_pad, - self._pipeline.get_by_name('queue').get_pad('sink')) - - def _teardown_pipeline(self): - self._pipeline.set_state(gst.STATE_NULL) - - def _setup_output(self): - try: - self._output = gst.parse_bin_from_description( - settings.OUTPUT, ghost_unconnected_pads=True) - except gobject.GError as ex: - logger.error('Failed to create output "%s": %s', - settings.OUTPUT, ex) - process.exit_process() - return - - self._pipeline.add(self._output) - gst.element_link_many(self._pipeline.get_by_name('queue'), - self._output) - logger.info('Output set to %s', settings.OUTPUT) - - def _setup_mixer(self): - if not settings.MIXER: - logger.info('Not setting up mixer.') - return - - try: - mixerbin = gst.parse_bin_from_description(settings.MIXER, - ghost_unconnected_pads=False) - except gobject.GError as ex: - logger.warning('Failed to create mixer "%s": %s', - settings.MIXER, ex) - return - - # We assume that the bin will contain a single mixer. - mixer = mixerbin.get_by_interface('GstMixer') - if not mixer: - logger.warning('Did not find any mixers in %r', settings.MIXER) - return - - if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: - logger.warning('Setting mixer %r to READY failed.', settings.MIXER) - return - - track = self._select_mixer_track(mixer, settings.MIXER_TRACK) - if not track: - logger.warning('Could not find usable mixer track.') - return - - self._mixer = (mixer, track) - logger.info('Mixer set to %s using track called %s', - mixer.get_factory().get_name(), track.label) - - def _select_mixer_track(self, mixer, track_label): - # Look for track with label == MIXER_TRACK, otherwise fallback to - # master track which is also an output. - for track in mixer.list_tracks(): - if track_label: - if track.label == track_label: - return track - elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER | - gst.interfaces.MIXER_TRACK_OUTPUT): - return track - - def _teardown_mixer(self): - if self._mixer is not None: - (mixer, track) = self._mixer - mixer.set_state(gst.STATE_NULL) - - def _setup_message_processor(self): - bus = self._pipeline.get_bus() - bus.add_signal_watch() - bus.connect('message', self._on_message) - self._message_processor_set_up = True - - def _teardown_message_processor(self): - if self._message_processor_set_up: - bus = self._pipeline.get_bus() - bus.remove_signal_watch() - - def _on_new_source(self, element, pad): - self._source = element.get_property('source') - try: - self._source.set_property('caps', self._default_caps) - except TypeError: - pass - - def _on_new_pad(self, source, pad, target_pad): - if not pad.is_linked(): - if target_pad.is_linked(): - target_pad.get_peer().unlink(target_pad) - pad.link(target_pad) - - def _on_message(self, bus, message): - if message.type == gst.MESSAGE_EOS: - self._notify_backend_of_eos() - elif message.type == gst.MESSAGE_ERROR: - error, debug = message.parse_error() - logger.error(u'%s %s', error, debug) - self.stop_playback() - elif message.type == gst.MESSAGE_WARNING: - error, debug = message.parse_warning() - logger.warning(u'%s %s', error, debug) - - def _notify_backend_of_eos(self): - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) <= 1, 'Expected at most one running backend.' - if backend_refs: - logger.debug(u'Notifying backend of end-of-stream.') - backend_refs[0].proxy().playback.on_end_of_track() - else: - logger.debug(u'No backend to notify of end-of-stream found.') - - def set_uri(self, uri): - """ - Set URI of audio to be played. - - You *MUST* call :meth:`prepare_change` before calling this method. - - :param uri: the URI to play - :type uri: string - """ - self._uridecodebin.set_property('uri', uri) - - def emit_data(self, capabilities, data): - """ - Call this to deliver raw audio data to be played. - - Note that the uri must be set to ``appsrc://`` for this to work. - - :param capabilities: a GStreamer capabilities string - :type capabilities: string - :param data: raw audio data to be played - """ - caps = gst.caps_from_string(capabilities) - buffer_ = gst.Buffer(buffer(data)) - buffer_.set_caps(caps) - self._source.set_property('caps', caps) - self._source.emit('push-buffer', buffer_) - - def emit_end_of_stream(self): - """ - Put an end-of-stream token on the pipeline. This is typically used in - combination with :meth:`emit_data`. - - We will get a GStreamer message when the stream playback reaches the - token, and can then do any end-of-stream related tasks. - """ - self._source.emit('end-of-stream') - - def get_position(self): - """ - Get position in milliseconds. - - :rtype: int - """ - if self._pipeline.get_state()[1] == gst.STATE_NULL: - return 0 - try: - position = self._pipeline.query_position(gst.FORMAT_TIME)[0] - return position // gst.MSECOND - except gst.QueryError, e: - logger.error('time_position failed: %s', e) - return 0 - - def set_position(self, position): - """ - Set position in milliseconds. - - :param position: the position in milliseconds - :type volume: int - :rtype: :class:`True` if successful, else :class:`False` - """ - self._pipeline.get_state() # block until state changes are done - handeled = self._pipeline.seek_simple(gst.Format(gst.FORMAT_TIME), - gst.SEEK_FLAG_FLUSH, position * gst.MSECOND) - self._pipeline.get_state() # block until seek is done - return handeled - - def start_playback(self): - """ - Notify GStreamer that it should start playback. - - :rtype: :class:`True` if successfull, else :class:`False` - """ - return self._set_state(gst.STATE_PLAYING) - - def pause_playback(self): - """ - Notify GStreamer that it should pause playback. - - :rtype: :class:`True` if successfull, else :class:`False` - """ - return self._set_state(gst.STATE_PAUSED) - - def prepare_change(self): - """ - Notify GStreamer that we are about to change state of playback. - - This function *MUST* be called before changing URIs or doing - changes like updating data that is being pushed. The reason for this - is that GStreamer will reset all its state when it changes to - :attr:`gst.STATE_READY`. - """ - return self._set_state(gst.STATE_READY) - - def stop_playback(self): - """ - Notify GStreamer that is should stop playback. - - :rtype: :class:`True` if successfull, else :class:`False` - """ - return self._set_state(gst.STATE_NULL) - - def _set_state(self, state): - """ - Internal method for setting the raw GStreamer state. - - .. digraph:: gst_state_transitions - - graph [rankdir="LR"]; - node [fontsize=10]; - - "NULL" -> "READY" - "PAUSED" -> "PLAYING" - "PAUSED" -> "READY" - "PLAYING" -> "PAUSED" - "READY" -> "NULL" - "READY" -> "PAUSED" - - :param state: State to set pipeline to. One of: `gst.STATE_NULL`, - `gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`. - :type state: :class:`gst.State` - :rtype: :class:`True` if successfull, else :class:`False` - """ - result = self._pipeline.set_state(state) - if result == gst.STATE_CHANGE_FAILURE: - logger.warning('Setting GStreamer state to %s: failed', - state.value_name) - return False - elif result == gst.STATE_CHANGE_ASYNC: - logger.debug('Setting GStreamer state to %s: async', - state.value_name) - return True - else: - logger.debug('Setting GStreamer state to %s: OK', - state.value_name) - return True - - def get_volume(self): - """ - Get volume level of the installed mixer. - - Example values: - - 0: - Muted. - 100: - Max volume for given system. - :class:`None`: - No mixer present, so the volume is unknown. - - :rtype: int in range [0..100] or :class:`None` - """ - if self._mixer is None: - return None - - mixer, track = self._mixer - - volumes = mixer.get_volume(track) - avg_volume = float(sum(volumes)) / len(volumes) - - new_scale = (0, 100) - old_scale = (track.min_volume, track.max_volume) - return utils.rescale(avg_volume, old=old_scale, new=new_scale) - - def set_volume(self, volume): - """ - Set volume level of the installed mixer. - - :param volume: the volume in the range [0..100] - :type volume: int - :rtype: :class:`True` if successful, else :class:`False` - """ - if self._mixer is None: - return False - - mixer, track = self._mixer - - old_scale = (0, 100) - new_scale = (track.min_volume, track.max_volume) - - volume = utils.rescale(volume, old=old_scale, new=new_scale) - - volumes = (volume,) * track.num_channels - mixer.set_volume(track, volumes) - - return mixer.get_volume(track) == volumes - - def set_metadata(self, track): - """ - Set track metadata for currently playing song. - - Only needs to be called by sources such as `appsrc` which do not - already inject tags in pipeline, e.g. when using :meth:`emit_data` to - deliver raw audio data to GStreamer. - - :param track: the current track - :type track: :class:`mopidy.models.Track` - """ - taglist = gst.TagList() - artists = [a for a in (track.artists or []) if a.name] - - # Default to blank data to trick shoutcast into clearing any previous - # values it might have. - taglist[gst.TAG_ARTIST] = u' ' - taglist[gst.TAG_TITLE] = u' ' - taglist[gst.TAG_ALBUM] = u' ' - - if artists: - taglist[gst.TAG_ARTIST] = u', '.join([a.name for a in artists]) - - if track.name: - taglist[gst.TAG_TITLE] = track.name - - if track.album and track.album.name: - taglist[gst.TAG_ALBUM] = track.album.name - - event = gst.event_new_tag(taglist) - self._pipeline.send_event(event) +# flake8: noqa +from .actor import Audio +from .listener import AudioListener diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py new file mode 100644 index 00000000..852d5d57 --- /dev/null +++ b/mopidy/audio/actor.py @@ -0,0 +1,406 @@ +import pygst +pygst.require('0.10') +import gst +import gobject + +import logging + +import pykka + +from mopidy import settings +from mopidy.utils import process + +from . import mixers +from .listener import AudioListener + +logger = logging.getLogger('mopidy.audio') + +mixers.register_mixers() + + +class Audio(pykka.ThreadingActor): + """ + Audio output through `GStreamer `_. + + **Settings:** + + - :attr:`mopidy.settings.OUTPUT` + - :attr:`mopidy.settings.MIXER` + - :attr:`mopidy.settings.MIXER_TRACK` + + """ + + def __init__(self): + super(Audio, self).__init__() + + self._playbin = None + self._mixer = None + self._mixer_track = None + self._software_mixing = False + + self._message_processor_set_up = False + + def on_start(self): + try: + self._setup_playbin() + self._setup_output() + self._setup_mixer() + self._setup_message_processor() + except gobject.GError as ex: + logger.exception(ex) + process.exit_process() + + def on_stop(self): + self._teardown_message_processor() + self._teardown_mixer() + self._teardown_playbin() + + def _setup_playbin(self): + self._playbin = gst.element_factory_make('playbin2') + + fakesink = gst.element_factory_make('fakesink') + self._playbin.set_property('video-sink', fakesink) + + self._playbin.connect('notify::source', self._on_new_source) + + def _on_new_source(self, element, pad): + uri = element.get_property('uri') + if not uri or not uri.startswith('appsrc://'): + return + + # These caps matches the audio data provided by libspotify + default_caps = gst.Caps( + 'audio/x-raw-int, endianness=(int)1234, channels=(int)2, ' + 'width=(int)16, depth=(int)16, signed=(boolean)true, ' + 'rate=(int)44100') + source = element.get_property('source') + source.set_property('caps', default_caps) + + def _teardown_playbin(self): + self._playbin.set_state(gst.STATE_NULL) + + def _setup_output(self): + try: + output = gst.parse_bin_from_description( + settings.OUTPUT, ghost_unconnected_pads=True) + self._playbin.set_property('audio-sink', output) + logger.info('Audio output set to "%s"', settings.OUTPUT) + except gobject.GError as ex: + logger.error( + 'Failed to create audio output "%s": %s', settings.OUTPUT, ex) + process.exit_process() + + def _setup_mixer(self): + if not settings.MIXER: + logger.info('Not setting up audio mixer') + return + + if settings.MIXER == 'software': + self._software_mixing = True + logger.info('Audio mixer is using software mixing') + return + + try: + mixerbin = gst.parse_bin_from_description( + settings.MIXER, ghost_unconnected_pads=False) + except gobject.GError as ex: + logger.warning( + 'Failed to create audio mixer "%s": %s', settings.MIXER, ex) + return + + # We assume that the bin will contain a single mixer. + mixer = mixerbin.get_by_interface('GstMixer') + if not mixer: + logger.warning( + 'Did not find any audio mixers in "%s"', settings.MIXER) + return + + if mixerbin.set_state(gst.STATE_READY) != gst.STATE_CHANGE_SUCCESS: + logger.warning( + 'Setting audio mixer "%s" to READY failed', settings.MIXER) + return + + track = self._select_mixer_track(mixer, settings.MIXER_TRACK) + if not track: + logger.warning('Could not find usable audio mixer track') + return + + self._mixer = mixer + self._mixer_track = track + logger.info( + 'Audio mixer set to "%s" using track "%s"', + mixer.get_factory().get_name(), track.label) + + def _select_mixer_track(self, mixer, track_label): + # Look for track with label == MIXER_TRACK, otherwise fallback to + # master track which is also an output. + for track in mixer.list_tracks(): + if track_label: + if track.label == track_label: + return track + elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER | + gst.interfaces.MIXER_TRACK_OUTPUT): + return track + + def _teardown_mixer(self): + if self._mixer is not None: + self._mixer.set_state(gst.STATE_NULL) + + def _setup_message_processor(self): + bus = self._playbin.get_bus() + bus.add_signal_watch() + bus.connect('message', self._on_message) + self._message_processor_set_up = True + + def _teardown_message_processor(self): + if self._message_processor_set_up: + bus = self._playbin.get_bus() + bus.remove_signal_watch() + + def _on_message(self, bus, message): + if message.type == gst.MESSAGE_EOS: + self._trigger_reached_end_of_stream_event() + elif message.type == gst.MESSAGE_ERROR: + error, debug = message.parse_error() + logger.error(u'%s %s', error, debug) + self.stop_playback() + elif message.type == gst.MESSAGE_WARNING: + error, debug = message.parse_warning() + logger.warning(u'%s %s', error, debug) + + def _trigger_reached_end_of_stream_event(self): + logger.debug(u'Triggering reached end of stream event') + AudioListener.send('reached_end_of_stream') + + def set_uri(self, uri): + """ + Set URI of audio to be played. + + You *MUST* call :meth:`prepare_change` before calling this method. + + :param uri: the URI to play + :type uri: string + """ + self._playbin.set_property('uri', uri) + + def emit_data(self, capabilities, data): + """ + Call this to deliver raw audio data to be played. + + Note that the uri must be set to ``appsrc://`` for this to work. + + :param capabilities: a GStreamer capabilities string + :type capabilities: string + :param data: raw audio data to be played + """ + caps = gst.caps_from_string(capabilities) + buffer_ = gst.Buffer(buffer(data)) + buffer_.set_caps(caps) + + source = self._playbin.get_property('source') + source.set_property('caps', caps) + source.emit('push-buffer', buffer_) + + def emit_end_of_stream(self): + """ + Put an end-of-stream token on the playbin. This is typically used in + combination with :meth:`emit_data`. + + We will get a GStreamer message when the stream playback reaches the + token, and can then do any end-of-stream related tasks. + """ + self._playbin.get_property('source').emit('end-of-stream') + + def get_position(self): + """ + Get position in milliseconds. + + :rtype: int + """ + if self._playbin.get_state()[1] == gst.STATE_NULL: + return 0 + try: + position = self._playbin.query_position(gst.FORMAT_TIME)[0] + return position // gst.MSECOND + except gst.QueryError, e: + logger.error('time_position failed: %s', e) + return 0 + + def set_position(self, position): + """ + Set position in milliseconds. + + :param position: the position in milliseconds + :type position: int + :rtype: :class:`True` if successful, else :class:`False` + """ + self._playbin.get_state() # block until state changes are done + handeled = self._playbin.seek_simple( + gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, + position * gst.MSECOND) + self._playbin.get_state() # block until seek is done + return handeled + + def start_playback(self): + """ + Notify GStreamer that it should start playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ + return self._set_state(gst.STATE_PLAYING) + + def pause_playback(self): + """ + Notify GStreamer that it should pause playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ + return self._set_state(gst.STATE_PAUSED) + + def prepare_change(self): + """ + Notify GStreamer that we are about to change state of playback. + + This function *MUST* be called before changing URIs or doing + changes like updating data that is being pushed. The reason for this + is that GStreamer will reset all its state when it changes to + :attr:`gst.STATE_READY`. + """ + return self._set_state(gst.STATE_READY) + + def stop_playback(self): + """ + Notify GStreamer that is should stop playback. + + :rtype: :class:`True` if successfull, else :class:`False` + """ + return self._set_state(gst.STATE_NULL) + + def _set_state(self, state): + """ + Internal method for setting the raw GStreamer state. + + .. digraph:: gst_state_transitions + + graph [rankdir="LR"]; + node [fontsize=10]; + + "NULL" -> "READY" + "PAUSED" -> "PLAYING" + "PAUSED" -> "READY" + "PLAYING" -> "PAUSED" + "READY" -> "NULL" + "READY" -> "PAUSED" + + :param state: State to set playbin to. One of: `gst.STATE_NULL`, + `gst.STATE_READY`, `gst.STATE_PAUSED` and `gst.STATE_PLAYING`. + :type state: :class:`gst.State` + :rtype: :class:`True` if successfull, else :class:`False` + """ + result = self._playbin.set_state(state) + if result == gst.STATE_CHANGE_FAILURE: + logger.warning( + 'Setting GStreamer state to %s failed', state.value_name) + return False + elif result == gst.STATE_CHANGE_ASYNC: + logger.debug( + 'Setting GStreamer state to %s is async', state.value_name) + return True + else: + logger.debug( + 'Setting GStreamer state to %s is OK', state.value_name) + return True + + def get_volume(self): + """ + Get volume level of the installed mixer. + + Example values: + + 0: + Muted. + 100: + Max volume for given system. + :class:`None`: + No mixer present, so the volume is unknown. + + :rtype: int in range [0..100] or :class:`None` + """ + if self._software_mixing: + return round(self._playbin.get_property('volume') * 100) + + if self._mixer is None: + return None + + volumes = self._mixer.get_volume(self._mixer_track) + avg_volume = float(sum(volumes)) / len(volumes) + + new_scale = (0, 100) + old_scale = ( + self._mixer_track.min_volume, self._mixer_track.max_volume) + return self._rescale(avg_volume, old=old_scale, new=new_scale) + + def set_volume(self, volume): + """ + Set volume level of the installed mixer. + + :param volume: the volume in the range [0..100] + :type volume: int + :rtype: :class:`True` if successful, else :class:`False` + """ + if self._software_mixing: + self._playbin.set_property('volume', volume / 100.0) + return True + + if self._mixer is None: + return False + + old_scale = (0, 100) + new_scale = ( + self._mixer_track.min_volume, self._mixer_track.max_volume) + + volume = self._rescale(volume, old=old_scale, new=new_scale) + + volumes = (volume,) * self._mixer_track.num_channels + self._mixer.set_volume(self._mixer_track, volumes) + + return self._mixer.get_volume(self._mixer_track) == volumes + + def _rescale(self, value, old=None, new=None): + """Convert value between scales.""" + new_min, new_max = new + old_min, old_max = old + scaling = float(new_max - new_min) / (old_max - old_min) + return int(round(scaling * (value - old_min) + new_min)) + + def set_metadata(self, track): + """ + Set track metadata for currently playing song. + + Only needs to be called by sources such as `appsrc` which do not + already inject tags in playbin, e.g. when using :meth:`emit_data` to + deliver raw audio data to GStreamer. + + :param track: the current track + :type track: :class:`mopidy.models.Track` + """ + taglist = gst.TagList() + artists = [a for a in (track.artists or []) if a.name] + + # Default to blank data to trick shoutcast into clearing any previous + # values it might have. + taglist[gst.TAG_ARTIST] = u' ' + taglist[gst.TAG_TITLE] = u' ' + taglist[gst.TAG_ALBUM] = u' ' + + if artists: + taglist[gst.TAG_ARTIST] = u', '.join([a.name for a in artists]) + + if track.name: + taglist[gst.TAG_TITLE] = track.name + + if track.album and track.album.name: + taglist[gst.TAG_ALBUM] = track.album.name + + event = gst.event_new_tag(taglist) + self._playbin.send_event(event) diff --git a/mopidy/audio/listener.py b/mopidy/audio/listener.py new file mode 100644 index 00000000..54fe058d --- /dev/null +++ b/mopidy/audio/listener.py @@ -0,0 +1,28 @@ +import pykka + + +class AudioListener(object): + """ + Marker interface for recipients of events sent by the audio actor. + + Any Pykka actor that mixes in this class will receive calls to the methods + defined here when the corresponding events happen in the core actor. This + interface is used both for looking up what actors to notify of the events, + and for providing default implementations for those listeners that are not + interested in all events. + """ + + @staticmethod + def send(event, **kwargs): + """Helper to allow calling of audio listener events""" + listeners = pykka.ActorRegistry.get_by_class(AudioListener) + for listener in listeners: + getattr(listener.proxy(), event)(**kwargs) + + def reached_end_of_stream(self): + """ + Called whenever the end of the audio stream is reached. + + *MAY* be implemented by actor. + """ + pass diff --git a/mopidy/audio/mixers/__init__.py b/mopidy/audio/mixers/__init__.py index a0247519..034b0fa9 100644 --- a/mopidy/audio/mixers/__init__.py +++ b/mopidy/audio/mixers/__init__.py @@ -3,41 +3,18 @@ pygst.require('0.10') import gst import gobject - -def create_track(label, initial_volume, min_volume, max_volume, - num_channels, flags): - class Track(gst.interfaces.MixerTrack): - def __init__(self): - super(Track, self).__init__() - self.volumes = (initial_volume,) * self.num_channels - - @gobject.property - def label(self): - return label - - @gobject.property - def min_volume(self): - return min_volume - - @gobject.property - def max_volume(self): - return max_volume - - @gobject.property - def num_channels(self): - return num_channels - - @gobject.property - def flags(self): - return flags - - return Track() - - -# Import all mixers so that they are registered with GStreamer. -# -# Keep these imports at the bottom of the file to avoid cyclic import problems -# when mixers use the above code. from .auto import AutoAudioMixer from .fake import FakeMixer from .nad import NadMixer + + +def register_mixer(mixer_class): + gobject.type_register(mixer_class) + gst.element_register( + mixer_class, mixer_class.__name__.lower(), gst.RANK_MARGINAL) + + +def register_mixers(): + register_mixer(AutoAudioMixer) + register_mixer(FakeMixer) + register_mixer(NadMixer) diff --git a/mopidy/audio/mixers/auto.py b/mopidy/audio/mixers/auto.py index 1233afa3..05294801 100644 --- a/mopidy/audio/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -1,6 +1,19 @@ +"""Mixer element that automatically selects the real mixer to use. + +This is Mopidy's default mixer. + +**Dependencies:** + +- None + +**Settings:** + +- If this wasn't the default, you would set :attr:`mopidy.settings.MIXER` + to ``autoaudiomixer`` to use this mixer. +""" + import pygst pygst.require('0.10') -import gobject import gst import logging @@ -10,16 +23,19 @@ logger = logging.getLogger('mopidy.audio.mixers.auto') # TODO: we might want to add some ranking to the mixers we know about? class AutoAudioMixer(gst.Bin): - __gstdetails__ = ('AutoAudioMixer', - 'Mixer', - 'Element automatically selects a mixer.', - 'Thomas Adamcik') + __gstdetails__ = ( + 'AutoAudioMixer', + 'Mixer', + 'Element automatically selects a mixer.', + 'Mopidy') def __init__(self): gst.Bin.__init__(self) mixer = self._find_mixer() if mixer: + # pylint: disable=E1101 self.add(mixer) + # pylint: enable=E1101 logger.debug('AutoAudioMixer chose: %s', mixer.get_name()) else: logger.debug('AutoAudioMixer did not find any usable mixers') @@ -66,7 +82,3 @@ class AutoAudioMixer(gst.Bin): if track.flags & flags: return True return False - - -gobject.type_register(AutoAudioMixer) -gst.element_register(AutoAudioMixer, 'autoaudiomixer', gst.RANK_MARGINAL) diff --git a/mopidy/audio/mixers/fake.py b/mopidy/audio/mixers/fake.py index c5faa03f..10710466 100644 --- a/mopidy/audio/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -1,31 +1,39 @@ +"""Fake mixer for use in tests. + +**Dependencies:** + +- None + +**Settings:** + +- Set :attr:`mopidy.settings.MIXER` to ``fakemixer`` to use this mixer. +""" + import pygst pygst.require('0.10') import gobject import gst -from mopidy.audio.mixers import create_track +from . import utils class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): - __gstdetails__ = ('FakeMixer', - 'Mixer', - 'Fake mixer for use in tests.', - 'Thomas Adamcik') + __gstdetails__ = ( + 'FakeMixer', + 'Mixer', + 'Fake mixer for use in tests.', + 'Mopidy') track_label = gobject.property(type=str, default='Master') track_initial_volume = gobject.property(type=int, default=0) track_min_volume = gobject.property(type=int, default=0) track_max_volume = gobject.property(type=int, default=100) track_num_channels = gobject.property(type=int, default=2) - track_flags = gobject.property(type=int, - default=(gst.interfaces.MIXER_TRACK_MASTER | - gst.interfaces.MIXER_TRACK_OUTPUT)) - - def __init__(self): - gst.Element.__init__(self) + track_flags = gobject.property(type=int, default=( + gst.interfaces.MIXER_TRACK_MASTER | gst.interfaces.MIXER_TRACK_OUTPUT)) def list_tracks(self): - track = create_track( + track = utils.create_track( self.track_label, self.track_initial_volume, self.track_min_volume, @@ -42,7 +50,3 @@ class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): def set_record(self, track, record): pass - - -gobject.type_register(FakeMixer) -gst.element_register(FakeMixer, 'fakemixer', gst.RANK_MARGINAL) diff --git a/mopidy/audio/mixers/nad.py b/mopidy/audio/mixers/nad.py index 667dee53..1a807e39 100644 --- a/mopidy/audio/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -1,3 +1,50 @@ +"""Mixer that controls volume using a NAD amplifier. + +**Dependencies:** + +- pyserial (python-serial in Debian/Ubuntu) + +- The NAD amplifier must be connected to the machine running Mopidy using a + serial cable. + +**Settings:** + +- Set :attr:`mopidy.settings.MIXER` to ``nadmixer`` to use it. You probably + also needs to add some properties to the ``MIXER`` setting. + +Supported properties includes: + +``port``: + The serial device to use, defaults to ``/dev/ttyUSB0``. This must be + set correctly for the mixer to work. + +``source``: + The source that should be selected on the amplifier, like ``aux``, + ``disc``, ``tape``, ``tuner``, etc. Leave unset if you don't want the + mixer to change it for you. + +``speakers-a``: + Set to ``on`` or ``off`` if you want the mixer to make sure that + speaker set A is turned on or off. Leave unset if you don't want the + mixer to change it for you. + +``speakers-b``: + See ``speakers-a``. + +Configuration examples:: + + # Minimum configuration, if the amplifier is available at /dev/ttyUSB0 + MIXER = u'nadmixer' + + # Minimum configuration, if the amplifier is available elsewhere + MIXER = u'nadmixer port=/dev/ttyUSB3' + + # Full configuration + MIXER = ( + u'nadmixer port=/dev/ttyUSB0 ' + u'source=aux speakers-a=on speakers-b=off') +""" + import logging import pygst @@ -8,41 +55,41 @@ import gst try: import serial except ImportError: - serial = None + serial = None # noqa -from pykka.actor import ThreadingActor +import pykka -from mopidy.audio.mixers import create_track +from . import utils logger = logging.getLogger('mopidy.audio.mixers.nad') class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): - __gstdetails__ = ('NadMixer', - 'Mixer', - 'Mixer to control NAD amplifiers using a serial link', - 'Stein Magnus Jodal') + __gstdetails__ = ( + 'NadMixer', + 'Mixer', + 'Mixer to control NAD amplifiers using a serial link', + 'Mopidy') port = gobject.property(type=str, default='/dev/ttyUSB0') source = gobject.property(type=str) speakers_a = gobject.property(type=str) speakers_b = gobject.property(type=str) - def __init__(self): - gst.Element.__init__(self) - self._volume_cache = 0 - self._nad_talker = None + _volume_cache = 0 + _nad_talker = None def list_tracks(self): - track = create_track( + track = utils.create_track( label='Master', initial_volume=0, min_volume=0, max_volume=100, num_channels=1, - flags=(gst.interfaces.MIXER_TRACK_MASTER | - gst.interfaces.MIXER_TRACK_OUTPUT)) + flags=( + gst.interfaces.MIXER_TRACK_MASTER | + gst.interfaces.MIXER_TRACK_OUTPUT)) return [track] def get_volume(self, track): @@ -74,13 +121,9 @@ class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): ).proxy() -gobject.type_register(NadMixer) -gst.element_register(NadMixer, 'nadmixer', gst.RANK_MARGINAL) - - -class NadTalker(ThreadingActor): +class NadTalker(pykka.ThreadingActor): """ - Independent thread which does the communication with the NAD amplifier + Independent thread which does the communication with the NAD amplifier. Since the communication is done in an independent thread, Mopidy won't block other requests while doing rather time consuming work like @@ -121,8 +164,7 @@ class NadTalker(ThreadingActor): self._set_device_to_known_state() def _open_connection(self): - logger.info(u'NAD amplifier: Connecting through "%s"', - self.port) + logger.info(u'NAD amplifier: Connecting through "%s"', self.port) self._device = serial.Serial( port=self.port, baudrate=self.BAUDRATE, @@ -137,7 +179,7 @@ class NadTalker(ThreadingActor): self._select_speakers() self._select_input_source() self.mute(False) - self._calibrate_volume() + self.calibrate_volume() def _get_device_model(self): model = self._ask_device('Main.Model') @@ -163,14 +205,21 @@ class NadTalker(ThreadingActor): else: self._check_and_set('Main.Mute', 'Off') - def _calibrate_volume(self): + def calibrate_volume(self, current_nad_volume=None): # 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'NAD amplifier: Calibrating by setting volume to 0') - self._nad_volume = self.VOLUME_LEVELS - self.set_volume(0) - logger.info(u'NAD amplifier: Done calibrating') + if current_nad_volume is None: + current_nad_volume = self.VOLUME_LEVELS + if current_nad_volume == self.VOLUME_LEVELS: + logger.info(u'NAD amplifier: Calibrating by setting volume to 0') + self._nad_volume = current_nad_volume + if self._decrease_volume(): + current_nad_volume -= 1 + if current_nad_volume == 0: + logger.info(u'NAD amplifier: Done calibrating') + else: + self.actor_ref.proxy().calibrate_volume(current_nad_volume) def set_volume(self, volume): # Increase or decrease the amplifier volume until it matches the given @@ -200,11 +249,13 @@ class NadTalker(ThreadingActor): for attempt in range(1, 4): if self._ask_device(key) == value: return - logger.info(u'NAD amplifier: Setting "%s" to "%s" (attempt %d/3)', + logger.info( + u'NAD amplifier: Setting "%s" to "%s" (attempt %d/3)', key, value, attempt) self._command_device(key, value) if self._ask_device(key) != value: - logger.info(u'NAD amplifier: Gave up on setting "%s" to "%s"', + logger.info( + u'NAD amplifier: Gave up on setting "%s" to "%s"', key, value) def _ask_device(self, key): diff --git a/mopidy/audio/mixers/utils.py b/mopidy/audio/mixers/utils.py new file mode 100644 index 00000000..c257ffd7 --- /dev/null +++ b/mopidy/audio/mixers/utils.py @@ -0,0 +1,35 @@ +import pygst +pygst.require('0.10') +import gst +import gobject + + +def create_track(label, initial_volume, min_volume, max_volume, + num_channels, flags): + + class Track(gst.interfaces.MixerTrack): + def __init__(self): + super(Track, self).__init__() + self.volumes = (initial_volume,) * self.num_channels + + @gobject.property + def label(self): + return label + + @gobject.property + def min_volume(self): + return min_volume + + @gobject.property + def max_volume(self): + return max_volume + + @gobject.property + def num_channels(self): + return num_channels + + @gobject.property + def flags(self): + return flags + + return Track() diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py new file mode 100644 index 00000000..de33e6e5 --- /dev/null +++ b/mopidy/backends/base.py @@ -0,0 +1,215 @@ +import copy + + +class Backend(object): + #: Actor proxy to an instance of :class:`mopidy.audio.Audio`. + #: + #: Should be passed to the backend constructor as the kwarg ``audio``, + #: which will then set this field. + audio = None + + #: The library provider. An instance of + # :class:`mopidy.backends.base.BaseLibraryProvider`. + library = None + + #: The playback provider. An instance of + #: :class:`mopidy.backends.base.BasePlaybackProvider`. + playback = None + + #: The stored playlists provider. An instance of + #: :class:`mopidy.backends.base.BaseStoredPlaylistsProvider`. + stored_playlists = None + + #: List of URI schemes this backend can handle. + uri_schemes = [] + + +class BaseLibraryProvider(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backends.base.Backend` + """ + + pykka_traversable = True + + def __init__(self, backend): + self.backend = backend + + def find_exact(self, **query): + """ + See :meth:`mopidy.core.LibraryController.find_exact`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def lookup(self, uri): + """ + See :meth:`mopidy.core.LibraryController.lookup`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def refresh(self, uri=None): + """ + See :meth:`mopidy.core.LibraryController.refresh`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def search(self, **query): + """ + See :meth:`mopidy.core.LibraryController.search`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + +class BasePlaybackProvider(object): + """ + :param audio: the audio actor + :type audio: actor proxy to an instance of :class:`mopidy.audio.Audio` + :param backend: the backend + :type backend: :class:`mopidy.backends.base.Backend` + """ + + pykka_traversable = True + + def __init__(self, audio, backend): + self.audio = audio + self.backend = backend + + def pause(self): + """ + Pause playback. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.pause_playback().get() + + def play(self, track): + """ + Play given track. + + *MAY be reimplemented by subclass.* + + :param track: the track to play + :type track: :class:`mopidy.models.Track` + :rtype: :class:`True` if successful, else :class:`False` + """ + self.audio.prepare_change() + self.audio.set_uri(track.uri).get() + return self.audio.start_playback().get() + + def resume(self): + """ + Resume playback at the same time position playback was paused. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.start_playback().get() + + def seek(self, time_position): + """ + Seek to a given time position. + + *MAY be reimplemented by subclass.* + + :param time_position: time position in milliseconds + :type time_position: int + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.set_position(time_position).get() + + def stop(self): + """ + Stop playback. + + *MAY be reimplemented by subclass.* + + :rtype: :class:`True` if successful, else :class:`False` + """ + return self.audio.stop_playback().get() + + def get_time_position(self): + """ + Get the current time position in milliseconds. + + *MAY be reimplemented by subclass.* + + :rtype: int + """ + return self.audio.get_position().get() + + +class BaseStoredPlaylistsProvider(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backends.base.Backend` + """ + + pykka_traversable = True + + def __init__(self, backend): + self.backend = backend + self._playlists = [] + + @property + def playlists(self): + """ + Currently stored playlists. + + Read/write. List of :class:`mopidy.models.Playlist`. + """ + return copy.copy(self._playlists) + + @playlists.setter # noqa + def playlists(self, playlists): + self._playlists = playlists + + def create(self, name): + """ + See :meth:`mopidy.core.StoredPlaylistsController.create`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def delete(self, uri): + """ + See :meth:`mopidy.core.StoredPlaylistsController.delete`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def lookup(self, uri): + """ + See :meth:`mopidy.core.StoredPlaylistsController.lookup`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def refresh(self): + """ + See :meth:`mopidy.core.StoredPlaylistsController.refresh`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError + + def save(self, playlist): + """ + See :meth:`mopidy.core.StoredPlaylistsController.save`. + + *MUST be implemented by subclass.* + """ + raise NotImplementedError diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py deleted file mode 100644 index e6c8b70a..00000000 --- a/mopidy/backends/base/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -from .library import BaseLibraryProvider -from .playback import BasePlaybackProvider -from .stored_playlists import BaseStoredPlaylistsProvider - - -class Backend(object): - #: The current playlist controller. An instance of - #: :class:`mopidy.backends.base.CurrentPlaylistController`. - current_playlist = None - - #: The library controller. An instance of - # :class:`mopidy.backends.base.LibraryController`. - library = None - - #: The playback controller. An instance of - #: :class:`mopidy.backends.base.PlaybackController`. - playback = None - - #: The stored playlists controller. An instance of - #: :class:`mopidy.backends.base.StoredPlaylistsController`. - stored_playlists = None - - #: List of URI schemes this backend can handle. - uri_schemes = [] diff --git a/mopidy/backends/base/library.py b/mopidy/backends/base/library.py deleted file mode 100644 index 837eef49..00000000 --- a/mopidy/backends/base/library.py +++ /dev/null @@ -1,42 +0,0 @@ -class BaseLibraryProvider(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, backend): - self.backend = backend - - def find_exact(self, **query): - """ - See :meth:`mopidy.backends.base.LibraryController.find_exact`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def lookup(self, uri): - """ - See :meth:`mopidy.backends.base.LibraryController.lookup`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def refresh(self, uri=None): - """ - See :meth:`mopidy.backends.base.LibraryController.refresh`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def search(self, **query): - """ - See :meth:`mopidy.backends.base.LibraryController.search`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py deleted file mode 100644 index ae5a4383..00000000 --- a/mopidy/backends/base/playback.py +++ /dev/null @@ -1,87 +0,0 @@ -class BasePlaybackProvider(object): - """ - :param backend: the backend - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, backend): - self.backend = backend - - def pause(self): - """ - Pause playback. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.backend.audio.pause_playback().get() - - def play(self, track): - """ - Play given track. - - *MAY be reimplemented by subclass.* - - :param track: the track to play - :type track: :class:`mopidy.models.Track` - :rtype: :class:`True` if successful, else :class:`False` - """ - self.backend.audio.prepare_change() - self.backend.audio.set_uri(track.uri).get() - return self.backend.audio.start_playback().get() - - def resume(self): - """ - Resume playback at the same time position playback was paused. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.backend.audio.start_playback().get() - - def seek(self, time_position): - """ - Seek to a given time position. - - *MAY be reimplemented by subclass.* - - :param time_position: time position in milliseconds - :type time_position: int - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.backend.audio.set_position(time_position).get() - - def stop(self): - """ - Stop playback. - - *MAY be reimplemented by subclass.* - - :rtype: :class:`True` if successful, else :class:`False` - """ - return self.backend.audio.stop_playback().get() - - def get_volume(self): - """ - Get current volume - - *MAY be reimplemented by subclass.* - - :rtype: int [0..100] or :class:`None` - """ - return self.backend.audio.get_volume().get() - - def set_volume(self, volume): - """ - Get current volume - - *MAY be reimplemented by subclass.* - - :param: volume - :type volume: int [0..100] - """ - self.backend.audio.set_volume(volume) diff --git a/mopidy/backends/base/stored_playlists.py b/mopidy/backends/base/stored_playlists.py deleted file mode 100644 index d1d52c9a..00000000 --- a/mopidy/backends/base/stored_playlists.py +++ /dev/null @@ -1,75 +0,0 @@ -from copy import copy - - -class BaseStoredPlaylistsProvider(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - """ - - pykka_traversable = True - - def __init__(self, backend): - self.backend = backend - self._playlists = [] - - @property - def playlists(self): - """ - Currently stored playlists. - - Read/write. List of :class:`mopidy.models.Playlist`. - """ - return copy(self._playlists) - - @playlists.setter - def playlists(self, playlists): - self._playlists = playlists - - def create(self, name): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.create`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def delete(self, playlist): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.delete`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def lookup(self, uri): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.lookup`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def refresh(self): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.refresh`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def rename(self, playlist, new_name): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.rename`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError - - def save(self, playlist): - """ - See :meth:`mopidy.backends.base.StoredPlaylistsController.save`. - - *MUST be implemented by subclass.* - """ - raise NotImplementedError diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy.py similarity index 57% rename from mopidy/backends/dummy/__init__.py rename to mopidy/backends/dummy.py index 3ada0052..51129200 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy.py @@ -1,34 +1,32 @@ -from pykka.actor import ThreadingActor +"""A dummy backend for use in tests. + +This backend implements the backend API in the simplest way possible. It is +used in tests of the frontends. + +The backend handles URIs starting with ``dummy:``. + +**Dependencies:** + +- None + +**Settings:** + +- None +""" + +import pykka -from mopidy import core from mopidy.backends import base from mopidy.models import Playlist -class DummyBackend(ThreadingActor, base.Backend): - """ - A backend which implements the backend API in the simplest way possible. - Used in tests of the frontends. +class DummyBackend(pykka.ThreadingActor, base.Backend): + def __init__(self, audio): + super(DummyBackend, self).__init__() - Handles URIs starting with ``dummy:``. - """ - - def __init__(self, *args, **kwargs): - super(DummyBackend, self).__init__(*args, **kwargs) - - self.current_playlist = core.CurrentPlaylistController(backend=self) - - library_provider = DummyLibraryProvider(backend=self) - self.library = core.LibraryController(backend=self, - provider=library_provider) - - playback_provider = DummyPlaybackProvider(backend=self) - self.playback = core.PlaybackController(backend=self, - provider=playback_provider) - - stored_playlists_provider = DummyStoredPlaylistsProvider(backend=self) - self.stored_playlists = core.StoredPlaylistsController(backend=self, - provider=stored_playlists_provider) + self.library = DummyLibraryProvider(backend=self) + self.playback = DummyPlaybackProvider(audio=audio, backend=self) + self.stored_playlists = DummyStoredPlaylistsProvider(backend=self) self.uri_schemes = [u'dummy'] @@ -56,29 +54,28 @@ class DummyLibraryProvider(base.BaseLibraryProvider): class DummyPlaybackProvider(base.BasePlaybackProvider): def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) - self._volume = None + self._time_position = 0 def pause(self): return True def play(self, track): - """Pass None as track to force failure""" - return track is not None + """Pass a track with URI 'dummy:error' to force failure""" + self._time_position = 0 + return track.uri != 'dummy:error' def resume(self): return True def seek(self, time_position): + self._time_position = time_position return True def stop(self): return True - def get_volume(self): - return self._volume - - def set_volume(self, volume): - self._volume = volume + def get_time_position(self): + return self._time_position class DummyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index c7126824..6f049474 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -1,251 +1,24 @@ -import glob -import glib -import logging -import os -import shutil +"""A backend for playing music from a local music archive. -from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry +This backend handles URIs starting with ``file:``. -from mopidy import audio, core, settings, DATA_PATH -from mopidy.backends import base -from mopidy.models import Playlist, Track, Album +See :ref:`music-from-local-storage` for further instructions on using this +backend. -from .translator import parse_m3u, parse_mpd_tag_cache +**Issues:** -logger = logging.getLogger(u'mopidy.backends.local') +https://github.com/mopidy/mopidy/issues?labels=Local+backend -DEFAULT_PLAYLIST_PATH = os.path.join(DATA_PATH, 'playlists') -DEFAULT_TAG_CACHE_FILE = os.path.join(DATA_PATH, 'tag_cache') -DEFAULT_MUSIC_PATH = str(glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC)) +**Dependencies:** -if not DEFAULT_MUSIC_PATH or DEFAULT_MUSIC_PATH == os.path.expanduser(u'~'): - DEFAULT_MUSIC_PATH = os.path.expanduser(u'~/music') +- None +**Settings:** -class LocalBackend(ThreadingActor, base.Backend): - """ - A backend for playing music from a local music archive. +- :attr:`mopidy.settings.LOCAL_MUSIC_PATH` +- :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH` +- :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` +""" - **Dependencies:** - - - None - - **Settings:** - - - :attr:`mopidy.settings.LOCAL_MUSIC_PATH` - - :attr:`mopidy.settings.LOCAL_PLAYLIST_PATH` - - :attr:`mopidy.settings.LOCAL_TAG_CACHE_FILE` - """ - - def __init__(self, *args, **kwargs): - super(LocalBackend, self).__init__(*args, **kwargs) - - self.current_playlist = core.CurrentPlaylistController(backend=self) - - library_provider = LocalLibraryProvider(backend=self) - self.library = core.LibraryController(backend=self, - provider=library_provider) - - playback_provider = base.BasePlaybackProvider(backend=self) - self.playback = LocalPlaybackController(backend=self, - provider=playback_provider) - - stored_playlists_provider = LocalStoredPlaylistsProvider(backend=self) - self.stored_playlists = core.StoredPlaylistsController(backend=self, - provider=stored_playlists_provider) - - self.uri_schemes = [u'file'] - - self.audio = None - - def on_start(self): - audio_refs = ActorRegistry.get_by_class(audio.Audio) - assert len(audio_refs) == 1, \ - 'Expected exactly one running Audio instance.' - self.audio = audio_refs[0].proxy() - - -class LocalPlaybackController(core.PlaybackController): - def __init__(self, *args, **kwargs): - super(LocalPlaybackController, self).__init__(*args, **kwargs) - - # XXX Why do we call stop()? Is it to set GStreamer state to 'READY'? - self.stop() - - @property - def time_position(self): - return self.backend.audio.get_position().get() - - -class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): - def __init__(self, *args, **kwargs): - super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) - self._folder = settings.LOCAL_PLAYLIST_PATH or DEFAULT_PLAYLIST_PATH - self.refresh() - - def lookup(self, uri): - pass # TODO - - def refresh(self): - playlists = [] - - logger.info('Loading playlists from %s', self._folder) - - for m3u in glob.glob(os.path.join(self._folder, '*.m3u')): - name = os.path.basename(m3u)[:-len('.m3u')] - tracks = [] - for uri in parse_m3u(m3u): - try: - tracks.append(self.backend.library.lookup(uri)) - except LookupError, e: - logger.error('Playlist item could not be added: %s', e) - playlist = Playlist(tracks=tracks, name=name) - - # FIXME playlist name needs better handling - # FIXME tracks should come from lib. lookup - - playlists.append(playlist) - - self.playlists = playlists - - def create(self, name): - playlist = Playlist(name=name) - self.save(playlist) - return playlist - - def delete(self, playlist): - if playlist not in self._playlists: - return - - self._playlists.remove(playlist) - filename = os.path.join(self._folder, playlist.name + '.m3u') - - if os.path.exists(filename): - os.remove(filename) - - def rename(self, playlist, name): - if playlist not in self._playlists: - return - - src = os.path.join(self._folder, playlist.name + '.m3u') - dst = os.path.join(self._folder, name + '.m3u') - - renamed = playlist.copy(name=name) - index = self._playlists.index(playlist) - self._playlists[index] = renamed - - shutil.move(src, dst) - - def save(self, playlist): - file_path = os.path.join(self._folder, playlist.name + '.m3u') - - # FIXME this should be a save_m3u function, not inside save - with open(file_path, 'w') as file_handle: - for track in playlist.tracks: - if track.uri.startswith('file://'): - file_handle.write(track.uri[len('file://'):] + '\n') - else: - file_handle.write(track.uri + '\n') - - self._playlists.append(playlist) - - -class LocalLibraryProvider(base.BaseLibraryProvider): - def __init__(self, *args, **kwargs): - super(LocalLibraryProvider, self).__init__(*args, **kwargs) - self._uri_mapping = {} - self.refresh() - - def refresh(self, uri=None): - tag_cache = settings.LOCAL_TAG_CACHE_FILE or DEFAULT_TAG_CACHE_FILE - music_folder = settings.LOCAL_MUSIC_PATH or DEFAULT_MUSIC_PATH - - tracks = parse_mpd_tag_cache(tag_cache, music_folder) - - logger.info('Loading tracks in %s from %s', music_folder, tag_cache) - - for track in tracks: - self._uri_mapping[track.uri] = track - - def lookup(self, uri): - try: - return self._uri_mapping[uri] - except KeyError: - logger.debug(u'Failed to lookup "%s"', uri) - return None - - def find_exact(self, **query): - self._validate_query(query) - result_tracks = self._uri_mapping.values() - - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] - # FIXME this is bound to be slow for large libraries - for value in values: - q = value.strip() - - track_filter = lambda t: q == t.name - album_filter = lambda t: q == getattr(t, 'album', Album()).name - artist_filter = lambda t: filter( - lambda a: q == a.name, t.artists) - uri_filter = lambda t: q == t.uri - any_filter = lambda t: (track_filter(t) or album_filter(t) or - artist_filter(t) or uri_filter(t)) - - if field == 'track': - result_tracks = filter(track_filter, result_tracks) - elif field == 'album': - result_tracks = filter(album_filter, result_tracks) - elif field == 'artist': - result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri': - result_tracks = filter(uri_filter, result_tracks) - elif field == 'any': - result_tracks = filter(any_filter, result_tracks) - else: - raise LookupError('Invalid lookup field: %s' % field) - return Playlist(tracks=result_tracks) - - def search(self, **query): - self._validate_query(query) - result_tracks = self._uri_mapping.values() - - for (field, values) in query.iteritems(): - if not hasattr(values, '__iter__'): - values = [values] - # FIXME this is bound to be slow for large libraries - for value in values: - q = value.strip().lower() - - track_filter = lambda t: q in t.name.lower() - album_filter = lambda t: q in getattr( - t, 'album', Album()).name.lower() - artist_filter = lambda t: filter( - lambda a: q in a.name.lower(), t.artists) - uri_filter = lambda t: q in t.uri.lower() - any_filter = lambda t: track_filter(t) or album_filter(t) or \ - artist_filter(t) or uri_filter(t) - - if field == 'track': - result_tracks = filter(track_filter, result_tracks) - elif field == 'album': - result_tracks = filter(album_filter, result_tracks) - elif field == 'artist': - result_tracks = filter(artist_filter, result_tracks) - elif field == 'uri': - result_tracks = filter(uri_filter, result_tracks) - elif field == 'any': - result_tracks = filter(any_filter, result_tracks) - else: - raise LookupError('Invalid lookup field: %s' % field) - return Playlist(tracks=result_tracks) - - def _validate_query(self, query): - for (_, values) in query.iteritems(): - if not values: - raise LookupError('Missing query') - for value in values: - if not value: - raise LookupError('Missing query') +# flake8: noqa +from .actor import LocalBackend diff --git a/mopidy/backends/local/actor.py b/mopidy/backends/local/actor.py new file mode 100644 index 00000000..70351ed1 --- /dev/null +++ b/mopidy/backends/local/actor.py @@ -0,0 +1,21 @@ +import logging + +import pykka + +from mopidy.backends import base + +from .library import LocalLibraryProvider +from .stored_playlists import LocalStoredPlaylistsProvider + +logger = logging.getLogger(u'mopidy.backends.local') + + +class LocalBackend(pykka.ThreadingActor, base.Backend): + def __init__(self, audio): + super(LocalBackend, self).__init__() + + self.library = LocalLibraryProvider(backend=self) + self.playback = base.BasePlaybackProvider(audio=audio, backend=self) + self.stored_playlists = LocalStoredPlaylistsProvider(backend=self) + + self.uri_schemes = [u'file'] diff --git a/mopidy/backends/local/library.py b/mopidy/backends/local/library.py new file mode 100644 index 00000000..9abdf7ed --- /dev/null +++ b/mopidy/backends/local/library.py @@ -0,0 +1,110 @@ +import logging + +from mopidy import settings +from mopidy.backends import base +from mopidy.models import Playlist, Album + +from .translator import parse_mpd_tag_cache + +logger = logging.getLogger(u'mopidy.backends.local') + + +class LocalLibraryProvider(base.BaseLibraryProvider): + def __init__(self, *args, **kwargs): + super(LocalLibraryProvider, self).__init__(*args, **kwargs) + self._uri_mapping = {} + self.refresh() + + def refresh(self, uri=None): + tracks = parse_mpd_tag_cache( + settings.LOCAL_TAG_CACHE_FILE, settings.LOCAL_MUSIC_PATH) + + logger.info( + 'Loading tracks from %s using %s', + settings.LOCAL_MUSIC_PATH, settings.LOCAL_TAG_CACHE_FILE) + + for track in tracks: + self._uri_mapping[track.uri] = track + + def lookup(self, uri): + try: + return self._uri_mapping[uri] + except KeyError: + logger.debug(u'Failed to lookup %r', uri) + return None + + def find_exact(self, **query): + self._validate_query(query) + result_tracks = self._uri_mapping.values() + + for (field, values) in query.iteritems(): + if not hasattr(values, '__iter__'): + values = [values] + # FIXME this is bound to be slow for large libraries + for value in values: + q = value.strip() + + track_filter = lambda t: q == t.name + album_filter = lambda t: q == getattr(t, 'album', Album()).name + artist_filter = lambda t: filter( + lambda a: q == a.name, t.artists) + uri_filter = lambda t: q == t.uri + any_filter = lambda t: ( + track_filter(t) or album_filter(t) or + artist_filter(t) or uri_filter(t)) + + if field == 'track': + result_tracks = filter(track_filter, result_tracks) + elif field == 'album': + result_tracks = filter(album_filter, result_tracks) + elif field == 'artist': + result_tracks = filter(artist_filter, result_tracks) + elif field in ('uri', 'filename'): + result_tracks = filter(uri_filter, result_tracks) + elif field == 'any': + result_tracks = filter(any_filter, result_tracks) + else: + raise LookupError('Invalid lookup field: %s' % field) + return Playlist(tracks=result_tracks) + + def search(self, **query): + self._validate_query(query) + result_tracks = self._uri_mapping.values() + + for (field, values) in query.iteritems(): + if not hasattr(values, '__iter__'): + values = [values] + # FIXME this is bound to be slow for large libraries + for value in values: + q = value.strip().lower() + + track_filter = lambda t: q in t.name.lower() + album_filter = lambda t: q in getattr( + t, 'album', Album()).name.lower() + artist_filter = lambda t: filter( + lambda a: q in a.name.lower(), t.artists) + uri_filter = lambda t: q in t.uri.lower() + any_filter = lambda t: track_filter(t) or album_filter(t) or \ + artist_filter(t) or uri_filter(t) + + if field == 'track': + result_tracks = filter(track_filter, result_tracks) + elif field == 'album': + result_tracks = filter(album_filter, result_tracks) + elif field == 'artist': + result_tracks = filter(artist_filter, result_tracks) + elif field in ('uri', 'filename'): + result_tracks = filter(uri_filter, result_tracks) + elif field == 'any': + result_tracks = filter(any_filter, result_tracks) + else: + raise LookupError('Invalid lookup field: %s' % field) + return Playlist(tracks=result_tracks) + + def _validate_query(self, query): + for (_, values) in query.iteritems(): + if not values: + raise LookupError('Missing query') + for value in values: + if not value: + raise LookupError('Missing query') diff --git a/mopidy/backends/local/stored_playlists.py b/mopidy/backends/local/stored_playlists.py new file mode 100644 index 00000000..04406c32 --- /dev/null +++ b/mopidy/backends/local/stored_playlists.py @@ -0,0 +1,116 @@ +import glob +import logging +import os +import shutil + +from mopidy import settings +from mopidy.backends import base +from mopidy.models import Playlist +from mopidy.utils import formatting, path + +from .translator import parse_m3u + + +logger = logging.getLogger(u'mopidy.backends.local') + + +class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): + def __init__(self, *args, **kwargs): + super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) + self._path = settings.LOCAL_PLAYLIST_PATH + self.refresh() + + def create(self, name): + name = formatting.slugify(name) + uri = path.path_to_uri(self._get_m3u_path(name)) + playlist = Playlist(uri=uri, name=name) + return self.save(playlist) + + def delete(self, uri): + playlist = self.lookup(uri) + if not playlist: + return + + self._playlists.remove(playlist) + self._delete_m3u(playlist.uri) + + def lookup(self, uri): + for playlist in self._playlists: + if playlist.uri == uri: + return playlist + + def refresh(self): + logger.info('Loading playlists from %s', self._path) + + playlists = [] + + for m3u in glob.glob(os.path.join(self._path, '*.m3u')): + uri = path.path_to_uri(m3u) + name = os.path.splitext(os.path.basename(m3u))[0] + + tracks = [] + for track_uri in parse_m3u(m3u, settings.LOCAL_MUSIC_PATH): + try: + # TODO We must use core.library.lookup() to support tracks + # from other backends + tracks.append(self.backend.library.lookup(track_uri)) + except LookupError as ex: + logger.error('Playlist item could not be added: %s', ex) + + playlist = Playlist(uri=uri, name=name, tracks=tracks) + playlists.append(playlist) + + self.playlists = playlists + + def save(self, playlist): + assert playlist.uri, 'Cannot save playlist without URI' + + old_playlist = self.lookup(playlist.uri) + + if old_playlist and playlist.name != old_playlist.name: + playlist = playlist.copy(name=formatting.slugify(playlist.name)) + playlist = self._rename_m3u(playlist) + + self._save_m3u(playlist) + + if old_playlist is not None: + index = self._playlists.index(old_playlist) + self._playlists[index] = playlist + else: + self._playlists.append(playlist) + + return playlist + + def _get_m3u_path(self, name): + name = formatting.slugify(name) + file_path = os.path.join(self._path, name + '.m3u') + path.check_file_path_is_inside_base_dir(file_path, self._path) + return file_path + + def _save_m3u(self, playlist): + file_path = path.uri_to_path(playlist.uri) + path.check_file_path_is_inside_base_dir(file_path, self._path) + with open(file_path, 'w') as file_handle: + for track in playlist.tracks: + if track.uri.startswith('file://'): + uri = path.uri_to_path(track.uri) + else: + uri = track.uri + file_handle.write(uri + '\n') + + def _delete_m3u(self, uri): + file_path = path.uri_to_path(uri) + path.check_file_path_is_inside_base_dir(file_path, self._path) + if os.path.exists(file_path): + os.remove(file_path) + + def _rename_m3u(self, playlist): + src_file_path = path.uri_to_path(playlist.uri) + path.check_file_path_is_inside_base_dir(src_file_path, self._path) + + dst_file_path = self._get_m3u_path(playlist.name) + path.check_file_path_is_inside_base_dir(dst_file_path, self._path) + + shutil.move(src_file_path, dst_file_path) + + return playlist.copy(uri=path.path_to_uri(dst_file_path)) diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 3b610a94..01aad440 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -1,14 +1,14 @@ import logging -import os - -logger = logging.getLogger('mopidy.backends.local.translator') from mopidy.models import Track, Artist, Album -from mopidy.utils import locale_decode +from mopidy.utils.encoding import locale_decode from mopidy.utils.path import path_to_uri -def parse_m3u(file_path): - """ +logger = logging.getLogger('mopidy.backends.local') + + +def parse_m3u(file_path, music_folder): + r""" Convert M3U file list of uris Example M3U data:: @@ -29,8 +29,6 @@ def parse_m3u(file_path): """ uris = [] - folder = os.path.dirname(file_path) - try: with open(file_path) as m3u: contents = m3u.readlines() @@ -48,11 +46,12 @@ def parse_m3u(file_path): if line.startswith('file://'): uris.append(line) else: - path = path_to_uri(folder, line) + path = path_to_uri(music_folder, line) uris.append(path) return uris + def parse_mpd_tag_cache(tag_cache, music_dir=''): """ Converts a MPD tag_cache into a lists of tracks, artists and albums. @@ -91,6 +90,7 @@ def parse_mpd_tag_cache(tag_cache, music_dir=''): return tracks + def _convert_mpd_data(data, tracks, music_dir): if not data: return @@ -130,7 +130,8 @@ def _convert_mpd_data(data, tracks, music_dir): artist_kwargs['musicbrainz_id'] = data['musicbrainz_artistid'] if 'musicbrainz_albumartistid' in data: - albumartist_kwargs['musicbrainz_id'] = data['musicbrainz_albumartistid'] + albumartist_kwargs['musicbrainz_id'] = ( + data['musicbrainz_albumartistid']) if data['file'][0] == '/': path = data['file'][1:] @@ -144,7 +145,7 @@ def _convert_mpd_data(data, tracks, music_dir): if albumartist_kwargs: albumartist = Artist(**albumartist_kwargs) album_kwargs['artists'] = [albumartist] - + if album_kwargs: album = Album(**album_kwargs) track_kwargs['album'] = album diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 1feb1c65..28813bc2 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -1,94 +1,34 @@ -import logging +"""A backend for playing music from Spotify -from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry +`Spotify `_ is a music streaming service. The backend +uses the official `libspotify +`_ library and the +`pyspotify `_ Python bindings for +libspotify. This backend handles URIs starting with ``spotify:``. -from mopidy import audio, core, settings -from mopidy.backends import base +See :ref:`music-from-spotify` for further instructions on using this backend. -logger = logging.getLogger('mopidy.backends.spotify') +.. note:: -BITRATES = {96: 2, 160: 0, 320: 1} + This product uses SPOTIFY(R) CORE but is not endorsed, certified or + otherwise approved in any way by Spotify. Spotify is the registered + trade mark of the Spotify Group. -class SpotifyBackend(ThreadingActor, base.Backend): - """ - A backend for playing music from the `Spotify `_ - music streaming service. The backend uses the official `libspotify - `_ library and the - `pyspotify `_ Python bindings for - libspotify. +**Issues:** - .. note:: +https://github.com/mopidy/mopidy/issues?labels=Spotify+backend - This product uses SPOTIFY(R) CORE but is not endorsed, certified or - otherwise approved in any way by Spotify. Spotify is the registered - trade mark of the Spotify Group. +**Dependencies:** - **Issues:** - https://github.com/mopidy/mopidy/issues?labels=backend-spotify +- libspotify >= 12, < 13 (libspotify12 package from apt.mopidy.com) +- pyspotify >= 1.8, < 1.9 (python-spotify package from apt.mopidy.com) - **Dependencies:** +**Settings:** - - libspotify >= 10, < 11 (libspotify10 package from apt.mopidy.com) - - pyspotify >= 1.5 (python-spotify package from apt.mopidy.com) +- :attr:`mopidy.settings.SPOTIFY_CACHE_PATH` +- :attr:`mopidy.settings.SPOTIFY_USERNAME` +- :attr:`mopidy.settings.SPOTIFY_PASSWORD` +""" - **Settings:** - - - :attr:`mopidy.settings.SPOTIFY_CACHE_PATH` - - :attr:`mopidy.settings.SPOTIFY_USERNAME` - - :attr:`mopidy.settings.SPOTIFY_PASSWORD` - """ - - # Imports inside methods are to prevent loading of __init__.py to fail on - # missing spotify dependencies. - - def __init__(self, *args, **kwargs): - from .library import SpotifyLibraryProvider - from .playback import SpotifyPlaybackProvider - from .stored_playlists import SpotifyStoredPlaylistsProvider - - super(SpotifyBackend, self).__init__(*args, **kwargs) - - self.current_playlist = core.CurrentPlaylistController(backend=self) - - library_provider = SpotifyLibraryProvider(backend=self) - self.library = core.LibraryController(backend=self, - provider=library_provider) - - playback_provider = SpotifyPlaybackProvider(backend=self) - self.playback = core.PlaybackController(backend=self, - provider=playback_provider) - - stored_playlists_provider = SpotifyStoredPlaylistsProvider( - backend=self) - self.stored_playlists = core.StoredPlaylistsController(backend=self, - provider=stored_playlists_provider) - - self.uri_schemes = [u'spotify'] - - self.audio = None - self.spotify = None - - # Fail early if settings are not present - self.username = settings.SPOTIFY_USERNAME - self.password = settings.SPOTIFY_PASSWORD - - def on_start(self): - audio_refs = ActorRegistry.get_by_class(audio.Audio) - assert len(audio_refs) == 1, \ - 'Expected exactly one running Audio instance.' - self.audio = audio_refs[0].proxy() - - logger.info(u'Mopidy uses SPOTIFY(R) CORE') - self.spotify = self._connect() - - def on_stop(self): - self.spotify.logout() - - def _connect(self): - from .session_manager import SpotifySessionManager - - logger.debug(u'Connecting to Spotify') - spotify = SpotifySessionManager(self.username, self.password) - spotify.start() - return spotify +# flake8: noqa +from .actor import SpotifyBackend diff --git a/mopidy/backends/spotify/actor.py b/mopidy/backends/spotify/actor.py new file mode 100644 index 00000000..943600fc --- /dev/null +++ b/mopidy/backends/spotify/actor.py @@ -0,0 +1,42 @@ +import logging + +import pykka + +from mopidy import settings +from mopidy.backends import base + +logger = logging.getLogger('mopidy.backends.spotify') + + +class SpotifyBackend(pykka.ThreadingActor, base.Backend): + # Imports inside methods are to prevent loading of __init__.py to fail on + # missing spotify dependencies. + + def __init__(self, audio): + super(SpotifyBackend, self).__init__() + + from .library import SpotifyLibraryProvider + from .playback import SpotifyPlaybackProvider + from .session_manager import SpotifySessionManager + from .stored_playlists import SpotifyStoredPlaylistsProvider + + self.library = SpotifyLibraryProvider(backend=self) + self.playback = SpotifyPlaybackProvider(audio=audio, backend=self) + self.stored_playlists = SpotifyStoredPlaylistsProvider(backend=self) + + self.uri_schemes = [u'spotify'] + + # Fail early if settings are not present + username = settings.SPOTIFY_USERNAME + password = settings.SPOTIFY_PASSWORD + + self.spotify = SpotifySessionManager( + username, password, audio=audio, backend_ref=self.actor_ref) + + def on_start(self): + logger.info(u'Mopidy uses SPOTIFY(R) CORE') + logger.debug(u'Connecting to Spotify') + self.spotify.start() + + def on_stop(self): + self.spotify.logout() diff --git a/mopidy/backends/spotify/container_manager.py b/mopidy/backends/spotify/container_manager.py index 27a4d78a..e3388e0b 100644 --- a/mopidy/backends/spotify/container_manager.py +++ b/mopidy/backends/spotify/container_manager.py @@ -3,7 +3,8 @@ import logging from spotify.manager import SpotifyContainerManager as \ PyspotifyContainerManager -logger = logging.getLogger('mopidy.backends.spotify.container_manager') +logger = logging.getLogger('mopidy.backends.spotify') + class SpotifyContainerManager(PyspotifyContainerManager): def __init__(self, session_manager): @@ -25,13 +26,13 @@ class SpotifyContainerManager(PyspotifyContainerManager): def playlist_added(self, container, playlist, position, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: playlist added at position %d', - position) + logger.debug( + u'Callback called: playlist added at position %d', position) # container_loaded() is called after this callback, so we do not need # to handle this callback. def playlist_moved(self, container, playlist, old_position, new_position, - userdata): + userdata): """Callback used by pyspotify""" logger.debug( u'Callback called: playlist "%s" moved from position %d to %d', diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index 18276ecd..bf057bee 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -3,16 +3,18 @@ import Queue from spotify import Link, SpotifyError -from mopidy.backends.base import BaseLibraryProvider -from mopidy.backends.spotify.translator import SpotifyTranslator +from mopidy.backends import base from mopidy.models import Track, Playlist -logger = logging.getLogger('mopidy.backends.spotify.library') +from . import translator + +logger = logging.getLogger('mopidy.backends.spotify') class SpotifyTrack(Track): """Proxy object for unloaded Spotify tracks.""" def __init__(self, uri): + super(SpotifyTrack, self).__init__() self._spotify_track = Link.from_string(uri).as_track() self._unloaded_track = Track(uri=uri, name=u'[loading...]') self._track = None @@ -22,7 +24,7 @@ class SpotifyTrack(Track): if self._track: return self._track elif self._spotify_track.is_loaded(): - self._track = SpotifyTranslator.to_mopidy_track(self._spotify_track) + self._track = translator.to_mopidy_track(self._spotify_track) return self._track else: return self._unloaded_track @@ -47,7 +49,7 @@ class SpotifyTrack(Track): return self._proxy.copy(**values) -class SpotifyLibraryProvider(BaseLibraryProvider): +class SpotifyLibraryProvider(base.BaseLibraryProvider): def find_exact(self, **query): return self.search(**query) @@ -59,7 +61,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider): return None def refresh(self, uri=None): - pass # TODO + pass # TODO def search(self, **query): if not query: @@ -81,7 +83,7 @@ class SpotifyLibraryProvider(BaseLibraryProvider): if field == u'any': spotify_query.append(value) elif field == u'year': - value = int(value.split('-')[0]) # Extract year + value = int(value.split('-')[0]) # Extract year spotify_query.append(u'%s:%d' % (field, value)) else: spotify_query.append(u'%s:"%s"' % (field, value)) @@ -90,6 +92,6 @@ class SpotifyLibraryProvider(BaseLibraryProvider): queue = Queue.Queue() self.backend.spotify.search(spotify_query, queue) try: - return queue.get(timeout=3) # XXX What is an reasonable timeout? + return queue.get(timeout=3) # XXX What is an reasonable timeout? except Queue.Empty: return Playlist(tracks=[]) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 1c20da87..40868745 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,40 +1,114 @@ import logging +import time from spotify import Link, SpotifyError -from mopidy.backends.base import BasePlaybackProvider +from mopidy.backends import base from mopidy.core import PlaybackState -logger = logging.getLogger('mopidy.backends.spotify.playback') -class SpotifyPlaybackProvider(BasePlaybackProvider): +logger = logging.getLogger('mopidy.backends.spotify') + + +class SpotifyPlaybackProvider(base.BasePlaybackProvider): + def __init__(self, *args, **kwargs): + super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) + + self._timer = TrackPositionTimer() + + def pause(self): + self._timer.pause() + + return super(SpotifyPlaybackProvider, self).pause() + def play(self, track): - if self.backend.playback.state == PlaybackState.PLAYING: - self.backend.spotify.session.play(0) if track.uri is None: return False + try: self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self.backend.audio.prepare_change() - self.backend.audio.set_uri('appsrc://') - self.backend.audio.start_playback() - self.backend.audio.set_metadata(track) + + self.audio.prepare_change() + self.audio.set_uri('appsrc://') + self.audio.start_playback() + self.audio.set_metadata(track) + + self._timer.play() + return True except SpotifyError as e: logger.info('Playback of %s failed: %s', track.uri, e) return False def resume(self): - return self.seek(self.backend.playback.time_position) + time_position = self.get_time_position() + + self._timer.resume() + + return self.seek(time_position) def seek(self, time_position): - self.backend.audio.prepare_change() + self.audio.prepare_change() self.backend.spotify.session.seek(time_position) - self.backend.audio.start_playback() + self.audio.start_playback() + + self._timer.seek(time_position) + return True def stop(self): self.backend.spotify.session.play(0) + return super(SpotifyPlaybackProvider, self).stop() + + def get_time_position(self): + # XXX: The default implementation of get_time_position hangs/times out + # when used with the Spotify backend and GStreamer appsrc. If this can + # be resolved, we no longer need to use a wall clock based time + # position for Spotify playback. + return self._timer.get_time_position() + + +class TrackPositionTimer(object): + """ + Keeps track of time position in a track using the wall clock and playback + events. + + To not introduce a reverse dependency on the playback controller, this + class keeps track of playback state itself. + """ + + def __init__(self): + self._state = PlaybackState.STOPPED + self._accumulated = 0 + self._started = 0 + + def play(self): + self._state = PlaybackState.PLAYING + self._accumulated = 0 + self._started = self._wall_time() + + def pause(self): + self._state = PlaybackState.PAUSED + self._accumulated += self._wall_time() - self._started + + def resume(self): + self._state = PlaybackState.PLAYING + + def seek(self, time_position): + self._started = self._wall_time() + self._accumulated = time_position + + def get_time_position(self): + if self._state == PlaybackState.PLAYING: + time_since_started = self._wall_time() - self._started + return self._accumulated + time_since_started + elif self._state == PlaybackState.PAUSED: + return self._accumulated + elif self._state == PlaybackState.STOPPED: + return 0 + + def _wall_time(self): + return int(time.time() * 1000) diff --git a/mopidy/backends/spotify/playlist_manager.py b/mopidy/backends/spotify/playlist_manager.py index 05f9514d..645a574c 100644 --- a/mopidy/backends/spotify/playlist_manager.py +++ b/mopidy/backends/spotify/playlist_manager.py @@ -3,7 +3,8 @@ import logging from spotify.manager import SpotifyPlaylistManager as PyspotifyPlaylistManager -logger = logging.getLogger('mopidy.backends.spotify.playlist_manager') +logger = logging.getLogger('mopidy.backends.spotify') + class SpotifyPlaylistManager(PyspotifyPlaylistManager): def __init__(self, session_manager): @@ -12,48 +13,55 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager): def tracks_added(self, playlist, tracks, position, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: ' + logger.debug( + u'Callback called: ' u'%d track(s) added to position %d in playlist "%s"', len(tracks), position, playlist.name()) self.session_manager.refresh_stored_playlists() def tracks_moved(self, playlist, tracks, new_position, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: ' + logger.debug( + u'Callback called: ' u'%d track(s) moved to position %d in playlist "%s"', len(tracks), new_position, playlist.name()) self.session_manager.refresh_stored_playlists() def tracks_removed(self, playlist, tracks, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: ' + logger.debug( + u'Callback called: ' u'%d track(s) removed from playlist "%s"', len(tracks), playlist.name()) self.session_manager.refresh_stored_playlists() def playlist_renamed(self, playlist, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: Playlist renamed to "%s"', - playlist.name()) + logger.debug( + u'Callback called: Playlist renamed to "%s"', playlist.name()) self.session_manager.refresh_stored_playlists() def playlist_state_changed(self, playlist, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: The state of playlist "%s" changed', + logger.debug( + u'Callback called: The state of playlist "%s" changed', playlist.name()) def playlist_update_in_progress(self, playlist, done, userdata): """Callback used by pyspotify""" if done: - logger.debug(u'Callback called: ' - u'Update of playlist "%s" done', playlist.name()) + logger.debug( + u'Callback called: Update of playlist "%s" done', + playlist.name()) else: - logger.debug(u'Callback called: ' - u'Update of playlist "%s" in progress', playlist.name()) + logger.debug( + u'Callback called: Update of playlist "%s" in progress', + playlist.name()) def playlist_metadata_updated(self, playlist, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: Metadata updated for playlist "%s"', + logger.debug( + u'Callback called: Metadata updated for playlist "%s"', playlist.name()) def track_created_changed(self, playlist, position, user, when, userdata): @@ -90,5 +98,6 @@ class SpotifyPlaylistManager(PyspotifyPlaylistManager): def image_changed(self, playlist, image, userdata): """Callback used by pyspotify""" - logger.debug(u'Callback called: Image changed for playlist "%s"', + logger.debug( + u'Callback called: Image changed for playlist "%s"', playlist.name()) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index aa3734ae..23b99d48 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -4,37 +4,36 @@ import threading from spotify.manager import SpotifySessionManager as PyspotifySessionManager -from pykka.registry import ActorRegistry - -from mopidy import audio, get_version, settings, CACHE_PATH -from mopidy.backends.base import Backend -from mopidy.backends.spotify import BITRATES -from mopidy.backends.spotify.container_manager import SpotifyContainerManager -from mopidy.backends.spotify.playlist_manager import SpotifyPlaylistManager -from mopidy.backends.spotify.translator import SpotifyTranslator +from mopidy import settings from mopidy.models import Playlist -from mopidy.utils.process import BaseThread +from mopidy.utils import process, versioning -logger = logging.getLogger('mopidy.backends.spotify.session_manager') +from . import translator +from .container_manager import SpotifyContainerManager +from .playlist_manager import SpotifyPlaylistManager + +logger = logging.getLogger('mopidy.backends.spotify') + +BITRATES = {96: 2, 160: 0, 320: 1} # pylint: disable = R0901 # SpotifySessionManager: Too many ancestors (9/7) -class SpotifySessionManager(BaseThread, PyspotifySessionManager): - cache_location = (settings.SPOTIFY_CACHE_PATH - or os.path.join(CACHE_PATH, 'spotify')) +class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): + cache_location = settings.SPOTIFY_CACHE_PATH settings_location = cache_location appkey_file = os.path.join(os.path.dirname(__file__), 'spotify_appkey.key') - user_agent = 'Mopidy %s' % get_version() + user_agent = 'Mopidy %s' % versioning.get_version() - def __init__(self, username, password): + def __init__(self, username, password, audio, backend_ref): PyspotifySessionManager.__init__(self, username, password) - BaseThread.__init__(self) + process.BaseThread.__init__(self) self.name = 'SpotifyThread' - self.audio = None + self.audio = audio self.backend = None + self.backend_ref = backend_ref self.connected = threading.Event() self.session = None @@ -45,19 +44,9 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self._initial_data_receive_completed = False def run_inside_try(self): - self.setup() + self.backend = self.backend_ref.proxy() self.connect() - def setup(self): - audio_refs = ActorRegistry.get_by_class(audio.Audio) - assert len(audio_refs) == 1, \ - 'Expected exactly one running Audio instance.' - self.audio = audio_refs[0].proxy() - - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) == 1, 'Expected exactly one running backend.' - self.backend = backend_refs[0].proxy() - def logged_in(self, session, error): """Callback used by pyspotify""" if error: @@ -67,7 +56,8 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): logger.info(u'Connected to Spotify') self.session = session - logger.debug(u'Preferred Spotify bitrate is %s kbps', + logger.debug( + u'Preferred Spotify bitrate is %s kbps', settings.SPOTIFY_BITRATE) self.session.set_preferred_bitrate(BITRATES[settings.SPOTIFY_BITRATE]) @@ -99,7 +89,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): logger.debug(u'User message: %s', message.strip()) def music_delivery(self, session, frames, frame_size, num_frames, - sample_type, sample_rate, channels): + sample_type, sample_rate, channels): """Callback used by pyspotify""" # pylint: disable = R0913 # Too many arguments (8/5) @@ -150,11 +140,11 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): if not self._initial_data_receive_completed: logger.debug(u'Still getting data; skipped refresh of playlists') return - playlists = map(SpotifyTranslator.to_mopidy_playlist, - self.session.playlist_container()) + playlists = map( + translator.to_mopidy_playlist, self.session.playlist_container()) playlists = filter(None, playlists) self.backend.stored_playlists.playlists = playlists - logger.debug(u'Refreshed %d stored playlist(s)', len(playlists)) + logger.info(u'Loaded %d Spotify playlist(s)', len(playlists)) def search(self, query, queue): """Search method used by Mopidy backend""" @@ -163,12 +153,11 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): # TODO Consider launching a second search if results.total_tracks() # is larger than len(results.tracks()) playlist = Playlist(tracks=[ - SpotifyTranslator.to_mopidy_track(t) - for t in results.tracks()]) + translator.to_mopidy_track(t) for t in results.tracks()]) queue.put(playlist) self.connected.wait() - self.session.search(query, callback, track_count=100, - album_count=0, artist_count=0) + self.session.search( + query, callback, track_count=100, album_count=0, artist_count=0) def logout(self): """Log out from spotify""" diff --git a/mopidy/backends/spotify/stored_playlists.py b/mopidy/backends/spotify/stored_playlists.py index 054e2bd1..9a2328c4 100644 --- a/mopidy/backends/spotify/stored_playlists.py +++ b/mopidy/backends/spotify/stored_playlists.py @@ -1,20 +1,21 @@ -from mopidy.backends.base import BaseStoredPlaylistsProvider +from mopidy.backends import base -class SpotifyStoredPlaylistsProvider(BaseStoredPlaylistsProvider): + +class SpotifyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def create(self, name): - pass # TODO + pass # TODO def delete(self, playlist): - pass # TODO + pass # TODO def lookup(self, uri): - pass # TODO + pass # TODO def refresh(self): - pass # TODO + pass # TODO def rename(self, playlist, new_name): - pass # TODO + pass # TODO def save(self, playlist): - pass # TODO + pass # TODO diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 1a8f048d..4ad92fe9 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -1,63 +1,63 @@ -import logging - -from spotify import Link, SpotifyError +from spotify import Link from mopidy import settings from mopidy.models import Artist, Album, Track, Playlist -logger = logging.getLogger('mopidy.backends.spotify.translator') -class SpotifyTranslator(object): - @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() - ) +def to_mopidy_artist(spotify_artist): + if spotify_artist is None: + return + uri = str(Link.from_artist(spotify_artist)) + if not spotify_artist.is_loaded(): + return Artist(uri=uri, name=u'[loading...]') + return Artist(uri=uri, name=spotify_artist.name()) - @classmethod - def to_mopidy_album(cls, spotify_album): - if spotify_album is None or 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()) - @classmethod - def to_mopidy_track(cls, spotify_track): - uri = str(Link.from_track(spotify_track, 0)) - if not spotify_track.is_loaded(): - return Track(uri=uri, name=u'[loading...]') - spotify_album = spotify_track.album() - if spotify_album is not None and spotify_album.is_loaded(): - date = spotify_album.year() - else: - date = None - return Track( - uri=uri, - name=spotify_track.name(), - 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=date, - length=spotify_track.duration(), - bitrate=settings.SPOTIFY_BITRATE, - ) +def to_mopidy_album(spotify_album): + if spotify_album is None: + return + uri = str(Link.from_album(spotify_album)) + if not spotify_album.is_loaded(): + return Album(uri=uri, name=u'[loading...]') + return Album( + uri=uri, + name=spotify_album.name(), + artists=[to_mopidy_artist(spotify_album.artist())], + date=spotify_album.year()) - @classmethod - def to_mopidy_playlist(cls, spotify_playlist): - if not spotify_playlist.is_loaded(): - return Playlist(name=u'[loading...]') - if spotify_playlist.type() != 'playlist': - return - try: - return Playlist( - uri=str(Link.from_playlist(spotify_playlist)), - name=spotify_playlist.name(), - # FIXME if check on link is a hackish workaround for is_local - tracks=[cls.to_mopidy_track(t) for t in spotify_playlist - if str(Link.from_track(t, 0))], - ) - except SpotifyError, e: - logger.warning(u'Failed translating Spotify playlist: %s', e) + +def to_mopidy_track(spotify_track): + if spotify_track is None: + return + uri = str(Link.from_track(spotify_track, 0)) + if not spotify_track.is_loaded(): + return Track(uri=uri, name=u'[loading...]') + spotify_album = spotify_track.album() + if spotify_album is not None and spotify_album.is_loaded(): + date = spotify_album.year() + else: + date = None + return Track( + uri=uri, + name=spotify_track.name(), + artists=[to_mopidy_artist(a) for a in spotify_track.artists()], + album=to_mopidy_album(spotify_track.album()), + track_no=spotify_track.index(), + date=date, + length=spotify_track.duration(), + bitrate=settings.SPOTIFY_BITRATE) + + +def to_mopidy_playlist(spotify_playlist): + if spotify_playlist is None or spotify_playlist.type() != 'playlist': + return + uri = str(Link.from_playlist(spotify_playlist)) + if not spotify_playlist.is_loaded(): + return Playlist(uri=uri, name=u'[loading...]') + return Playlist( + uri=uri, + name=spotify_playlist.name(), + tracks=[ + to_mopidy_track(spotify_track) + for spotify_track in spotify_playlist + if not spotify_track.is_local()]) diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py index 87df96c9..7fecfd79 100644 --- a/mopidy/core/__init__.py +++ b/mopidy/core/__init__.py @@ -1,4 +1,7 @@ +# flake8: noqa +from .actor import Core from .current_playlist import CurrentPlaylistController from .library import LibraryController +from .listener import CoreListener from .playback import PlaybackController, PlaybackState from .stored_playlists import StoredPlaylistsController diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py new file mode 100644 index 00000000..482868ad --- /dev/null +++ b/mopidy/core/actor.py @@ -0,0 +1,71 @@ +import itertools + +import pykka + +from mopidy.audio import AudioListener + +from .current_playlist import CurrentPlaylistController +from .library import LibraryController +from .playback import PlaybackController +from .stored_playlists import StoredPlaylistsController + + +class Core(pykka.ThreadingActor, AudioListener): + #: The current playlist controller. An instance of + #: :class:`mopidy.core.CurrentPlaylistController`. + current_playlist = None + + #: The library controller. An instance of + # :class:`mopidy.core.LibraryController`. + library = None + + #: The playback controller. An instance of + #: :class:`mopidy.core.PlaybackController`. + playback = None + + #: The stored playlists controller. An instance of + #: :class:`mopidy.core.StoredPlaylistsController`. + stored_playlists = None + + def __init__(self, audio=None, backends=None): + super(Core, self).__init__() + + self.backends = Backends(backends) + + self.current_playlist = CurrentPlaylistController(core=self) + + self.library = LibraryController(backends=self.backends, core=self) + + self.playback = PlaybackController( + audio=audio, backends=self.backends, core=self) + + self.stored_playlists = StoredPlaylistsController( + backends=self.backends, core=self) + + @property + def uri_schemes(self): + """List of URI schemes we can handle""" + futures = [b.uri_schemes for b in self.backends] + results = pykka.get_all(futures) + uri_schemes = itertools.chain(*results) + return sorted(uri_schemes) + + def reached_end_of_stream(self): + self.playback.on_end_of_track() + + +class Backends(list): + def __init__(self, backends): + super(Backends, self).__init__(backends) + + self.by_uri_scheme = {} + for backend in backends: + uri_schemes = backend.uri_schemes.get() + for uri_scheme in uri_schemes: + assert uri_scheme not in self.by_uri_scheme, ( + 'Cannot add URI scheme %s for %s, ' + 'it is already handled by %s' + ) % ( + uri_scheme, backend.__class__.__name__, + self.by_uri_scheme[uri_scheme].__class__.__name__) + self.by_uri_scheme[uri_scheme] = backend diff --git a/mopidy/core/current_playlist.py b/mopidy/core/current_playlist.py index af06e05e..fb296a52 100644 --- a/mopidy/core/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -2,23 +2,19 @@ from copy import copy import logging import random -from mopidy.listeners import BackendListener from mopidy.models import CpTrack +from . import listener + logger = logging.getLogger('mopidy.core') class CurrentPlaylistController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - """ - pykka_traversable = True - def __init__(self, backend): - self.backend = backend + def __init__(self, core): + self.core = core self.cp_id = 0 self._cp_tracks = [] self._version = 0 @@ -56,10 +52,10 @@ class CurrentPlaylistController(object): """ return self._version - @version.setter + @version.setter # noqa def version(self, version): self._version = version - self.backend.playback.on_current_playlist_change() + self.core.playback.on_current_playlist_change() self._trigger_playlist_changed() def add(self, track, at_position=None, increase_version=True): @@ -71,6 +67,8 @@ class CurrentPlaylistController(object): :type track: :class:`mopidy.models.Track` :param at_position: position in current playlist to add track :type at_position: int or :class:`None` + :param increase_version: if the playlist version should be increased + :type increase_version: :class:`True` or :class:`False` :rtype: two-tuple of (CPID integer, :class:`mopidy.models.Track`) that was added to the current playlist playlist """ @@ -127,8 +125,8 @@ class CurrentPlaylistController(object): if key == 'cpid': matches = filter(lambda ct: ct.cpid == value, matches) else: - matches = filter(lambda ct: getattr(ct.track, key) == value, - matches) + matches = filter( + lambda ct: getattr(ct.track, key) == value, matches) if len(matches) == 1: return matches[0] criteria_string = ', '.join( @@ -240,4 +238,4 @@ class CurrentPlaylistController(object): def _trigger_playlist_changed(self): logger.debug(u'Triggering playlist changed event') - BackendListener.send('playlist_changed') + listener.CoreListener.send('playlist_changed') diff --git a/mopidy/core/library.py b/mopidy/core/library.py index fc55aaeb..f7514fd8 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,16 +1,21 @@ -class LibraryController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BaseLibraryProvider` - """ +import itertools +import urlparse +import pykka + +from mopidy.models import Playlist + + +class LibraryController(object): pykka_traversable = True - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider + def __init__(self, backends, core): + self.backends = backends + self.core = core + + def _get_backend(self, uri): + uri_scheme = urlparse.urlparse(uri).scheme + return self.backends.by_uri_scheme.get(uri_scheme, None) def find_exact(self, **query): """ @@ -29,7 +34,10 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.provider.find_exact(**query) + futures = [b.library.find_exact(**query) for b in self.backends] + results = pykka.get_all(futures) + return Playlist(tracks=[ + track for playlist in results for track in playlist.tracks]) def lookup(self, uri): """ @@ -39,7 +47,11 @@ class LibraryController(object): :type uri: string :rtype: :class:`mopidy.models.Track` or :class:`None` """ - return self.provider.lookup(uri) + backend = self._get_backend(uri) + if backend: + return backend.library.lookup(uri).get() + else: + return None def refresh(self, uri=None): """ @@ -48,7 +60,13 @@ class LibraryController(object): :param uri: directory or track URI :type uri: string """ - return self.provider.refresh(uri) + if uri is not None: + backend = self._get_backend(uri) + if backend: + backend.library.refresh(uri).get() + else: + futures = [b.library.refresh(uri) for b in self.backends] + pykka.get_all(futures) def search(self, **query): """ @@ -67,4 +85,8 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.provider.search(**query) + futures = [b.library.search(**query) for b in self.backends] + results = pykka.get_all(futures) + track_lists = [playlist.tracks for playlist in results] + tracks = list(itertools.chain(*track_lists)) + return Playlist(tracks=tracks) diff --git a/mopidy/listeners.py b/mopidy/core/listener.py similarity index 74% rename from mopidy/listeners.py rename to mopidy/core/listener.py index ee360bf3..ed7dae2f 100644 --- a/mopidy/listeners.py +++ b/mopidy/core/listener.py @@ -1,11 +1,12 @@ -from pykka import registry +import pykka -class BackendListener(object): + +class CoreListener(object): """ - Marker interface for recipients of events sent by the backend. + Marker interface for recipients of events sent by the core actor. Any Pykka actor that mixes in this class will receive calls to the methods - defined here when the corresponding events happen in the backend. This + defined here when the corresponding events happen in the core actor. This interface is used both for looking up what actors to notify of the events, and for providing default implementations for those listeners that are not interested in all events. @@ -13,15 +14,10 @@ class BackendListener(object): @staticmethod def send(event, **kwargs): - """Helper to allow calling of backend listener events""" - # FIXME this should be updated once Pykka supports non-blocking calls - # on proxies or some similar solution. - registry.ActorRegistry.broadcast({ - 'command': 'pykka_call', - 'attr_path': (event,), - 'args': [], - 'kwargs': kwargs, - }, target_class=BackendListener) + """Helper to allow calling of core listener events""" + listeners = pykka.ActorRegistry.get_by_class(CoreListener) + for listener in listeners: + getattr(listener.proxy(), event)(**kwargs) def track_playback_paused(self, track, time_position): """ @@ -49,7 +45,6 @@ class BackendListener(object): """ pass - def track_playback_started(self, track): """ Called whenever a new track starts playing. @@ -74,11 +69,16 @@ class BackendListener(object): """ pass - def playback_state_changed(self): + def playback_state_changed(self, old_state, new_state): """ Called whenever playback state is changed. *MAY* be implemented by actor. + + :param old_state: the state before the change + :type old_state: string from :class:`mopidy.core.PlaybackState` field + :param new_state: the state after the change + :type new_state: string from :class:`mopidy.core.PlaybackState` field """ pass @@ -106,11 +106,14 @@ class BackendListener(object): """ pass - def seeked(self): + def seeked(self, time_position): """ Called whenever the time position changes by an unexpected amount, e.g. at seek to a new time position. *MAY* be implemented by actor. + + :param time_position: the position that was seeked to in milliseconds + :type time_position: int """ pass diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index dfd1676e..74f4bebd 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,11 +1,11 @@ import logging import random -import time +import urlparse -from mopidy.listeners import BackendListener +from . import listener -logger = logging.getLogger('mopidy.backends.base') +logger = logging.getLogger('mopidy.core') def option_wrapper(name, default): @@ -14,13 +14,14 @@ def option_wrapper(name, default): def set_option(self, value): if getattr(self, name, default) != value: + # pylint: disable = W0212 self._trigger_options_changed() + # pylint: enable = W0212 return setattr(self, name, value) return property(get_option, set_option) - class PlaybackState(object): """ Enum of playback states. @@ -37,13 +38,6 @@ class PlaybackState(object): class PlaybackController(object): - """ - :param backend: the backend - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BasePlaybackProvider` - """ - # pylint: disable = R0902 # Too many instance attributes @@ -81,14 +75,22 @@ class PlaybackController(object): #: Playback continues after current song. single = option_wrapper('_single', False) - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider + def __init__(self, audio, backends, core): + self.audio = audio + self.backends = backends + self.core = core + self._state = PlaybackState.STOPPED self._shuffled = [] self._first_shuffle = True - self.play_time_accumulated = 0 - self.play_time_started = None + self._volume = None + + def _get_backend(self): + if self.current_cp_track is None: + return None + uri = self.current_cp_track.track.uri + uri_scheme = urlparse.urlparse(uri).scheme + return self.backends.by_uri_scheme[uri_scheme] def _get_cpid(self, cp_track): if cp_track is None: @@ -129,7 +131,7 @@ class PlaybackController(object): if self.current_cp_track is None: return None try: - return self.backend.current_playlist.cp_tracks.index( + return self.core.current_playlist.cp_tracks.index( self.current_cp_track) except ValueError: return None @@ -156,7 +158,7 @@ class PlaybackController(object): # pylint: disable = R0911 # Too many return statements - cp_tracks = self.backend.current_playlist.cp_tracks + cp_tracks = self.core.current_playlist.cp_tracks if not cp_tracks: return None @@ -208,7 +210,7 @@ class PlaybackController(object): enabled this should be a random track, all tracks should be played once before the list repeats. """ - cp_tracks = self.backend.current_playlist.cp_tracks + cp_tracks = self.core.current_playlist.cp_tracks if not cp_tracks: return None @@ -262,7 +264,7 @@ class PlaybackController(object): if self.current_playlist_position in (None, 0): return None - return self.backend.current_playlist.cp_tracks[ + return self.core.current_playlist.cp_tracks[ self.current_playlist_position - 1] @property @@ -285,59 +287,37 @@ class PlaybackController(object): """ return self._state - @state.setter + @state.setter # noqa def state(self, new_state): (old_state, self._state) = (self.state, new_state) logger.debug(u'Changing state: %s -> %s', old_state, new_state) - self._trigger_playback_state_changed() - - # FIXME play_time stuff assumes backend does not have a better way of - # handeling this stuff :/ - if (old_state in (PlaybackState.PLAYING, PlaybackState.STOPPED) - and new_state == PlaybackState.PLAYING): - self._play_time_start() - elif (old_state == PlaybackState.PLAYING - and new_state == PlaybackState.PAUSED): - self._play_time_pause() - elif (old_state == PlaybackState.PAUSED - and new_state == PlaybackState.PLAYING): - self._play_time_resume() + self._trigger_playback_state_changed(old_state, new_state) @property def time_position(self): """Time position in milliseconds.""" - if self.state == PlaybackState.PLAYING: - time_since_started = (self._current_wall_time - - self.play_time_started) - return self.play_time_accumulated + time_since_started - elif self.state == PlaybackState.PAUSED: - return self.play_time_accumulated - elif self.state == PlaybackState.STOPPED: + backend = self._get_backend() + if backend is None: 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) + return backend.playback.get_time_position().get() @property def volume(self): - return self.provider.get_volume() + """Volume as int in range [0..100] or :class:`None`""" + if self.audio: + return self.audio.get_volume().get() + else: + # For testing + return self._volume - @volume.setter + @volume.setter # noqa def volume(self, volume): - self.provider.set_volume(volume) + if self.audio: + self.audio.set_volume(volume) + else: + # For testing + self._volume = volume def change_track(self, cp_track, on_error_step=1): """ @@ -375,20 +355,20 @@ class PlaybackController(object): self.stop(clear_current_track=True) if self.consume: - self.backend.current_playlist.remove(cpid=original_cp_track.cpid) + self.core.current_playlist.remove(cpid=original_cp_track.cpid) def on_current_playlist_change(self): """ Tell the playback controller that the current playlist has changed. - Used by :class:`mopidy.backends.base.CurrentPlaylistController`. + Used by :class:`mopidy.core.CurrentPlaylistController`. """ self._first_shuffle = True self._shuffled = [] - if (not self.backend.current_playlist.cp_tracks or + if (not self.core.current_playlist.cp_tracks or self.current_cp_track not in - self.backend.current_playlist.cp_tracks): + self.core.current_playlist.cp_tracks): self.stop(clear_current_track=True) def next(self): @@ -406,7 +386,8 @@ class PlaybackController(object): def pause(self): """Pause playback.""" - if self.provider.pause(): + backend = self._get_backend() + if backend is None or backend.playback.pause().get(): self.state = PlaybackState.PAUSED self._trigger_track_playback_paused() @@ -424,7 +405,7 @@ class PlaybackController(object): """ if cp_track is not None: - assert cp_track in self.backend.current_playlist.cp_tracks + assert cp_track in self.core.current_playlist.cp_tracks elif cp_track is None: if self.state == PlaybackState.PAUSED: return self.resume() @@ -438,7 +419,7 @@ class PlaybackController(object): if cp_track is not None: self.current_cp_track = cp_track self.state = PlaybackState.PLAYING - if not self.provider.play(cp_track.track): + if not self._get_backend().playback.play(cp_track.track).get(): # Track is not playable if self.random and self._shuffled: self._shuffled.remove(cp_track) @@ -464,7 +445,8 @@ class PlaybackController(object): def resume(self): """If paused, resume playing the current track.""" - if self.state == PlaybackState.PAUSED and self.provider.resume(): + if (self.state == PlaybackState.PAUSED and + self._get_backend().playback.resume().get()): self.state = PlaybackState.PLAYING self._trigger_track_playback_resumed() @@ -476,7 +458,7 @@ class PlaybackController(object): :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ - if not self.backend.current_playlist.tracks: + if not self.core.current_playlist.tracks: return False if self.state == PlaybackState.STOPPED: @@ -490,12 +472,9 @@ class PlaybackController(object): self.next() return True - self.play_time_started = self._current_wall_time - self.play_time_accumulated = time_position - - success = self.provider.seek(time_position) + success = self._get_backend().playback.seek(time_position).get() if success: - self._trigger_seeked() + self._trigger_seeked(time_position) return success def stop(self, clear_current_track=False): @@ -507,7 +486,7 @@ class PlaybackController(object): :type clear_current_track: boolean """ if self.state != PlaybackState.STOPPED: - if self.provider.stop(): + if self._get_backend().playback.stop().get(): self._trigger_track_playback_ended() self.state = PlaybackState.STOPPED if clear_current_track: @@ -517,41 +496,43 @@ class PlaybackController(object): logger.debug(u'Triggering track playback paused event') if self.current_track is None: return - BackendListener.send('track_playback_paused', - track=self.current_track, - time_position=self.time_position) + listener.CoreListener.send( + 'track_playback_paused', + track=self.current_track, time_position=self.time_position) def _trigger_track_playback_resumed(self): logger.debug(u'Triggering track playback resumed event') if self.current_track is None: return - BackendListener.send('track_playback_resumed', - track=self.current_track, - time_position=self.time_position) + listener.CoreListener.send( + 'track_playback_resumed', + track=self.current_track, time_position=self.time_position) def _trigger_track_playback_started(self): logger.debug(u'Triggering track playback started event') if self.current_track is None: return - BackendListener.send('track_playback_started', - track=self.current_track) + listener.CoreListener.send( + 'track_playback_started', track=self.current_track) def _trigger_track_playback_ended(self): logger.debug(u'Triggering track playback ended event') if self.current_track is None: return - BackendListener.send('track_playback_ended', - track=self.current_track, - time_position=self.time_position) + listener.CoreListener.send( + 'track_playback_ended', + track=self.current_track, time_position=self.time_position) - def _trigger_playback_state_changed(self): + def _trigger_playback_state_changed(self, old_state, new_state): logger.debug(u'Triggering playback state change event') - BackendListener.send('playback_state_changed') + listener.CoreListener.send( + 'playback_state_changed', + old_state=old_state, new_state=new_state) def _trigger_options_changed(self): logger.debug(u'Triggering options changed event') - BackendListener.send('options_changed') + listener.CoreListener.send('options_changed') - def _trigger_seeked(self): + def _trigger_seeked(self, time_position): logger.debug(u'Triggering seeked event') - BackendListener.send('seeked') + listener.CoreListener.send('seeked', time_position=time_position) diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index a29e34fc..8c04d5ad 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -1,48 +1,65 @@ -class StoredPlaylistsController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BaseStoredPlaylistsProvider` - """ +import itertools +import urlparse +import pykka + + +class StoredPlaylistsController(object): pykka_traversable = True - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider + def __init__(self, backends, core): + self.backends = backends + self.core = core @property def playlists(self): """ Currently stored playlists. - Read/write. List of :class:`mopidy.models.Playlist`. + Read-only. List of :class:`mopidy.models.Playlist`. """ - return self.provider.playlists + futures = [b.stored_playlists.playlists for b in self.backends] + results = pykka.get_all(futures) + return list(itertools.chain(*results)) - @playlists.setter - def playlists(self, playlists): - self.provider.playlists = playlists - - def create(self, name): + def create(self, name, uri_scheme=None): """ Create a new playlist. + If ``uri_scheme`` matches an URI scheme handled by a current backend, + that backend is asked to create the playlist. If ``uri_scheme`` is + :class:`None` or doesn't match a current backend, the first backend is + asked to create the playlist. + + All new playlists should be created by calling this method, and **not** + by creating new instances of :class:`mopidy.models.Playlist`. + :param name: name of the new playlist :type name: string + :param uri_scheme: use the backend matching the URI scheme + :type uri_scheme: string :rtype: :class:`mopidy.models.Playlist` """ - return self.provider.create(name) + if uri_scheme in self.backends.by_uri_scheme: + backend = self.backends.by_uri_scheme[uri_scheme] + else: + backend = self.backends[0] + return backend.stored_playlists.create(name).get() - def delete(self, playlist): + def delete(self, uri): """ - Delete playlist. + Delete playlist identified by the URI. - :param playlist: the playlist to delete - :type playlist: :class:`mopidy.models.Playlist` + If the URI doesn't match the URI schemes handled by the current + backends, nothing happens. + + :param uri: URI of the playlist to delete + :type uri: string """ - return self.provider.delete(playlist) + uri_scheme = urlparse.urlparse(uri).scheme + if uri_scheme in self.backends.by_uri_scheme: + backend = self.backends.by_uri_scheme[uri_scheme] + backend.stored_playlists.delete(uri).get() def get(self, **criteria): """ @@ -71,43 +88,71 @@ class StoredPlaylistsController(object): if len(matches) == 0: raise LookupError('"%s" match no playlists' % criteria_string) else: - raise LookupError('"%s" match multiple playlists' - % criteria_string) + 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. + in any other playlist sources. Returns :class:`None` if not found. :param uri: playlist URI :type uri: string - :rtype: :class:`mopidy.models.Playlist` + :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ - return self.provider.lookup(uri) + uri_scheme = urlparse.urlparse(uri).scheme + backend = self.backends.by_uri_scheme.get(uri_scheme, None) + if backend: + return backend.stored_playlists.lookup(uri).get() + else: + return None - def refresh(self): + def refresh(self, uri_scheme=None): """ - Refresh the stored playlists in - :attr:`mopidy.backends.base.StoredPlaylistsController.playlists`. - """ - return self.provider.refresh() + Refresh the stored playlists in :attr:`playlists`. - def rename(self, playlist, new_name): - """ - Rename playlist. + If ``uri_scheme`` is :class:`None`, all backends are asked to refresh. + If ``uri_scheme`` is an URI scheme handled by a backend, only that + backend is asked to refresh. If ``uri_scheme`` doesn't match any + current backend, nothing happens. - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - :param new_name: the new name - :type new_name: string + :param uri_scheme: limit to the backend matching the URI scheme + :type uri_scheme: string """ - return self.provider.rename(playlist, new_name) + if uri_scheme is None: + futures = [b.stored_playlists.refresh() for b in self.backends] + pykka.get_all(futures) + else: + if uri_scheme in self.backends.by_uri_scheme: + backend = self.backends.by_uri_scheme[uri_scheme] + backend.stored_playlists.refresh().get() def save(self, playlist): """ Save the playlist to the set of stored playlists. + For a playlist to be saveable, it must have the ``uri`` attribute set. + You should not set the ``uri`` atribute yourself, but use playlist + objects returned by :meth:`create` or retrieved from :attr:`playlists`, + which will always give you saveable playlists. + + The method returns the saved playlist. The return playlist may differ + from the saved playlist. E.g. if the playlist name was changed, the + returned playlist may have a different URI. The caller of this method + should throw away the playlist sent to this method, and use the + returned playlist instead. + + If the playlist's URI isn't set or doesn't match the URI scheme of a + current backend, nothing is done and :class:`None` is returned. + :param playlist: the playlist :type playlist: :class:`mopidy.models.Playlist` + :rtype: :class:`mopidy.models.Playlist` or :class:`None` """ - return self.provider.save(playlist) + if playlist.uri is None: + return + uri_scheme = urlparse.urlparse(playlist.uri).scheme + if uri_scheme not in self.backends.by_uri_scheme: + return + backend = self.backends.by_uri_scheme[uri_scheme] + return backend.stored_playlists.save(playlist).get() diff --git a/mopidy/exceptions.py b/mopidy/exceptions.py new file mode 100644 index 00000000..6e0c575e --- /dev/null +++ b/mopidy/exceptions.py @@ -0,0 +1,21 @@ +class MopidyException(Exception): + def __init__(self, message, *args, **kwargs): + super(MopidyException, self).__init__(message, *args, **kwargs) + self._message = message + + @property + def message(self): + """Reimplement message field that was deprecated in Python 2.6""" + return self._message + + @message.setter # noqa + def message(self, message): + self._message = message + + +class SettingsError(MopidyException): + pass + + +class OptionalDependencyError(MopidyException): + pass diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 0e79024b..aaf55ec1 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -1,42 +1,48 @@ +""" +Frontend which scrobbles the music you play to your `Last.fm +`_ profile. + +.. note:: + + This frontend requires a free user account at Last.fm. + +**Dependencies:** + +- `pylast `_ >= 0.5.7 + +**Settings:** + +- :attr:`mopidy.settings.LASTFM_USERNAME` +- :attr:`mopidy.settings.LASTFM_PASSWORD` + +**Usage:** + +Make sure :attr:`mopidy.settings.FRONTENDS` includes +``mopidy.frontends.lastfm.LastfmFrontend``. By default, the setting includes +the Last.fm frontend. +""" + import logging import time +import pykka + +from mopidy import exceptions, settings +from mopidy.core import CoreListener + try: import pylast except ImportError as import_error: - from mopidy import OptionalDependencyError - raise OptionalDependencyError(import_error) - -from pykka.actor import ThreadingActor - -from mopidy import settings, SettingsError -from mopidy.listeners import BackendListener + raise exceptions.OptionalDependencyError(import_error) logger = logging.getLogger('mopidy.frontends.lastfm') API_KEY = '2236babefa8ebb3d93ea467560d00d04' API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' -class LastfmFrontend(ThreadingActor, BackendListener): - """ - Frontend which scrobbles the music you play to your `Last.fm - `_ profile. - .. note:: - - This frontend requires a free user account at Last.fm. - - **Dependencies:** - - - `pylast `_ >= 0.5.7 - - **Settings:** - - - :attr:`mopidy.settings.LASTFM_USERNAME` - - :attr:`mopidy.settings.LASTFM_PASSWORD` - """ - - def __init__(self): +class LastfmFrontend(pykka.ThreadingActor, CoreListener): + def __init__(self, core): super(LastfmFrontend, self).__init__() self.lastfm = None self.last_start_time = None @@ -49,7 +55,7 @@ class LastfmFrontend(ThreadingActor, BackendListener): api_key=API_KEY, api_secret=API_SECRET, username=username, password_hash=password_hash) logger.info(u'Connected to Last.fm') - except SettingsError as e: + except exceptions.SettingsError as e: logger.info(u'Last.fm scrobbler not started') logger.debug(u'Last.fm settings error: %s', e) self.stop() diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index e8b2aabe..a6cfd386 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,110 +1,25 @@ -import logging -import sys +"""The MPD server frontend. -from pykka import registry, actor +MPD stands for Music Player Daemon. MPD is an independent project and server. +Mopidy implements the MPD protocol, and is thus compatible with clients for the +original MPD server. -from mopidy import listeners, settings -from mopidy.frontends.mpd import dispatcher, protocol -from mopidy.utils import locale_decode, log, network, process +**Dependencies:** -logger = logging.getLogger('mopidy.frontends.mpd') +- None -class MpdFrontend(actor.ThreadingActor, listeners.BackendListener): - """ - The MPD frontend. +**Settings:** - **Dependencies:** +- :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` +- :attr:`mopidy.settings.MPD_SERVER_PORT` +- :attr:`mopidy.settings.MPD_SERVER_PASSWORD` - - None +**Usage:** - **Settings:** +Make sure :attr:`mopidy.settings.FRONTENDS` includes +``mopidy.frontends.mpd.MpdFrontend``. By default, the setting includes the MPD +frontend. +""" - - :attr:`mopidy.settings.MPD_SERVER_HOSTNAME` - - :attr:`mopidy.settings.MPD_SERVER_PORT` - - :attr:`mopidy.settings.MPD_SERVER_PASSWORD` - """ - - def __init__(self): - super(MpdFrontend, self).__init__() - hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) - port = settings.MPD_SERVER_PORT - - try: - network.Server(hostname, port, protocol=MpdSession, - max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) - except IOError as error: - logger.error(u'MPD server startup failed: %s', locale_decode(error)) - sys.exit(1) - - logger.info(u'MPD server running at [%s]:%s', hostname, port) - - def on_stop(self): - process.stop_actors_by_class(MpdSession) - - def send_idle(self, subsystem): - # FIXME this should be updated once pykka supports non-blocking calls - # on proxies or some similar solution - registry.ActorRegistry.broadcast({ - 'command': 'pykka_call', - 'attr_path': ('on_idle',), - 'args': [subsystem], - 'kwargs': {}, - }, target_class=MpdSession) - - def playback_state_changed(self): - self.send_idle('player') - - def playlist_changed(self): - self.send_idle('playlist') - - def options_changed(self): - self.send_idle('options') - - def volume_changed(self): - self.send_idle('mixer') - - -class MpdSession(network.LineProtocol): - """ - The MPD client session. Keeps track of a single client session. Any - requests from the client is passed on to the MPD request dispatcher. - """ - - terminator = protocol.LINE_TERMINATOR - encoding = protocol.ENCODING - delimeter = r'\r?\n' - - def __init__(self, connection): - super(MpdSession, self).__init__(connection) - self.dispatcher = dispatcher.MpdDispatcher(self) - - def on_start(self): - logger.info(u'New MPD connection from [%s]:%s', self.host, self.port) - self.send_lines([u'OK MPD %s' % protocol.VERSION]) - - def on_line_received(self, line): - logger.debug(u'Request from [%s]:%s to %s: %s', self.host, self.port, - self.actor_urn, line) - - response = self.dispatcher.handle_request(line) - if not response: - return - - logger.debug(u'Response to [%s]:%s from %s: %s', self.host, self.port, - self.actor_urn, log.indent(self.terminator.join(response))) - - self.send_lines(response) - - def on_idle(self, subsystem): - self.dispatcher.handle_idle(subsystem) - - def decode(self, line): - try: - return super(MpdSession, self).decode(line.decode('string_escape')) - except ValueError: - logger.warning(u'Stopping actor due to unescaping error, data ' - 'supplied by client was not valid.') - self.stop() - - def close(self): - self.stop() +# flake8: noqa +from .actor import MpdFrontend diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py new file mode 100644 index 00000000..e136ddee --- /dev/null +++ b/mopidy/frontends/mpd/actor.py @@ -0,0 +1,51 @@ +import logging +import sys + +import pykka + +from mopidy import settings +from mopidy.core import CoreListener +from mopidy.frontends.mpd import session +from mopidy.utils import encoding, network, process + +logger = logging.getLogger('mopidy.frontends.mpd') + + +class MpdFrontend(pykka.ThreadingActor, CoreListener): + def __init__(self, core): + super(MpdFrontend, self).__init__() + hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) + port = settings.MPD_SERVER_PORT + + try: + network.Server( + hostname, port, + protocol=session.MpdSession, protocol_kwargs={'core': core}, + max_connections=settings.MPD_SERVER_MAX_CONNECTIONS) + except IOError as error: + logger.error( + u'MPD server startup failed: %s', + encoding.locale_decode(error)) + sys.exit(1) + + logger.info(u'MPD server running at [%s]:%s', hostname, port) + + def on_stop(self): + process.stop_actors_by_class(session.MpdSession) + + def send_idle(self, subsystem): + listeners = pykka.ActorRegistry.get_by_class(session.MpdSession) + for listener in listeners: + getattr(listener.proxy(), 'on_idle')(subsystem) + + def playback_state_changed(self, old_state, new_state): + self.send_idle('player') + + def playlist_changed(self): + self.send_idle('playlist') + + def options_changed(self): + self.send_idle('options') + + def volume_changed(self): + self.send_idle('mixer') diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 94ac6bf9..148fe443 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -1,24 +1,16 @@ import logging import re -from pykka import ActorDeadError -from pykka.registry import ActorRegistry +import pykka from mopidy import settings -from mopidy.backends.base import Backend -from mopidy.frontends.mpd import exceptions -from mopidy.frontends.mpd.protocol import mpd_commands, request_handlers -# Do not remove the following import. The protocol modules must be imported to -# get them registered as request handlers. -# pylint: disable = W0611 -from mopidy.frontends.mpd.protocol import (audio_output, command_list, - connection, current_playlist, empty, music_db, playback, reflection, - status, stickers, stored_playlists) -# pylint: enable = W0611 -from mopidy.utils import flatten +from mopidy.frontends.mpd import exceptions, protocol logger = logging.getLogger('mopidy.frontends.mpd.dispatcher') +protocol.load_protocol_modules() + + class MpdDispatcher(object): """ The MPD session feeds the MPD dispatcher with requests. The dispatcher @@ -28,12 +20,13 @@ class MpdDispatcher(object): _noidle = re.compile(r'^noidle$') - def __init__(self, session=None): + def __init__(self, session=None, core=None): self.authenticated = False - self.command_list = False + self.command_list_receiving = False self.command_list_ok = False + self.command_list = [] self.command_list_index = None - self.context = MpdContext(self, session=session) + self.context = MpdContext(self, session=session, core=core) def handle_request(self, request, current_command_list_index=None): """Dispatch incoming requests to the correct handler.""" @@ -72,7 +65,6 @@ class MpdDispatcher(object): else: return response - ### Filter: catch MPD ACK errors def _catch_mpd_ack_errors_filter(self, request, response, filter_chain): @@ -83,7 +75,6 @@ class MpdDispatcher(object): mpd_ack_error.index = self.command_list_index return [mpd_ack_error.get_mpd_ack()] - ### Filter: authenticate def _authenticate_filter(self, request, response, filter_chain): @@ -95,14 +86,13 @@ class MpdDispatcher(object): else: command_name = request.split(' ')[0] command_names_not_requiring_auth = [ - command.name for command in mpd_commands + command.name for command in protocol.mpd_commands if not command.auth_required] if command_name in command_names_not_requiring_auth: return self._call_next_filter(request, response, filter_chain) else: raise exceptions.MpdPermissionError(command=command_name) - ### Filter: command list def _command_list_filter(self, request, response, filter_chain): @@ -118,25 +108,26 @@ class MpdDispatcher(object): return response def _is_receiving_command_list(self, request): - return (self.command_list is not False - and request != u'command_list_end') + return ( + self.command_list_receiving and request != u'command_list_end') def _is_processing_command_list(self, request): - return (self.command_list_index is not None - and request != u'command_list_end') - + return ( + self.command_list_index is not None and + request != u'command_list_end') ### Filter: idle def _idle_filter(self, request, response, filter_chain): if self._is_currently_idle() and not self._noidle.match(request): - logger.debug(u'Client sent us %s, only %s is allowed while in ' - 'the idle state', repr(request), repr(u'noidle')) + logger.debug( + u'Client sent us %s, only %s is allowed while in ' + u'the idle state', repr(request), repr(u'noidle')) self.context.session.close() return [] if not self._is_currently_idle() and self._noidle.match(request): - return [] # noidle was called before idle + return [] # noidle was called before idle response = self._call_next_filter(request, response, filter_chain) @@ -148,7 +139,6 @@ class MpdDispatcher(object): def _is_currently_idle(self): return bool(self.context.subscriptions) - ### Filter: add OK def _add_ok_filter(self, request, response, filter_chain): @@ -160,14 +150,13 @@ class MpdDispatcher(object): def _has_error(self, response): return response and response[-1].startswith(u'ACK') - ### Filter: call handler def _call_handler_filter(self, request, response, filter_chain): try: response = self._format_response(self._call_handler(request)) return self._call_next_filter(request, response, filter_chain) - except ActorDeadError as e: + except pykka.ActorDeadError as e: logger.warning(u'Tried to communicate with dead actor.') raise exceptions.MpdSystemError(e) @@ -176,14 +165,15 @@ class MpdDispatcher(object): return handler(self.context, **kwargs) def _find_handler(self, request): - for pattern in request_handlers: + for pattern in protocol.request_handlers: matches = re.match(pattern, request) if matches is not None: - return (request_handlers[pattern], matches.groupdict()) + return ( + protocol.request_handlers[pattern], matches.groupdict()) command_name = request.split(' ')[0] - if command_name in [command.name for command in mpd_commands]: - raise exceptions.MpdArgError(u'incorrect arguments', - command=command_name) + if command_name in [command.name for command in protocol.mpd_commands]: + raise exceptions.MpdArgError( + u'incorrect arguments', command=command_name) raise exceptions.MpdUnknownCommand(command=command_name) def _format_response(self, response): @@ -196,10 +186,19 @@ class MpdDispatcher(object): if result is None: return [] if isinstance(result, set): - return flatten(list(result)) + return self._flatten(list(result)) if not isinstance(result, list): return [result] - return flatten(result) + return self._flatten(result) + + def _flatten(self, the_list): + result = [] + for element in the_list: + if isinstance(element, list): + result.extend(self._flatten(element)) + else: + result.append(element) + return result def _format_lines(self, line): if isinstance(line, dict): @@ -222,27 +221,18 @@ class MpdContext(object): #: The current :class:`mopidy.frontends.mpd.MpdSession`. session = None + #: The Mopidy core API. An instance of :class:`mopidy.core.Core`. + core = None + #: The active subsystems that have pending events. events = None #: The subsytems that we want to be notified about in idle mode. subscriptions = None - def __init__(self, dispatcher, session=None): + def __init__(self, dispatcher, session=None, core=None): self.dispatcher = dispatcher self.session = session + self.core = core self.events = set() self.subscriptions = set() - self._backend = None - - @property - def backend(self): - """ - The backend. An instance of :class:`mopidy.backends.base.Backend`. - """ - if self._backend is None: - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) == 1, \ - 'Expected exactly one running backend.' - self._backend = backend_refs[0].proxy() - return self._backend diff --git a/mopidy/frontends/mpd/exceptions.py b/mopidy/frontends/mpd/exceptions.py index 661d6905..5925d6bc 100644 --- a/mopidy/frontends/mpd/exceptions.py +++ b/mopidy/frontends/mpd/exceptions.py @@ -1,4 +1,5 @@ -from mopidy import MopidyException +from mopidy.exceptions import MopidyException + class MpdAckError(MopidyException): """See fields on this class for available MPD error codes""" @@ -33,12 +34,15 @@ class MpdAckError(MopidyException): return u'ACK [%i@%i] {%s} %s' % ( self.__class__.error_code, self.index, self.command, self.message) + class MpdArgError(MpdAckError): error_code = MpdAckError.ACK_ERROR_ARG + class MpdPasswordError(MpdAckError): error_code = MpdAckError.ACK_ERROR_PASSWORD + class MpdPermissionError(MpdAckError): error_code = MpdAckError.ACK_ERROR_PERMISSION @@ -46,6 +50,7 @@ class MpdPermissionError(MpdAckError): super(MpdPermissionError, self).__init__(*args, **kwargs) self.message = u'you don\'t have permission for "%s"' % self.command + class MpdUnknownCommand(MpdAckError): error_code = MpdAckError.ACK_ERROR_UNKNOWN @@ -54,12 +59,15 @@ class MpdUnknownCommand(MpdAckError): self.message = u'unknown command "%s"' % self.command self.command = u'' + class MpdNoExistError(MpdAckError): error_code = MpdAckError.ACK_ERROR_NO_EXIST + class MpdSystemError(MpdAckError): error_code = MpdAckError.ACK_ERROR_SYSTEM + class MpdNotImplemented(MpdAckError): error_code = 0 diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index f0b56a57..968a7dac 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -24,11 +24,13 @@ VERSION = u'0.16.0' MpdCommand = namedtuple('MpdCommand', ['name', 'auth_required']) -#: List of all available commands, represented as :class:`MpdCommand` objects. +#: Set of all available commands, represented as :class:`MpdCommand` objects. mpd_commands = set() +#: Map between request matchers and request handler functions. request_handlers = {} + def handle_request(pattern, auth_required=True): """ Decorator for connecting command handlers to command requests. @@ -60,3 +62,15 @@ def handle_request(pattern, auth_required=True): pattern, func.__doc__ or '') return func return decorator + + +def load_protocol_modules(): + """ + The protocol modules must be imported to get them registered in + :attr:`request_handlers` and :attr:`mpd_commands`. + """ + # pylint: disable = W0612 + from . import ( # noqa + audio_output, command_list, connection, current_playlist, empty, + music_db, playback, reflection, status, stickers, stored_playlists) + # pylint: enable = W0612 diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py index 7147963a..7e50c8c0 100644 --- a/mopidy/frontends/mpd/protocol/audio_output.py +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -1,6 +1,7 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNotImplemented + @handle_request(r'^disableoutput "(?P\d+)"$') def disableoutput(context, outputid): """ @@ -10,7 +11,8 @@ def disableoutput(context, outputid): Turns an output off. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^enableoutput "(?P\d+)"$') def enableoutput(context, outputid): @@ -21,7 +23,8 @@ def enableoutput(context, outputid): Turns an output on. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^outputs$') def outputs(context): diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/frontends/mpd/protocol/command_list.py index 37e5c93d..d422f97e 100644 --- a/mopidy/frontends/mpd/protocol/command_list.py +++ b/mopidy/frontends/mpd/protocol/command_list.py @@ -1,6 +1,7 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdUnknownCommand + @handle_request(r'^command_list_begin$') def command_list_begin(context): """ @@ -18,17 +19,19 @@ def command_list_begin(context): returned. If ``command_list_ok_begin`` is used, ``list_OK`` is returned for each successful command executed in the command list. """ - context.dispatcher.command_list = [] + context.dispatcher.command_list_receiving = True context.dispatcher.command_list_ok = False + context.dispatcher.command_list = [] + @handle_request(r'^command_list_end$') def command_list_end(context): """See :meth:`command_list_begin()`.""" - if context.dispatcher.command_list is False: - # Test for False exactly, and not e.g. empty list + if not context.dispatcher.command_list_receiving: raise MpdUnknownCommand(command='command_list_end') + context.dispatcher.command_list_receiving = False (command_list, context.dispatcher.command_list) = ( - context.dispatcher.command_list, False) + context.dispatcher.command_list, []) (command_list_ok, context.dispatcher.command_list_ok) = ( context.dispatcher.command_list_ok, False) command_list_response = [] @@ -43,8 +46,10 @@ def command_list_end(context): command_list_response.append(u'list_OK') return command_list_response + @handle_request(r'^command_list_ok_begin$') def command_list_ok_begin(context): """See :meth:`command_list_begin()`.""" - context.dispatcher.command_list = [] + context.dispatcher.command_list_receiving = True context.dispatcher.command_list_ok = True + context.dispatcher.command_list = [] diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py index ff230173..3228807f 100644 --- a/mopidy/frontends/mpd/protocol/connection.py +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -1,7 +1,8 @@ from mopidy import settings from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import (MpdPasswordError, - MpdPermissionError) +from mopidy.frontends.mpd.exceptions import ( + MpdPasswordError, MpdPermissionError) + @handle_request(r'^close$', auth_required=False) def close(context): @@ -14,6 +15,7 @@ def close(context): """ context.session.close() + @handle_request(r'^kill$') def kill(context): """ @@ -25,6 +27,7 @@ def kill(context): """ raise MpdPermissionError(command=u'kill') + @handle_request(r'^password "(?P[^"]+)"$', auth_required=False) def password_(context, password): """ @@ -40,6 +43,7 @@ def password_(context, password): else: raise MpdPasswordError(u'incorrect password', command=u'password') + @handle_request(r'^ping$', auth_required=False) def ping(context): """ diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index c60cbc4a..5a88d41b 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -1,8 +1,8 @@ -from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, - MpdNotImplemented) +from mopidy.frontends.mpd import translator +from mopidy.frontends.mpd.exceptions import ( + MpdArgError, MpdNoExistError, MpdNotImplemented) from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.translator import (track_to_mpd_format, - tracks_to_mpd_format) + @handle_request(r'^add "(?P[^"]*)"$') def add(context, uri): @@ -20,15 +20,16 @@ def add(context, uri): """ if not uri: return - for uri_scheme in context.backend.uri_schemes.get(): + for uri_scheme in context.core.uri_schemes.get(): if uri.startswith(uri_scheme): - track = context.backend.library.lookup(uri).get() + track = context.core.library.lookup(uri).get() if track is not None: - context.backend.current_playlist.add(track) + context.core.current_playlist.add(track) return raise MpdNoExistError( u'directory or file not found', command=u'add') + @handle_request(r'^addid "(?P[^"]*)"( "(?P\d+)")*$') def addid(context, uri, songpos=None): """ @@ -52,15 +53,16 @@ def addid(context, uri, songpos=None): raise MpdNoExistError(u'No such song', command=u'addid') if songpos is not None: songpos = int(songpos) - track = context.backend.library.lookup(uri).get() + track = context.core.library.lookup(uri).get() if track is None: raise MpdNoExistError(u'No such song', command=u'addid') - if songpos and songpos > context.backend.current_playlist.length.get(): + if songpos and songpos > context.core.current_playlist.length.get(): raise MpdArgError(u'Bad song index', command=u'addid') - cp_track = context.backend.current_playlist.add(track, - at_position=songpos).get() + cp_track = context.core.current_playlist.add( + track, at_position=songpos).get() return ('Id', cp_track.cpid) + @handle_request(r'^delete "(?P\d+):(?P\d+)*"$') def delete_range(context, start, end=None): """ @@ -74,24 +76,26 @@ def delete_range(context, start, end=None): if end is not None: end = int(end) else: - end = context.backend.current_playlist.length.get() - cp_tracks = context.backend.current_playlist.slice(start, end).get() + end = context.core.current_playlist.length.get() + cp_tracks = context.core.current_playlist.slice(start, end).get() if not cp_tracks: raise MpdArgError(u'Bad song index', command=u'delete') for (cpid, _) in cp_tracks: - context.backend.current_playlist.remove(cpid=cpid) + context.core.current_playlist.remove(cpid=cpid) + @handle_request(r'^delete "(?P\d+)"$') def delete_songpos(context, songpos): """See :meth:`delete_range`""" try: songpos = int(songpos) - (cpid, _) = context.backend.current_playlist.slice( + (cpid, _) = context.core.current_playlist.slice( songpos, songpos + 1).get()[0] - context.backend.current_playlist.remove(cpid=cpid) + context.core.current_playlist.remove(cpid=cpid) except IndexError: raise MpdArgError(u'Bad song index', command=u'delete') + @handle_request(r'^deleteid "(?P\d+)"$') def deleteid(context, cpid): """ @@ -103,12 +107,13 @@ def deleteid(context, cpid): """ try: cpid = int(cpid) - if context.backend.playback.current_cpid.get() == cpid: - context.backend.playback.next() - return context.backend.current_playlist.remove(cpid=cpid).get() + if context.core.playback.current_cpid.get() == cpid: + context.core.playback.next() + return context.core.current_playlist.remove(cpid=cpid).get() except LookupError: raise MpdNoExistError(u'No such song', command=u'deleteid') + @handle_request(r'^clear$') def clear(context): """ @@ -118,7 +123,8 @@ def clear(context): Clears the current playlist. """ - context.backend.current_playlist.clear() + context.core.current_playlist.clear() + @handle_request(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') def move_range(context, start, to, end=None): @@ -131,18 +137,20 @@ def move_range(context, start, to, end=None): ``TO`` in the playlist. """ if end is None: - end = context.backend.current_playlist.length.get() + end = context.core.current_playlist.length.get() start = int(start) end = int(end) to = int(to) - context.backend.current_playlist.move(start, end, to) + context.core.current_playlist.move(start, end, to) + @handle_request(r'^move "(?P\d+)" "(?P\d+)"$') def move_songpos(context, songpos, to): """See :meth:`move_range`.""" songpos = int(songpos) to = int(to) - context.backend.current_playlist.move(songpos, songpos + 1, to) + context.core.current_playlist.move(songpos, songpos + 1, to) + @handle_request(r'^moveid "(?P\d+)" "(?P\d+)"$') def moveid(context, cpid, to): @@ -157,9 +165,10 @@ def moveid(context, cpid, to): """ cpid = int(cpid) to = int(to) - cp_track = context.backend.current_playlist.get(cpid=cpid).get() - position = context.backend.current_playlist.index(cp_track).get() - context.backend.current_playlist.move(position, position + 1, to) + cp_track = context.core.current_playlist.get(cpid=cpid).get() + position = context.core.current_playlist.index(cp_track).get() + context.core.current_playlist.move(position, position + 1, to) + @handle_request(r'^playlist$') def playlist(context): @@ -176,6 +185,7 @@ def playlist(context): """ return playlistinfo(context) + @handle_request(r'^playlistfind (?P[^"]+) "(?P[^"]+)"$') @handle_request(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') def playlistfind(context, tag, needle): @@ -192,12 +202,13 @@ def playlistfind(context, tag, needle): """ if tag == 'filename': try: - cp_track = context.backend.current_playlist.get(uri=needle).get() - position = context.backend.current_playlist.index(cp_track).get() - return track_to_mpd_format(cp_track, position=position) + cp_track = context.core.current_playlist.get(uri=needle).get() + position = context.core.current_playlist.index(cp_track).get() + return translator.track_to_mpd_format(cp_track, position=position) except LookupError: return None - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^playlistid( "(?P\d+)")*$') def playlistid(context, cpid=None): @@ -212,21 +223,21 @@ def playlistid(context, cpid=None): if cpid is not None: try: cpid = int(cpid) - cp_track = context.backend.current_playlist.get(cpid=cpid).get() - position = context.backend.current_playlist.index(cp_track).get() - return track_to_mpd_format(cp_track, position=position) + cp_track = context.core.current_playlist.get(cpid=cpid).get() + position = context.core.current_playlist.index(cp_track).get() + return translator.track_to_mpd_format(cp_track, position=position) except LookupError: raise MpdNoExistError(u'No such song', command=u'playlistid') else: - return tracks_to_mpd_format( - context.backend.current_playlist.cp_tracks.get()) + return translator.tracks_to_mpd_format( + context.core.current_playlist.cp_tracks.get()) + @handle_request(r'^playlistinfo$') @handle_request(r'^playlistinfo "-1"$') @handle_request(r'^playlistinfo "(?P-?\d+)"$') @handle_request(r'^playlistinfo "(?P\d+):(?P\d+)*"$') -def playlistinfo(context, songpos=None, - start=None, end=None): +def playlistinfo(context, songpos=None, start=None, end=None): """ *musicpd.org, current playlist section:* @@ -243,20 +254,21 @@ def playlistinfo(context, songpos=None, """ if songpos is not None: songpos = int(songpos) - cp_track = context.backend.current_playlist.cp_tracks.get()[songpos] - return track_to_mpd_format(cp_track, position=songpos) + cp_track = context.core.current_playlist.cp_tracks.get()[songpos] + return translator.track_to_mpd_format(cp_track, position=songpos) else: if start is None: start = 0 start = int(start) - if not (0 <= start <= context.backend.current_playlist.length.get()): + if not (0 <= start <= context.core.current_playlist.length.get()): raise MpdArgError(u'Bad song index', command=u'playlistinfo') if end is not None: end = int(end) - if end > context.backend.current_playlist.length.get(): + if end > context.core.current_playlist.length.get(): end = None - cp_tracks = context.backend.current_playlist.cp_tracks.get() - return tracks_to_mpd_format(cp_tracks, start, end) + cp_tracks = context.core.current_playlist.cp_tracks.get() + return translator.tracks_to_mpd_format(cp_tracks, start, end) + @handle_request(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') @handle_request(r'^playlistsearch (?P\S+) "(?P[^"]+)"$') @@ -274,7 +286,8 @@ def playlistsearch(context, tag, needle): - does not add quotes around the tag - uses ``filename`` and ``any`` as tags """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^plchanges (?P-?\d+)$') @handle_request(r'^plchanges "(?P-?\d+)"$') @@ -294,9 +307,10 @@ def plchanges(context, version): - Calls ``plchanges "-1"`` two times per second to get the entire playlist. """ # XXX Naive implementation that returns all tracks as changed - if int(version) < context.backend.current_playlist.version: - return tracks_to_mpd_format( - context.backend.current_playlist.cp_tracks.get()) + if int(version) < context.core.current_playlist.version.get(): + return translator.tracks_to_mpd_format( + context.core.current_playlist.cp_tracks.get()) + @handle_request(r'^plchangesposid "(?P\d+)"$') def plchangesposid(context, version): @@ -313,14 +327,15 @@ def plchangesposid(context, version): ``playlistlength`` returned by status command. """ # XXX Naive implementation that returns all tracks as changed - if int(version) != context.backend.current_playlist.version.get(): + if int(version) != context.core.current_playlist.version.get(): result = [] for (position, (cpid, _)) in enumerate( - context.backend.current_playlist.cp_tracks.get()): + context.core.current_playlist.cp_tracks.get()): result.append((u'cpos', position)) result.append((u'Id', cpid)) return result + @handle_request(r'^shuffle$') @handle_request(r'^shuffle "(?P\d+):(?P\d+)*"$') def shuffle(context, start=None, end=None): @@ -336,7 +351,8 @@ def shuffle(context, start=None, end=None): start = int(start) if end is not None: end = int(end) - context.backend.current_playlist.shuffle(start, end) + context.core.current_playlist.shuffle(start, end) + @handle_request(r'^swap "(?P\d+)" "(?P\d+)"$') def swap(context, songpos1, songpos2): @@ -349,15 +365,16 @@ def swap(context, songpos1, songpos2): """ songpos1 = int(songpos1) songpos2 = int(songpos2) - tracks = context.backend.current_playlist.tracks.get() + tracks = context.core.current_playlist.tracks.get() song1 = tracks[songpos1] song2 = tracks[songpos2] del tracks[songpos1] tracks.insert(songpos1, song2) del tracks[songpos2] tracks.insert(songpos2, song1) - context.backend.current_playlist.clear() - context.backend.current_playlist.append(tracks) + context.core.current_playlist.clear() + context.core.current_playlist.append(tracks) + @handle_request(r'^swapid "(?P\d+)" "(?P\d+)"$') def swapid(context, cpid1, cpid2): @@ -370,8 +387,8 @@ def swapid(context, cpid1, cpid2): """ cpid1 = int(cpid1) cpid2 = int(cpid2) - cp_track1 = context.backend.current_playlist.get(cpid=cpid1).get() - cp_track2 = context.backend.current_playlist.get(cpid=cpid2).get() - position1 = context.backend.current_playlist.index(cp_track1).get() - position2 = context.backend.current_playlist.index(cp_track2).get() + cp_track1 = context.core.current_playlist.get(cpid=cpid1).get() + cp_track2 = context.core.current_playlist.get(cpid=cpid2).get() + position1 = context.core.current_playlist.index(cp_track1).get() + position2 = context.core.current_playlist.index(cp_track2).get() swap(context, position1, position2) diff --git a/mopidy/frontends/mpd/protocol/empty.py b/mopidy/frontends/mpd/protocol/empty.py index 4cdafd87..f2ee4757 100644 --- a/mopidy/frontends/mpd/protocol/empty.py +++ b/mopidy/frontends/mpd/protocol/empty.py @@ -1,5 +1,6 @@ from mopidy.frontends.mpd.protocol import handle_request + @handle_request(r'^[ ]*$') def empty(context): """The original MPD server returns ``OK`` on an empty request.""" diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index d0128a1e..a5d5b214 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -5,6 +5,7 @@ from mopidy.frontends.mpd.exceptions import MpdArgError, MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request, stored_playlists from mopidy.frontends.mpd.translator import playlist_to_mpd_format + def _build_query(mpd_query): """ Parses a MPD query string and converts it to the Mopidy query format. @@ -21,7 +22,7 @@ def _build_query(mpd_query): field = m.groupdict()['field'].lower() if field == u'title': field = u'track' - field = str(field) # Needed for kwargs keys on OS X and Windows + field = str(field) # Needed for kwargs keys on OS X and Windows what = m.groupdict()['what'].lower() if field in query: query[field].append(what) @@ -29,6 +30,7 @@ def _build_query(mpd_query): query[field] = [what] return query + @handle_request(r'^count "(?P[^"]+)" "(?P[^"]*)"$') def count(context, tag, needle): """ @@ -39,11 +41,12 @@ def count(context, tag, needle): Counts the number of songs and their total playtime in the db matching ``TAG`` exactly. """ - return [('songs', 0), ('playtime', 0)] # TODO + return [('songs', 0), ('playtime', 0)] # TODO -@handle_request(r'^find ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' - r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') + +@handle_request( + r'^find (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' + r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') def find(context, mpd_query): """ *musicpd.org, music database section:* @@ -70,11 +73,13 @@ def find(context, mpd_query): """ query = _build_query(mpd_query) return playlist_to_mpd_format( - context.backend.library.find_exact(**query).get()) + context.core.library.find_exact(**query).get()) -@handle_request(r'^findadd ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' - '"[^"]+"\s?)+)$') + +@handle_request( + r'^findadd ' + r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' + r'"[^"]+"\s?)+)$') def findadd(context, query): """ *musicpd.org, music database section:* @@ -88,8 +93,10 @@ def findadd(context, query): # TODO Add result to current playlist #result = context.find(query) -@handle_request(r'^list "?(?P([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?' - '( (?P.*))?$') + +@handle_request( + r'^list "?(?P([Aa]rtist|[Aa]lbum|[Dd]ate|[Gg]enre))"?' + r'( (?P.*))?$') def list_(context, field, mpd_query=None): """ *musicpd.org, music database section:* @@ -183,7 +190,8 @@ def list_(context, field, mpd_query=None): elif field == u'date': return _list_date(context, query) elif field == u'genre': - pass # TODO We don't have genre in our internal data structures yet + pass # TODO We don't have genre in our internal data structures yet + def _list_build_query(field, mpd_query): """Converts a ``list`` query to a Mopidy query.""" @@ -208,7 +216,7 @@ def _list_build_query(field, mpd_query): query = {} while tokens: key = tokens[0].lower() - key = str(key) # Needed for kwargs keys on OS X and Windows + key = str(key) # Needed for kwargs keys on OS X and Windows value = tokens[1] tokens = tokens[2:] if key not in (u'artist', u'album', u'date', u'genre'): @@ -221,30 +229,34 @@ def _list_build_query(field, mpd_query): else: raise MpdArgError(u'not able to parse args', command=u'list') + def _list_artist(context, query): artists = set() - playlist = context.backend.library.find_exact(**query).get() + playlist = context.core.library.find_exact(**query).get() for track in playlist.tracks: for artist in track.artists: artists.add((u'Artist', artist.name)) return artists + def _list_album(context, query): albums = set() - playlist = context.backend.library.find_exact(**query).get() + playlist = context.core.library.find_exact(**query).get() for track in playlist.tracks: if track.album is not None: albums.add((u'Album', track.album.name)) return albums + def _list_date(context, query): dates = set() - playlist = context.backend.library.find_exact(**query).get() + playlist = context.core.library.find_exact(**query).get() for track in playlist.tracks: if track.date is not None: dates.add((u'Date', track.date)) return dates + @handle_request(r'^listall "(?P[^"]+)"') def listall(context, uri): """ @@ -254,7 +266,8 @@ def listall(context, uri): Lists all songs and directories in ``URI``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^listallinfo "(?P[^"]+)"') def listallinfo(context, uri): @@ -266,7 +279,8 @@ def listallinfo(context, uri): Same as ``listall``, except it also returns metadata info in the same format as ``lsinfo``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^lsinfo$') @handle_request(r'^lsinfo "(?P[^"]*)"$') @@ -288,7 +302,8 @@ def lsinfo(context, uri=None): """ if uri is None or uri == u'/' or uri == u'': return stored_playlists.listplaylists(context) - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^rescan( "(?P[^"]+)")*$') def rescan(context, uri=None): @@ -301,9 +316,10 @@ def rescan(context, uri=None): """ return update(context, uri, rescan_unmodified_files=True) -@handle_request(r'^search ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' - r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') + +@handle_request( + r'^search (?P("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ilename|' + r'[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') def search(context, mpd_query): """ *musicpd.org, music database section:* @@ -333,7 +349,8 @@ def search(context, mpd_query): """ query = _build_query(mpd_query) return playlist_to_mpd_format( - context.backend.library.search(**query).get()) + context.core.library.search(**query).get()) + @handle_request(r'^update( "(?P[^"]+)")*$') def update(context, uri=None, rescan_unmodified_files=False): @@ -352,4 +369,4 @@ def update(context, uri=None, rescan_unmodified_files=False): identifying the update job. You can read the current job id in the ``status`` response. """ - return {'updating_db': 0} # TODO + return {'updating_db': 0} # TODO diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index b0c299c8..7851ebe0 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -1,7 +1,8 @@ from mopidy.core import PlaybackState from mopidy.frontends.mpd.protocol import handle_request -from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, - MpdNotImplemented) +from mopidy.frontends.mpd.exceptions import ( + MpdArgError, MpdNoExistError, MpdNotImplemented) + @handle_request(r'^consume (?P[01])$') @handle_request(r'^consume "(?P[01])"$') @@ -16,9 +17,10 @@ def consume(context, state): playlist. """ if int(state): - context.backend.playback.consume = True + context.core.playback.consume = True else: - context.backend.playback.consume = False + context.core.playback.consume = False + @handle_request(r'^crossfade "(?P\d+)"$') def crossfade(context, seconds): @@ -30,7 +32,8 @@ def crossfade(context, seconds): Sets crossfading between songs. """ seconds = int(seconds) - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^next$') def next_(context): @@ -87,7 +90,8 @@ def next_(context): order as the first time. """ - return context.backend.playback.next().get() + return context.core.playback.next().get() + @handle_request(r'^pause$') @handle_request(r'^pause "(?P[01])"$') @@ -104,14 +108,15 @@ def pause(context, state=None): - Calls ``pause`` without any arguments to toogle pause. """ if state is None: - if (context.backend.playback.state.get() == PlaybackState.PLAYING): - context.backend.playback.pause() - elif (context.backend.playback.state.get() == PlaybackState.PAUSED): - context.backend.playback.resume() + if (context.core.playback.state.get() == PlaybackState.PLAYING): + context.core.playback.pause() + elif (context.core.playback.state.get() == PlaybackState.PAUSED): + context.core.playback.resume() elif int(state): - context.backend.playback.pause() + context.core.playback.pause() else: - context.backend.playback.resume() + context.core.playback.resume() + @handle_request(r'^play$') def play(context): @@ -119,10 +124,11 @@ def play(context): The original MPD server resumes from the paused state on ``play`` without arguments. """ - return context.backend.playback.play().get() + return context.core.playback.play().get() -@handle_request(r'^playid "(?P\d+)"$') -@handle_request(r'^playid "(?P-1)"$') + +@handle_request(r'^playid (?P-?\d+)$') +@handle_request(r'^playid "(?P-?\d+)"$') def playid(context, cpid): """ *musicpd.org, playback section:* @@ -144,11 +150,12 @@ def playid(context, cpid): if cpid == -1: return _play_minus_one(context) try: - cp_track = context.backend.current_playlist.get(cpid=cpid).get() - return context.backend.playback.play(cp_track).get() + cp_track = context.core.current_playlist.get(cpid=cpid).get() + return context.core.playback.play(cp_track).get() except LookupError: raise MpdNoExistError(u'No such song', command=u'playid') + @handle_request(r'^play (?P-?\d+)$') @handle_request(r'^play "(?P-?\d+)"$') def playpos(context, songpos): @@ -161,11 +168,11 @@ def playpos(context, songpos): *Clarifications:* - - ``playid "-1"`` when playing is ignored. - - ``playid "-1"`` when paused resumes playback. - - ``playid "-1"`` when stopped with a current track starts playback at the + - ``play "-1"`` when playing is ignored. + - ``play "-1"`` when paused resumes playback. + - ``play "-1"`` when stopped with a current track starts playback at the current track. - - ``playid "-1"`` when stopped without a current track, e.g. after playlist + - ``play "-1"`` when stopped without a current track, e.g. after playlist replacement, starts playback at the first track. *BitMPC:* @@ -176,25 +183,27 @@ def playpos(context, songpos): if songpos == -1: return _play_minus_one(context) try: - cp_track = context.backend.current_playlist.slice( + cp_track = context.core.current_playlist.slice( songpos, songpos + 1).get()[0] - return context.backend.playback.play(cp_track).get() + return context.core.playback.play(cp_track).get() except IndexError: raise MpdArgError(u'Bad song index', command=u'play') + def _play_minus_one(context): - if (context.backend.playback.state.get() == PlaybackState.PLAYING): - return # Nothing to do - elif (context.backend.playback.state.get() == PlaybackState.PAUSED): - return context.backend.playback.resume().get() - elif context.backend.playback.current_cp_track.get() is not None: - cp_track = context.backend.playback.current_cp_track.get() - return context.backend.playback.play(cp_track).get() - elif context.backend.current_playlist.slice(0, 1).get(): - cp_track = context.backend.current_playlist.slice(0, 1).get()[0] - return context.backend.playback.play(cp_track).get() + if (context.core.playback.state.get() == PlaybackState.PLAYING): + return # Nothing to do + elif (context.core.playback.state.get() == PlaybackState.PAUSED): + return context.core.playback.resume().get() + elif context.core.playback.current_cp_track.get() is not None: + cp_track = context.core.playback.current_cp_track.get() + return context.core.playback.play(cp_track).get() + elif context.core.current_playlist.slice(0, 1).get(): + cp_track = context.core.current_playlist.slice(0, 1).get()[0] + return context.core.playback.play(cp_track).get() else: - return # Fail silently + return # Fail silently + @handle_request(r'^previous$') def previous(context): @@ -240,7 +249,8 @@ def previous(context): ``previous`` should do a seek to time position 0. """ - return context.backend.playback.previous().get() + return context.core.playback.previous().get() + @handle_request(r'^random (?P[01])$') @handle_request(r'^random "(?P[01])"$') @@ -253,9 +263,10 @@ def random(context, state): Sets random state to ``STATE``, ``STATE`` should be 0 or 1. """ if int(state): - context.backend.playback.random = True + context.core.playback.random = True else: - context.backend.playback.random = False + context.core.playback.random = False + @handle_request(r'^repeat (?P[01])$') @handle_request(r'^repeat "(?P[01])"$') @@ -268,9 +279,10 @@ def repeat(context, state): Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. """ if int(state): - context.backend.playback.repeat = True + context.core.playback.repeat = True else: - context.backend.playback.repeat = False + context.core.playback.repeat = False + @handle_request(r'^replay_gain_mode "(?P(off|track|album))"$') def replay_gain_mode(context, mode): @@ -286,7 +298,8 @@ def replay_gain_mode(context, mode): This command triggers the options idle event. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^replay_gain_status$') def replay_gain_status(context): @@ -298,7 +311,8 @@ def replay_gain_status(context): Prints replay gain options. Currently, only the variable ``replay_gain_mode`` is returned. """ - return u'off' # TODO + return u'off' # TODO + @handle_request(r'^seek (?P\d+) (?P\d+)$') @handle_request(r'^seek "(?P\d+)" "(?P\d+)"$') @@ -315,9 +329,10 @@ def seek(context, songpos, seconds): - issues ``seek 1 120`` without quotes around the arguments. """ - if context.backend.playback.current_playlist_position != songpos: + if context.core.playback.current_playlist_position != songpos: playpos(context, songpos) - context.backend.playback.seek(int(seconds) * 1000) + context.core.playback.seek(int(seconds) * 1000) + @handle_request(r'^seekid "(?P\d+)" "(?P\d+)"$') def seekid(context, cpid, seconds): @@ -328,9 +343,10 @@ def seekid(context, cpid, seconds): Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. """ - if context.backend.playback.current_cpid != cpid: + if context.core.playback.current_cpid != cpid: playid(context, cpid) - context.backend.playback.seek(int(seconds) * 1000) + context.core.playback.seek(int(seconds) * 1000) + @handle_request(r'^setvol (?P[-+]*\d+)$') @handle_request(r'^setvol "(?P[-+]*\d+)"$') @@ -351,7 +367,8 @@ def setvol(context, volume): volume = 0 if volume > 100: volume = 100 - context.backend.playback.volume = volume + context.core.playback.volume = volume + @handle_request(r'^single (?P[01])$') @handle_request(r'^single "(?P[01])"$') @@ -366,9 +383,10 @@ def single(context, state): song is repeated if the ``repeat`` mode is enabled. """ if int(state): - context.backend.playback.single = True + context.core.playback.single = True else: - context.backend.playback.single = False + context.core.playback.single = False + @handle_request(r'^stop$') def stop(context): @@ -379,4 +397,4 @@ def stop(context): Stops playing. """ - context.backend.playback.stop() + context.core.playback.stop() diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index df13b4b4..bc18eb3a 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -1,6 +1,7 @@ from mopidy.frontends.mpd.protocol import handle_request, mpd_commands from mopidy.frontends.mpd.exceptions import MpdNotImplemented + @handle_request(r'^commands$', auth_required=False) def commands(context): """ @@ -13,16 +14,20 @@ def commands(context): if context.dispatcher.authenticated: command_names = set([command.name for command in mpd_commands]) else: - command_names = set([command.name for command in mpd_commands + command_names = set([ + command.name for command in mpd_commands if not command.auth_required]) # No one is permited to use kill, rest of commands are not listed by MPD, # so we shouldn't either. - command_names = command_names - set(['kill', 'command_list_begin', - 'command_list_ok_begin', 'command_list_ok_begin', 'command_list_end', - 'idle', 'noidle', 'sticker']) + command_names = command_names - set([ + 'kill', 'command_list_begin', 'command_list_ok_begin', + 'command_list_ok_begin', 'command_list_end', 'idle', 'noidle', + 'sticker']) + + return [ + ('command', command_name) for command_name in sorted(command_names)] - return [('command', command_name) for command_name in sorted(command_names)] @handle_request(r'^decoders$') def decoders(context): @@ -41,7 +46,8 @@ def decoders(context): plugin: mpcdec suffix: mpc """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^notcommands$', auth_required=False) def notcommands(context): @@ -55,13 +61,15 @@ def notcommands(context): if context.dispatcher.authenticated: command_names = [] else: - command_names = [command.name for command in mpd_commands - if command.auth_required] + command_names = [ + command.name for command in mpd_commands if command.auth_required] # No permission to use command_names.append('kill') - return [('command', command_name) for command_name in sorted(command_names)] + return [ + ('command', command_name) for command_name in sorted(command_names)] + @handle_request(r'^tagtypes$') def tagtypes(context): @@ -72,7 +80,8 @@ def tagtypes(context): Shows a list of available song metadata. """ - pass # TODO + pass # TODO + @handle_request(r'^urlhandlers$') def urlhandlers(context): @@ -83,5 +92,6 @@ def urlhandlers(context): Gets a list of available URL handlers. """ - return [(u'handler', uri_scheme) - for uri_scheme in context.backend.uri_schemes.get()] + return [ + (u'handler', uri_scheme) + for uri_scheme in context.core.uri_schemes.get()] diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index fc24e1e1..b8e207d1 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -1,4 +1,4 @@ -import pykka.future +import pykka from mopidy.core import PlaybackState from mopidy.frontends.mpd.exceptions import MpdNotImplemented @@ -6,8 +6,10 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import track_to_mpd_format #: Subsystems that can be registered with idle command. -SUBSYSTEMS = ['database', 'mixer', 'options', 'output', - 'player', 'playlist', 'stored_playlist', 'update', ] +SUBSYSTEMS = [ + 'database', 'mixer', 'options', 'output', 'player', 'playlist', + 'stored_playlist', 'update'] + @handle_request(r'^clearerror$') def clearerror(context): @@ -19,7 +21,8 @@ def clearerror(context): Clears the current error message in status (this is also accomplished by any command that starts playback). """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^currentsong$') def currentsong(context): @@ -31,11 +34,12 @@ def currentsong(context): Displays the song info of the current song (same song that is identified in status). """ - current_cp_track = context.backend.playback.current_cp_track.get() + current_cp_track = context.core.playback.current_cp_track.get() if current_cp_track is not None: - position = context.backend.playback.current_playlist_position.get() + position = context.core.playback.current_playlist_position.get() return track_to_mpd_format(current_cp_track, position=position) + @handle_request(r'^idle$') @handle_request(r'^idle (?P.+)$') def idle(context, subsystems=None): @@ -93,6 +97,7 @@ def idle(context, subsystems=None): response.append(u'changed: %s' % subsystem) return response + @handle_request(r'^noidle$') def noidle(context): """See :meth:`_status_idle`.""" @@ -102,6 +107,7 @@ def noidle(context): context.events = set() context.session.prevent_timeout = False + @handle_request(r'^stats$') def stats(context): """ @@ -119,15 +125,16 @@ def stats(context): - ``playtime``: time length of music played """ return { - 'artists': 0, # TODO - 'albums': 0, # TODO - 'songs': 0, # TODO - 'uptime': 0, # TODO - 'db_playtime': 0, # TODO - 'db_update': 0, # TODO - 'playtime': 0, # TODO + 'artists': 0, # TODO + 'albums': 0, # TODO + 'songs': 0, # TODO + 'uptime': 0, # TODO + 'db_playtime': 0, # TODO + 'db_update': 0, # TODO + 'playtime': 0, # TODO } + @handle_request(r'^status$') def status(context): """ @@ -153,7 +160,7 @@ def status(context): - ``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. + higher resolution. - ``bitrate``: instantaneous bitrate in kbps - ``xfade``: crossfade in seconds - ``audio``: sampleRate``:bits``:channels @@ -166,20 +173,20 @@ def status(context): decimal places for millisecond precision. """ futures = { - 'current_playlist.length': context.backend.current_playlist.length, - 'current_playlist.version': context.backend.current_playlist.version, - 'playback.volume': context.backend.playback.volume, - 'playback.consume': context.backend.playback.consume, - 'playback.random': context.backend.playback.random, - 'playback.repeat': context.backend.playback.repeat, - 'playback.single': context.backend.playback.single, - 'playback.state': context.backend.playback.state, - 'playback.current_cp_track': context.backend.playback.current_cp_track, - 'playback.current_playlist_position': - context.backend.playback.current_playlist_position, - 'playback.time_position': context.backend.playback.time_position, + 'current_playlist.length': context.core.current_playlist.length, + 'current_playlist.version': context.core.current_playlist.version, + 'playback.volume': context.core.playback.volume, + 'playback.consume': context.core.playback.consume, + 'playback.random': context.core.playback.random, + 'playback.repeat': context.core.playback.repeat, + 'playback.single': context.core.playback.single, + 'playback.state': context.core.playback.state, + 'playback.current_cp_track': context.core.playback.current_cp_track, + 'playback.current_playlist_position': ( + context.core.playback.current_playlist_position), + 'playback.time_position': context.core.playback.time_position, } - pykka.future.get_all(futures.values()) + pykka.get_all(futures.values()) result = [ ('volume', _status_volume(futures)), ('repeat', _status_repeat(futures)), @@ -194,39 +201,47 @@ def status(context): if futures['playback.current_cp_track'].get() is not None: result.append(('song', _status_songpos(futures))) result.append(('songid', _status_songid(futures))) - if futures['playback.state'].get() in (PlaybackState.PLAYING, - PlaybackState.PAUSED): + if futures['playback.state'].get() in ( + PlaybackState.PLAYING, PlaybackState.PAUSED): result.append(('time', _status_time(futures))) result.append(('elapsed', _status_time_elapsed(futures))) result.append(('bitrate', _status_bitrate(futures))) return result + def _status_bitrate(futures): current_cp_track = futures['playback.current_cp_track'].get() if current_cp_track is not None: return current_cp_track.track.bitrate + def _status_consume(futures): if futures['playback.consume'].get(): return 1 else: return 0 + def _status_playlist_length(futures): return futures['current_playlist.length'].get() + def _status_playlist_version(futures): return futures['current_playlist.version'].get() + def _status_random(futures): return int(futures['playback.random'].get()) + def _status_repeat(futures): return int(futures['playback.repeat'].get()) + def _status_single(futures): return int(futures['playback.single'].get()) + def _status_songid(futures): current_cp_track = futures['playback.current_cp_track'].get() if current_cp_track is not None: @@ -234,9 +249,11 @@ def _status_songid(futures): else: return _status_songpos(futures) + def _status_songpos(futures): return futures['playback.current_playlist_position'].get() + def _status_state(futures): state = futures['playback.state'].get() if state == PlaybackState.PLAYING: @@ -246,13 +263,17 @@ def _status_state(futures): elif state == PlaybackState.PAUSED: return u'pause' + def _status_time(futures): - return u'%d:%d' % (futures['playback.time_position'].get() // 1000, + return u'%d:%d' % ( + futures['playback.time_position'].get() // 1000, _status_time_total(futures) // 1000) + def _status_time_elapsed(futures): return u'%.3f' % (futures['playback.time_position'].get() / 1000.0) + def _status_time_total(futures): current_cp_track = futures['playback.current_cp_track'].get() if current_cp_track is None: @@ -262,6 +283,7 @@ def _status_time_total(futures): else: return current_cp_track.track.length + def _status_volume(futures): volume = futures['playback.volume'].get() if volume is not None: @@ -269,5 +291,6 @@ def _status_volume(futures): else: return -1 + def _status_xfade(futures): - return 0 # Not supported + return 0 # Not supported diff --git a/mopidy/frontends/mpd/protocol/stickers.py b/mopidy/frontends/mpd/protocol/stickers.py index c3663ff1..074a306d 100644 --- a/mopidy/frontends/mpd/protocol/stickers.py +++ b/mopidy/frontends/mpd/protocol/stickers.py @@ -1,7 +1,9 @@ from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import MpdNotImplemented -@handle_request(r'^sticker delete "(?P[^"]+)" ' + +@handle_request( + r'^sticker delete "(?P[^"]+)" ' r'"(?P[^"]+)"( "(?P[^"]+)")*$') def sticker_delete(context, field, uri, name=None): """ @@ -12,9 +14,11 @@ def sticker_delete(context, field, uri, name=None): Deletes a sticker value from the specified object. If you do not specify a sticker name, all sticker values are deleted. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO -@handle_request(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" ' + +@handle_request( + r'^sticker find "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)"$') def sticker_find(context, field, uri, name): """ @@ -26,9 +30,11 @@ def sticker_find(context, field, uri, name): below the specified directory (``URI``). For each matching song, it prints the ``URI`` and that one sticker's value. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO -@handle_request(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" ' + +@handle_request( + r'^sticker get "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)"$') def sticker_get(context, field, uri, name): """ @@ -38,7 +44,8 @@ def sticker_get(context, field, uri, name): Reads a sticker value for the specified object. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^sticker list "(?P[^"]+)" "(?P[^"]+)"$') def sticker_list(context, field, uri): @@ -49,9 +56,11 @@ def sticker_list(context, field, uri): Lists the stickers for the specified object. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO -@handle_request(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" ' + +@handle_request( + r'^sticker set "(?P[^"]+)" "(?P[^"]+)" ' r'"(?P[^"]+)" "(?P[^"]+)"$') def sticker_set(context, field, uri, name, value): """ @@ -62,4 +71,4 @@ def sticker_set(context, field, 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 + raise MpdNotImplemented # TODO diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py index bb39d328..17e5abf7 100644 --- a/mopidy/frontends/mpd/protocol/stored_playlists.py +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -4,6 +4,8 @@ from mopidy.frontends.mpd.exceptions import MpdNoExistError, MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import playlist_to_mpd_format + +@handle_request(r'^listplaylist (?P\S+)$') @handle_request(r'^listplaylist "(?P[^"]+)"$') def listplaylist(context, name): """ @@ -20,11 +22,13 @@ def listplaylist(context, name): file: relative/path/to/file3.mp3 """ try: - playlist = context.backend.stored_playlists.get(name=name).get() + playlist = context.core.stored_playlists.get(name=name).get() return ['file: %s' % t.uri for t in playlist.tracks] except LookupError: raise MpdNoExistError(u'No such playlist', command=u'listplaylist') + +@handle_request(r'^listplaylistinfo (?P\S+)$') @handle_request(r'^listplaylistinfo "(?P[^"]+)"$') def listplaylistinfo(context, name): """ @@ -40,12 +44,13 @@ def listplaylistinfo(context, name): Album, Artist, Track """ try: - playlist = context.backend.stored_playlists.get(name=name).get() + playlist = context.core.stored_playlists.get(name=name).get() return playlist_to_mpd_format(playlist) except LookupError: raise MpdNoExistError( u'No such playlist', command=u'listplaylistinfo') + @handle_request(r'^listplaylists$') def listplaylists(context): """ @@ -68,10 +73,10 @@ def listplaylists(context): Last-Modified: 2010-02-06T02:11:08Z """ result = [] - for playlist in context.backend.stored_playlists.playlists.get(): + for playlist in context.core.stored_playlists.playlists.get(): result.append((u'playlist', playlist.name)) - last_modified = (playlist.last_modified or - dt.datetime.now()).isoformat() + last_modified = ( + playlist.last_modified or dt.datetime.now()).isoformat() # Remove microseconds last_modified = last_modified.split('.')[0] # Add time zone information @@ -80,6 +85,7 @@ def listplaylists(context): result.append((u'Last-Modified', last_modified)) return result + @handle_request(r'^load "(?P[^"]+)"$') def load(context, name): """ @@ -94,11 +100,12 @@ def load(context, name): - ``load`` appends the given playlist to the current playlist. """ try: - playlist = context.backend.stored_playlists.get(name=name).get() - context.backend.current_playlist.append(playlist.tracks) + playlist = context.core.stored_playlists.get(name=name).get() + context.core.current_playlist.append(playlist.tracks) except LookupError: raise MpdNoExistError(u'No such playlist', command=u'load') + @handle_request(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') def playlistadd(context, name, uri): """ @@ -110,7 +117,8 @@ def playlistadd(context, name, uri): ``NAME.m3u`` will be created if it does not exist. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^playlistclear "(?P[^"]+)"$') def playlistclear(context, name): @@ -121,7 +129,8 @@ def playlistclear(context, name): Clears the playlist ``NAME.m3u``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') def playlistdelete(context, name, songpos): @@ -132,9 +141,11 @@ def playlistdelete(context, name, songpos): Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO -@handle_request(r'^playlistmove "(?P[^"]+)" ' + +@handle_request( + r'^playlistmove "(?P[^"]+)" ' r'"(?P\d+)" "(?P\d+)"$') def playlistmove(context, name, from_pos, to_pos): """ @@ -151,7 +162,8 @@ def playlistmove(context, name, from_pos, to_pos): documentation, but just the ``SONGPOS`` to move *from*, i.e. ``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') def rename(context, old_name, new_name): @@ -162,7 +174,8 @@ def rename(context, old_name, new_name): Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^rm "(?P[^"]+)"$') def rm(context, name): @@ -173,7 +186,8 @@ def rm(context, name): Removes the playlist ``NAME.m3u`` from the playlist directory. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO + @handle_request(r'^save "(?P[^"]+)"$') def save(context, name): @@ -185,4 +199,4 @@ def save(context, name): Saves the current playlist to ``NAME.m3u`` in the playlist directory. """ - raise MpdNotImplemented # TODO + raise MpdNotImplemented # TODO diff --git a/mopidy/frontends/mpd/session.py b/mopidy/frontends/mpd/session.py new file mode 100644 index 00000000..5d535f75 --- /dev/null +++ b/mopidy/frontends/mpd/session.py @@ -0,0 +1,53 @@ +import logging + +from mopidy.frontends.mpd import dispatcher, protocol +from mopidy.utils import formatting, network + +logger = logging.getLogger('mopidy.frontends.mpd') + + +class MpdSession(network.LineProtocol): + """ + The MPD client session. Keeps track of a single client session. Any + requests from the client is passed on to the MPD request dispatcher. + """ + + terminator = protocol.LINE_TERMINATOR + encoding = protocol.ENCODING + delimiter = r'\r?\n' + + def __init__(self, connection, core=None): + super(MpdSession, self).__init__(connection) + self.dispatcher = dispatcher.MpdDispatcher(session=self, core=core) + + def on_start(self): + logger.info(u'New MPD connection from [%s]:%s', self.host, self.port) + self.send_lines([u'OK MPD %s' % protocol.VERSION]) + + def on_line_received(self, line): + logger.debug(u'Request from [%s]:%s: %s', self.host, self.port, line) + + response = self.dispatcher.handle_request(line) + if not response: + return + + logger.debug( + u'Response to [%s]:%s: %s', self.host, self.port, + formatting.indent(self.terminator.join(response))) + + self.send_lines(response) + + def on_idle(self, subsystem): + self.dispatcher.handle_idle(subsystem) + + def decode(self, line): + try: + return super(MpdSession, self).decode(line.decode('string_escape')) + except ValueError: + logger.warning( + u'Stopping actor due to unescaping error, data ' + u'supplied by client was not valid.') + self.stop() + + def close(self): + self.stop() diff --git a/mopidy/frontends/mpd/translator.py b/mopidy/frontends/mpd/translator.py index 6ae32c9e..0ab28271 100644 --- a/mopidy/frontends/mpd/translator.py +++ b/mopidy/frontends/mpd/translator.py @@ -6,6 +6,7 @@ from mopidy.frontends.mpd import protocol from mopidy.models import CpTrack from mopidy.utils.path import mtime as get_mtime, uri_to_path, split_path + def track_to_mpd_format(track, position=None): """ Format track for output to MPD client. @@ -48,8 +49,8 @@ def track_to_mpd_format(track, position=None): # FIXME don't use first and best artist? # FIXME don't duplicate following code? if track.album is not None and track.album.artists: - artists = filter(lambda a: a.musicbrainz_id is not None, - track.album.artists) + artists = filter( + lambda a: a.musicbrainz_id is not None, track.album.artists) if artists: result.append( ('MUSICBRAINZ_ALBUMARTISTID', artists[0].musicbrainz_id)) @@ -61,16 +62,19 @@ def track_to_mpd_format(track, position=None): result.append(('MUSICBRAINZ_TRACKID', track.musicbrainz_id)) return result + MPD_KEY_ORDER = ''' key file Time Artist AlbumArtist Title Album Track Date MUSICBRAINZ_ALBUMID MUSICBRAINZ_ALBUMARTISTID MUSICBRAINZ_ARTISTID MUSICBRAINZ_TRACKID mtime '''.split() + def order_mpd_track_info(result): """ - Order results from :func:`mopidy.frontends.mpd.translator.track_to_mpd_format` - so that it matches MPD's ordering. Simply a cosmetic fix for easier - diffing of tag_caches. + Order results from + :func:`mopidy.frontends.mpd.translator.track_to_mpd_format` so that it + matches MPD's ordering. Simply a cosmetic fix for easier diffing of + tag_caches. :param result: the track info :type result: list of tuples @@ -78,6 +82,7 @@ def order_mpd_track_info(result): """ return sorted(result, key=lambda i: MPD_KEY_ORDER.index(i[0])) + def artists_to_mpd_format(artists): """ Format track artists for output to MPD client. @@ -90,6 +95,7 @@ def artists_to_mpd_format(artists): artists.sort(key=lambda a: a.name) return u', '.join([a.name for a in artists if a.name]) + def tracks_to_mpd_format(tracks, start=0, end=None): """ Format list of tracks for output to MPD client. @@ -115,6 +121,7 @@ def tracks_to_mpd_format(tracks, start=0, end=None): result.append(track_to_mpd_format(track, position)) return result + def playlist_to_mpd_format(playlist, *args, **kwargs): """ Format playlist for output to MPD client. @@ -123,6 +130,7 @@ def playlist_to_mpd_format(playlist, *args, **kwargs): """ return tracks_to_mpd_format(playlist.tracks, *args, **kwargs) + def tracks_to_tag_cache_format(tracks): """ Format list of tracks for output to MPD tag cache @@ -141,6 +149,7 @@ def tracks_to_tag_cache_format(tracks): _add_to_tag_cache(result, *tracks_to_directory_tree(tracks)) return result + def _add_to_tag_cache(result, folders, files): music_folder = settings.LOCAL_MUSIC_PATH regexp = '^' + re.escape(music_folder).rstrip('/') + '/?' @@ -165,6 +174,7 @@ def _add_to_tag_cache(result, folders, files): result.extend(track_result) result.append(('songList end',)) + def tracks_to_directory_tree(tracks): directories = ({}, []) for track in tracks: diff --git a/mopidy/frontends/mpris/__init__.py b/mopidy/frontends/mpris/__init__.py index 0f5d35c5..38deac7a 100644 --- a/mopidy/frontends/mpris/__init__.py +++ b/mopidy/frontends/mpris/__init__.py @@ -1,131 +1,54 @@ -import logging +""" +Frontend which lets you control Mopidy through the Media Player Remote +Interfacing Specification (`MPRIS `_) D-Bus +interface. -logger = logging.getLogger('mopidy.frontends.mpris') +An example of an MPRIS client is the `Ubuntu Sound Menu +`_. -try: - import indicate -except ImportError as import_error: - indicate = None - logger.debug(u'Startup notification will not be sent (%s)', import_error) +**Dependencies:** -from pykka.actor import ThreadingActor +- D-Bus Python bindings. The package is named ``python-dbus`` in + Ubuntu/Debian. -from mopidy import settings -from mopidy.frontends.mpris import objects -from mopidy.listeners import BackendListener +- ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the + Ubuntu Sound Menu. The package is named ``python-indicate`` in + Ubuntu/Debian. +- An ``.desktop`` file for Mopidy installed at the path set in + :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install-desktop-file` for + details. -class MprisFrontend(ThreadingActor, BackendListener): - """ - Frontend which lets you control Mopidy through the Media Player Remote - Interfacing Specification (`MPRIS `_) D-Bus - interface. +**Settings:** - An example of an MPRIS client is the `Ubuntu Sound Menu - `_. +- :attr:`mopidy.settings.DESKTOP_FILE` - **Dependencies:** +**Usage:** - - D-Bus Python bindings. The package is named ``python-dbus`` in - Ubuntu/Debian. - - ``libindicate`` Python bindings is needed to expose Mopidy in e.g. the - Ubuntu Sound Menu. The package is named ``python-indicate`` in - Ubuntu/Debian. - - An ``.desktop`` file for Mopidy installed at the path set in - :attr:`mopidy.settings.DESKTOP_FILE`. See :ref:`install_desktop_file` for - details. +Make sure :attr:`mopidy.settings.FRONTENDS` includes +``mopidy.frontends.mpris.MprisFrontend``. By default, the setting includes the +MPRIS frontend. - **Testing the frontend** +**Testing the frontend** - To test, start Mopidy, and then run the following in a Python shell:: +To test, start Mopidy, and then run the following in a Python shell:: - import dbus - bus = dbus.SessionBus() - player = bus.get_object('org.mpris.MediaPlayer2.mopidy', - '/org/mpris/MediaPlayer2') + import dbus + bus = dbus.SessionBus() + player = bus.get_object('org.mpris.MediaPlayer2.mopidy', + '/org/mpris/MediaPlayer2') - Now you can control Mopidy through the player object. Examples: +Now you can control Mopidy through the player object. Examples: - - To get some properties from Mopidy, run:: +- To get some properties from Mopidy, run:: - props = player.GetAll('org.mpris.MediaPlayer2', - dbus_interface='org.freedesktop.DBus.Properties') + props = player.GetAll('org.mpris.MediaPlayer2', + dbus_interface='org.freedesktop.DBus.Properties') - - To quit Mopidy through D-Bus, run:: +- To quit Mopidy through D-Bus, run:: - player.Quit(dbus_interface='org.mpris.MediaPlayer2') - """ + player.Quit(dbus_interface='org.mpris.MediaPlayer2') +""" - def __init__(self): - super(MprisFrontend, self).__init__() - self.indicate_server = None - self.mpris_object = None - - def on_start(self): - try: - self.mpris_object = objects.MprisObject() - self._send_startup_notification() - except Exception as e: - logger.error(u'MPRIS frontend setup failed (%s)', e) - self.stop() - - def on_stop(self): - logger.debug(u'Removing MPRIS object from D-Bus connection...') - if self.mpris_object: - self.mpris_object.remove_from_connection() - self.mpris_object = None - logger.debug(u'Removed MPRIS object from D-Bus connection') - - def _send_startup_notification(self): - """ - Send startup notification using libindicate to make Mopidy appear in - e.g. `Ubuntu's sound menu `_. - - A reference to the libindicate server is kept for as long as Mopidy is - running. When Mopidy exits, the server will be unreferenced and Mopidy - will automatically be unregistered from e.g. the sound menu. - """ - if not indicate: - return - logger.debug(u'Sending startup notification...') - self.indicate_server = indicate.Server() - self.indicate_server.set_type('music.mopidy') - self.indicate_server.set_desktop_file(settings.DESKTOP_FILE) - self.indicate_server.show() - logger.debug(u'Startup notification sent') - - def _emit_properties_changed(self, *changed_properties): - if self.mpris_object is None: - return - props_with_new_values = [ - (p, self.mpris_object.Get(objects.PLAYER_IFACE, p)) - for p in changed_properties] - self.mpris_object.PropertiesChanged(objects.PLAYER_IFACE, - dict(props_with_new_values), []) - - def track_playback_paused(self, track, time_position): - logger.debug(u'Received track playback paused event') - self._emit_properties_changed('PlaybackStatus') - - def track_playback_resumed(self, track, time_position): - logger.debug(u'Received track playback resumed event') - self._emit_properties_changed('PlaybackStatus') - - def track_playback_started(self, track): - logger.debug(u'Received track playback started event') - self._emit_properties_changed('PlaybackStatus', 'Metadata') - - def track_playback_ended(self, track, time_position): - logger.debug(u'Received track playback ended event') - self._emit_properties_changed('PlaybackStatus', 'Metadata') - - def volume_changed(self): - logger.debug(u'Received volume changed event') - self._emit_properties_changed('Volume') - - def seeked(self): - logger.debug(u'Received seeked event') - if self.mpris_object is None: - return - self.mpris_object.Seeked( - self.mpris_object.Get(objects.PLAYER_IFACE, 'Position')) +# flake8: noqa +from .actor import MprisFrontend diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py new file mode 100644 index 00000000..5d8d5492 --- /dev/null +++ b/mopidy/frontends/mpris/actor.py @@ -0,0 +1,89 @@ +import logging + +import pykka + +from mopidy import settings +from mopidy.core import CoreListener +from mopidy.frontends.mpris import objects + +logger = logging.getLogger('mopidy.frontends.mpris') + +try: + import indicate +except ImportError as import_error: + indicate = None # noqa + logger.debug(u'Startup notification will not be sent (%s)', import_error) + + +class MprisFrontend(pykka.ThreadingActor, CoreListener): + def __init__(self, core): + super(MprisFrontend, self).__init__() + self.core = core + self.indicate_server = None + self.mpris_object = None + + def on_start(self): + try: + self.mpris_object = objects.MprisObject(self.core) + self._send_startup_notification() + except Exception as e: + logger.error(u'MPRIS frontend setup failed (%s)', e) + self.stop() + + def on_stop(self): + logger.debug(u'Removing MPRIS object from D-Bus connection...') + if self.mpris_object: + self.mpris_object.remove_from_connection() + self.mpris_object = None + logger.debug(u'Removed MPRIS object from D-Bus connection') + + def _send_startup_notification(self): + """ + Send startup notification using libindicate to make Mopidy appear in + e.g. `Ubuntu's sound menu `_. + + A reference to the libindicate server is kept for as long as Mopidy is + running. When Mopidy exits, the server will be unreferenced and Mopidy + will automatically be unregistered from e.g. the sound menu. + """ + if not indicate: + return + logger.debug(u'Sending startup notification...') + self.indicate_server = indicate.Server() + self.indicate_server.set_type('music.mopidy') + self.indicate_server.set_desktop_file(settings.DESKTOP_FILE) + self.indicate_server.show() + logger.debug(u'Startup notification sent') + + def _emit_properties_changed(self, *changed_properties): + if self.mpris_object is None: + return + props_with_new_values = [ + (p, self.mpris_object.Get(objects.PLAYER_IFACE, p)) + for p in changed_properties] + self.mpris_object.PropertiesChanged( + objects.PLAYER_IFACE, dict(props_with_new_values), []) + + def track_playback_paused(self, track, time_position): + logger.debug(u'Received track playback paused event') + self._emit_properties_changed('PlaybackStatus') + + def track_playback_resumed(self, track, time_position): + logger.debug(u'Received track playback resumed event') + self._emit_properties_changed('PlaybackStatus') + + def track_playback_started(self, track): + logger.debug(u'Received track playback started event') + self._emit_properties_changed('PlaybackStatus', 'Metadata') + + def track_playback_ended(self, track, time_position): + logger.debug(u'Received track playback ended event') + self._emit_properties_changed('PlaybackStatus', 'Metadata') + + def volume_changed(self): + logger.debug(u'Received volume changed event') + self._emit_properties_changed('Volume') + + def seeked(self, time_position_in_ms): + logger.debug(u'Received seeked event') + self.mpris_object.Seeked(time_position_in_ms * 1000) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 93669977..4d4efe1e 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -1,24 +1,22 @@ import logging import os -logger = logging.getLogger('mopidy.frontends.mpris') - try: import dbus import dbus.mainloop.glib import dbus.service import gobject except ImportError as import_error: - from mopidy import OptionalDependencyError + from mopidy.exceptions import OptionalDependencyError raise OptionalDependencyError(import_error) -from pykka.registry import ActorRegistry - from mopidy import settings -from mopidy.backends.base import Backend from mopidy.core import PlaybackState from mopidy.utils.process import exit_process + +logger = logging.getLogger('mopidy.frontends.mpris') + # Must be done before dbus.SessionBus() is called gobject.threads_init() dbus.mainloop.glib.threads_init() @@ -34,14 +32,14 @@ class MprisObject(dbus.service.Object): properties = None - def __init__(self): - self._backend = None + def __init__(self, core): + self.core = core self.properties = { ROOT_IFACE: self._get_root_iface_properties(), PLAYER_IFACE: self._get_player_iface_properties(), } bus_name = self._connect_to_dbus() - super(MprisObject, self).__init__(bus_name, OBJECT_PATH) + dbus.service.Object.__init__(self, bus_name, OBJECT_PATH) def _get_root_iface_properties(self): return { @@ -79,20 +77,11 @@ class MprisObject(dbus.service.Object): def _connect_to_dbus(self): logger.debug(u'Connecting to D-Bus...') mainloop = dbus.mainloop.glib.DBusGMainLoop() - bus_name = dbus.service.BusName(BUS_NAME, - dbus.SessionBus(mainloop=mainloop)) + bus_name = dbus.service.BusName( + BUS_NAME, dbus.SessionBus(mainloop=mainloop)) logger.info(u'Connected to D-Bus') return bus_name - @property - def backend(self): - if self._backend is None: - backend_refs = ActorRegistry.get_by_class(Backend) - assert len(backend_refs) == 1, \ - 'Expected exactly one running backend.' - self._backend = backend_refs[0].proxy() - return self._backend - def _get_track_id(self, cp_track): return '/com/mopidy/track/%d' % cp_track.cpid @@ -103,46 +92,48 @@ class MprisObject(dbus.service.Object): ### Properties interface @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='ss', out_signature='v') + in_signature='ss', out_signature='v') def Get(self, interface, prop): - logger.debug(u'%s.Get(%s, %s) called', + logger.debug( + u'%s.Get(%s, %s) called', dbus.PROPERTIES_IFACE, repr(interface), repr(prop)) - (getter, setter) = self.properties[interface][prop] + (getter, _) = self.properties[interface][prop] if callable(getter): return getter() else: return getter @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='s', out_signature='a{sv}') + in_signature='s', out_signature='a{sv}') def GetAll(self, interface): - logger.debug(u'%s.GetAll(%s) called', - dbus.PROPERTIES_IFACE, repr(interface)) + logger.debug( + u'%s.GetAll(%s) called', dbus.PROPERTIES_IFACE, repr(interface)) getters = {} - for key, (getter, setter) in self.properties[interface].iteritems(): + for key, (getter, _) in self.properties[interface].iteritems(): getters[key] = getter() if callable(getter) else getter return getters @dbus.service.method(dbus_interface=dbus.PROPERTIES_IFACE, - in_signature='ssv', out_signature='') + in_signature='ssv', out_signature='') def Set(self, interface, prop, value): - logger.debug(u'%s.Set(%s, %s, %s) called', + logger.debug( + u'%s.Set(%s, %s, %s) called', dbus.PROPERTIES_IFACE, repr(interface), repr(prop), repr(value)) - getter, setter = self.properties[interface][prop] + _, setter = self.properties[interface][prop] if setter is not None: setter(value) - self.PropertiesChanged(interface, - {prop: self.Get(interface, prop)}, []) + self.PropertiesChanged( + interface, {prop: self.Get(interface, prop)}, []) @dbus.service.signal(dbus_interface=dbus.PROPERTIES_IFACE, - signature='sa{sv}as') + signature='sa{sv}as') def PropertiesChanged(self, interface, changed_properties, - invalidated_properties): - logger.debug(u'%s.PropertiesChanged(%s, %s, %s) signaled', + invalidated_properties): + logger.debug( + u'%s.PropertiesChanged(%s, %s, %s) signaled', dbus.PROPERTIES_IFACE, interface, changed_properties, invalidated_properties) - ### Root interface methods @dbus.service.method(dbus_interface=ROOT_IFACE) @@ -155,15 +146,13 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.Quit called', ROOT_IFACE) exit_process() - ### Root interface properties def get_DesktopEntry(self): return os.path.splitext(os.path.basename(settings.DESKTOP_FILE))[0] def get_SupportedUriSchemes(self): - return dbus.Array(self.backend.uri_schemes.get(), signature='s') - + return dbus.Array(self.core.uri_schemes.get(), signature='s') ### Player interface methods @@ -173,7 +162,7 @@ class MprisObject(dbus.service.Object): if not self.get_CanGoNext(): logger.debug(u'%s.Next not allowed', PLAYER_IFACE) return - self.backend.playback.next().get() + self.core.playback.next().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Previous(self): @@ -181,7 +170,7 @@ class MprisObject(dbus.service.Object): if not self.get_CanGoPrevious(): logger.debug(u'%s.Previous not allowed', PLAYER_IFACE) return - self.backend.playback.previous().get() + self.core.playback.previous().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Pause(self): @@ -189,7 +178,7 @@ class MprisObject(dbus.service.Object): if not self.get_CanPause(): logger.debug(u'%s.Pause not allowed', PLAYER_IFACE) return - self.backend.playback.pause().get() + self.core.playback.pause().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def PlayPause(self): @@ -197,13 +186,13 @@ class MprisObject(dbus.service.Object): if not self.get_CanPause(): logger.debug(u'%s.PlayPause not allowed', PLAYER_IFACE) return - state = self.backend.playback.state.get() + state = self.core.playback.state.get() if state == PlaybackState.PLAYING: - self.backend.playback.pause().get() + self.core.playback.pause().get() elif state == PlaybackState.PAUSED: - self.backend.playback.resume().get() + self.core.playback.resume().get() elif state == PlaybackState.STOPPED: - self.backend.playback.play().get() + self.core.playback.play().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Stop(self): @@ -211,7 +200,7 @@ class MprisObject(dbus.service.Object): if not self.get_CanControl(): logger.debug(u'%s.Stop not allowed', PLAYER_IFACE) return - self.backend.playback.stop().get() + self.core.playback.stop().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Play(self): @@ -219,11 +208,11 @@ class MprisObject(dbus.service.Object): if not self.get_CanPlay(): logger.debug(u'%s.Play not allowed', PLAYER_IFACE) return - state = self.backend.playback.state.get() + state = self.core.playback.state.get() if state == PlaybackState.PAUSED: - self.backend.playback.resume().get() + self.core.playback.resume().get() else: - self.backend.playback.play().get() + self.core.playback.play().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) def Seek(self, offset): @@ -232,9 +221,9 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.Seek not allowed', PLAYER_IFACE) return offset_in_milliseconds = offset // 1000 - current_position = self.backend.playback.time_position.get() + current_position = self.core.playback.time_position.get() new_position = current_position + offset_in_milliseconds - self.backend.playback.seek(new_position) + self.core.playback.seek(new_position) @dbus.service.method(dbus_interface=PLAYER_IFACE) def SetPosition(self, track_id, position): @@ -243,7 +232,7 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.SetPosition not allowed', PLAYER_IFACE) return position = position // 1000 - current_cp_track = self.backend.playback.current_cp_track.get() + current_cp_track = self.core.playback.current_cp_track.get() if current_cp_track is None: return if track_id != self._get_track_id(current_cp_track): @@ -252,7 +241,7 @@ class MprisObject(dbus.service.Object): return if current_cp_track.track.length < position: return - self.backend.playback.seek(position) + self.core.playback.seek(position) @dbus.service.method(dbus_interface=PLAYER_IFACE) def OpenUri(self, uri): @@ -264,17 +253,16 @@ class MprisObject(dbus.service.Object): return # NOTE Check if URI has MIME type known to the backend, if MIME support # is added to the backend. - uri_schemes = self.backend.uri_schemes.get() + uri_schemes = self.core.uri_schemes.get() if not any([uri.startswith(uri_scheme) for uri_scheme in uri_schemes]): return - track = self.backend.library.lookup(uri).get() + track = self.core.library.lookup(uri).get() if track is not None: - cp_track = self.backend.current_playlist.add(track).get() - self.backend.playback.play(cp_track) + cp_track = self.core.current_playlist.add(track).get() + self.core.playback.play(cp_track) else: logger.debug(u'Track with URI "%s" not found in library.', uri) - ### Player interface signals @dbus.service.signal(dbus_interface=PLAYER_IFACE, signature='x') @@ -282,11 +270,10 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.Seeked signaled', PLAYER_IFACE) # Do nothing, as just calling the method is enough to emit the signal. - ### Player interface properties def get_PlaybackStatus(self): - state = self.backend.playback.state.get() + state = self.core.playback.state.get() if state == PlaybackState.PLAYING: return 'Playing' elif state == PlaybackState.PAUSED: @@ -295,8 +282,8 @@ class MprisObject(dbus.service.Object): return 'Stopped' def get_LoopStatus(self): - repeat = self.backend.playback.repeat.get() - single = self.backend.playback.single.get() + repeat = self.core.playback.repeat.get() + single = self.core.playback.single.get() if not repeat: return 'None' else: @@ -310,14 +297,14 @@ class MprisObject(dbus.service.Object): logger.debug(u'Setting %s.LoopStatus not allowed', PLAYER_IFACE) return if value == 'None': - self.backend.playback.repeat = False - self.backend.playback.single = False + self.core.playback.repeat = False + self.core.playback.single = False elif value == 'Track': - self.backend.playback.repeat = True - self.backend.playback.single = True + self.core.playback.repeat = True + self.core.playback.single = True elif value == 'Playlist': - self.backend.playback.repeat = True - self.backend.playback.single = False + self.core.playback.repeat = True + self.core.playback.single = False def set_Rate(self, value): if not self.get_CanControl(): @@ -329,23 +316,23 @@ class MprisObject(dbus.service.Object): self.Pause() def get_Shuffle(self): - return self.backend.playback.random.get() + return self.core.playback.random.get() def set_Shuffle(self, value): if not self.get_CanControl(): logger.debug(u'Setting %s.Shuffle not allowed', PLAYER_IFACE) return if value: - self.backend.playback.random = True + self.core.playback.random = True else: - self.backend.playback.random = False + self.core.playback.random = False def get_Metadata(self): - current_cp_track = self.backend.playback.current_cp_track.get() + current_cp_track = self.core.playback.current_cp_track.get() if current_cp_track is None: return {'mpris:trackid': ''} else: - (cpid, track) = current_cp_track + (_, track) = current_cp_track metadata = {'mpris:trackid': self._get_track_id(current_cp_track)} if track.length: metadata['mpris:length'] = track.length * 1000 @@ -370,7 +357,7 @@ class MprisObject(dbus.service.Object): return dbus.Dictionary(metadata, signature='sv') def get_Volume(self): - volume = self.backend.playback.volume.get() + volume = self.core.playback.volume.get() if volume is None: return 0 return volume / 100.0 @@ -382,32 +369,35 @@ class MprisObject(dbus.service.Object): if value is None: return elif value < 0: - self.backend.playback.volume = 0 + self.core.playback.volume = 0 elif value > 1: - self.backend.playback.volume = 100 + self.core.playback.volume = 100 elif 0 <= value <= 1: - self.backend.playback.volume = int(value * 100) + self.core.playback.volume = int(value * 100) def get_Position(self): - return self.backend.playback.time_position.get() * 1000 + return self.core.playback.time_position.get() * 1000 def get_CanGoNext(self): if not self.get_CanControl(): return False - return (self.backend.playback.cp_track_at_next.get() != - self.backend.playback.current_cp_track.get()) + return ( + self.core.playback.cp_track_at_next.get() != + self.core.playback.current_cp_track.get()) def get_CanGoPrevious(self): if not self.get_CanControl(): return False - return (self.backend.playback.cp_track_at_previous.get() != - self.backend.playback.current_cp_track.get()) + return ( + self.core.playback.cp_track_at_previous.get() != + self.core.playback.current_cp_track.get()) def get_CanPlay(self): if not self.get_CanControl(): return False - return (self.backend.playback.current_track.get() is not None - or self.backend.playback.track_at_next.get() is not None) + return ( + self.core.playback.current_track.get() is not None or + self.core.playback.track_at_next.get() is not None) def get_CanPause(self): if not self.get_CanControl(): diff --git a/mopidy/models.py b/mopidy/models.py index 507ca088..77561fe3 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -13,8 +13,9 @@ class ImmutableObject(object): def __init__(self, *args, **kwargs): for key, value in kwargs.items(): if not hasattr(self, key): - raise TypeError('__init__() got an unexpected keyword ' + \ - 'argument \'%s\'' % key) + raise TypeError( + u"__init__() got an unexpected keyword argument '%s'" % + key) self.__dict__[key] = value def __setattr__(self, name, value): @@ -71,8 +72,8 @@ class ImmutableObject(object): if hasattr(self, key): data[key] = values.pop(key) if values: - raise TypeError("copy() got an unexpected keyword argument '%s'" - % key) + raise TypeError( + u"copy() got an unexpected keyword argument '%s'" % key) return self.__class__(**data) def serialize(self): @@ -119,6 +120,8 @@ class Album(ImmutableObject): :type artists: list of :class:`Artist` :param num_tracks: number of tracks in album :type num_tracks: integer + :param date: album release date (YYYY or YYYY-MM-DD) + :type date: string :param musicbrainz_id: MusicBrainz ID :type musicbrainz_id: string """ @@ -135,6 +138,9 @@ class Album(ImmutableObject): #: The number of tracks in the album. Read-only. num_tracks = 0 + #: The album release date. Read-only. + date = None + #: The MusicBrainz ID of the album. Read-only. musicbrainz_id = None @@ -202,14 +208,14 @@ class Track(ImmutableObject): class Playlist(ImmutableObject): """ - :param uri: playlist URI - :type uri: string - :param name: playlist name - :type name: string - :param tracks: playlist's tracks - :type tracks: list of :class:`Track` elements - :param last_modified: playlist's modification time - :type last_modified: :class:`datetime.datetime` + :param uri: playlist URI + :type uri: string + :param name: playlist name + :type name: string + :param tracks: playlist's tracks + :type tracks: list of :class:`Track` elements + :param last_modified: playlist's modification time + :type last_modified: :class:`datetime.datetime` """ #: The playlist URI. Read-only. diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 3bcf03d9..2c12d26a 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -10,6 +10,7 @@ import datetime from mopidy.utils.path import path_to_uri, find_files from mopidy.models import Track, Artist, Album + def translator(data): albumartist_kwargs = {} album_kwargs = {} @@ -37,7 +38,8 @@ def translator(data): _retrieve('musicbrainz-trackid', 'musicbrainz_id', track_kwargs) _retrieve('musicbrainz-artistid', 'musicbrainz_id', artist_kwargs) _retrieve('musicbrainz-albumid', 'musicbrainz_id', album_kwargs) - _retrieve('musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) + _retrieve( + 'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) if albumartist_kwargs: album_kwargs['artists'] = [Artist(**albumartist_kwargs)] @@ -52,7 +54,7 @@ def translator(data): class Scanner(object): def __init__(self, folder, data_callback, error_callback=None): - self.uris = [path_to_uri(f) for f in find_files(folder)] + self.files = find_files(folder) self.data_callback = data_callback self.error_callback = error_callback self.loop = gobject.MainLoop() @@ -61,8 +63,8 @@ class Scanner(object): self.uribin = gst.element_factory_make('uridecodebin') self.uribin.set_property('caps', gst.Caps('audio/x-raw-int')) - self.uribin.connect('pad-added', self.process_new_pad, - fakesink.get_pad('sink')) + self.uribin.connect( + 'pad-added', self.process_new_pad, fakesink.get_pad('sink')) self.pipe = gst.element_factory_make('pipeline') self.pipe.add(self.uribin) @@ -106,7 +108,7 @@ class Scanner(object): self.next_uri() def get_duration(self): - self.pipe.get_state() # Block until state change is done. + self.pipe.get_state() # Block until state change is done. try: return self.pipe.query_duration( gst.FORMAT_TIME, None)[0] // gst.MSECOND @@ -114,18 +116,19 @@ class Scanner(object): return None def next_uri(self): - if not self.uris: - return self.stop() - + try: + uri = path_to_uri(self.files.next()) + except StopIteration: + self.stop() + return False self.pipe.set_state(gst.STATE_NULL) - self.uribin.set_property('uri', self.uris.pop()) + self.uribin.set_property('uri', uri) self.pipe.set_state(gst.STATE_PAUSED) + return True def start(self): - if not self.uris: - return - self.next_uri() - self.loop.run() + if self.next_uri(): + self.loop.run() def stop(self): self.pipe.set_state(gst.STATE_NULL) diff --git a/mopidy/settings.py b/mopidy/settings.py index 0612fc24..fbc71f0e 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -7,26 +7,26 @@ All available settings and their default values. file called ``~/.config/mopidy/settings.py`` and redefine settings there. """ -#: List of playback backends to use. See :mod:`mopidy.backends` for all +#: List of playback backends to use. See :ref:`backend-implementations` for all #: available backends. #: +#: When results from multiple backends are combined, they are combined in the +#: order the backends are listed here. +#: #: Default:: #: -#: BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) -#: -#: Other typical values:: -#: -#: BACKENDS = (u'mopidy.backends.local.LocalBackend',) -#: -#: .. note:: -#: Currently only the first backend in the list is used. +#: BACKENDS = ( +#: u'mopidy.backends.local.LocalBackend', +#: u'mopidy.backends.spotify.SpotifyBackend', +#: ) BACKENDS = ( + u'mopidy.backends.local.LocalBackend', u'mopidy.backends.spotify.SpotifyBackend', ) #: The log format used for informational logging. #: -#: See http://docs.python.org/library/logging.html#formatter-objects for +#: See http://docs.python.org/2/library/logging.html#formatter-objects for #: details on the format. CONSOLE_LOG_FORMAT = u'%(levelname)-8s %(message)s' @@ -54,7 +54,8 @@ DEBUG_LOG_FILENAME = u'mopidy.log' #: DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' DESKTOP_FILE = u'/usr/share/applications/mopidy.desktop' -#: List of server frontends to use. +#: List of server frontends to use. See :ref:`frontend-implementations` for +#: available frontends. #: #: Default:: #: @@ -85,9 +86,8 @@ LASTFM_PASSWORD = u'' #: #: Default:: #: -#: # Defaults to asking glib where music is stored, fallback is ~/music -#: LOCAL_MUSIC_PATH = None -LOCAL_MUSIC_PATH = None +#: LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR' +LOCAL_MUSIC_PATH = u'$XDG_MUSIC_DIR' #: Path to playlist folder with m3u files for local music. #: @@ -95,8 +95,8 @@ LOCAL_MUSIC_PATH = None #: #: Default:: #: -#: LOCAL_PLAYLIST_PATH = None # Implies $XDG_DATA_DIR/mopidy/playlists -LOCAL_PLAYLIST_PATH = None +#: LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists' +LOCAL_PLAYLIST_PATH = u'$XDG_DATA_DIR/mopidy/playlists' #: Path to tag cache for local music. #: @@ -104,22 +104,23 @@ LOCAL_PLAYLIST_PATH = None #: #: Default:: #: -#: LOCAL_TAG_CACHE_FILE = None # Implies $XDG_DATA_DIR/mopidy/tag_cache -LOCAL_TAG_CACHE_FILE = None +#: LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' +LOCAL_TAG_CACHE_FILE = u'$XDG_DATA_DIR/mopidy/tag_cache' -#: Sound mixer to use. +#: Audio mixer to use. #: #: Expects a GStreamer mixer to use, typical values are: #: ``alsamixer``, ``pulsemixer``, ``ossmixer``, and ``oss4mixer``. #: -#: Setting this to :class:`None` turns off volume control. +#: Setting this to :class:`None` turns off volume control. ``software`` +#: can be used to force software mixing in the application. #: #: Default:: #: #: MIXER = u'autoaudiomixer' MIXER = u'autoaudiomixer' -#: Sound mixer track to use. +#: Audio mixer track to use. #: #: Name of the mixer track to use. If this is not set we will try to find the #: master output track. As an example, using ``alsamixer`` you would @@ -167,7 +168,11 @@ MPD_SERVER_PASSWORD = None #: Default: 20 MPD_SERVER_MAX_CONNECTIONS = 20 -#: Output to use. See :mod:`mopidy.outputs` for all available backends +#: Audio output to use. +#: +#: Expects a GStreamer sink. Typical values are ``autoaudiosink``, +#: ``alsasink``, ``osssink``, ``oss4sink``, ``pulsesink``, and ``shout2send``, +#: and additional arguments specific to each sink. #: #: Default:: #: @@ -177,7 +182,11 @@ OUTPUT = u'autoaudiosink' #: Path to the Spotify cache. #: #: Used by :mod:`mopidy.backends.spotify`. -SPOTIFY_CACHE_PATH = None +#: +#: Default:: +#: +#: SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify' +SPOTIFY_CACHE_PATH = u'$XDG_CACHE_DIR/mopidy/spotify' #: Your Spotify Premium username. #: @@ -194,7 +203,7 @@ SPOTIFY_PASSWORD = u'' #: Available values are 96, 160, and 320. #: #: Used by :mod:`mopidy.backends.spotify`. -# +#: #: Default:: #: #: SPOTIFY_BITRATE = 160 diff --git a/mopidy/utils/__init__.py b/mopidy/utils/__init__.py index aacc2e85..e69de29b 100644 --- a/mopidy/utils/__init__.py +++ b/mopidy/utils/__init__.py @@ -1,53 +0,0 @@ -from __future__ import division - -import locale -import logging -import os -import sys - -logger = logging.getLogger('mopidy.utils') - - -# TODO: use itertools.chain.from_iterable(the_list)? -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 rescale(v, old=None, new=None): - """Convert value between scales.""" - new_min, new_max = new - old_min, old_max = old - scaling = float(new_max - new_min) / (old_max - old_min) - return round(scaling * (v - old_min) + new_min) - - -def import_module(name): - __import__(name) - return sys.modules[name] - - -def get_class(name): - logger.debug('Loading: %s', name) - if '.' not in name: - raise ImportError("Couldn't load: %s" % name) - module_name = name[:name.rindex('.')] - cls_name = name[name.rindex('.') + 1:] - try: - module = import_module(module_name) - cls = getattr(module, cls_name) - except (ImportError, AttributeError): - raise ImportError("Couldn't load: %s" % name) - return cls - - -def locale_decode(bytestr): - try: - return unicode(bytestr) - except UnicodeError: - return str(bytestr).decode(locale.getpreferredencoding()) diff --git a/mopidy/utils/deps.py b/mopidy/utils/deps.py index 2c68e429..32949f55 100644 --- a/mopidy/utils/deps.py +++ b/mopidy/utils/deps.py @@ -8,7 +8,7 @@ import gst import pykka -from mopidy.utils.log import indent +from . import formatting def list_deps_optparse_callback(*args): @@ -47,7 +47,7 @@ def format_dependency_list(adapters=None): os.path.dirname(dep_info['path']))) if 'other' in dep_info: lines.append(' Other: %s' % ( - indent(dep_info['other'])),) + formatting.indent(dep_info['other'])),) return '\n'.join(lines) @@ -61,8 +61,8 @@ def platform_info(): def python_info(): return { 'name': 'Python', - 'version': '%s %s' % (platform.python_implementation(), - platform.python_version()), + 'version': '%s %s' % ( + platform.python_implementation(), platform.python_version()), 'path': platform.__file__, } @@ -125,9 +125,11 @@ def _gstreamer_check_elements(): # Shoutcast output 'shout2send', ] - known_elements = [factory.get_name() for factory in + known_elements = [ + factory.get_name() for factory in gst.registry_get_default().get_feature_list(gst.TYPE_ELEMENT_FACTORY)] - return [(element, element in known_elements) for element in elements_to_check] + return [ + (element, element in known_elements) for element in elements_to_check] def pykka_info(): diff --git a/mopidy/utils/encoding.py b/mopidy/utils/encoding.py new file mode 100644 index 00000000..888896c5 --- /dev/null +++ b/mopidy/utils/encoding.py @@ -0,0 +1,8 @@ +import locale + + +def locale_decode(bytestr): + try: + return unicode(bytestr) + except UnicodeError: + return str(bytestr).decode(locale.getpreferredencoding()) diff --git a/mopidy/utils/formatting.py b/mopidy/utils/formatting.py new file mode 100644 index 00000000..9091bc2a --- /dev/null +++ b/mopidy/utils/formatting.py @@ -0,0 +1,26 @@ +import re +import unicodedata + + +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 slugify(value): + """ + Converts to lowercase, removes non-word characters (alphanumerics and + underscores) and converts spaces to hyphens. Also strips leading and + trailing whitespace. + + This function is based on Django's slugify implementation. + """ + value = unicodedata.normalize('NFKD', value) + value = value.encode('ascii', 'ignore').decode('ascii') + value = re.sub(r'[^\w\s-]', '', value).strip().lower() + return re.sub(r'[-\s]+', '-', value) diff --git a/mopidy/utils/importing.py b/mopidy/utils/importing.py new file mode 100644 index 00000000..3df6abe4 --- /dev/null +++ b/mopidy/utils/importing.py @@ -0,0 +1,23 @@ +import logging +import sys + +logger = logging.getLogger('mopidy.utils') + + +def import_module(name): + __import__(name) + return sys.modules[name] + + +def get_class(name): + logger.debug('Loading: %s', name) + if '.' not in name: + raise ImportError("Couldn't load: %s" % name) + module_name = name[:name.rindex('.')] + cls_name = name[name.rindex('.') + 1:] + try: + module = import_module(module_name) + cls = getattr(module, cls_name) + except (ImportError, AttributeError): + raise ImportError("Couldn't load: %s" % name) + return cls diff --git a/mopidy/utils/log.py b/mopidy/utils/log.py index 0e5dfc29..bb966a1d 100644 --- a/mopidy/utils/log.py +++ b/mopidy/utils/log.py @@ -1,21 +1,29 @@ import logging import logging.handlers -from mopidy import get_version, get_platform, get_python, settings +from mopidy import settings +from . import deps, versioning + def setup_logging(verbosity_level, save_debug_log): setup_root_logger() setup_console_logging(verbosity_level) if save_debug_log: setup_debug_logging_to_file() + if hasattr(logging, 'captureWarnings'): + # New in Python 2.7 + logging.captureWarnings(True) logger = logging.getLogger('mopidy.utils.log') - logger.info(u'Starting Mopidy %s on %s %s', - get_version(), get_platform(), get_python()) + logger.info(u'Starting Mopidy %s', versioning.get_version()) + logger.info(u'%(name)s: %(version)s', deps.platform_info()) + logger.info(u'%(name)s: %(version)s', deps.python_info()) + def setup_root_logger(): root = logging.getLogger('') root.setLevel(logging.DEBUG) + def setup_console_logging(verbosity_level): if verbosity_level == 0: log_level = logging.WARNING @@ -36,6 +44,7 @@ def setup_console_logging(verbosity_level): if verbosity_level < 3: logging.getLogger('pykka').setLevel(logging.INFO) + def setup_debug_logging_to_file(): formatter = logging.Formatter(settings.DEBUG_LOG_FORMAT) handler = logging.handlers.RotatingFileHandler( @@ -44,12 +53,3 @@ def setup_debug_logging_to_file(): handler.setLevel(logging.DEBUG) root = logging.getLogger('') root.addHandler(handler) - -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 diff --git a/mopidy/utils/network.py b/mopidy/utils/network.py index 7d97daf8..e56f6a81 100644 --- a/mopidy/utils/network.py +++ b/mopidy/utils/network.py @@ -5,17 +5,18 @@ import re import socket import threading -from pykka import ActorDeadError -from pykka.actor import ThreadingActor -from pykka.registry import ActorRegistry +import pykka + +from mopidy.utils import encoding -from mopidy.utils import locale_decode logger = logging.getLogger('mopidy.utils.server') + class ShouldRetrySocketCall(Exception): """Indicate that attempted socket call should be retried""" + def try_ipv6_socket(): """Determine if system really supports IPv6""" if not socket.has_ipv6: @@ -24,13 +25,17 @@ def try_ipv6_socket(): socket.socket(socket.AF_INET6).close() return True except IOError as error: - logger.debug(u'Platform supports IPv6, but socket ' - 'creation failed, disabling: %s', locale_decode(error)) + logger.debug( + u'Platform supports IPv6, but socket creation failed, ' + u'disabling: %s', + encoding.locale_decode(error)) return False + #: Boolean value that indicates if creating an IPv6 socket will succeed. has_ipv6 = try_ipv6_socket() + def create_socket(): """Create a TCP socket with or without IPv6 depending on system support""" if has_ipv6: @@ -42,17 +47,21 @@ def create_socket(): sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) return sock + def format_hostname(hostname): """Format hostname for display.""" - if (has_ipv6 and re.match('\d+.\d+.\d+.\d+', hostname) is not None): + if (has_ipv6 and re.match(r'\d+.\d+.\d+.\d+', hostname) is not None): hostname = '::ffff:%s' % hostname return hostname + class Server(object): """Setup listener and register it with gobject's event loop.""" - def __init__(self, host, port, protocol, max_connections=5, timeout=30): + def __init__(self, host, port, protocol, protocol_kwargs=None, + max_connections=5, timeout=30): self.protocol = protocol + self.protocol_kwargs = protocol_kwargs or {} self.max_connections = max_connections self.timeout = timeout self.server_socket = self.create_server_socket(host, port) @@ -94,7 +103,7 @@ class Server(object): self.number_of_connections() >= self.max_connections) def number_of_connections(self): - return len(ActorRegistry.get_by_class(self.protocol)) + return len(pykka.ActorRegistry.get_by_class(self.protocol)) def reject_connection(self, sock, addr): # FIXME provide more context in logging? @@ -105,7 +114,8 @@ class Server(object): pass def init_connection(self, sock, addr): - Connection(self.protocol, sock, addr, self.timeout) + Connection( + self.protocol, self.protocol_kwargs, sock, addr, self.timeout) class Connection(object): @@ -117,13 +127,14 @@ class Connection(object): # false return value would only tell us that what we thought was registered # is already gone, there is really nothing more we can do. - def __init__(self, protocol, sock, addr, timeout): + def __init__(self, protocol, protocol_kwargs, sock, addr, timeout): sock.setblocking(False) - self.host, self.port = addr[:2] # IPv6 has larger addr + self.host, self.port = addr[:2] # IPv6 has larger addr self.sock = sock self.protocol = protocol + self.protocol_kwargs = protocol_kwargs self.timeout = timeout self.send_lock = threading.Lock() @@ -135,7 +146,7 @@ class Connection(object): self.send_id = None self.timeout_id = None - self.actor_ref = self.protocol.start(self) + self.actor_ref = self.protocol.start(self, **self.protocol_kwargs) self.enable_recv() self.enable_timeout() @@ -151,7 +162,7 @@ class Connection(object): try: self.actor_ref.stop(block=False) - except ActorDeadError: + except pykka.ActorDeadError: pass self.disable_timeout() @@ -203,7 +214,8 @@ class Connection(object): return try: - self.recv_id = gobject.io_add_watch(self.sock.fileno(), + self.recv_id = gobject.io_add_watch( + self.sock.fileno(), gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, self.recv_callback) except socket.error as e: @@ -220,7 +232,8 @@ class Connection(object): return try: - self.send_id = gobject.io_add_watch(self.sock.fileno(), + self.send_id = gobject.io_add_watch( + self.sock.fileno(), gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, self.send_callback) except socket.error as e: @@ -250,8 +263,8 @@ class Connection(object): return True try: - self.actor_ref.send_one_way({'received': data}) - except ActorDeadError: + self.actor_ref.tell({'received': data}) + except pykka.ActorDeadError: self.stop(u'Actor is dead.') return True @@ -280,7 +293,7 @@ class Connection(object): return False -class LineProtocol(ThreadingActor): +class LineProtocol(pykka.ThreadingActor): """ Base class for handling line based protocols. @@ -293,7 +306,7 @@ class LineProtocol(ThreadingActor): #: Regex to use for spliting lines, will be set compiled version of its #: own value, or to ``terminator``s value if it is not set itself. - delimeter = None + delimiter = None #: What encoding to expect incomming data to be in, can be :class:`None`. encoding = 'utf-8' @@ -304,10 +317,10 @@ class LineProtocol(ThreadingActor): self.prevent_timeout = False self.recv_buffer = '' - if self.delimeter: - self.delimeter = re.compile(self.delimeter) + if self.delimiter: + self.delimiter = re.compile(self.delimiter) else: - self.delimeter = re.compile(self.terminator) + self.delimiter = re.compile(self.terminator) @property def host(self): @@ -348,7 +361,7 @@ class LineProtocol(ThreadingActor): def parse_lines(self): """Consume new data and yield any lines found.""" while re.search(self.terminator, self.recv_buffer): - line, self.recv_buffer = self.delimeter.split( + line, self.recv_buffer = self.delimiter.split( self.recv_buffer, 1) yield line @@ -361,8 +374,10 @@ class LineProtocol(ThreadingActor): try: return line.encode(self.encoding) except UnicodeError: - logger.warning(u'Stopping actor due to encode problem, data ' - 'supplied by client was not valid %s', self.encoding) + logger.warning( + u'Stopping actor due to encode problem, data ' + u'supplied by client was not valid %s', + self.encoding) self.stop() def decode(self, line): @@ -374,8 +389,10 @@ class LineProtocol(ThreadingActor): try: return line.decode(self.encoding) except UnicodeError: - logger.warning(u'Stopping actor due to decode problem, data ' - 'supplied by client was not valid %s', self.encoding) + logger.warning( + u'Stopping actor due to decode problem, data ' + u'supplied by client was not valid %s', + self.encoding) self.stop() def join_lines(self, lines): diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index 5d99ac12..1092534f 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -1,21 +1,38 @@ import logging import os -import sys import re +# pylint: disable = W0402 +import string +# pylint: enable = W0402 +import sys import urllib +import glib + logger = logging.getLogger('mopidy.utils.path') +DATA_PATH = os.path.join(str(glib.get_user_data_dir()), 'mopidy') +SETTINGS_PATH = os.path.join(str(glib.get_user_config_dir()), 'mopidy') +SETTINGS_FILE = os.path.join(SETTINGS_PATH, 'settings.py') +XDG_DIRS = { + 'XDG_CACHE_DIR': glib.get_user_cache_dir(), + 'XDG_DATA_DIR': glib.get_user_data_dir(), + 'XDG_MUSIC_DIR': glib.get_user_special_dir(glib.USER_DIRECTORY_MUSIC), +} + + def get_or_create_folder(folder): folder = os.path.expanduser(folder) if os.path.isfile(folder): - raise OSError('A file with the same name as the desired ' \ - 'dir, "%s", already exists.' % folder) + raise OSError( + u'A file with the same name as the desired dir, ' + u'"%s", already exists.' % folder) elif not os.path.isdir(folder): logger.info(u'Creating dir %s', folder) os.makedirs(folder, 0755) return folder + def get_or_create_file(filename): filename = os.path.expanduser(filename) if not os.path.isfile(filename): @@ -23,6 +40,7 @@ def get_or_create_file(filename): open(filename, 'w') return filename + def path_to_uri(*paths): path = os.path.join(*paths) path = path.encode('utf-8') @@ -30,12 +48,14 @@ def path_to_uri(*paths): return 'file:' + urllib.pathname2url(path) return 'file://' + urllib.pathname2url(path) + def uri_to_path(uri): if sys.platform == 'win32': path = urllib.url2pathname(re.sub('^file:', '', uri)) else: path = urllib.url2pathname(re.sub('^file://', '', uri)) - return path.encode('latin1').decode('utf-8') # Undo double encoding + return path.encode('latin1').decode('utf-8') # Undo double encoding + def split_path(path): parts = [] @@ -47,21 +67,59 @@ def split_path(path): break return parts -# pylint: disable = W0612 -# Unused variable 'dirnames' + +def expand_path(path): + path = string.Template(path).safe_substitute(XDG_DIRS) + path = os.path.expanduser(path) + path = os.path.abspath(path) + return path + + def find_files(path): if os.path.isfile(path): if not isinstance(path, unicode): path = path.decode('utf-8') - yield path + if not os.path.basename(path).startswith('.'): + yield path else: for dirpath, dirnames, filenames in os.walk(path): + # Filter out hidden folders by modifying dirnames in place. + for dirname in dirnames: + if dirname.startswith('.'): + dirnames.remove(dirname) + for filename in filenames: + # Skip hidden files. + if filename.startswith('.'): + continue + filename = os.path.join(dirpath, filename) if not isinstance(filename, unicode): - filename = filename.decode('utf-8') + try: + filename = filename.decode('utf-8') + except UnicodeDecodeError: + filename = filename.decode('latin1') yield filename -# pylint: enable = W0612 + + +def check_file_path_is_inside_base_dir(file_path, base_path): + assert not file_path.endswith(os.sep), ( + 'File path %s cannot end with a path separator' % file_path) + + # Expand symlinks + real_base_path = os.path.realpath(base_path) + real_file_path = os.path.realpath(file_path) + + # Use dir of file for prefix comparision, so we don't accept + # /tmp/foo.m3u as being inside /tmp/foo, simply because they have a + # common prefix, /tmp/foo, which matches the base path, /tmp/foo. + real_dir_path = os.path.dirname(real_file_path) + + # Check if dir of file is the base path or a subdir + common_prefix = os.path.commonprefix([real_base_path, real_dir_path]) + assert common_prefix == real_base_path, ( + 'File path %s must be in %s' % (real_file_path, real_base_path)) + # FIXME replace with mock usage in tests. class Mtime(object): diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 909fe7c1..c6b27533 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -8,11 +8,11 @@ import traceback from pykka import ActorDeadError from pykka.registry import ActorRegistry -from mopidy import SettingsError +from mopidy import exceptions logger = logging.getLogger('mopidy.utils.process') -signals = dict((k, v) for v, k in signal.__dict__.iteritems() +SIGNALS = dict((k, v) for v, k in signal.__dict__.iteritems() if v.startswith('SIG') and not v.startswith('SIG_')) def exit_process(): @@ -20,23 +20,27 @@ def exit_process(): thread.interrupt_main() logger.debug(u'Interrupted main') + def exit_handler(signum, frame): """A :mod:`signal` handler which will exit the program on signal.""" - logger.info(u'Got %s signal', signals[signum]) + logger.info(u'Got %s signal', SIGNALS[signum]) exit_process() + def stop_actors_by_class(klass): actors = ActorRegistry.get_by_class(klass) logger.debug(u'Stopping %d instance(s) of %s', len(actors), klass.__name__) for actor in actors: actor.stop() + def stop_remaining_actors(): num_actors = len(ActorRegistry.get_all()) while num_actors: logger.error( u'There are actor threads still running, this is probably a bug') - logger.debug(u'Seeing %d actor and %d non-actor thread(s): %s', + logger.debug( + u'Seeing %d actor and %d non-actor thread(s): %s', num_actors, threading.active_count() - num_actors, ', '.join([t.name for t in threading.enumerate()])) logger.debug(u'Stopping %d actor(s)...', num_actors) @@ -44,6 +48,7 @@ def stop_remaining_actors(): num_actors = len(ActorRegistry.get_all()) logger.debug(u'All actors stopped.') + class BaseThread(threading.Thread): def __init__(self): super(BaseThread, self).__init__() @@ -56,7 +61,7 @@ class BaseThread(threading.Thread): self.run_inside_try() except KeyboardInterrupt: logger.info(u'Interrupted by user') - except SettingsError as e: + except exceptions.SettingsError as e: logger.error(e.message) except ImportError as e: logger.error(e) @@ -76,7 +81,7 @@ class DebugThread(threading.Thread): event = threading.Event() def handler(self, signum, frame): - logger.info(u'Got %s signal', signals[signum]) + logger.info(u'Got %s signal', SIGNALS[signum]) self.event.set() def run(self): diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 726917c6..5760106b 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -1,14 +1,16 @@ -# Absolute import needed to import ~/.mopidy/settings.py and not ourselves +# Absolute import needed to import ~/.config/mopidy/settings.py and not +# ourselves from __future__ import absolute_import -from copy import copy + +import copy import getpass import logging import os -from pprint import pformat +import pprint import sys -from mopidy import SettingsError, SETTINGS_PATH, SETTINGS_FILE -from mopidy.utils.log import indent +from mopidy import exceptions +from mopidy.utils import formatting, path logger = logging.getLogger('mopidy.utils.settings') @@ -21,16 +23,17 @@ class SettingsProxy(object): self.runtime = {} def _get_local_settings(self): - if not os.path.isfile(SETTINGS_FILE): + if not os.path.isfile(path.SETTINGS_FILE): return {} - sys.path.insert(0, SETTINGS_PATH) + sys.path.insert(0, path.SETTINGS_PATH) # pylint: disable = F0401 import settings as local_settings_module # pylint: enable = F0401 return self._get_settings_dict_from_module(local_settings_module) def _get_settings_dict_from_module(self, module): - settings = filter(lambda (key, value): self._is_setting(key), + settings = filter( + lambda (key, value): self._is_setting(key), module.__dict__.iteritems()) return dict(settings) @@ -39,7 +42,7 @@ class SettingsProxy(object): @property def current(self): - current = copy(self.default) + current = copy.copy(self.default) current.update(self.local) current.update(self.runtime) return current @@ -47,16 +50,18 @@ class SettingsProxy(object): def __getattr__(self, attr): if not self._is_setting(attr): return - if attr not in self.current: - raise SettingsError(u'Setting "%s" is not set.' % attr) - value = self.current[attr] + + current = self.current # bind locally to avoid copying+updates + if attr not in current: + raise exceptions.SettingsError(u'Setting "%s" is not set.' % attr) + + value = current[attr] if isinstance(value, basestring) and len(value) == 0: - raise SettingsError(u'Setting "%s" is empty.' % attr) + raise exceptions.SettingsError(u'Setting "%s" is empty.' % attr) if not value: return value if attr.endswith('_PATH') or attr.endswith('_FILE'): - value = os.path.expanduser(value) - value = os.path.abspath(value) + value = path.expand_path(value) return value def __setattr__(self, attr, value): @@ -69,9 +74,10 @@ class SettingsProxy(object): if interactive: self._read_missing_settings_from_stdin(self.current, self.runtime) if self.get_errors(): - logger.error(u'Settings validation errors: %s', - indent(self.get_errors_as_string())) - raise SettingsError(u'Settings validation failed.') + logger.error( + u'Settings validation errors: %s', + formatting.indent(self.get_errors_as_string())) + raise exceptions.SettingsError(u'Settings validation failed.') def _read_missing_settings_from_stdin(self, current, runtime): for setting, value in sorted(current.iteritems()): @@ -80,11 +86,13 @@ class SettingsProxy(object): def _read_from_stdin(self, prompt): if u'_PASSWORD' in prompt: - return (getpass.getpass(prompt) + return ( + getpass.getpass(prompt) .decode(sys.stdin.encoding, 'ignore')) else: sys.stdout.write(prompt) - return (sys.stdin.readline().strip() + return ( + sys.stdin.readline().strip() .decode(sys.stdin.encoding, 'ignore')) def get_errors(self): @@ -135,6 +143,11 @@ def validate_settings(defaults, settings): 'SPOTIFY_LIB_CACHE': 'SPOTIFY_CACHE_PATH', } + list_of_one_or_more = [ + 'BACKENDS', + 'FRONTENDS', + ] + for setting, value in settings.iteritems(): if setting in changed: if changed[setting] is None: @@ -143,13 +156,6 @@ def validate_settings(defaults, settings): errors[setting] = u'Deprecated setting. Use %s.' % ( changed[setting],) - elif setting == 'BACKENDS': - if 'mopidy.backends.despotify.DespotifyBackend' in value: - errors[setting] = ( - u'Deprecated setting value. ' - u'"mopidy.backends.despotify.DespotifyBackend" is no ' - u'longer available.') - elif setting == 'OUTPUTS': errors[setting] = ( u'Deprecated setting, please change to OUTPUT. OUTPUT expects ' @@ -166,6 +172,10 @@ def validate_settings(defaults, settings): u'Deprecated setting, please set the value via the GStreamer ' u'bin in OUTPUT.') + elif setting in list_of_one_or_more: + if not value: + errors[setting] = u'Must contain at least one value.' + elif setting not in defaults: errors[setting] = u'Unknown setting.' suggestion = did_you_mean(setting, defaults) @@ -194,10 +204,12 @@ def format_settings_list(settings): for (key, value) in sorted(settings.current.iteritems()): default_value = settings.default.get(key) masked_value = mask_value_if_secret(key, value) - lines.append(u'%s: %s' % (key, indent(pformat(masked_value), places=2))) + lines.append(u'%s: %s' % ( + key, formatting.indent(pprint.pformat(masked_value), places=2))) if value != default_value and default_value is not None: - lines.append(u' Default: %s' % - indent(pformat(default_value), places=4)) + lines.append( + u' Default: %s' % + formatting.indent(pprint.pformat(default_value), places=4)) if errors.get(key) is not None: lines.append(u' Error: %s' % errors[key]) return '\n'.join(lines) @@ -224,19 +236,19 @@ def did_you_mean(setting, defaults): return None -def levenshtein(a, b, max=3): +def levenshtein(a, b): """Calculates the Levenshtein distance between a and b.""" n, m = len(a), len(b) if n > m: return levenshtein(b, a) - current = xrange(n+1) - for i in xrange(1, m+1): + current = xrange(n + 1) + for i in xrange(1, m + 1): previous, current = current, [i] + [0] * n - for j in xrange(1, n+1): - add, delete = previous[j] + 1, current[j-1] + 1 - change = previous[j-1] - if a[j-1] != b[i-1]: + for j in xrange(1, n + 1): + add, delete = previous[j] + 1, current[j - 1] + 1 + change = previous[j - 1] + if a[j - 1] != b[i - 1]: change += 1 current[j] = min(add, delete, change) return current[n] diff --git a/mopidy/utils/versioning.py b/mopidy/utils/versioning.py new file mode 100644 index 00000000..8e7d55bd --- /dev/null +++ b/mopidy/utils/versioning.py @@ -0,0 +1,22 @@ +from subprocess import PIPE, Popen + +from mopidy import __version__ + + +def get_version(): + try: + return get_git_version() + except EnvironmentError: + return __version__ + + +def get_git_version(): + process = Popen(['git', 'describe'], stdout=PIPE, stderr=PIPE) + # pylint: disable = E1101 + if process.wait() != 0: + raise EnvironmentError('Execution of "git describe" failed') + version = process.stdout.read().strip() + # pylint: enable = E1101 + if version.startswith('v'): + version = version[1:] + return version diff --git a/pylintrc b/pylintrc index 98e10416..41e1ab5d 100644 --- a/pylintrc +++ b/pylintrc @@ -5,20 +5,17 @@ # # C0103 - Invalid name "%s" (should match %s) # C0111 - Missing docstring -# E0102 - %s already defined line %s -# Does not understand @property getters and setters -# E0202 - An attribute inherited from %s hide this method -# Does not understand @property getters and setters -# E1101 - %s %r has no %r member -# Does not understand @property getters and setters # R0201 - Method could be a function # R0801 - Similar lines in %s files +# R0902 - Too many instance attributes (%s/%s) # R0903 - Too few public methods (%s/%s) # R0904 - Too many public methods (%s/%s) +# R0912 - Too many branches (%s/%s) +# R0913 - Too many arguments (%s/%s) # R0921 - Abstract class not referenced # W0141 - Used builtin function '%s' # W0142 - Used * or ** magic # W0511 - TODO, FIXME and XXX in the code # W0613 - Unused argument %r # -disable = C0103,C0111,E0102,E0202,E1101,R0201,R0801,R0903,R0904,R0921,W0141,W0142,W0511,W0613 +disable = C0103,C0111,R0201,R0801,R0902,R0903,R0904,R0912,R0913,R0921,W0141,W0142,W0511,W0613 diff --git a/requirements/core.txt b/requirements/core.txt index 8f9da622..7f83e251 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1 +1 @@ -Pykka >= 0.12.3 +Pykka >= 1.0 diff --git a/requirements/tests.txt b/requirements/tests.txt index e24edd3c..20aff929 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -1,6 +1,8 @@ coverage +flake8 mock >= 0.7 nose +pylint tox unittest2 yappi diff --git a/setup.cfg b/setup.cfg index e09a7b15..bce0a6e2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,8 +1,6 @@ [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 index ae6cc699..99fb7f49 100644 --- a/setup.py +++ b/setup.py @@ -9,11 +9,13 @@ import os import re import sys + def get_version(): init_py = open('mopidy/__init__.py').read() metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", init_py)) return metadata['version'] + class osx_install_data(install_data): # On MacOS, the platform-specific lib dir is # /System/Library/Framework/Python/.../ which is wrong. Python 2.5 supplied @@ -28,11 +30,13 @@ class osx_install_data(install_data): self.set_undefined_options('install', ('install_lib', 'install_dir')) install_data.finalize_options(self) + if sys.platform == "darwin": cmdclasses = {'install_data': osx_install_data} else: cmdclasses = {'install_data': install_data} + def fullsplit(path, result=None): """ Split a pathname into components (the opposite of os.path.join) in a @@ -47,6 +51,7 @@ def fullsplit(path, result=None): 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/ @@ -54,6 +59,7 @@ def fullsplit(path, result=None): 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 = [], [] @@ -62,6 +68,7 @@ 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): @@ -70,8 +77,9 @@ for dirpath, dirnames, filenames in os.walk(project_dir): 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]]) + data_files.append([ + dirpath, [os.path.join(dirpath, f) for f in filenames]]) + setup( name='Mopidy', diff --git a/tests/__init__.py b/tests/__init__.py index 833ff239..5d9ea2b5 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -4,7 +4,7 @@ import sys if sys.version_info < (2, 7): import unittest2 as unittest else: - import unittest + import unittest # noqa from mopidy import settings diff --git a/tests/audio_test.py b/tests/audio_test.py index fcafa75f..852ce36b 100644 --- a/tests/audio_test.py +++ b/tests/audio_test.py @@ -1,13 +1,9 @@ -import sys - from mopidy import audio, settings from mopidy.utils.path import path_to_uri from tests import unittest, path_to_data_dir -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') class AudioTest(unittest.TestCase): def setUp(self): settings.MIXER = 'fakemixer track_max_volume=65536' @@ -43,11 +39,11 @@ class AudioTest(unittest.TestCase): @unittest.SkipTest def test_deliver_data(self): - pass # TODO + pass # TODO @unittest.SkipTest def test_end_of_data_stream(self): - pass # TODO + pass # TODO def test_set_volume(self): for value in range(0, 101): @@ -56,12 +52,12 @@ class AudioTest(unittest.TestCase): @unittest.SkipTest def test_set_state_encapsulation(self): - pass # TODO + pass # TODO @unittest.SkipTest def test_set_position(self): - pass # TODO + pass # TODO @unittest.SkipTest def test_invalid_output_raises_error(self): - pass # TODO + pass # TODO diff --git a/tests/backends/base/__init__.py b/tests/backends/base/__init__.py index 29f010e1..84eee193 100644 --- a/tests/backends/base/__init__.py +++ b/tests/backends/base/__init__.py @@ -1,7 +1,7 @@ def populate_playlist(func): def wrapper(self): for track in self.tracks: - self.backend.current_playlist.add(track) + self.core.current_playlist.add(track) return func(self) wrapper.__name__ = func.__name__ diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index 430e4c40..2ba77ee3 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -1,7 +1,9 @@ import mock import random -from mopidy import audio +import pykka + +from mopidy import audio, core from mopidy.core import PlaybackState from mopidy.models import CpTrack, Playlist, Track @@ -12,13 +14,17 @@ class CurrentPlaylistControllerTest(object): tracks = [] def setUp(self): - self.backend = self.backend_class() - self.backend.audio = mock.Mock(spec=audio.Audio) - self.controller = self.backend.current_playlist - self.playback = self.backend.playback + self.audio = mock.Mock(spec=audio.Audio) + self.backend = self.backend_class.start(audio=self.audio).proxy() + self.core = core.Core(audio=audio, backends=[self.backend]) + self.controller = self.core.current_playlist + self.playback = self.core.playback assert len(self.tracks) == 3, 'Need three tracks to run tests.' + def tearDown(self): + pykka.ActorRegistry.stop_all() + def test_length(self): self.assertEqual(0, len(self.controller.cp_tracks)) self.assertEqual(0, self.controller.length) @@ -42,7 +48,8 @@ class CurrentPlaylistControllerTest(object): @populate_playlist def test_add_at_position_outside_of_playlist(self): - test = lambda: self.controller.add(self.tracks[0], len(self.tracks)+2) + test = lambda: self.controller.add( + self.tracks[0], len(self.tracks) + 2) self.assertRaises(AssertionError, test) @populate_playlist @@ -174,19 +181,19 @@ class CurrentPlaylistControllerTest(object): @populate_playlist def test_moving_track_outside_of_playlist(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 0, tracks+5) + test = lambda: self.controller.move(0, 0, tracks + 5) self.assertRaises(AssertionError, test) @populate_playlist def test_move_group_outside_of_playlist(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.move(0, 2, tracks+5) + test = lambda: self.controller.move(0, 2, tracks + 5) self.assertRaises(AssertionError, test) @populate_playlist def test_move_group_out_of_range(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.move(tracks+2, tracks+3, 0) + test = lambda: self.controller.move(tracks + 2, tracks + 3, 0) self.assertRaises(AssertionError, test) @populate_playlist @@ -205,7 +212,7 @@ class CurrentPlaylistControllerTest(object): track2 = self.controller.tracks[2] version = self.controller.version self.controller.remove(uri=track1.uri) - self.assert_(version < self.controller.version) + self.assertLess(version, self.controller.version) self.assertNotIn(track1, self.controller.tracks) self.assertEqual(track2, self.controller.tracks[1]) @@ -247,7 +254,7 @@ class CurrentPlaylistControllerTest(object): @populate_playlist def test_shuffle_superset(self): tracks = len(self.controller.tracks) - test = lambda: self.controller.shuffle(1, tracks+5) + test = lambda: self.controller.shuffle(1, tracks + 5) self.assertRaises(AssertionError, test) @populate_playlist @@ -281,4 +288,4 @@ class CurrentPlaylistControllerTest(object): def test_version_increases_when_appending_something(self): version = self.controller.version self.controller.append([Track()]) - self.assert_(version < self.controller.version) + self.assertLess(version, self.controller.version) diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index f76d9d75..b7510dbb 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -1,3 +1,6 @@ +import pykka + +from mopidy import core from mopidy.models import Playlist, Track, Album, Artist from tests import unittest, path_to_data_dir @@ -5,18 +8,26 @@ from tests import unittest, path_to_data_dir class LibraryControllerTest(object): artists = [Artist(name='artist1'), Artist(name='artist2'), Artist()] - albums = [Album(name='album1', artists=artists[:1]), + albums = [ + Album(name='album1', artists=artists[:1]), Album(name='album2', artists=artists[1:2]), Album()] - tracks = [Track(name='track1', length=4000, artists=artists[:1], + tracks = [ + Track( + name='track1', length=4000, artists=artists[:1], album=albums[0], uri='file://' + path_to_data_dir('uri1')), - Track(name='track2', length=4000, artists=artists[1:2], + Track( + name='track2', length=4000, artists=artists[1:2], album=albums[1], uri='file://' + path_to_data_dir('uri2')), Track()] def setUp(self): - self.backend = self.backend_class() - self.library = self.backend.library + self.backend = self.backend_class.start(audio=None).proxy() + self.core = core.Core(backends=[self.backend]) + self.library = self.core.library + + def tearDown(self): + pykka.ActorRegistry.stop_all() def test_refresh(self): self.library.refresh() @@ -68,6 +79,15 @@ class LibraryControllerTest(object): result = self.library.find_exact(album=['album2']) self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + def test_find_exact_filename(self): + track_1_filename = 'file://' + path_to_data_dir('uri1') + result = self.library.find_exact(filename=track_1_filename) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + + track_2_filename = 'file://' + path_to_data_dir('uri2') + result = self.library.find_exact(filename=track_2_filename) + self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + def test_find_exact_wrong_type(self): test = lambda: self.library.find_exact(wrong=['test']) self.assertRaises(LookupError, test) @@ -126,6 +146,13 @@ class LibraryControllerTest(object): result = self.library.search(uri=['RI2']) self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + def test_search_filename(self): + result = self.library.search(filename=['RI1']) + self.assertEqual(result, Playlist(tracks=self.tracks[:1])) + + result = self.library.search(filename=['RI2']) + self.assertEqual(result, Playlist(tracks=self.tracks[1:2])) + def test_search_any(self): result = self.library.search(any=['Tist1']) self.assertEqual(result, Playlist(tracks=self.tracks[:1])) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 1e434e35..cd55668c 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -2,7 +2,7 @@ import mock import random import time -from mopidy import audio +from mopidy import audio, core from mopidy.core import PlaybackState from mopidy.models import Track @@ -16,10 +16,11 @@ class PlaybackControllerTest(object): tracks = [] def setUp(self): - self.backend = self.backend_class() - self.backend.audio = mock.Mock(spec=audio.Audio) - self.playback = self.backend.playback - self.current_playlist = self.backend.current_playlist + self.audio = mock.Mock(spec=audio.Audio) + self.backend = self.backend_class.start(audio=self.audio).proxy() + self.core = core.Core(backends=[self.backend]) + self.playback = self.core.playback + self.current_playlist = self.core.current_playlist assert len(self.tracks) >= 3, \ 'Need at least three tracks to run tests.' @@ -97,8 +98,8 @@ class PlaybackControllerTest(object): @populate_playlist def test_play_skips_to_next_track_on_failure(self): - # If provider.play() returns False, it is a failure. - self.playback.provider.play = lambda track: track != self.tracks[0] + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[0] self.playback.play() self.assertNotEqual(self.playback.current_track, self.tracks[0]) self.assertEqual(self.playback.current_track, self.tracks[1]) @@ -124,10 +125,10 @@ class PlaybackControllerTest(object): @populate_playlist def test_previous_more(self): - self.playback.play() # At track 0 - self.playback.next() # At track 1 - self.playback.next() # At track 2 - self.playback.previous() # At track 1 + self.playback.play() # At track 0 + self.playback.next() # At track 1 + self.playback.next() # At track 2 + self.playback.previous() # At track 1 self.assertEqual(self.playback.current_track, self.tracks[1]) @populate_playlist @@ -157,8 +158,8 @@ class PlaybackControllerTest(object): @populate_playlist def test_previous_skips_to_previous_track_on_failure(self): - # If provider.play() returns False, it is a failure. - self.playback.provider.play = lambda track: track != self.tracks[1] + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] self.playback.play(self.current_playlist.cp_tracks[2]) self.assertEqual(self.playback.current_track, self.tracks[2]) self.playback.previous() @@ -174,8 +175,8 @@ class PlaybackControllerTest(object): self.playback.next() - self.assertEqual(self.playback.current_playlist_position, - old_position+1) + self.assertEqual( + self.playback.current_playlist_position, old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_playlist @@ -221,8 +222,8 @@ class PlaybackControllerTest(object): @populate_playlist def test_next_skips_to_next_track_on_failure(self): - # If provider.play() returns False, it is a failure. - self.playback.provider.play = lambda track: track != self.tracks[1] + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.next() @@ -274,7 +275,7 @@ class PlaybackControllerTest(object): self.playback.consume = True self.playback.play() self.playback.next() - self.assertIn(self.tracks[0], self.backend.current_playlist.tracks) + self.assertIn(self.tracks[0], self.current_playlist.tracks) @populate_playlist def test_next_with_single_and_repeat(self): @@ -298,7 +299,7 @@ class PlaybackControllerTest(object): random.seed(1) self.playback.random = True self.assertEqual(self.playback.track_at_next, self.tracks[2]) - self.backend.current_playlist.append(self.tracks[:1]) + self.current_playlist.append(self.tracks[:1]) self.assertEqual(self.playback.track_at_next, self.tracks[1]) @populate_playlist @@ -310,8 +311,8 @@ class PlaybackControllerTest(object): self.playback.on_end_of_track() - self.assertEqual(self.playback.current_playlist_position, - old_position+1) + self.assertEqual( + self.playback.current_playlist_position, old_position + 1) self.assertNotEqual(self.playback.current_track.uri, old_uri) @populate_playlist @@ -357,8 +358,8 @@ class PlaybackControllerTest(object): @populate_playlist def test_end_of_track_skips_to_next_track_on_failure(self): - # If provider.play() returns False, it is a failure. - self.playback.provider.play = lambda track: track != self.tracks[1] + # If backend's play() returns False, it is a failure. + self.backend.playback.play = lambda track: track != self.tracks[1] self.playback.play() self.assertEqual(self.playback.current_track, self.tracks[0]) self.playback.on_end_of_track() @@ -405,13 +406,12 @@ class PlaybackControllerTest(object): self.playback.random = True self.assertEqual(self.playback.track_at_next, self.tracks[2]) - @populate_playlist def test_end_of_track_with_consume(self): self.playback.consume = True self.playback.play() self.playback.on_end_of_track() - self.assertNotIn(self.tracks[0], self.backend.current_playlist.tracks) + self.assertNotIn(self.tracks[0], self.current_playlist.tracks) @populate_playlist def test_end_of_track_with_random(self): @@ -427,7 +427,7 @@ class PlaybackControllerTest(object): random.seed(1) self.playback.random = True self.assertEqual(self.playback.track_at_next, self.tracks[2]) - self.backend.current_playlist.append(self.tracks[:1]) + self.current_playlist.append(self.tracks[:1]) self.assertEqual(self.playback.track_at_next, self.tracks[1]) @populate_playlist @@ -447,10 +447,10 @@ class PlaybackControllerTest(object): @populate_playlist def test_previous_track_after_previous(self): - self.playback.play() # At track 0 - self.playback.next() # At track 1 - self.playback.next() # At track 2 - self.playback.previous() # At track 1 + self.playback.play() # At track 0 + self.playback.next() # At track 1 + self.playback.next() # At track 2 + self.playback.previous() # At track 1 self.assertEqual(self.playback.track_at_previous, self.tracks[0]) def test_previous_track_empty_playlist(self): @@ -461,16 +461,16 @@ class PlaybackControllerTest(object): self.playback.consume = True for _ in self.tracks: self.playback.next() - self.assertEqual(self.playback.track_at_previous, - self.playback.current_track) + self.assertEqual( + self.playback.track_at_previous, self.playback.current_track) @populate_playlist def test_previous_track_with_random(self): self.playback.random = True for _ in self.tracks: self.playback.next() - self.assertEqual(self.playback.track_at_previous, - self.playback.current_track) + self.assertEqual( + self.playback.track_at_previous, self.playback.current_track) @populate_playlist def test_initial_current_track(self): @@ -517,11 +517,11 @@ class PlaybackControllerTest(object): wrapper.called = False self.playback.on_current_playlist_change = wrapper - self.backend.current_playlist.append([Track()]) + self.current_playlist.append([Track()]) self.assert_(wrapper.called) - @unittest.SkipTest # Blocks for 10ms + @unittest.SkipTest # Blocks for 10ms @populate_playlist def test_end_of_track_callback_gets_called(self): self.playback.play() @@ -534,13 +534,13 @@ class PlaybackControllerTest(object): def test_on_current_playlist_change_when_playing(self): self.playback.play() current_track = self.playback.current_track - self.backend.current_playlist.append([self.tracks[2]]) + self.current_playlist.append([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, current_track) @populate_playlist def test_on_current_playlist_change_when_stopped(self): - self.backend.current_playlist.append([self.tracks[2]]) + self.current_playlist.append([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @@ -549,7 +549,7 @@ class PlaybackControllerTest(object): self.playback.play() self.playback.pause() current_track = self.playback.current_track - self.backend.current_playlist.append([self.tracks[2]]) + self.current_playlist.append([self.tracks[2]]) self.assertEqual(self.playback.state, PlaybackState.PAUSED) self.assertEqual(self.playback.current_track, current_track) @@ -600,7 +600,7 @@ class PlaybackControllerTest(object): self.playback.pause() self.assertEqual(self.playback.resume(), None) - @unittest.SkipTest # Uses sleep and might not work with LocalBackend + @unittest.SkipTest # Uses sleep and might not work with LocalBackend @populate_playlist def test_resume_continues_from_right_position(self): self.playback.play() @@ -618,7 +618,7 @@ class PlaybackControllerTest(object): def test_seek_when_stopped_updates_position(self): self.playback.seek(1000) position = self.playback.time_position - self.assert_(position >= 990, position) + self.assertGreaterEqual(position, 990) def test_seek_on_empty_playlist(self): self.assertFalse(self.playback.seek(0)) @@ -640,11 +640,11 @@ class PlaybackControllerTest(object): @populate_playlist def test_seek_when_playing_updates_position(self): - length = self.backend.current_playlist.tracks[0].length + length = self.current_playlist.tracks[0].length self.playback.play() self.playback.seek(length - 1000) position = self.playback.time_position - self.assert_(position >= length - 1010, position) + self.assertGreaterEqual(position, length - 1010) @populate_playlist def test_seek_when_paused(self): @@ -655,12 +655,12 @@ class PlaybackControllerTest(object): @populate_playlist def test_seek_when_paused_updates_position(self): - length = self.backend.current_playlist.tracks[0].length + length = self.current_playlist.tracks[0].length self.playback.play() self.playback.pause() self.playback.seek(length - 1000) position = self.playback.time_position - self.assert_(position >= length - 1010, position) + self.assertGreaterEqual(position, length - 1010) @populate_playlist def test_seek_when_paused_triggers_play(self): @@ -674,13 +674,13 @@ class PlaybackControllerTest(object): def test_seek_beyond_end_of_song(self): # FIXME need to decide return value self.playback.play() - result = self.playback.seek(self.tracks[0].length*100) + result = self.playback.seek(self.tracks[0].length * 100) self.assert_(not result, 'Seek return value was %s' % result) @populate_playlist def test_seek_beyond_end_of_song_jumps_to_next_song(self): self.playback.play() - self.playback.seek(self.tracks[0].length*100) + self.playback.seek(self.tracks[0].length * 100) self.assertEqual(self.playback.current_track, self.tracks[1]) @populate_playlist @@ -702,7 +702,7 @@ class PlaybackControllerTest(object): self.playback.play() self.playback.seek(-1000) position = self.playback.time_position - self.assert_(position >= 0, position) + self.assertGreaterEqual(position, 0) self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist @@ -730,7 +730,7 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.audio.get_position = mock.Mock(return_value=future) + self.audio.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) @@ -738,20 +738,20 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped_with_playlist(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.audio.get_position = mock.Mock(return_value=future) + self.audio.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) - @unittest.SkipTest # Uses sleep and does might not work with LocalBackend + @unittest.SkipTest # Uses sleep and does might not work with LocalBackend @populate_playlist def test_time_position_when_playing(self): self.playback.play() first = self.playback.time_position time.sleep(1) second = self.playback.time_position - self.assert_(second > first, '%s - %s' % (first, second)) + self.assertGreater(second, first) - @unittest.SkipTest # Uses sleep + @unittest.SkipTest # Uses sleep @populate_playlist def test_time_position_when_paused(self): self.playback.play() @@ -772,9 +772,9 @@ class PlaybackControllerTest(object): def test_playlist_is_empty_after_all_tracks_are_played_with_consume(self): self.playback.consume = True self.playback.play() - for _ in range(len(self.backend.current_playlist.tracks)): + for _ in range(len(self.current_playlist.tracks)): self.playback.on_end_of_track() - self.assertEqual(len(self.backend.current_playlist.tracks), 0) + self.assertEqual(len(self.current_playlist.tracks), 0) @populate_playlist def test_play_with_random(self): diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 1e575b9e..267a025c 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -2,7 +2,10 @@ import os import shutil import tempfile -from mopidy import settings +import mock +import pykka + +from mopidy import audio, core, settings from mopidy.models import Playlist from tests import unittest, path_to_data_dir @@ -14,21 +17,29 @@ class StoredPlaylistsControllerTest(object): settings.LOCAL_TAG_CACHE_FILE = path_to_data_dir('library_tag_cache') settings.LOCAL_MUSIC_PATH = path_to_data_dir('') - self.backend = self.backend_class() - self.stored = self.backend.stored_playlists + self.audio = mock.Mock(spec=audio.Audio) + self.backend = self.backend_class.start(audio=self.audio).proxy() + self.core = core.Core(backends=[self.backend]) + self.stored = self.core.stored_playlists def tearDown(self): + pykka.ActorRegistry.stop_all() + if os.path.exists(settings.LOCAL_PLAYLIST_PATH): shutil.rmtree(settings.LOCAL_PLAYLIST_PATH) settings.runtime.clear() - def test_create(self): - playlist = self.stored.create('test') + def test_create_returns_playlist_with_name_set(self): + playlist = self.stored.create(u'test') self.assertEqual(playlist.name, 'test') - def test_create_in_playlists(self): - playlist = self.stored.create('test') + def test_create_returns_playlist_with_uri_set(self): + playlist = self.stored.create(u'test') + self.assert_(playlist.uri) + + def test_create_adds_playlist_to_playlists_collection(self): + playlist = self.stored.create(u'test') self.assert_(self.stored.playlists) self.assertIn(playlist, self.stored.playlists) @@ -36,12 +47,15 @@ class StoredPlaylistsControllerTest(object): self.assert_(not self.stored.playlists) def test_delete_non_existant_playlist(self): - self.stored.delete(Playlist()) + self.stored.delete('file:///unknown/playlist') - def test_delete_playlist(self): - playlist = self.stored.create('test') - self.stored.delete(playlist) - self.assert_(not self.stored.playlists) + def test_delete_playlist_removes_it_from_the_collection(self): + playlist = self.stored.create(u'test') + self.assertIn(playlist, self.stored.playlists) + + self.stored.delete(playlist.uri) + + self.assertNotIn(playlist, self.stored.playlists) def test_get_without_criteria(self): test = self.stored.get @@ -52,18 +66,19 @@ class StoredPlaylistsControllerTest(object): self.assertRaises(LookupError, test) def test_get_with_right_criteria(self): - playlist1 = self.stored.create('test') + playlist1 = self.stored.create(u'test') playlist2 = self.stored.get(name='test') self.assertEqual(playlist1, playlist2) def test_get_by_name_returns_unique_match(self): playlist = Playlist(name='b') - self.stored.playlists = [Playlist(name='a'), playlist] + self.backend.stored_playlists.playlists = [ + Playlist(name='a'), playlist] self.assertEqual(playlist, self.stored.get(name='b')) def test_get_by_name_returns_first_of_multiple_matches(self): playlist = Playlist(name='b') - self.stored.playlists = [ + self.backend.stored_playlists.playlists = [ playlist, Playlist(name='a'), Playlist(name='b')] try: self.stored.get(name='b') @@ -72,36 +87,33 @@ class StoredPlaylistsControllerTest(object): self.assertEqual(u'"name=b" match multiple playlists', e[0]) def test_get_by_name_raises_keyerror_if_no_match(self): - self.stored.playlists = [Playlist(name='a'), Playlist(name='b')] + self.backend.stored_playlists.playlists = [ + Playlist(name='a'), Playlist(name='b')] try: self.stored.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]) - @unittest.SkipTest - def test_lookup(self): - pass + def test_lookup_finds_playlist_by_uri(self): + original_playlist = self.stored.create(u'test') + + looked_up_playlist = self.stored.lookup(original_playlist.uri) + + self.assertEqual(original_playlist, looked_up_playlist) @unittest.SkipTest def test_refresh(self): pass - def test_rename(self): - playlist = self.stored.create('test') - self.stored.rename(playlist, 'test2') - self.stored.get(name='test2') + def test_save_replaces_stored_playlist_with_updated_playlist(self): + playlist1 = self.stored.create(u'test1') + self.assertIn(playlist1, self.stored.playlists) - def test_rename_unknown_playlist(self): - self.stored.rename(Playlist(), 'test2') - test = lambda: self.stored.get(name='test2') - self.assertRaises(LookupError, test) - - def test_save(self): - # FIXME should we handle playlists without names? - playlist = Playlist(name='test') - self.stored.save(playlist) - self.assertIn(playlist, self.stored.playlists) + playlist2 = playlist1.copy(name=u'test2') + playlist2 = self.stored.save(playlist2) + self.assertNotIn(playlist1, self.stored.playlists) + self.assertIn(playlist2, self.stored.playlists) @unittest.SkipTest def test_playlist_with_unknown_track(self): diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py index d761676d..600dbf6c 100644 --- a/tests/backends/events_test.py +++ b/tests/backends/events_test.py @@ -1,53 +1,54 @@ import mock +import pykka -from pykka.registry import ActorRegistry - -from mopidy.backends.dummy import DummyBackend -from mopidy.listeners import BackendListener +from mopidy import audio, core +from mopidy.backends import dummy from mopidy.models import Track from tests import unittest -@mock.patch.object(BackendListener, 'send') +@mock.patch.object(core.CoreListener, 'send') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.backend = DummyBackend.start().proxy() + self.audio = mock.Mock(spec=audio.Audio) + self.backend = dummy.DummyBackend.start(audio=audio).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() def tearDown(self): - ActorRegistry.stop_all() + pykka.ActorRegistry.stop_all() def test_pause_sends_track_playback_paused_event(self, send): - self.backend.current_playlist.add(Track(uri='a')) - self.backend.playback.play().get() + self.core.current_playlist.add(Track(uri='dummy:a')) + self.core.playback.play().get() send.reset_mock() - self.backend.playback.pause().get() + self.core.playback.pause().get() self.assertEqual(send.call_args[0][0], 'track_playback_paused') def test_resume_sends_track_playback_resumed(self, send): - self.backend.current_playlist.add(Track(uri='a')) - self.backend.playback.play() - self.backend.playback.pause().get() + self.core.current_playlist.add(Track(uri='dummy:a')) + self.core.playback.play() + self.core.playback.pause().get() send.reset_mock() - self.backend.playback.resume().get() + self.core.playback.resume().get() self.assertEqual(send.call_args[0][0], 'track_playback_resumed') def test_play_sends_track_playback_started_event(self, send): - self.backend.current_playlist.add(Track(uri='a')) + self.core.current_playlist.add(Track(uri='dummy:a')) send.reset_mock() - self.backend.playback.play().get() + self.core.playback.play().get() self.assertEqual(send.call_args[0][0], 'track_playback_started') def test_stop_sends_track_playback_ended_event(self, send): - self.backend.current_playlist.add(Track(uri='a')) - self.backend.playback.play().get() + self.core.current_playlist.add(Track(uri='dummy:a')) + self.core.playback.play().get() send.reset_mock() - self.backend.playback.stop().get() + self.core.playback.stop().get() self.assertEqual(send.call_args_list[0][0][0], 'track_playback_ended') def test_seek_sends_seeked_event(self, send): - self.backend.current_playlist.add(Track(uri='a', length=40000)) - self.backend.playback.play().get() + self.core.current_playlist.add(Track(uri='dummy:a', length=40000)) + self.core.playback.play().get() send.reset_mock() - self.backend.playback.seek(1000).get() + self.core.playback.seek(1000).get() self.assertEqual(send.call_args[0][0], 'seeked') diff --git a/tests/backends/local/current_playlist_test.py b/tests/backends/local/current_playlist_test.py index a475a6fd..52fa9eb5 100644 --- a/tests/backends/local/current_playlist_test.py +++ b/tests/backends/local/current_playlist_test.py @@ -1,5 +1,3 @@ -import sys - from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.models import Track @@ -9,14 +7,12 @@ from tests.backends.base.current_playlist import CurrentPlaylistControllerTest from tests.backends.local import generate_song -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') class LocalCurrentPlaylistControllerTest(CurrentPlaylistControllerTest, - unittest.TestCase): + unittest.TestCase): backend_class = LocalBackend - tracks = [Track(uri=generate_song(i), length=4464) - for i in range(1, 4)] + tracks = [ + Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) diff --git a/tests/backends/local/library_test.py b/tests/backends/local/library_test.py index 046e747a..75cebdbc 100644 --- a/tests/backends/local/library_test.py +++ b/tests/backends/local/library_test.py @@ -1,5 +1,3 @@ -import sys - from mopidy import settings from mopidy.backends.local import LocalBackend @@ -7,8 +5,6 @@ from tests import unittest, path_to_data_dir from tests.backends.base.library import LibraryControllerTest -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') class LocalLibraryControllerTest(LibraryControllerTest, unittest.TestCase): backend_class = LocalBackend diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index c167fbcc..fea93ae3 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -1,5 +1,3 @@ -import sys - from mopidy import settings from mopidy.backends.local import LocalBackend from mopidy.core import PlaybackState @@ -11,19 +9,14 @@ from tests.backends.base.playback import PlaybackControllerTest from tests.backends.local import generate_song -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): backend_class = LocalBackend - tracks = [Track(uri=generate_song(i), length=4464) - for i in range(1, 4)] + tracks = [ + Track(uri=generate_song(i), length=4464) for i in range(1, 4)] def setUp(self): settings.BACKENDS = ('mopidy.backends.local.LocalBackend',) - super(LocalPlaybackControllerTest, self).setUp() - # Two tests does not work at all when using the fake sink - #self.backend.playback.use_fake_sink() def tearDown(self): super(LocalPlaybackControllerTest, self).tearDown() @@ -32,10 +25,10 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): def add_track(self, path): uri = path_to_uri(path_to_data_dir(path)) track = Track(uri=uri, length=4464) - self.backend.current_playlist.add(track) + self.current_playlist.add(track) def test_uri_scheme(self): - self.assertIn('file', self.backend.uri_schemes) + self.assertIn('file', self.core.uri_schemes) def test_play_mp3(self): self.add_track('blank.mp3') diff --git a/tests/backends/local/stored_playlists_test.py b/tests/backends/local/stored_playlists_test.py index 56be92c4..cd1ecd3c 100644 --- a/tests/backends/local/stored_playlists_test.py +++ b/tests/backends/local/stored_playlists_test.py @@ -1,9 +1,8 @@ import os -import sys from mopidy import settings from mopidy.backends.local import LocalBackend -from mopidy.models import Playlist, Track +from mopidy.models import Track from mopidy.utils.path import path_to_uri from tests import unittest, path_to_data_dir @@ -12,65 +11,91 @@ from tests.backends.base.stored_playlists import ( from tests.backends.local import generate_song -@unittest.skipIf(sys.platform == 'win32', - 'Our Windows build server does not support GStreamer yet') -class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, - unittest.TestCase): +class LocalStoredPlaylistsControllerTest( + StoredPlaylistsControllerTest, unittest.TestCase): backend_class = LocalBackend def test_created_playlist_is_persisted(self): path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') - self.assert_(not os.path.exists(path)) - self.stored.create('test') - self.assert_(os.path.exists(path)) + self.assertFalse(os.path.exists(path)) + + self.stored.create(u'test') + self.assertTrue(os.path.exists(path)) + + def test_create_slugifies_playlist_name(self): + path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test-foo-bar.m3u') + self.assertFalse(os.path.exists(path)) + + playlist = self.stored.create(u'test FOO baR') + self.assertEqual(u'test-foo-bar', playlist.name) + self.assertTrue(os.path.exists(path)) + + def test_create_slugifies_names_which_tries_to_change_directory(self): + path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test-foo-bar.m3u') + self.assertFalse(os.path.exists(path)) + + playlist = self.stored.create(u'../../test FOO baR') + self.assertEqual(u'test-foo-bar', playlist.name) + self.assertTrue(os.path.exists(path)) def test_saved_playlist_is_persisted(self): - path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') - self.assert_(not os.path.exists(path)) - self.stored.save(Playlist(name='test2')) - self.assert_(os.path.exists(path)) + path1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test1.m3u') + path2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2-foo-bar.m3u') - def test_deleted_playlist_get_removed(self): - playlist = self.stored.create('test') - self.stored.delete(playlist) + playlist = self.stored.create(u'test1') + + self.assertTrue(os.path.exists(path1)) + self.assertFalse(os.path.exists(path2)) + + playlist = playlist.copy(name=u'test2 FOO baR') + playlist = self.stored.save(playlist) + + self.assertEqual(u'test2-foo-bar', playlist.name) + self.assertFalse(os.path.exists(path1)) + self.assertTrue(os.path.exists(path2)) + + def test_deleted_playlist_is_removed(self): path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') - self.assert_(not os.path.exists(path)) + self.assertFalse(os.path.exists(path)) - def test_renamed_playlist_gets_moved(self): - playlist = self.stored.create('test') - file1 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') - file2 = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test2.m3u') - self.assert_(not os.path.exists(file2)) - self.stored.rename(playlist, 'test2') - self.assert_(not os.path.exists(file1)) - self.assert_(os.path.exists(file2)) + playlist = self.stored.create(u'test') + self.assertTrue(os.path.exists(path)) - def test_playlist_contents_get_written_to_disk(self): + self.stored.delete(playlist.uri) + self.assertFalse(os.path.exists(path)) + + def test_playlist_contents_is_written_to_disk(self): track = Track(uri=generate_song(1)) - uri = track.uri[len('file://'):] - playlist = Playlist(tracks=[track], name='test') - path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') + track_path = track.uri[len('file://'):] + playlist = self.stored.create(u'test') + playlist_path = playlist.uri[len('file://'):] + playlist = playlist.copy(tracks=[track]) + playlist = self.stored.save(playlist) - self.stored.save(playlist) - - with open(path) as playlist_file: + with open(playlist_path) as playlist_file: contents = playlist_file.read() - self.assertEqual(uri, contents.strip()) + self.assertEqual(track_path, contents.strip()) def test_playlists_are_loaded_at_startup(self): + playlist_path = os.path.join(settings.LOCAL_PLAYLIST_PATH, 'test.m3u') + track = Track(uri=path_to_uri(path_to_data_dir('uri2'))) - playlist = Playlist(tracks=[track], name='test') + playlist = self.stored.create(u'test') + playlist = playlist.copy(tracks=[track]) + playlist = self.stored.save(playlist) - self.stored.save(playlist) + backend = self.backend_class(audio=self.audio) - self.backend = self.backend_class() - self.stored = self.backend.stored_playlists - - self.assert_(self.stored.playlists) - self.assertEqual('test', self.stored.playlists[0].name) - self.assertEqual(track.uri, self.stored.playlists[0].tracks[0].uri) + self.assert_(backend.stored_playlists.playlists) + self.assertEqual( + path_to_uri(playlist_path), + backend.stored_playlists.playlists[0].uri) + self.assertEqual( + playlist.name, backend.stored_playlists.playlists[0].name) + self.assertEqual( + track.uri, backend.stored_playlists.playlists[0].tracks[0].uri) @unittest.SkipTest def test_santitising_of_playlist_filenames(self): @@ -79,11 +104,3 @@ class LocalStoredPlaylistsControllerTest(StoredPlaylistsControllerTest, @unittest.SkipTest def test_playlist_folder_is_createad(self): pass - - @unittest.SkipTest - def test_create_sets_playlist_uri(self): - pass - - @unittest.SkipTest - def test_save_sets_playlist_uri(self): - pass diff --git a/tests/backends/local/translator_test.py b/tests/backends/local/translator_test.py index 1dceb737..6f754399 100644 --- a/tests/backends/local/translator_test.py +++ b/tests/backends/local/translator_test.py @@ -9,6 +9,7 @@ from mopidy.models import Track, Artist, Album from tests import unittest, path_to_data_dir +data_dir = path_to_data_dir('') song1_path = path_to_data_dir('song1.mp3') song2_path = path_to_data_dir('song2.mp3') encoded_path = path_to_data_dir(u'æøå.mp3') @@ -21,22 +22,32 @@ encoded_uri = path_to_uri(encoded_path) class M3UToUriTest(unittest.TestCase): def test_empty_file(self): - uris = parse_m3u(path_to_data_dir('empty.m3u')) + uris = parse_m3u(path_to_data_dir('empty.m3u'), data_dir) self.assertEqual([], uris) def test_basic_file(self): - uris = parse_m3u(path_to_data_dir('one.m3u')) + uris = parse_m3u(path_to_data_dir('one.m3u'), data_dir) self.assertEqual([song1_uri], uris) def test_file_with_comment(self): - uris = parse_m3u(path_to_data_dir('comment.m3u')) + uris = parse_m3u(path_to_data_dir('comment.m3u'), data_dir) self.assertEqual([song1_uri], uris) + def test_file_is_relative_to_correct_folder(self): + with tempfile.NamedTemporaryFile(delete=False) as tmp: + tmp.write('song1.mp3') + try: + uris = parse_m3u(tmp.name, data_dir) + self.assertEqual([song1_uri], uris) + finally: + if os.path.exists(tmp.name): + os.remove(tmp.name) + def test_file_with_absolute_files(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_path) try: - uris = parse_m3u(tmp.name) + uris = parse_m3u(tmp.name, data_dir) self.assertEqual([song1_uri], uris) finally: if os.path.exists(tmp.name): @@ -44,50 +55,53 @@ class M3UToUriTest(unittest.TestCase): def test_file_with_multiple_absolute_files(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: - tmp.write(song1_path+'\n') + tmp.write(song1_path + '\n') tmp.write('# comment \n') tmp.write(song2_path) try: - uris = parse_m3u(tmp.name) + uris = parse_m3u(tmp.name, data_dir) self.assertEqual([song1_uri, song2_uri], uris) finally: if os.path.exists(tmp.name): os.remove(tmp.name) - def test_file_with_uri(self): with tempfile.NamedTemporaryFile(delete=False) as tmp: tmp.write(song1_uri) try: - uris = parse_m3u(tmp.name) + uris = parse_m3u(tmp.name, data_dir) self.assertEqual([song1_uri], uris) finally: if os.path.exists(tmp.name): os.remove(tmp.name) def test_encoding_is_latin1(self): - uris = parse_m3u(path_to_data_dir('encoding.m3u')) + uris = parse_m3u(path_to_data_dir('encoding.m3u'), data_dir) self.assertEqual([encoded_uri], uris) def test_open_missing_file(self): - uris = parse_m3u(path_to_data_dir('non-existant.m3u')) + uris = parse_m3u(path_to_data_dir('non-existant.m3u'), data_dir) self.assertEqual([], uris) class URItoM3UTest(unittest.TestCase): pass + expected_artists = [Artist(name='name')] -expected_albums = [Album(name='albumname', artists=expected_artists, - num_tracks=2)] +expected_albums = [ + Album(name='albumname', artists=expected_artists, num_tracks=2)] expected_tracks = [] + def generate_track(path, ident): uri = path_to_uri(path_to_data_dir(path)) - track = Track(name='trackname', artists=expected_artists, track_no=1, + track = Track( + name='trackname', artists=expected_artists, track_no=1, album=expected_albums[0], length=4000, uri=uri) expected_tracks.append(track) + generate_track('song1.mp3', 6) generate_track('song2.mp3', 7) generate_track('song3.mp3', 8) @@ -98,34 +112,36 @@ generate_track('subdir2/song7.mp3', 5) generate_track('subdir1/subsubdir/song8.mp3', 0) generate_track('subdir1/subsubdir/song9.mp3', 1) + class MPDTagCacheToTracksTest(unittest.TestCase): def test_emtpy_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('empty_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('empty_tag_cache'), path_to_data_dir('')) self.assertEqual(set(), tracks) def test_simple_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('simple_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('simple_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) - track = Track(name='trackname', artists=expected_artists, track_no=1, + track = Track( + name='trackname', artists=expected_artists, track_no=1, album=expected_albums[0], length=4000, uri=uri) self.assertEqual(set([track]), tracks) def test_advanced_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('advanced_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('advanced_tag_cache'), path_to_data_dir('')) self.assertEqual(set(expected_tracks), tracks) def test_unicode_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('utf8_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('utf8_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) artists = [Artist(name=u'æøå')] album = Album(name=u'æøå', artists=artists) - track = Track(uri=uri, name=u'æøå', artists=artists, - album=album, length=4000) + track = Track( + uri=uri, name=u'æøå', artists=artists, album=album, length=4000) self.assertEqual(track, list(tracks)[0]) @@ -135,32 +151,35 @@ class MPDTagCacheToTracksTest(unittest.TestCase): pass def test_cache_with_blank_track_info(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('blank_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('blank_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) self.assertEqual(set([Track(uri=uri, length=4000)]), tracks) def test_musicbrainz_tagcache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('musicbrainz_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('musicbrainz_tag_cache'), path_to_data_dir('')) artist = list(expected_tracks[0].artists)[0].copy( musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') albumartist = list(expected_tracks[0].artists)[0].copy( name='albumartistname', musicbrainz_id='7364dea6-ca9a-48e3-be01-b44ad0d19897') - album = expected_tracks[0].album.copy(artists=[albumartist], + album = expected_tracks[0].album.copy( + artists=[albumartist], musicbrainz_id='cb5f1603-d314-4c9c-91e5-e295cfb125d2') - track = expected_tracks[0].copy(artists=[artist], album=album, + track = expected_tracks[0].copy( + artists=[artist], album=album, musicbrainz_id='90488461-8c1f-4a4e-826b-4c6dc70801f0') self.assertEqual(track, list(tracks)[0]) def test_albumartist_tag_cache(self): - tracks = parse_mpd_tag_cache(path_to_data_dir('albumartist_tag_cache'), - path_to_data_dir('')) + tracks = parse_mpd_tag_cache( + path_to_data_dir('albumartist_tag_cache'), path_to_data_dir('')) uri = path_to_uri(path_to_data_dir('song1.mp3')) artist = Artist(name='albumartistname') album = expected_albums[0].copy(artists=[artist]) - track = Track(name='trackname', artists=expected_artists, track_no=1, + track = Track( + name='trackname', artists=expected_artists, track_no=1, album=album, length=4000, uri=uri) self.assertEqual(track, list(tracks)[0]) diff --git a/tests/core/actor_test.py b/tests/core/actor_test.py new file mode 100644 index 00000000..8212c1da --- /dev/null +++ b/tests/core/actor_test.py @@ -0,0 +1,35 @@ +import mock +import pykka + +from mopidy.core import Core + +from tests import unittest + + +class CoreActorTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + def test_uri_schemes_has_uris_from_all_backends(self): + result = self.core.uri_schemes + + self.assertIn('dummy1', result) + self.assertIn('dummy2', result) + + def test_backends_with_colliding_uri_schemes_fails(self): + self.backend1.__class__.__name__ = 'B1' + self.backend2.__class__.__name__ = 'B2' + self.backend2.uri_schemes.get.return_value = ['dummy1', 'dummy2'] + self.assertRaisesRegexp( + AssertionError, + 'Cannot add URI scheme dummy1 for B2, it is already handled by B1', + Core, audio=None, backends=[self.backend1, self.backend2]) diff --git a/tests/core/library_test.py b/tests/core/library_test.py new file mode 100644 index 00000000..04f19909 --- /dev/null +++ b/tests/core/library_test.py @@ -0,0 +1,82 @@ +import mock + +from mopidy.backends import base +from mopidy.core import Core +from mopidy.models import Playlist, Track + +from tests import unittest + + +class CoreLibraryTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.library1 = mock.Mock(spec=base.BaseLibraryProvider) + self.backend1.library = self.library1 + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.library2 = mock.Mock(spec=base.BaseLibraryProvider) + self.backend2.library = self.library2 + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + + def test_lookup_selects_dummy1_backend(self): + self.core.library.lookup('dummy1:a') + + self.library1.lookup.assert_called_once_with('dummy1:a') + self.assertFalse(self.library2.lookup.called) + + def test_lookup_selects_dummy2_backend(self): + self.core.library.lookup('dummy2:a') + + self.assertFalse(self.library1.lookup.called) + self.library2.lookup.assert_called_once_with('dummy2:a') + + def test_refresh_with_uri_selects_dummy1_backend(self): + self.core.library.refresh('dummy1:a') + + self.library1.refresh.assert_called_once_with('dummy1:a') + self.assertFalse(self.library2.refresh.called) + + def test_refresh_with_uri_selects_dummy2_backend(self): + self.core.library.refresh('dummy2:a') + + self.assertFalse(self.library1.refresh.called) + self.library2.refresh.assert_called_once_with('dummy2:a') + + def test_refresh_without_uri_calls_all_backends(self): + self.core.library.refresh() + + self.library1.refresh.assert_called_once_with(None) + self.library2.refresh.assert_called_once_with(None) + + def test_find_exact_combines_results_from_all_backends(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + self.library1.find_exact().get.return_value = Playlist(tracks=[track1]) + self.library1.find_exact.reset_mock() + self.library2.find_exact().get.return_value = Playlist(tracks=[track2]) + self.library2.find_exact.reset_mock() + + result = self.core.library.find_exact(any=['a']) + + self.assertIn(track1, result.tracks) + self.assertIn(track2, result.tracks) + self.library1.find_exact.assert_called_once_with(any=['a']) + self.library2.find_exact.assert_called_once_with(any=['a']) + + def test_search_combines_results_from_all_backends(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + self.library1.search().get.return_value = Playlist(tracks=[track1]) + self.library1.search.reset_mock() + self.library2.search().get.return_value = Playlist(tracks=[track2]) + self.library2.search.reset_mock() + + result = self.core.library.search(any=['a']) + + self.assertIn(track1, result.tracks) + self.assertIn(track2, result.tracks) + self.library1.search.assert_called_once_with(any=['a']) + self.library2.search.assert_called_once_with(any=['a']) diff --git a/tests/listeners_test.py b/tests/core/listener_test.py similarity index 79% rename from tests/listeners_test.py rename to tests/core/listener_test.py index 486dcf9c..2abd9479 100644 --- a/tests/listeners_test.py +++ b/tests/core/listener_test.py @@ -1,12 +1,12 @@ -from mopidy.listeners import BackendListener +from mopidy.core import CoreListener, PlaybackState from mopidy.models import Track from tests import unittest -class BackendListenerTest(unittest.TestCase): +class CoreListenerTest(unittest.TestCase): def setUp(self): - self.listener = BackendListener() + self.listener = CoreListener() def test_listener_has_default_impl_for_track_playback_paused(self): self.listener.track_playback_paused(Track(), 0) @@ -21,7 +21,8 @@ class BackendListenerTest(unittest.TestCase): self.listener.track_playback_ended(Track(), 0) def test_listener_has_default_impl_for_playback_state_changed(self): - self.listener.playback_state_changed() + self.listener.playback_state_changed( + PlaybackState.STOPPED, PlaybackState.PLAYING) def test_listener_has_default_impl_for_playlist_changed(self): self.listener.playlist_changed() @@ -33,4 +34,4 @@ class BackendListenerTest(unittest.TestCase): self.listener.volume_changed() def test_listener_has_default_impl_for_seeked(self): - self.listener.seeked() + self.listener.seeked(0) diff --git a/tests/core/playback_test.py b/tests/core/playback_test.py new file mode 100644 index 00000000..b3a75773 --- /dev/null +++ b/tests/core/playback_test.py @@ -0,0 +1,118 @@ +import mock + +from mopidy.backends import base +from mopidy.core import Core +from mopidy.models import Track + +from tests import unittest + + +class CorePlaybackTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.playback1 = mock.Mock(spec=base.BasePlaybackProvider) + self.backend1.playback = self.playback1 + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.playback2 = mock.Mock(spec=base.BasePlaybackProvider) + self.backend2.playback = self.playback2 + + self.tracks = [ + Track(uri='dummy1://foo', length=40000), + Track(uri='dummy1://bar', length=40000), + Track(uri='dummy2://foo', length=40000), + Track(uri='dummy2://bar', length=40000), + ] + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + self.core.current_playlist.append(self.tracks) + + self.cp_tracks = self.core.current_playlist.cp_tracks + + def test_play_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + + self.playback1.play.assert_called_once_with(self.tracks[0]) + self.assertFalse(self.playback2.play.called) + + def test_play_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + + self.assertFalse(self.playback1.play.called) + self.playback2.play.assert_called_once_with(self.tracks[2]) + + def test_pause_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.pause() + + self.playback1.pause.assert_called_once_with() + self.assertFalse(self.playback2.pause.called) + + def test_pause_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.pause() + + self.assertFalse(self.playback1.pause.called) + self.playback2.pause.assert_called_once_with() + + def test_resume_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.pause() + self.core.playback.resume() + + self.playback1.resume.assert_called_once_with() + self.assertFalse(self.playback2.resume.called) + + def test_resume_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.pause() + self.core.playback.resume() + + self.assertFalse(self.playback1.resume.called) + self.playback2.resume.assert_called_once_with() + + def test_stop_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.stop() + + self.playback1.stop.assert_called_once_with() + self.assertFalse(self.playback2.stop.called) + + def test_stop_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.stop() + + self.assertFalse(self.playback1.stop.called) + self.playback2.stop.assert_called_once_with() + + def test_seek_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.seek(10000) + + self.playback1.seek.assert_called_once_with(10000) + self.assertFalse(self.playback2.seek.called) + + def test_seek_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.seek(10000) + + self.assertFalse(self.playback1.seek.called) + self.playback2.seek.assert_called_once_with(10000) + + def test_time_position_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.seek(10000) + self.core.playback.time_position + + self.playback1.get_time_position.assert_called_once_with() + self.assertFalse(self.playback2.get_time_position.called) + + def test_time_position_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.seek(10000) + self.core.playback.time_position + + self.assertFalse(self.playback1.get_time_position.called) + self.playback2.get_time_position.assert_called_once_with() diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py new file mode 100644 index 00000000..b0d48512 --- /dev/null +++ b/tests/core/stored_playlists_test.py @@ -0,0 +1,144 @@ +import mock + +from mopidy.backends import base +from mopidy.core import Core +from mopidy.models import Playlist, Track + +from tests import unittest + + +class StoredPlaylistsTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.sp1 = mock.Mock(spec=base.BaseStoredPlaylistsProvider) + self.backend1.stored_playlists = self.sp1 + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.sp2 = mock.Mock(spec=base.BaseStoredPlaylistsProvider) + self.backend2.stored_playlists = self.sp2 + + self.pl1a = Playlist(tracks=[Track(uri='dummy1:a')]) + self.pl1b = Playlist(tracks=[Track(uri='dummy1:b')]) + self.sp1.playlists.get.return_value = [self.pl1a, self.pl1b] + + self.pl2a = Playlist(tracks=[Track(uri='dummy2:a')]) + self.pl2b = Playlist(tracks=[Track(uri='dummy2:b')]) + self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + + def test_get_playlists_combines_result_from_backends(self): + result = self.core.stored_playlists.playlists + + self.assertIn(self.pl1a, result) + self.assertIn(self.pl1b, result) + self.assertIn(self.pl2a, result) + self.assertIn(self.pl2b, result) + + def test_create_without_uri_scheme_uses_first_backend(self): + playlist = Playlist() + self.sp1.create().get.return_value = playlist + self.sp1.reset_mock() + + result = self.core.stored_playlists.create('foo') + + self.assertEqual(playlist, result) + self.sp1.create.assert_called_once_with('foo') + self.assertFalse(self.sp2.create.called) + + def test_create_with_uri_scheme_selects_the_matching_backend(self): + playlist = Playlist() + self.sp2.create().get.return_value = playlist + self.sp2.reset_mock() + + result = self.core.stored_playlists.create('foo', uri_scheme='dummy2') + + self.assertEqual(playlist, result) + self.assertFalse(self.sp1.create.called) + self.sp2.create.assert_called_once_with('foo') + + def test_delete_selects_the_dummy1_backend(self): + self.core.stored_playlists.delete('dummy1:a') + + self.sp1.delete.assert_called_once_with('dummy1:a') + self.assertFalse(self.sp2.delete.called) + + def test_delete_selects_the_dummy2_backend(self): + self.core.stored_playlists.delete('dummy2:a') + + self.assertFalse(self.sp1.delete.called) + self.sp2.delete.assert_called_once_with('dummy2:a') + + def test_delete_with_unknown_uri_scheme_does_nothing(self): + self.core.stored_playlists.delete('unknown:a') + + self.assertFalse(self.sp1.delete.called) + self.assertFalse(self.sp2.delete.called) + + def test_lookup_selects_the_dummy1_backend(self): + self.core.stored_playlists.lookup('dummy1:a') + + self.sp1.lookup.assert_called_once_with('dummy1:a') + self.assertFalse(self.sp2.lookup.called) + + def test_lookup_selects_the_dummy2_backend(self): + self.core.stored_playlists.lookup('dummy2:a') + + self.assertFalse(self.sp1.lookup.called) + self.sp2.lookup.assert_called_once_with('dummy2:a') + + def test_refresh_without_uri_scheme_refreshes_all_backends(self): + self.core.stored_playlists.refresh() + + self.sp1.refresh.assert_called_once_with() + self.sp2.refresh.assert_called_once_with() + + def test_refresh_with_uri_scheme_refreshes_matching_backend(self): + self.core.stored_playlists.refresh(uri_scheme='dummy2') + + self.assertFalse(self.sp1.refresh.called) + self.sp2.refresh.assert_called_once_with() + + def test_refresh_with_unknown_uri_scheme_refreshes_nothing(self): + self.core.stored_playlists.refresh(uri_scheme='foobar') + + self.assertFalse(self.sp1.refresh.called) + self.assertFalse(self.sp2.refresh.called) + + def test_save_selects_the_dummy1_backend(self): + playlist = Playlist(uri='dummy1:a') + self.sp1.save().get.return_value = playlist + self.sp1.reset_mock() + + result = self.core.stored_playlists.save(playlist) + + self.assertEqual(playlist, result) + self.sp1.save.assert_called_once_with(playlist) + self.assertFalse(self.sp2.save.called) + + def test_save_selects_the_dummy2_backend(self): + playlist = Playlist(uri='dummy2:a') + self.sp2.save().get.return_value = playlist + self.sp2.reset_mock() + + result = self.core.stored_playlists.save(playlist) + + self.assertEqual(playlist, result) + self.assertFalse(self.sp1.save.called) + self.sp2.save.assert_called_once_with(playlist) + + def test_save_does_nothing_if_playlist_uri_is_unset(self): + result = self.core.stored_playlists.save(Playlist()) + + self.assertIsNone(result) + self.assertFalse(self.sp1.save.called) + self.assertFalse(self.sp2.save.called) + + def test_save_does_nothing_if_playlist_uri_has_unknown_scheme(self): + result = self.core.stored_playlists.save(Playlist(uri='foobar:a')) + + self.assertIsNone(result) + self.assertFalse(self.sp1.save.called) + self.assertFalse(self.sp2.save.called) diff --git a/tests/data/.blank.mp3 b/tests/data/.blank.mp3 new file mode 100644 index 00000000..ef159a70 Binary files /dev/null and b/tests/data/.blank.mp3 differ diff --git a/tests/data/.hidden/.gitignore b/tests/data/.hidden/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 9f05d7dd..9b047641 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -1,4 +1,7 @@ -from mopidy.backends.dummy import DummyBackend +import pykka + +from mopidy import core +from mopidy.backends import dummy from mopidy.frontends.mpd.dispatcher import MpdDispatcher from mopidy.frontends.mpd.exceptions import MpdAckError from mopidy.frontends.mpd.protocol import request_handlers, handle_request @@ -8,11 +11,12 @@ from tests import unittest class MpdDispatcherTest(unittest.TestCase): def setUp(self): - self.backend = DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() self.dispatcher = MpdDispatcher() def tearDown(self): - self.backend.stop().get() + pykka.ActorRegistry.stop_all() def test_register_same_pattern_twice_fails(self): func = lambda: None @@ -28,14 +32,16 @@ class MpdDispatcherTest(unittest.TestCase): self.dispatcher._find_handler('an_unknown_command with args') self.fail('Should raise exception') except MpdAckError as e: - self.assertEqual(e.get_mpd_ack(), + self.assertEqual( + e.get_mpd_ack(), u'ACK [5@0] {} unknown command "an_unknown_command"') - def test_finding_handler_for_known_command_returns_handler_and_kwargs(self): + def test_find_handler_for_known_command_returns_handler_and_kwargs(self): expected_handler = lambda x: None request_handlers['known_command (?P.+)'] = \ expected_handler - (handler, kwargs) = self.dispatcher._find_handler('known_command an_arg') + (handler, kwargs) = self.dispatcher._find_handler( + 'known_command an_arg') self.assertEqual(handler, expected_handler) self.assertIn('arg1', kwargs) self.assertEqual(kwargs['arg1'], 'an_arg') diff --git a/tests/frontends/mpd/exception_test.py b/tests/frontends/mpd/exception_test.py index 2ea3fe62..8fb0c933 100644 --- a/tests/frontends/mpd/exception_test.py +++ b/tests/frontends/mpd/exception_test.py @@ -1,5 +1,6 @@ -from mopidy.frontends.mpd.exceptions import (MpdAckError, MpdPermissionError, - MpdUnknownCommand, MpdSystemError, MpdNotImplemented) +from mopidy.frontends.mpd.exceptions import ( + MpdAckError, MpdPermissionError, MpdUnknownCommand, MpdSystemError, + MpdNotImplemented) from tests import unittest @@ -34,19 +35,22 @@ class MpdExceptionsTest(unittest.TestCase): try: raise MpdUnknownCommand(command=u'play') except MpdAckError as e: - self.assertEqual(e.get_mpd_ack(), + self.assertEqual( + e.get_mpd_ack(), u'ACK [5@0] {} unknown command "play"') def test_mpd_system_error(self): try: raise MpdSystemError('foo') except MpdSystemError as e: - self.assertEqual(e.get_mpd_ack(), + self.assertEqual( + e.get_mpd_ack(), u'ACK [52@0] {} foo') def test_mpd_permission_error(self): try: raise MpdPermissionError(command='foo') except MpdPermissionError as e: - self.assertEqual(e.get_mpd_ack(), + self.assertEqual( + e.get_mpd_ack(), u'ACK [4@0] {foo} you don\'t have permission for "foo"') diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index 3b8fbe33..f7b055fc 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -1,8 +1,9 @@ import mock +import pykka -from mopidy import settings -from mopidy.backends import dummy as backend -from mopidy.frontends import mpd +from mopidy import core, settings +from mopidy.backends import dummy +from mopidy.frontends.mpd import session from tests import unittest @@ -21,15 +22,16 @@ class MockConnection(mock.Mock): class BaseTestCase(unittest.TestCase): def setUp(self): - self.backend = backend.DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() self.connection = MockConnection() - self.session = mpd.MpdSession(self.connection) + self.session = session.MpdSession(self.connection, core=self.core) self.dispatcher = self.session.dispatcher self.context = self.dispatcher.context def tearDown(self): - self.backend.stop().get() + pykka.ActorRegistry.stop_all() settings.runtime.clear() def sendRequest(self, request): @@ -42,17 +44,23 @@ class BaseTestCase(unittest.TestCase): self.assertEqual([], self.connection.response) def assertInResponse(self, value): - self.assertIn(value, self.connection.response, u'Did not find %s ' - 'in %s' % (repr(value), repr(self.connection.response))) + self.assertIn( + value, self.connection.response, + u'Did not find %s in %s' % ( + repr(value), repr(self.connection.response))) def assertOnceInResponse(self, value): matched = len([r for r in self.connection.response if r == value]) - self.assertEqual(1, matched, 'Expected to find %s once in %s' % - (repr(value), repr(self.connection.response))) + self.assertEqual( + 1, matched, + u'Expected to find %s once in %s' % ( + repr(value), repr(self.connection.response))) def assertNotInResponse(self, value): - self.assertNotIn(value, self.connection.response, u'Found %s in %s' % - (repr(value), repr(self.connection.response))) + self.assertNotIn( + value, self.connection.response, + u'Found %s in %s' % ( + repr(value), repr(self.connection.response))) def assertEqualResponse(self, value): self.assertEqual(1, len(self.connection.response)) diff --git a/tests/frontends/mpd/protocol/authentication_test.py b/tests/frontends/mpd/protocol/authentication_test.py index 20422f5b..0f0d9c86 100644 --- a/tests/frontends/mpd/protocol/authentication_test.py +++ b/tests/frontends/mpd/protocol/authentication_test.py @@ -38,7 +38,6 @@ class AuthenticationTest(protocol.BaseTestCase): self.sendRequest(u'close') self.assertFalse(self.dispatcher.authenticated) - self.assertInResponse(u'OK') def test_commands_is_allowed_without_authentication(self): settings.MPD_SERVER_PASSWORD = u'topsecret' diff --git a/tests/frontends/mpd/protocol/command_list_test.py b/tests/frontends/mpd/protocol/command_list_test.py index 65b051d3..dbd7f9c9 100644 --- a/tests/frontends/mpd/protocol/command_list_test.py +++ b/tests/frontends/mpd/protocol/command_list_test.py @@ -18,18 +18,23 @@ class CommandListsTest(protocol.BaseTestCase): def test_command_list_with_ping(self): self.sendRequest(u'command_list_begin') + self.assertTrue(self.dispatcher.command_list_receiving) + self.assertFalse(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) - self.assertEqual(False, self.dispatcher.command_list_ok) + self.sendRequest(u'ping') self.assertIn(u'ping', self.dispatcher.command_list) + self.sendRequest(u'command_list_end') self.assertInResponse(u'OK') - self.assertEqual(False, self.dispatcher.command_list) + self.assertFalse(self.dispatcher.command_list_receiving) + self.assertFalse(self.dispatcher.command_list_ok) + self.assertEqual([], self.dispatcher.command_list) def test_command_list_with_error_returns_ack_with_correct_index(self): self.sendRequest(u'command_list_begin') - self.sendRequest(u'play') # Known command - self.sendRequest(u'paly') # Unknown command + self.sendRequest(u'play') # Known command + self.sendRequest(u'paly') # Unknown command self.sendRequest(u'command_list_end') self.assertEqualResponse(u'ACK [5@1] {} unknown command "paly"') @@ -39,15 +44,19 @@ class CommandListsTest(protocol.BaseTestCase): def test_command_list_ok_with_ping(self): self.sendRequest(u'command_list_ok_begin') + self.assertTrue(self.dispatcher.command_list_receiving) + self.assertTrue(self.dispatcher.command_list_ok) self.assertEqual([], self.dispatcher.command_list) - self.assertEqual(True, self.dispatcher.command_list_ok) + self.sendRequest(u'ping') self.assertIn(u'ping', self.dispatcher.command_list) + self.sendRequest(u'command_list_end') self.assertInResponse(u'list_OK') self.assertInResponse(u'OK') - self.assertEqual(False, self.dispatcher.command_list) - self.assertEqual(False, self.dispatcher.command_list_ok) + self.assertFalse(self.dispatcher.command_list_receiving) + self.assertFalse(self.dispatcher.command_list_ok) + self.assertEqual([], self.dispatcher.command_list) # FIXME this should also include the special handling of idle within a # command list. That is that once a idle/noidle command is found inside a diff --git a/tests/frontends/mpd/protocol/connection_test.py b/tests/frontends/mpd/protocol/connection_test.py index cd08313f..9b8972d3 100644 --- a/tests/frontends/mpd/protocol/connection_test.py +++ b/tests/frontends/mpd/protocol/connection_test.py @@ -8,7 +8,7 @@ from tests.frontends.mpd import protocol class ConnectionHandlerTest(protocol.BaseTestCase): def test_close_closes_the_client_connection(self): with patch.object(self.session, 'close') as close_mock: - response = self.sendRequest(u'close') + self.sendRequest(u'close') close_mock.assertEqualResponsecalled_once_with() self.assertEqualResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/current_playlist_test.py b/tests/frontends/mpd/protocol/current_playlist_test.py index 21889e82..bd58cf2d 100644 --- a/tests/frontends/mpd/protocol/current_playlist_test.py +++ b/tests/frontends/mpd/protocol/current_playlist_test.py @@ -6,15 +6,15 @@ from tests.frontends.mpd import protocol class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_add(self): needle = Track(uri='dummy://foo') - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'add "dummy://foo"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) - self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) + self.assertEqual(self.core.current_playlist.tracks.get()[5], needle) self.assertEqualResponse(u'OK') def test_add_with_uri_not_found_in_library_should_ack(self): @@ -29,17 +29,17 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_addid_without_songpos(self): needle = Track(uri='dummy://foo') - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'addid "dummy://foo"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) - self.assertEqual(self.backend.current_playlist.tracks.get()[5], needle) - self.assertInResponse(u'Id: %d' % - self.backend.current_playlist.cp_tracks.get()[5][0]) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) + self.assertEqual(self.core.current_playlist.tracks.get()[5], needle) + self.assertInResponse( + u'Id: %d' % self.core.current_playlist.cp_tracks.get()[5][0]) self.assertInResponse(u'OK') def test_addid_with_empty_uri_acks(self): @@ -48,26 +48,26 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_addid_with_songpos(self): needle = Track(uri='dummy://foo') - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'addid "dummy://foo" "3"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 6) - self.assertEqual(self.backend.current_playlist.tracks.get()[3], needle) - self.assertInResponse(u'Id: %d' % - self.backend.current_playlist.cp_tracks.get()[3][0]) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 6) + self.assertEqual(self.core.current_playlist.tracks.get()[3], needle) + self.assertInResponse( + u'Id: %d' % self.core.current_playlist.cp_tracks.get()[3][0]) self.assertInResponse(u'OK') def test_addid_with_songpos_out_of_bounds_should_ack(self): needle = Track(uri='dummy://foo') - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(), Track(), needle, Track()] - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'addid "dummy://foo" "6"') self.assertEqualResponse(u'ACK [2@0] {addid} Bad song index') @@ -77,85 +77,85 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse(u'ACK [50@0] {addid} No such song') def test_clear(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'clear') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 0) - self.assertEqual(self.backend.playback.current_track.get(), None) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 0) + self.assertEqual(self.core.playback.current_track.get(), None) self.assertInResponse(u'OK') def test_delete_songpos(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) - self.sendRequest(u'delete "%d"' % - self.backend.current_playlist.cp_tracks.get()[2][0]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 4) + self.sendRequest( + u'delete "%d"' % self.core.current_playlist.cp_tracks.get()[2][0]) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 4) self.assertInResponse(u'OK') def test_delete_songpos_out_of_bounds(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'delete "5"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.assertEqualResponse(u'ACK [2@0] {delete} Bad song index') def test_delete_open_range(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'delete "1:"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 1) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 1) self.assertInResponse(u'OK') def test_delete_closed_range(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'delete "1:3"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 3) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 3) self.assertInResponse(u'OK') def test_delete_range_out_of_bounds(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(), Track(), Track(), Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.sendRequest(u'delete "5:7"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 5) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 5) self.assertEqualResponse(u'ACK [2@0] {delete} Bad song index') def test_deleteid(self): - self.backend.current_playlist.append([Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) + self.core.current_playlist.append([Track(), Track()]) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) self.sendRequest(u'deleteid "1"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 1) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 1) self.assertInResponse(u'OK') def test_deleteid_does_not_exist(self): - self.backend.current_playlist.append([Track(), Track()]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) + self.core.current_playlist.append([Track(), Track()]) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) self.sendRequest(u'deleteid "12345"') - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) self.assertEqualResponse(u'ACK [50@0] {deleteid} No such song') def test_move_songpos(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'move "1" "0"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'a') self.assertEqual(tracks[2].name, 'c') @@ -165,13 +165,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_move_open_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'move "2:" "0"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'c') self.assertEqual(tracks[1].name, 'd') self.assertEqual(tracks[2].name, 'e') @@ -181,13 +181,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_move_closed_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'move "1:3" "0"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'b') self.assertEqual(tracks[1].name, 'c') self.assertEqual(tracks[2].name, 'a') @@ -197,13 +197,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_moveid(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'moveid "4" "2"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[2].name, 'e') @@ -230,17 +230,17 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqualResponse(u'OK') def test_playlistfind_by_filename_in_current_playlist(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(uri='file:///exists')]) - self.sendRequest( u'playlistfind filename "file:///exists"') + self.sendRequest(u'playlistfind filename "file:///exists"') self.assertInResponse(u'file: file:///exists') self.assertInResponse(u'Id: 0') self.assertInResponse(u'Pos: 0') self.assertInResponse(u'OK') def test_playlistid_without_songid(self): - self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) + self.core.current_playlist.append([Track(name='a'), Track(name='b')]) self.sendRequest(u'playlistid') self.assertInResponse(u'Title: a') @@ -248,7 +248,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistid_with_songid(self): - self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) + self.core.current_playlist.append([Track(name='a'), Track(name='b')]) self.sendRequest(u'playlistid "1"') self.assertNotInResponse(u'Title: a') @@ -258,13 +258,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistid_with_not_existing_songid_fails(self): - self.backend.current_playlist.append([Track(name='a'), Track(name='b')]) + self.core.current_playlist.append([Track(name='a'), Track(name='b')]) self.sendRequest(u'playlistid "25"') self.assertEqualResponse(u'ACK [50@0] {playlistid} No such song') def test_playlistinfo_without_songpos_or_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -286,8 +286,8 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): def test_playlistinfo_with_songpos(self): # Make the track's CPID not match the playlist position - self.backend.current_playlist.cp_id = 17 - self.backend.current_playlist.append([ + self.core.current_playlist.cp_id = 17 + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -313,7 +313,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertEqual(response1, response2) def test_playlistinfo_with_open_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -334,7 +334,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistinfo_with_closed_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) @@ -357,15 +357,15 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_playlistsearch(self): - self.sendRequest( u'playlistsearch "any" "needle"') + self.sendRequest(u'playlistsearch "any" "needle"') self.assertEqualResponse(u'ACK [0@0] {} Not implemented') def test_playlistsearch_without_quotes(self): self.sendRequest(u'playlistsearch any "needle"') self.assertEqualResponse(u'ACK [0@0] {} Not implemented') - def test_plchanges(self): - self.backend.current_playlist.append( + def test_plchanges_with_lower_version_returns_changes(self): + self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest(u'plchanges "0"') @@ -374,8 +374,30 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'Title: c') self.assertInResponse(u'OK') + def test_plchanges_with_equal_version_returns_nothing(self): + self.core.current_playlist.append( + [Track(name='a'), Track(name='b'), Track(name='c')]) + + self.assertEqual(self.core.current_playlist.version.get(), 1) + self.sendRequest(u'plchanges "1"') + self.assertNotInResponse(u'Title: a') + self.assertNotInResponse(u'Title: b') + self.assertNotInResponse(u'Title: c') + self.assertInResponse(u'OK') + + def test_plchanges_with_greater_version_returns_nothing(self): + self.core.current_playlist.append( + [Track(name='a'), Track(name='b'), Track(name='c')]) + + self.assertEqual(self.core.current_playlist.version.get(), 1) + self.sendRequest(u'plchanges "2"') + self.assertNotInResponse(u'Title: a') + self.assertNotInResponse(u'Title: b') + self.assertNotInResponse(u'Title: c') + self.assertInResponse(u'OK') + def test_plchanges_with_minus_one_returns_entire_playlist(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest(u'plchanges "-1"') @@ -385,7 +407,7 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_plchanges_without_quotes_works(self): - self.backend.current_playlist.append( + self.core.current_playlist.append( [Track(name='a'), Track(name='b'), Track(name='c')]) self.sendRequest(u'plchanges 0') @@ -395,10 +417,10 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_plchangesposid(self): - self.backend.current_playlist.append([Track(), Track(), Track()]) + self.core.current_playlist.append([Track(), Track(), Track()]) self.sendRequest(u'plchangesposid "0"') - cp_tracks = self.backend.current_playlist.cp_tracks.get() + cp_tracks = self.core.current_playlist.cp_tracks.get() self.assertInResponse(u'cpos: 0') self.assertInResponse(u'Id: %d' % cp_tracks[0][0]) self.assertInResponse(u'cpos: 2') @@ -408,26 +430,26 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_shuffle_without_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.backend.current_playlist.version.get() + version = self.core.current_playlist.version.get() self.sendRequest(u'shuffle') - self.assert_(version < self.backend.current_playlist.version.get()) + self.assertLess(version, self.core.current_playlist.version.get()) self.assertInResponse(u'OK') def test_shuffle_with_open_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.backend.current_playlist.version.get() + version = self.core.current_playlist.version.get() self.sendRequest(u'shuffle "4:"') - self.assert_(version < self.backend.current_playlist.version.get()) - tracks = self.backend.current_playlist.tracks.get() + self.assertLess(version, self.core.current_playlist.version.get()) + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'b') self.assertEqual(tracks[2].name, 'c') @@ -435,15 +457,15 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_shuffle_with_closed_range(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) - version = self.backend.current_playlist.version.get() + version = self.core.current_playlist.version.get() self.sendRequest(u'shuffle "1:3"') - self.assert_(version < self.backend.current_playlist.version.get()) - tracks = self.backend.current_playlist.tracks.get() + self.assertLess(version, self.core.current_playlist.version.get()) + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[3].name, 'd') self.assertEqual(tracks[4].name, 'e') @@ -451,13 +473,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_swap(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'swap "1" "4"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') self.assertEqual(tracks[2].name, 'c') @@ -467,13 +489,13 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_swapid(self): - self.backend.current_playlist.append([ + self.core.current_playlist.append([ Track(name='a'), Track(name='b'), Track(name='c'), Track(name='d'), Track(name='e'), Track(name='f'), ]) self.sendRequest(u'swapid "1" "4"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(tracks[0].name, 'a') self.assertEqual(tracks[1].name, 'e') self.assertEqual(tracks[2].name, 'c') diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 88452d3d..202ac649 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -13,22 +13,22 @@ STOPPED = PlaybackState.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_consume_off(self): self.sendRequest(u'consume "0"') - self.assertFalse(self.backend.playback.consume.get()) + self.assertFalse(self.core.playback.consume.get()) self.assertInResponse(u'OK') def test_consume_off_without_quotes(self): self.sendRequest(u'consume 0') - self.assertFalse(self.backend.playback.consume.get()) + self.assertFalse(self.core.playback.consume.get()) self.assertInResponse(u'OK') def test_consume_on(self): self.sendRequest(u'consume "1"') - self.assertTrue(self.backend.playback.consume.get()) + self.assertTrue(self.core.playback.consume.get()) self.assertInResponse(u'OK') def test_consume_on_without_quotes(self): self.sendRequest(u'consume 1') - self.assertTrue(self.backend.playback.consume.get()) + self.assertTrue(self.core.playback.consume.get()) self.assertInResponse(u'OK') def test_crossfade(self): @@ -37,97 +37,97 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): def test_random_off(self): self.sendRequest(u'random "0"') - self.assertFalse(self.backend.playback.random.get()) + self.assertFalse(self.core.playback.random.get()) self.assertInResponse(u'OK') def test_random_off_without_quotes(self): self.sendRequest(u'random 0') - self.assertFalse(self.backend.playback.random.get()) + self.assertFalse(self.core.playback.random.get()) self.assertInResponse(u'OK') def test_random_on(self): self.sendRequest(u'random "1"') - self.assertTrue(self.backend.playback.random.get()) + self.assertTrue(self.core.playback.random.get()) self.assertInResponse(u'OK') def test_random_on_without_quotes(self): self.sendRequest(u'random 1') - self.assertTrue(self.backend.playback.random.get()) + self.assertTrue(self.core.playback.random.get()) self.assertInResponse(u'OK') def test_repeat_off(self): self.sendRequest(u'repeat "0"') - self.assertFalse(self.backend.playback.repeat.get()) + self.assertFalse(self.core.playback.repeat.get()) self.assertInResponse(u'OK') def test_repeat_off_without_quotes(self): self.sendRequest(u'repeat 0') - self.assertFalse(self.backend.playback.repeat.get()) + self.assertFalse(self.core.playback.repeat.get()) self.assertInResponse(u'OK') def test_repeat_on(self): self.sendRequest(u'repeat "1"') - self.assertTrue(self.backend.playback.repeat.get()) + self.assertTrue(self.core.playback.repeat.get()) self.assertInResponse(u'OK') def test_repeat_on_without_quotes(self): self.sendRequest(u'repeat 1') - self.assertTrue(self.backend.playback.repeat.get()) + self.assertTrue(self.core.playback.repeat.get()) self.assertInResponse(u'OK') def test_setvol_below_min(self): self.sendRequest(u'setvol "-10"') - self.assertEqual(0, self.backend.playback.volume.get()) + self.assertEqual(0, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_min(self): self.sendRequest(u'setvol "0"') - self.assertEqual(0, self.backend.playback.volume.get()) + self.assertEqual(0, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_middle(self): self.sendRequest(u'setvol "50"') - self.assertEqual(50, self.backend.playback.volume.get()) + self.assertEqual(50, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_max(self): self.sendRequest(u'setvol "100"') - self.assertEqual(100, self.backend.playback.volume.get()) + self.assertEqual(100, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_above_max(self): self.sendRequest(u'setvol "110"') - self.assertEqual(100, self.backend.playback.volume.get()) + self.assertEqual(100, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_plus_is_ignored(self): self.sendRequest(u'setvol "+10"') - self.assertEqual(10, self.backend.playback.volume.get()) + self.assertEqual(10, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_setvol_without_quotes(self): self.sendRequest(u'setvol 50') - self.assertEqual(50, self.backend.playback.volume.get()) + self.assertEqual(50, self.core.playback.volume.get()) self.assertInResponse(u'OK') def test_single_off(self): self.sendRequest(u'single "0"') - self.assertFalse(self.backend.playback.single.get()) + self.assertFalse(self.core.playback.single.get()) self.assertInResponse(u'OK') def test_single_off_without_quotes(self): self.sendRequest(u'single 0') - self.assertFalse(self.backend.playback.single.get()) + self.assertFalse(self.core.playback.single.get()) self.assertInResponse(u'OK') def test_single_on(self): self.sendRequest(u'single "1"') - self.assertTrue(self.backend.playback.single.get()) + self.assertTrue(self.core.playback.single.get()) self.assertInResponse(u'OK') def test_single_on_without_quotes(self): self.sendRequest(u'single 1') - self.assertTrue(self.backend.playback.single.get()) + self.assertTrue(self.core.playback.single.get()) self.assertInResponse(u'OK') def test_replay_gain_mode_off(self): @@ -166,183 +166,215 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_pause_off(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play "0"') self.sendRequest(u'pause "1"') self.sendRequest(u'pause "0"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_pause_on(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play "0"') self.sendRequest(u'pause "1"') - self.assertEqual(PAUSED, self.backend.playback.state.get()) + self.assertEqual(PAUSED, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_pause_toggle(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play "0"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') self.sendRequest(u'pause') - self.assertEqual(PAUSED, self.backend.playback.state.get()) + self.assertEqual(PAUSED, self.core.playback.state.get()) self.assertInResponse(u'OK') self.sendRequest(u'pause') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_without_pos(self): - self.backend.current_playlist.append([Track()]) - self.backend.playback.state = PAUSED + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_with_pos(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play "0"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_with_pos_without_quotes(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'play 0') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') def test_play_with_pos_out_of_bounds(self): - self.backend.current_playlist.append([]) + self.core.current_playlist.append([]) self.sendRequest(u'play "0"') - self.assertEqual(STOPPED, self.backend.playback.state.get()) + self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse(u'ACK [2@0] {play} Bad song index') def test_play_minus_one_plays_first_in_playlist_if_no_current_track(self): - self.assertEqual(self.backend.playback.current_track.get(), None) - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(self.core.playback.current_track.get(), None) + self.core.current_playlist.append([ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + ]) self.sendRequest(u'play "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual('a', self.backend.playback.current_track.get().uri) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertEqual('dummy:a', + self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_play_minus_one_plays_current_track_if_current_track_is_set(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(self.backend.playback.current_track.get(), None) - self.backend.playback.play() - self.backend.playback.next() - self.backend.playback.stop() - self.assertNotEqual(self.backend.playback.current_track.get(), None) + self.core.current_playlist.append([ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + ]) + self.assertEqual(self.core.playback.current_track.get(), None) + self.core.playback.play() + self.core.playback.next() + self.core.playback.stop() + self.assertNotEqual(self.core.playback.current_track.get(), None) self.sendRequest(u'play "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual('b', self.backend.playback.current_track.get().uri) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertEqual('dummy:b', + self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_play_minus_one_on_empty_playlist_does_not_ack(self): - self.backend.current_playlist.clear() + self.core.current_playlist.clear() self.sendRequest(u'play "-1"') - self.assertEqual(STOPPED, self.backend.playback.state.get()) - self.assertEqual(None, self.backend.playback.current_track.get()) + self.assertEqual(STOPPED, self.core.playback.state.get()) + self.assertEqual(None, self.core.playback.current_track.get()) self.assertInResponse(u'OK') def test_play_minus_is_ignored_if_playing(self): - self.backend.current_playlist.append([Track(length=40000)]) - self.backend.playback.seek(30000) - self.assert_(self.backend.playback.time_position.get() >= 30000) - self.assertEquals(PLAYING, self.backend.playback.state.get()) + self.core.current_playlist.append([ + Track(uri='dummy:a', length=40000)]) + self.core.playback.seek(30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) + self.assertEquals(PLAYING, self.core.playback.state.get()) self.sendRequest(u'play "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_play_minus_one_resumes_if_paused(self): - self.backend.current_playlist.append([Track(length=40000)]) - self.backend.playback.seek(30000) - self.assert_(self.backend.playback.time_position.get() >= 30000) - self.assertEquals(PLAYING, self.backend.playback.state.get()) - self.backend.playback.pause() - self.assertEquals(PAUSED, self.backend.playback.state.get()) + self.core.current_playlist.append([ + Track(uri='dummy:a', length=40000)]) + self.core.playback.seek(30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) + self.assertEquals(PLAYING, self.core.playback.state.get()) + self.core.playback.pause() + self.assertEquals(PAUSED, self.core.playback.state.get()) self.sendRequest(u'play "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'playid "0"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') - def test_playid_minus_one_plays_first_in_playlist_if_no_current_track(self): - self.assertEqual(self.backend.playback.current_track.get(), None) - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) + def test_playid_without_quotes(self): + self.core.current_playlist.append([Track(uri='dummy:a')]) - self.sendRequest(u'playid "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual('a', self.backend.playback.current_track.get().uri) + self.sendRequest(u'playid 0') + self.assertEqual(PLAYING, self.core.playback.state.get()) self.assertInResponse(u'OK') - def test_playid_minus_one_plays_current_track_if_current_track_is_set(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(self.backend.playback.current_track.get(), None) - self.backend.playback.play() - self.backend.playback.next() - self.backend.playback.stop() - self.assertNotEqual(None, self.backend.playback.current_track.get()) + def test_playid_minus_1_plays_first_in_playlist_if_no_current_track(self): + self.assertEqual(self.core.playback.current_track.get(), None) + self.core.current_playlist.append([ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + ]) self.sendRequest(u'playid "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assertEqual('b', self.backend.playback.current_track.get().uri) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertEqual('dummy:a', + self.core.playback.current_track.get().uri) + self.assertInResponse(u'OK') + + def test_playid_minus_1_plays_current_track_if_current_track_is_set(self): + self.core.current_playlist.append([ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + ]) + self.assertEqual(self.core.playback.current_track.get(), None) + self.core.playback.play() + self.core.playback.next() + self.core.playback.stop() + self.assertNotEqual(None, self.core.playback.current_track.get()) + + self.sendRequest(u'playid "-1"') + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertEqual('dummy:b', + self.core.playback.current_track.get().uri) self.assertInResponse(u'OK') def test_playid_minus_one_on_empty_playlist_does_not_ack(self): - self.backend.current_playlist.clear() + self.core.current_playlist.clear() self.sendRequest(u'playid "-1"') - self.assertEqual(STOPPED, self.backend.playback.state.get()) - self.assertEqual(None, self.backend.playback.current_track.get()) + self.assertEqual(STOPPED, self.core.playback.state.get()) + self.assertEqual(None, self.core.playback.current_track.get()) self.assertInResponse(u'OK') def test_playid_minus_is_ignored_if_playing(self): - self.backend.current_playlist.append([Track(length=40000)]) - self.backend.playback.seek(30000) - self.assert_(self.backend.playback.time_position.get() >= 30000) - self.assertEquals(PLAYING, self.backend.playback.state.get()) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.playback.seek(30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) + self.assertEquals(PLAYING, self.core.playback.state.get()) self.sendRequest(u'playid "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid_minus_one_resumes_if_paused(self): - self.backend.current_playlist.append([Track(length=40000)]) - self.backend.playback.seek(30000) - self.assert_(self.backend.playback.time_position.get() >= 30000) - self.assertEquals(PLAYING, self.backend.playback.state.get()) - self.backend.playback.pause() - self.assertEquals(PAUSED, self.backend.playback.state.get()) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.playback.seek(30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) + self.assertEquals(PLAYING, self.core.playback.state.get()) + self.core.playback.pause() + self.assertEquals(PAUSED, self.core.playback.state.get()) self.sendRequest(u'playid "-1"') - self.assertEqual(PLAYING, self.backend.playback.state.get()) - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertEqual(PLAYING, self.core.playback.state.get()) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_playid_which_does_not_exist(self): - self.backend.current_playlist.append([Track()]) + self.core.current_playlist.append([Track(uri='dummy:a')]) self.sendRequest(u'playid "12345"') self.assertInResponse(u'ACK [50@0] {playid} No such song') @@ -352,47 +384,49 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seek(self): - self.backend.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.sendRequest(u'seek "0"') self.sendRequest(u'seek "0" "30"') - self.assert_(self.backend.playback.time_position >= 30000) + self.assertGreaterEqual(self.core.playback.time_position, 30000) self.assertInResponse(u'OK') def test_seek_with_songpos(self): - seek_track = Track(uri='2', length=40000) - self.backend.current_playlist.append( - [Track(uri='1', length=40000), seek_track]) + seek_track = Track(uri='dummy:b', length=40000) + self.core.current_playlist.append( + [Track(uri='dummy:a', length=40000), seek_track]) self.sendRequest(u'seek "1" "30"') - self.assertEqual(self.backend.playback.current_track.get(), seek_track) + self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertInResponse(u'OK') def test_seek_without_quotes(self): - self.backend.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.sendRequest(u'seek 0') self.sendRequest(u'seek 0 30') - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_seekid(self): - self.backend.current_playlist.append([Track(length=40000)]) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) self.sendRequest(u'seekid "0" "30"') - self.assert_(self.backend.playback.time_position.get() >= 30000) + self.assertGreaterEqual( + self.core.playback.time_position.get(), 30000) self.assertInResponse(u'OK') def test_seekid_with_cpid(self): - seek_track = Track(uri='2', length=40000) - self.backend.current_playlist.append( - [Track(length=40000), seek_track]) + seek_track = Track(uri='dummy:b', length=40000) + self.core.current_playlist.append( + [Track(uri='dummy:a', length=40000), seek_track]) self.sendRequest(u'seekid "1" "30"') - self.assertEqual(1, self.backend.playback.current_cpid.get()) - self.assertEqual(seek_track, self.backend.playback.current_track.get()) + self.assertEqual(1, self.core.playback.current_cpid.get()) + self.assertEqual(seek_track, self.core.playback.current_track.get()) self.assertInResponse(u'OK') def test_stop(self): self.sendRequest(u'stop') - self.assertEqual(STOPPED, self.backend.playback.state.get()) + self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/regression_test.py b/tests/frontends/mpd/protocol/regression_test.py index 7f214efa..a90e37ab 100644 --- a/tests/frontends/mpd/protocol/regression_test.py +++ b/tests/frontends/mpd/protocol/regression_test.py @@ -16,23 +16,33 @@ class IssueGH17RegressionTest(protocol.BaseTestCase): - Press next until you get to the unplayable track """ def test(self): - self.backend.current_playlist.append([ - Track(uri='a'), Track(uri='b'), None, - Track(uri='d'), Track(uri='e'), Track(uri='f')]) - random.seed(1) # Playlist order: abcfde + self.core.current_playlist.append([ + Track(uri='dummy:a'), + Track(uri='dummy:b'), + Track(uri='dummy:error'), + Track(uri='dummy:d'), + Track(uri='dummy:e'), + Track(uri='dummy:f'), + ]) + random.seed(1) # Playlist order: abcfde self.sendRequest(u'play') - self.assertEquals('a', self.backend.playback.current_track.get().uri) + self.assertEquals('dummy:a', + self.core.playback.current_track.get().uri) self.sendRequest(u'random "1"') self.sendRequest(u'next') - self.assertEquals('b', self.backend.playback.current_track.get().uri) + self.assertEquals('dummy:b', + self.core.playback.current_track.get().uri) self.sendRequest(u'next') # Should now be at track 'c', but playback fails and it skips ahead - self.assertEquals('f', self.backend.playback.current_track.get().uri) + self.assertEquals('dummy:f', + self.core.playback.current_track.get().uri) self.sendRequest(u'next') - self.assertEquals('d', self.backend.playback.current_track.get().uri) + self.assertEquals('dummy:d', + self.core.playback.current_track.get().uri) self.sendRequest(u'next') - self.assertEquals('e', self.backend.playback.current_track.get().uri) + self.assertEquals('dummy:e', + self.core.playback.current_track.get().uri) class IssueGH18RegressionTest(protocol.BaseTestCase): @@ -47,9 +57,9 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): """ def test(self): - self.backend.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) self.sendRequest(u'play') @@ -59,11 +69,11 @@ class IssueGH18RegressionTest(protocol.BaseTestCase): self.sendRequest(u'next') self.sendRequest(u'next') - cp_track_1 = self.backend.playback.current_cp_track.get() + cp_track_1 = self.core.playback.current_cp_track.get() self.sendRequest(u'next') - cp_track_2 = self.backend.playback.current_cp_track.get() + cp_track_2 = self.core.playback.current_cp_track.get() self.sendRequest(u'next') - cp_track_3 = self.backend.playback.current_cp_track.get() + cp_track_3 = self.core.playback.current_cp_track.get() self.assertNotEqual(cp_track_1, cp_track_2) self.assertNotEqual(cp_track_2, cp_track_3) @@ -83,9 +93,9 @@ class IssueGH22RegressionTest(protocol.BaseTestCase): """ def test(self): - self.backend.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) random.seed(1) self.sendRequest(u'play') @@ -111,10 +121,10 @@ class IssueGH69RegressionTest(protocol.BaseTestCase): """ def test(self): - self.backend.stored_playlists.create('foo') - self.backend.current_playlist.append([ - Track(uri='a'), Track(uri='b'), Track(uri='c'), - Track(uri='d'), Track(uri='e'), Track(uri='f')]) + self.core.stored_playlists.create('foo') + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b'), Track(uri='dummy:c'), + Track(uri='dummy:d'), Track(uri='dummy:e'), Track(uri='dummy:f')]) self.sendRequest(u'play') self.sendRequest(u'stop') @@ -136,7 +146,7 @@ class IssueGH113RegressionTest(protocol.BaseTestCase): """ def test(self): - self.backend.stored_playlists.create( + self.core.stored_playlists.create( u'all lart spotify:track:\w\{22\} pastes') self.sendRequest(u'lsinfo "/"') @@ -158,7 +168,8 @@ class IssueGH137RegressionTest(protocol.BaseTestCase): """ def test(self): - self.sendRequest(u'list Date Artist "Anita Ward" ' + self.sendRequest( + u'list Date Artist "Anita Ward" ' u'Album "This Is Remixed Hits - Mashups & Rare 12" Mixes"') self.assertInResponse('ACK [2@0] {list} Invalid unquoted character') diff --git a/tests/frontends/mpd/protocol/status_test.py b/tests/frontends/mpd/protocol/status_test.py index e6572eab..e2f0df9c 100644 --- a/tests/frontends/mpd/protocol/status_test.py +++ b/tests/frontends/mpd/protocol/status_test.py @@ -10,8 +10,8 @@ class StatusHandlerTest(protocol.BaseTestCase): def test_currentsong(self): track = Track() - self.backend.current_playlist.append([track]) - self.backend.playback.play() + self.core.current_playlist.append([track]) + self.core.playback.play() self.sendRequest(u'currentsong') self.assertInResponse(u'file: ') self.assertInResponse(u'Time: 0') diff --git a/tests/frontends/mpd/protocol/stored_playlists_test.py b/tests/frontends/mpd/protocol/stored_playlists_test.py index 45d6a09a..c8db3f8f 100644 --- a/tests/frontends/mpd/protocol/stored_playlists_test.py +++ b/tests/frontends/mpd/protocol/stored_playlists_test.py @@ -14,6 +14,14 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'file: file:///dev/urandom') self.assertInResponse(u'OK') + def test_listplaylist_without_quotes(self): + self.backend.stored_playlists.playlists = [ + Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + + self.sendRequest(u'listplaylist name') + self.assertInResponse(u'file: file:///dev/urandom') + self.assertInResponse(u'OK') + def test_listplaylist_fails_if_no_playlist_is_found(self): self.sendRequest(u'listplaylist "name"') self.assertEqualResponse(u'ACK [50@0] {listplaylist} No such playlist') @@ -28,6 +36,16 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertNotInResponse(u'Pos: 0') self.assertInResponse(u'OK') + def test_listplaylistinfo_without_quotes(self): + self.backend.stored_playlists.playlists = [ + Playlist(name='name', tracks=[Track(uri='file:///dev/urandom')])] + + self.sendRequest(u'listplaylistinfo name') + self.assertInResponse(u'file: file:///dev/urandom') + self.assertInResponse(u'Track: 0') + self.assertNotInResponse(u'Pos: 0') + self.assertInResponse(u'OK') + def test_listplaylistinfo_fails_if_no_playlist_is_found(self): self.sendRequest(u'listplaylistinfo "name"') self.assertEqualResponse( @@ -35,8 +53,8 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_listplaylists(self): last_modified = datetime.datetime(2001, 3, 17, 13, 41, 17, 12345) - self.backend.stored_playlists.playlists = [Playlist(name='a', - last_modified=last_modified)] + self.backend.stored_playlists.playlists = [ + Playlist(name='a', last_modified=last_modified)] self.sendRequest(u'listplaylists') self.assertInResponse(u'playlist: a') @@ -45,13 +63,14 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_load_known_playlist_appends_to_current_playlist(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEqual(len(self.backend.current_playlist.tracks.get()), 2) - self.backend.stored_playlists.playlists = [Playlist(name='A-list', - tracks=[Track(uri='c'), Track(uri='d'), Track(uri='e')])] + self.core.current_playlist.append([Track(uri='a'), Track(uri='b')]) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 2) + self.backend.stored_playlists.playlists = [ + Playlist(name='A-list', tracks=[ + Track(uri='c'), Track(uri='d'), Track(uri='e')])] self.sendRequest(u'load "A-list"') - tracks = self.backend.current_playlist.tracks.get() + tracks = self.core.current_playlist.tracks.get() self.assertEqual(5, len(tracks)) self.assertEqual('a', tracks[0].uri) self.assertEqual('b', tracks[1].uri) @@ -62,7 +81,7 @@ class StoredPlaylistsHandlerTest(protocol.BaseTestCase): def test_load_unknown_playlist_acks(self): self.sendRequest(u'load "unknown playlist"') - self.assertEqual(0, len(self.backend.current_playlist.tracks.get())) + self.assertEqual(0, len(self.core.current_playlist.tracks.get())) self.assertEqualResponse(u'ACK [50@0] {load} No such playlist') def test_playlistadd(self): diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py index e6cd80e2..2d2a9f87 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -49,7 +49,8 @@ class TrackMpdFormatTest(unittest.TestCase): self.assertNotIn(('Id', 1), result) def test_track_to_mpd_format_with_position_and_cpid(self): - result = translator.track_to_mpd_format(CpTrack(2, Track()), position=1) + result = translator.track_to_mpd_format( + CpTrack(2, Track()), position=1) self.assertIn(('Pos', 1), result) self.assertIn(('Id', 2), result) @@ -79,7 +80,7 @@ class TrackMpdFormatTest(unittest.TestCase): result = translator.track_to_mpd_format(track) self.assertIn(('MUSICBRAINZ_ALBUMID', 'foo'), result) - def test_track_to_mpd_format_musicbrainz_albumid(self): + def test_track_to_mpd_format_musicbrainz_albumartistid(self): artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') album = self.track.album.copy(artists=[artist]) track = self.track.copy(album=album) @@ -131,7 +132,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): folder = settings.LOCAL_MUSIC_PATH result = dict(translator.track_to_mpd_format(track)) result['file'] = uri_to_path(result['file']) - result['file'] = result['file'][len(folder)+1:] + result['file'] = result['file'][len(folder) + 1:] result['key'] = os.path.basename(result['file']) result['mtime'] = mtime('') return translator.order_mpd_track_info(result.items()) @@ -147,7 +148,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): self.assertEqual(('songList begin',), result[0]) for i, row in enumerate(result): if row == ('songList end',): - return result[1:i], result[i+1:] + return result[1:i], result[i + 1:] self.fail("Couldn't find songList end in result") def consume_directory(self, result): @@ -157,7 +158,7 @@ class TracksToTagCacheFormatTest(unittest.TestCase): directory = result[2][1] for i, row in enumerate(result): if row == ('end', directory): - return result[3:i], result[i+1:] + return result[3:i], result[i + 1:] self.fail("Couldn't find end %s in result" % directory) def test_empty_tag_cache_has_header(self): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 2bc3488b..9f2395e5 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,3 +1,6 @@ +import pykka + +from mopidy import core from mopidy.backends import dummy from mopidy.core import PlaybackState from mopidy.frontends.mpd import dispatcher @@ -17,29 +20,30 @@ STOPPED = PlaybackState.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.backend = dummy.DummyBackend.start().proxy() - self.dispatcher = dispatcher.MpdDispatcher() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() + self.dispatcher = dispatcher.MpdDispatcher(core=self.core) self.context = self.dispatcher.context def tearDown(self): - self.backend.stop().get() + pykka.ActorRegistry.stop_all() def test_stats_method(self): result = status.stats(self.context) self.assertIn('artists', result) - self.assert_(int(result['artists']) >= 0) + self.assertGreaterEqual(int(result['artists']), 0) self.assertIn('albums', result) - self.assert_(int(result['albums']) >= 0) + self.assertGreaterEqual(int(result['albums']), 0) self.assertIn('songs', result) - self.assert_(int(result['songs']) >= 0) + self.assertGreaterEqual(int(result['songs']), 0) self.assertIn('uptime', result) - self.assert_(int(result['uptime']) >= 0) + self.assertGreaterEqual(int(result['uptime']), 0) self.assertIn('db_playtime', result) - self.assert_(int(result['db_playtime']) >= 0) + self.assertGreaterEqual(int(result['db_playtime']), 0) self.assertIn('db_update', result) - self.assert_(int(result['db_update']) >= 0) + self.assertGreaterEqual(int(result['db_update']), 0) self.assertIn('playtime', result) - self.assert_(int(result['playtime']) >= 0) + self.assertGreaterEqual(int(result['playtime']), 0) def test_status_method_contains_volume_with_na_value(self): result = dict(status.status(self.context)) @@ -47,7 +51,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['volume']), -1) def test_status_method_contains_volume(self): - self.backend.playback.volume = 17 + self.core.playback.volume = 17 result = dict(status.status(self.context)) self.assertIn('volume', result) self.assertEqual(int(result['volume']), 17) @@ -58,7 +62,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): - self.backend.playback.repeat = 1 + self.core.playback.repeat = 1 result = dict(status.status(self.context)) self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 1) @@ -69,7 +73,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['random']), 0) def test_status_method_contains_random_is_1(self): - self.backend.playback.random = 1 + self.core.playback.random = 1 result = dict(status.status(self.context)) self.assertIn('random', result) self.assertEqual(int(result['random']), 1) @@ -85,7 +89,7 @@ class StatusHandlerTest(unittest.TestCase): self.assertEqual(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): - self.backend.playback.consume = 1 + self.core.playback.consume = 1 result = dict(status.status(self.context)) self.assertIn('consume', result) self.assertEqual(int(result['consume']), 1) @@ -93,88 +97,91 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_contains_playlist(self): result = dict(status.status(self.context)) self.assertIn('playlist', result) - self.assertIn(int(result['playlist']), xrange(0, 2**31 - 1)) + self.assertIn(int(result['playlist']), xrange(0, 2 ** 31 - 1)) def test_status_method_contains_playlistlength(self): result = dict(status.status(self.context)) self.assertIn('playlistlength', result) - self.assert_(int(result['playlistlength']) >= 0) + self.assertGreaterEqual(int(result['playlistlength']), 0) def test_status_method_contains_xfade(self): result = dict(status.status(self.context)) self.assertIn('xfade', result) - self.assert_(int(result['xfade']) >= 0) + self.assertGreaterEqual(int(result['xfade']), 0) def test_status_method_contains_state_is_play(self): - self.backend.playback.state = PLAYING + self.core.playback.state = PLAYING result = dict(status.status(self.context)) self.assertIn('state', result) self.assertEqual(result['state'], 'play') def test_status_method_contains_state_is_stop(self): - self.backend.playback.state = STOPPED + self.core.playback.state = STOPPED result = dict(status.status(self.context)) self.assertIn('state', result) self.assertEqual(result['state'], 'stop') def test_status_method_contains_state_is_pause(self): - self.backend.playback.state = PLAYING - self.backend.playback.state = PAUSED + self.core.playback.state = PLAYING + self.core.playback.state = PAUSED result = dict(status.status(self.context)) self.assertIn('state', result) self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): - self.backend.current_playlist.append([Track()]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('song', result) - self.assert_(int(result['song']) >= 0) + self.assertGreaterEqual(int(result['song']), 0) def test_status_method_when_playlist_loaded_contains_cpid_as_songid(self): - self.backend.current_playlist.append([Track()]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('songid', result) self.assertEqual(int(result['songid']), 0) def test_status_method_when_playing_contains_time_with_no_length(self): - self.backend.current_playlist.append([Track(length=None)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='dummy:a', length=None)]) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) (position, total) = result['time'].split(':') position = int(position) total = int(total) - self.assert_(position <= total) + self.assertLessEqual(position, total) def test_status_method_when_playing_contains_time_with_length(self): - self.backend.current_playlist.append([Track(length=10000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='dummy:a', length=10000)]) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('time', result) (position, total) = result['time'].split(':') position = int(position) total = int(total) - self.assert_(position <= total) + self.assertLessEqual(position, total) def test_status_method_when_playing_contains_elapsed(self): - self.backend.playback.state = PAUSED - self.backend.playback.play_time_accumulated = 59123 + self.core.current_playlist.append([Track(uri='dummy:a', length=60000)]) + self.core.playback.play() + self.core.playback.pause() + self.core.playback.seek(59123) result = dict(status.status(self.context)) self.assertIn('elapsed', result) self.assertEqual(result['elapsed'], '59.123') def test_status_method_when_starting_playing_contains_elapsed_zero(self): - self.backend.playback.state = PAUSED - self.backend.playback.play_time_accumulated = 123 # Less than 1000ms + self.core.current_playlist.append([Track(uri='dummy:a', length=10000)]) + self.core.playback.play() + self.core.playback.pause() result = dict(status.status(self.context)) self.assertIn('elapsed', result) - self.assertEqual(result['elapsed'], '0.123') + self.assertEqual(result['elapsed'], '0.000') def test_status_method_when_playing_contains_bitrate(self): - self.backend.current_playlist.append([Track(bitrate=320)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='dummy:a', bitrate=320)]) + self.core.playback.play() result = dict(status.status(self.context)) self.assertIn('bitrate', result) self.assertEqual(int(result['bitrate']), 320) diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 49e56226..a4efe344 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -2,7 +2,7 @@ import sys import mock -from mopidy import OptionalDependencyError +from mopidy.exceptions import OptionalDependencyError from mopidy.models import Track try: @@ -16,7 +16,8 @@ from tests import unittest @unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') class BackendEventsTest(unittest.TestCase): def setUp(self): - self.mpris_frontend = MprisFrontend() # As a plain class, not an actor + # As a plain class, not an actor: + self.mpris_frontend = MprisFrontend(core=None) self.mpris_object = mock.Mock(spec=objects.MprisObject) self.mpris_frontend.mpris_object = self.mpris_object @@ -38,7 +39,7 @@ class BackendEventsTest(unittest.TestCase): self.mpris_object.PropertiesChanged.assert_called_with( objects.PLAYER_IFACE, {'PlaybackStatus': 'Playing'}, []) - def test_track_playback_started_event_changes_playback_status_and_metadata(self): + def test_track_playback_started_changes_playback_status_and_metadata(self): self.mpris_object.Get.return_value = '...' self.mpris_frontend.track_playback_started(Track()) self.assertListEqual(self.mpris_object.Get.call_args_list, [ @@ -49,7 +50,7 @@ class BackendEventsTest(unittest.TestCase): objects.PLAYER_IFACE, {'Metadata': '...', 'PlaybackStatus': '...'}, []) - def test_track_playback_ended_event_changes_playback_status_and_metadata(self): + def test_track_playback_ended_changes_playback_status_and_metadata(self): self.mpris_object.Get.return_value = '...' self.mpris_frontend.track_playback_ended(Track(), 0) self.assertListEqual(self.mpris_object.Get.call_args_list, [ @@ -70,9 +71,5 @@ class BackendEventsTest(unittest.TestCase): objects.PLAYER_IFACE, {'Volume': 1.0}, []) def test_seeked_event_causes_mpris_seeked_event(self): - self.mpris_object.Get.return_value = 31000000 - self.mpris_frontend.seeked() - self.assertListEqual(self.mpris_object.Get.call_args_list, [ - ((objects.PLAYER_IFACE, 'Position'), {}), - ]) + self.mpris_frontend.seeked(31000) self.mpris_object.Seeked.assert_called_with(31000000) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index db7f9265..620845e4 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -1,15 +1,16 @@ import sys import mock +import pykka -from mopidy import OptionalDependencyError -from mopidy.backends.dummy import DummyBackend +from mopidy import core, exceptions +from mopidy.backends import dummy from mopidy.core import PlaybackState from mopidy.models import Album, Artist, Track try: from mopidy.frontends.mpris import objects -except OptionalDependencyError: +except exceptions.OptionalDependencyError: pass from tests import unittest @@ -23,301 +24,309 @@ STOPPED = PlaybackState.STOPPED class PlayerInterfaceTest(unittest.TestCase): def setUp(self): objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = DummyBackend.start().proxy() - self.mpris = objects.MprisObject() - self.mpris._backend = self.backend + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() + self.mpris = objects.MprisObject(core=self.core) def tearDown(self): - self.backend.stop() + pykka.ActorRegistry.stop_all() def test_get_playback_status_is_playing_when_playing(self): - self.backend.playback.state = PLAYING + self.core.playback.state = PLAYING result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') self.assertEqual('Playing', result) def test_get_playback_status_is_paused_when_paused(self): - self.backend.playback.state = PAUSED + self.core.playback.state = PAUSED result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') self.assertEqual('Paused', result) def test_get_playback_status_is_stopped_when_stopped(self): - self.backend.playback.state = STOPPED + self.core.playback.state = STOPPED result = self.mpris.Get(objects.PLAYER_IFACE, 'PlaybackStatus') self.assertEqual('Stopped', result) def test_get_loop_status_is_none_when_not_looping(self): - self.backend.playback.repeat = False - self.backend.playback.single = False + self.core.playback.repeat = False + self.core.playback.single = False result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') self.assertEqual('None', result) def test_get_loop_status_is_track_when_looping_a_single_track(self): - self.backend.playback.repeat = True - self.backend.playback.single = True + self.core.playback.repeat = True + self.core.playback.single = True result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') self.assertEqual('Track', result) - def test_get_loop_status_is_playlist_when_looping_the_current_playlist(self): - self.backend.playback.repeat = True - self.backend.playback.single = False + def test_get_loop_status_is_playlist_when_looping_current_playlist(self): + self.core.playback.repeat = True + self.core.playback.single = False result = self.mpris.Get(objects.PLAYER_IFACE, 'LoopStatus') self.assertEqual('Playlist', result) def test_set_loop_status_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.playback.repeat = True - self.backend.playback.single = True + self.core.playback.repeat = True + self.core.playback.single = True self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEquals(self.backend.playback.repeat.get(), True) - self.assertEquals(self.backend.playback.single.get(), True) + self.assertEqual(self.core.playback.repeat.get(), True) + self.assertEqual(self.core.playback.single.get(), True) def test_set_loop_status_to_none_unsets_repeat_and_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'None') - self.assertEquals(self.backend.playback.repeat.get(), False) - self.assertEquals(self.backend.playback.single.get(), False) + self.assertEqual(self.core.playback.repeat.get(), False) + self.assertEqual(self.core.playback.single.get(), False) def test_set_loop_status_to_track_sets_repeat_and_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Track') - self.assertEquals(self.backend.playback.repeat.get(), True) - self.assertEquals(self.backend.playback.single.get(), True) + self.assertEqual(self.core.playback.repeat.get(), True) + self.assertEqual(self.core.playback.single.get(), True) def test_set_loop_status_to_playlists_sets_repeat_and_not_single(self): self.mpris.Set(objects.PLAYER_IFACE, 'LoopStatus', 'Playlist') - self.assertEquals(self.backend.playback.repeat.get(), True) - self.assertEquals(self.backend.playback.single.get(), False) + self.assertEqual(self.core.playback.repeat.get(), True) + self.assertEqual(self.core.playback.single.get(), False) def test_get_rate_is_greater_or_equal_than_minimum_rate(self): rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') minimum_rate = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') - self.assert_(rate >= minimum_rate) + self.assertGreaterEqual(rate, minimum_rate) def test_get_rate_is_less_or_equal_than_maximum_rate(self): rate = self.mpris.Get(objects.PLAYER_IFACE, 'Rate') maximum_rate = self.mpris.Get(objects.PLAYER_IFACE, 'MaximumRate') - self.assert_(rate >= maximum_rate) + self.assertGreaterEqual(rate, maximum_rate) def test_set_rate_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_set_rate_to_zero_pauses_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Set(objects.PLAYER_IFACE, 'Rate', 0) - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_get_shuffle_returns_true_if_random_is_active(self): - self.backend.playback.random = True + self.core.playback.random = True result = self.mpris.Get(objects.PLAYER_IFACE, 'Shuffle') self.assertTrue(result) def test_get_shuffle_returns_false_if_random_is_inactive(self): - self.backend.playback.random = False + self.core.playback.random = False result = self.mpris.Get(objects.PLAYER_IFACE, 'Shuffle') self.assertFalse(result) def test_set_shuffle_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.playback.random = False - result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) - self.assertFalse(self.backend.playback.random.get()) + self.core.playback.random = False + self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) + self.assertFalse(self.core.playback.random.get()) def test_set_shuffle_to_true_activates_random_mode(self): - self.backend.playback.random = False - self.assertFalse(self.backend.playback.random.get()) - result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) - self.assertTrue(self.backend.playback.random.get()) + self.core.playback.random = False + self.assertFalse(self.core.playback.random.get()) + self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', True) + self.assertTrue(self.core.playback.random.get()) def test_set_shuffle_to_false_deactivates_random_mode(self): - self.backend.playback.random = True - self.assertTrue(self.backend.playback.random.get()) - result = self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', False) - self.assertFalse(self.backend.playback.random.get()) + self.core.playback.random = True + self.assertTrue(self.core.playback.random.get()) + self.mpris.Set(objects.PLAYER_IFACE, 'Shuffle', False) + self.assertFalse(self.core.playback.random.get()) def test_get_metadata_has_trackid_even_when_no_current_track(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:trackid', result.keys()) - self.assertEquals(result['mpris:trackid'], '') + self.assertEqual(result['mpris:trackid'], '') def test_get_metadata_has_trackid_based_on_cpid(self): - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.play() - (cpid, track) = self.backend.playback.current_cp_track.get() + self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.playback.play() + (cpid, track) = self.core.playback.current_cp_track.get() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:trackid', result.keys()) - self.assertEquals(result['mpris:trackid'], - '/com/mopidy/track/%d' % cpid) + self.assertEqual( + result['mpris:trackid'], '/com/mopidy/track/%d' % cpid) def test_get_metadata_has_track_length(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('mpris:length', result.keys()) - self.assertEquals(result['mpris:length'], 40000000) + self.assertEqual(result['mpris:length'], 40000000) def test_get_metadata_has_track_uri(self): - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:url', result.keys()) - self.assertEquals(result['xesam:url'], 'a') + self.assertEqual(result['xesam:url'], 'dummy:a') def test_get_metadata_has_track_title(self): - self.backend.current_playlist.append([Track(name='a')]) - self.backend.playback.play() + self.core.current_playlist.append([Track(name='a')]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:title', result.keys()) - self.assertEquals(result['xesam:title'], 'a') + self.assertEqual(result['xesam:title'], 'a') def test_get_metadata_has_track_artists(self): - self.backend.current_playlist.append([Track(artists=[ + self.core.current_playlist.append([Track(artists=[ Artist(name='a'), Artist(name='b'), Artist(name=None)])]) - self.backend.playback.play() + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:artist', result.keys()) - self.assertEquals(result['xesam:artist'], ['a', 'b']) + self.assertEqual(result['xesam:artist'], ['a', 'b']) def test_get_metadata_has_track_album(self): - self.backend.current_playlist.append([Track(album=Album(name='a'))]) - self.backend.playback.play() + self.core.current_playlist.append([Track(album=Album(name='a'))]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:album', result.keys()) - self.assertEquals(result['xesam:album'], 'a') + self.assertEqual(result['xesam:album'], 'a') def test_get_metadata_has_track_album_artists(self): - self.backend.current_playlist.append([Track(album=Album(artists=[ + self.core.current_playlist.append([Track(album=Album(artists=[ Artist(name='a'), Artist(name='b'), Artist(name=None)]))]) - self.backend.playback.play() + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:albumArtist', result.keys()) - self.assertEquals(result['xesam:albumArtist'], ['a', 'b']) + self.assertEqual(result['xesam:albumArtist'], ['a', 'b']) def test_get_metadata_has_track_number_in_album(self): - self.backend.current_playlist.append([Track(track_no=7)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(track_no=7)]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') self.assertIn('xesam:trackNumber', result.keys()) - self.assertEquals(result['xesam:trackNumber'], 7) + self.assertEqual(result['xesam:trackNumber'], 7) def test_get_volume_should_return_volume_between_zero_and_one(self): - self.backend.playback.volume = None + self.core.playback.volume = None result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEquals(result, 0) + self.assertEqual(result, 0) - self.backend.playback.volume = 0 + self.core.playback.volume = 0 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEquals(result, 0) + self.assertEqual(result, 0) - self.backend.playback.volume = 50 + self.core.playback.volume = 50 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEquals(result, 0.5) + self.assertEqual(result, 0.5) - self.backend.playback.volume = 100 + self.core.playback.volume = 100 result = self.mpris.Get(objects.PLAYER_IFACE, 'Volume') - self.assertEquals(result, 1) + self.assertEqual(result, 1) def test_set_volume_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.playback.volume = 0 + self.core.playback.volume = 0 self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEquals(self.backend.playback.volume.get(), 0) + self.assertEqual(self.core.playback.volume.get(), 0) def test_set_volume_to_one_should_set_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 1.0) - self.assertEquals(self.backend.playback.volume.get(), 100) + self.assertEqual(self.core.playback.volume.get(), 100) - def test_set_volume_to_anything_above_one_should_set_mixer_volume_to_100(self): + def test_set_volume_to_anything_above_one_sets_mixer_volume_to_100(self): self.mpris.Set(objects.PLAYER_IFACE, 'Volume', 2.0) - self.assertEquals(self.backend.playback.volume.get(), 100) + self.assertEqual(self.core.playback.volume.get(), 100) def test_set_volume_to_anything_not_a_number_does_not_change_volume(self): - self.backend.playback.volume = 10 + self.core.playback.volume = 10 self.mpris.Set(objects.PLAYER_IFACE, 'Volume', None) - self.assertEquals(self.backend.playback.volume.get(), 10) + self.assertEqual(self.core.playback.volume.get(), 10) def test_get_position_returns_time_position_in_microseconds(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(10000) - result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(10000) + result_in_microseconds = self.mpris.Get( + objects.PLAYER_IFACE, 'Position') result_in_milliseconds = result_in_microseconds // 1000 - self.assert_(result_in_milliseconds >= 10000) + self.assertGreaterEqual(result_in_milliseconds, 10000) def test_get_position_when_no_current_track_should_be_zero(self): - result_in_microseconds = self.mpris.Get(objects.PLAYER_IFACE, 'Position') + result_in_microseconds = self.mpris.Get( + objects.PLAYER_IFACE, 'Position') result_in_milliseconds = result_in_microseconds // 1000 - self.assertEquals(result_in_milliseconds, 0) + self.assertEqual(result_in_milliseconds, 0) def test_get_minimum_rate_is_one_or_less(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'MinimumRate') - self.assert_(result <= 1.0) + self.assertLessEqual(result, 1.0) def test_get_maximum_rate_is_one_or_more(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'MaximumRate') - self.assert_(result >= 1.0) + self.assertGreaterEqual(result, 1.0) def test_can_go_next_is_true_if_can_control_and_other_next_track(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertTrue(result) def test_can_go_next_is_false_if_next_track_is_the_same(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.repeat = True - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.playback.repeat = True + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertFalse(result) def test_can_go_next_is_false_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoNext') self.assertFalse(result) - def test_can_go_previous_is_true_if_can_control_and_other_previous_track(self): + def test_can_go_previous_is_true_if_can_control_and_previous_track(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.core.playback.next() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') self.assertTrue(result) def test_can_go_previous_is_false_if_previous_track_is_the_same(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.repeat = True - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.playback.repeat = True + self.core.playback.play() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') self.assertFalse(result) def test_can_go_previous_is_false_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.core.playback.next() result = self.mpris.Get(objects.PLAYER_IFACE, 'CanGoPrevious') self.assertFalse(result) def test_can_play_is_true_if_can_control_and_current_track(self): self.mpris.get_CanControl = lambda *_: True - self.backend.current_playlist.append([Track(uri='a')]) - self.backend.playback.play() - self.assertTrue(self.backend.playback.current_track.get()) + self.core.current_playlist.append([Track(uri='dummy:a')]) + self.core.playback.play() + self.assertTrue(self.core.playback.current_track.get()) result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') self.assertTrue(result) def test_can_play_is_false_if_no_current_track(self): self.mpris.get_CanControl = lambda *_: True - self.assertFalse(self.backend.playback.current_track.get()) + self.assertFalse(self.core.playback.current_track.get()) result = self.mpris.Get(objects.PLAYER_IFACE, 'CanPlay') self.assertFalse(result) @@ -352,484 +361,511 @@ class PlayerInterfaceTest(unittest.TestCase): def test_next_is_ignored_if_can_go_next_is_false(self): self.mpris.get_CanGoNext = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') self.mpris.Next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - def test_next_when_playing_should_skip_to_next_track_and_keep_playing(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + def test_next_when_playing_skips_to_next_track_and_keep_playing(self): + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_next_when_at_end_of_list_should_stop_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.core.playback.next() + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Next() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_next_when_paused_should_skip_to_next_track_and_stay_paused(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.core.playback.pause() + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PAUSED) self.mpris.Next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PAUSED) - def test_next_when_stopped_should_skip_to_next_track_and_stay_stopped(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.stop() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), STOPPED) + def test_next_when_stopped_skips_to_next_track_and_stay_stopped(self): + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.core.playback.stop() + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_previous_is_ignored_if_can_go_previous_is_false(self): self.mpris.get_CanGoPrevious = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.core.playback.next() + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') self.mpris.Previous() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - def test_previous_when_playing_should_skip_to_prev_track_and_keep_playing(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + def test_previous_when_playing_skips_to_prev_track_and_keep_playing(self): + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.core.playback.next() + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Previous() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_previous_when_at_start_of_list_should_stop_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Previous() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) - def test_previous_when_paused_should_skip_to_previous_track_and_stay_paused(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), PAUSED) + def test_previous_when_paused_skips_to_previous_track_and_pause(self): + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.core.playback.next() + self.core.playback.pause() + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), PAUSED) self.mpris.Previous() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), PAUSED) - def test_previous_when_stopped_should_skip_to_previous_track_and_stay_stopped(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.next() - self.backend.playback.stop() - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') - self.assertEquals(self.backend.playback.state.get(), STOPPED) + def test_previous_when_stopped_skips_to_previous_track_and_stops(self): + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.core.playback.next() + self.core.playback.stop() + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Previous() - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_pause_is_ignored_if_can_pause_is_false(self): self.mpris.get_CanPause = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Pause() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_pause_when_playing_should_pause_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_pause_when_paused_has_no_effect(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.core.playback.pause() + self.assertEqual(self.core.playback.state.get(), PAUSED) self.mpris.Pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_playpause_is_ignored_if_can_pause_is_false(self): self.mpris.get_CanPause = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.PlayPause() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_playpause_when_playing_should_pause_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.PlayPause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.state.get(), PAUSED) def test_playpause_when_paused_should_resume_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.core.playback.pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) - at_pause = self.backend.playback.time_position.get() - self.assert_(at_pause >= 0) + self.assertEqual(self.core.playback.state.get(), PAUSED) + at_pause = self.core.playback.time_position.get() + self.assertGreaterEqual(at_pause, 0) self.mpris.PlayPause() - self.assertEquals(self.backend.playback.state.get(), PLAYING) - after_pause = self.backend.playback.time_position.get() - self.assert_(after_pause >= at_pause) + self.assertEqual(self.core.playback.state.get(), PLAYING) + after_pause = self.core.playback.time_position.get() + self.assertGreaterEqual(after_pause, at_pause) def test_playpause_when_stopped_should_start_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.PlayPause() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_stop_is_ignored_if_can_control_is_false(self): self.mpris.get_CanControl = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Stop() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_stop_when_playing_should_stop_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.assertEqual(self.core.playback.state.get(), PLAYING) self.mpris.Stop() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_stop_when_paused_should_stop_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.core.playback.pause() + self.assertEqual(self.core.playback.state.get(), PAUSED) self.mpris.Stop() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_play_is_ignored_if_can_play_is_false(self): self.mpris.get_CanPlay = lambda *_: False - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_play_when_stopped_starts_playback(self): - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) def test_play_after_pause_resumes_from_same_position(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() - before_pause = self.backend.playback.time_position.get() - self.assert_(before_pause >= 0) + before_pause = self.core.playback.time_position.get() + self.assertGreaterEqual(before_pause, 0) self.mpris.Pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) - at_pause = self.backend.playback.time_position.get() - self.assert_(at_pause >= before_pause) + self.assertEqual(self.core.playback.state.get(), PAUSED) + at_pause = self.core.playback.time_position.get() + self.assertGreaterEqual(at_pause, before_pause) self.mpris.Play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) - after_pause = self.backend.playback.time_position.get() - self.assert_(after_pause >= at_pause) + self.assertEqual(self.core.playback.state.get(), PLAYING) + after_pause = self.core.playback.time_position.get() + self.assertGreaterEqual(after_pause, at_pause) def test_play_when_there_is_no_track_has_no_effect(self): - self.backend.current_playlist.clear() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.clear() + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.Play() - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.assertEqual(self.core.playback.state.get(), STOPPED) def test_seek_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() - before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 0) + before_seek = self.core.playback.time_position.get() + self.assertGreaterEqual(before_seek, 0) milliseconds_to_seek = 10000 microseconds_to_seek = milliseconds_to_seek * 1000 self.mpris.Seek(microseconds_to_seek) - after_seek = self.backend.playback.time_position.get() - self.assert_(before_seek <= after_seek < ( - before_seek + milliseconds_to_seek)) + after_seek = self.core.playback.time_position.get() + self.assertLessEqual(before_seek, after_seek) + self.assertLess(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_forward_in_the_current_track(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() - before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 0) + before_seek = self.core.playback.time_position.get() + self.assertGreaterEqual(before_seek, 0) milliseconds_to_seek = 10000 microseconds_to_seek = milliseconds_to_seek * 1000 self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) - after_seek = self.backend.playback.time_position.get() - self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) + after_seek = self.core.playback.time_position.get() + self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) def test_seek_seeks_given_microseconds_backward_if_negative(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 20000) + before_seek = self.core.playback.time_position.get() + self.assertGreaterEqual(before_seek, 20000) milliseconds_to_seek = -10000 microseconds_to_seek = milliseconds_to_seek * 1000 self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) - after_seek = self.backend.playback.time_position.get() - self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) - self.assert_(after_seek < before_seek) + after_seek = self.core.playback.time_position.get() + self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) + self.assertLess(after_seek, before_seek) def test_seek_seeks_to_start_of_track_if_new_position_is_negative(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 20000) + before_seek = self.core.playback.time_position.get() + self.assertGreaterEqual(before_seek, 20000) milliseconds_to_seek = -30000 microseconds_to_seek = milliseconds_to_seek * 1000 self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) - after_seek = self.backend.playback.time_position.get() - self.assert_(after_seek >= (before_seek + milliseconds_to_seek)) - self.assert_(after_seek < before_seek) - self.assert_(after_seek >= 0) + after_seek = self.core.playback.time_position.get() + self.assertGreaterEqual(after_seek, before_seek + milliseconds_to_seek) + self.assertLess(after_seek, before_seek) + self.assertGreaterEqual(after_seek, 0) - def test_seek_skips_to_next_track_if_new_position_larger_than_track_length(self): - self.backend.current_playlist.append([Track(uri='a', length=40000), - Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.seek(20000) + def test_seek_skips_to_next_track_if_new_position_gt_track_length(self): + self.core.current_playlist.append([ + Track(uri='dummy:a', length=40000), + Track(uri='dummy:b')]) + self.core.playback.play() + self.core.playback.seek(20000) - before_seek = self.backend.playback.time_position.get() - self.assert_(before_seek >= 20000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + before_seek = self.core.playback.time_position.get() + self.assertGreaterEqual(before_seek, 20000) + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') milliseconds_to_seek = 50000 microseconds_to_seek = milliseconds_to_seek * 1000 self.mpris.Seek(microseconds_to_seek) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'b') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:b') - after_seek = self.backend.playback.time_position.get() - self.assert_(after_seek >= 0) - self.assert_(after_seek < before_seek) + after_seek = self.core.playback.time_position.get() + self.assertGreaterEqual(after_seek, 0) + self.assertLess(after_seek, before_seek) def test_set_position_is_ignored_if_can_seek_is_false(self): self.mpris.get_CanSeek = lambda *_: False - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() - before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position <= 5000) + before_set_position = self.core.playback.time_position.get() + self.assertLessEqual(before_set_position, 5000) track_id = 'a' - position_to_set_in_milliseconds = 20000 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = 20000 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) - after_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position <= after_set_position < - position_to_set_in_milliseconds) + after_set_position = self.core.playback.time_position.get() + self.assertLessEqual(before_set_position, after_set_position) + self.assertLess(after_set_position, position_to_set_in_millisec) def test_set_position_sets_the_current_track_position_in_microsecs(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() - before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position <= 5000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + before_set_position = self.core.playback.time_position.get() + self.assertLessEqual(before_set_position, 5000) + self.assertEqual(self.core.playback.state.get(), PLAYING) track_id = '/com/mopidy/track/0' - position_to_set_in_milliseconds = 20000 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = 20000 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) - self.assertEquals(self.backend.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.state.get(), PLAYING) - after_set_position = self.backend.playback.time_position.get() - self.assert_(after_set_position >= position_to_set_in_milliseconds) + after_set_position = self.core.playback.time_position.get() + self.assertGreaterEqual( + after_set_position, position_to_set_in_millisec) def test_set_position_does_nothing_if_the_position_is_negative(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position >= 20000) - self.assert_(before_set_position <= 25000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + before_set_position = self.core.playback.time_position.get() + self.assertGreaterEqual(before_set_position, 20000) + self.assertLessEqual(before_set_position, 25000) + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') track_id = '/com/mopidy/track/0' - position_to_set_in_milliseconds = -1000 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = -1000 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) - after_set_position = self.backend.playback.time_position.get() - self.assert_(after_set_position >= before_set_position) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + after_set_position = self.core.playback.time_position.get() + self.assertGreaterEqual(after_set_position, before_set_position) + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - def test_set_position_does_nothing_if_position_is_larger_than_track_length(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + def test_set_position_does_nothing_if_position_is_gt_track_length(self): + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position >= 20000) - self.assert_(before_set_position <= 25000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + before_set_position = self.core.playback.time_position.get() + self.assertGreaterEqual(before_set_position, 20000) + self.assertLessEqual(before_set_position, 25000) + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') track_id = 'a' - position_to_set_in_milliseconds = 50000 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = 50000 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) - after_set_position = self.backend.playback.time_position.get() - self.assert_(after_set_position >= before_set_position) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + after_set_position = self.core.playback.time_position.get() + self.assertGreaterEqual(after_set_position, before_set_position) + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') - def test_set_position_does_nothing_if_track_id_does_not_match_current_track(self): - self.backend.current_playlist.append([Track(uri='a', length=40000)]) - self.backend.playback.play() - self.backend.playback.seek(20000) + def test_set_position_is_noop_if_track_id_isnt_current_track(self): + self.core.current_playlist.append([Track(uri='dummy:a', length=40000)]) + self.core.playback.play() + self.core.playback.seek(20000) - before_set_position = self.backend.playback.time_position.get() - self.assert_(before_set_position >= 20000) - self.assert_(before_set_position <= 25000) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + before_set_position = self.core.playback.time_position.get() + self.assertGreaterEqual(before_set_position, 20000) + self.assertLessEqual(before_set_position, 25000) + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') track_id = 'b' - position_to_set_in_milliseconds = 0 - position_to_set_in_microseconds = position_to_set_in_milliseconds * 1000 + position_to_set_in_millisec = 0 + position_to_set_in_microsec = position_to_set_in_millisec * 1000 - self.mpris.SetPosition(track_id, position_to_set_in_microseconds) + self.mpris.SetPosition(track_id, position_to_set_in_microsec) - after_set_position = self.backend.playback.time_position.get() - self.assert_(after_set_position >= before_set_position) - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + after_set_position = self.core.playback.time_position.get() + self.assertGreaterEqual(after_set_position, before_set_position) + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') def test_open_uri_is_ignored_if_can_play_is_false(self): self.mpris.get_CanPlay = lambda *_: False - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(len(self.backend.current_playlist.tracks.get()), 0) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 0) def test_open_uri_ignores_uris_with_unknown_uri_scheme(self): - self.assertListEqual(self.backend.uri_schemes.get(), ['dummy']) + self.assertListEqual(self.core.uri_schemes.get(), ['dummy']) self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='notdummy:/test/uri')] self.mpris.OpenUri('notdummy:/test/uri') - self.assertEquals(len(self.backend.current_playlist.tracks.get()), 0) + self.assertEqual(len(self.core.current_playlist.tracks.get()), 0) def test_open_uri_adds_uri_to_current_playlist(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.backend.current_playlist.tracks.get()[0].uri, - 'dummy:/test/uri') + self.assertEqual( + self.core.current_playlist.tracks.get()[0].uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_stopped(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.assertEquals(self.backend.playback.state.get(), STOPPED) + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.assertEqual(self.core.playback.state.get(), STOPPED) self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, - 'dummy:/test/uri') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual( + self.core.playback.current_track.get().uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_paused(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.backend.playback.pause() - self.assertEquals(self.backend.playback.state.get(), PAUSED) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.core.playback.pause() + self.assertEqual(self.core.playback.state.get(), PAUSED) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, - 'dummy:/test/uri') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual( + self.core.playback.current_track.get().uri, 'dummy:/test/uri') def test_open_uri_starts_playback_of_new_track_if_playing(self): self.mpris.get_CanPlay = lambda *_: True - self.backend.library.provider.dummy_library = [ + self.backend.library.dummy_library = [ Track(uri='dummy:/test/uri')] - self.backend.current_playlist.append([Track(uri='a'), Track(uri='b')]) - self.backend.playback.play() - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, 'a') + self.core.current_playlist.append([ + Track(uri='dummy:a'), Track(uri='dummy:b')]) + self.core.playback.play() + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual(self.core.playback.current_track.get().uri, 'dummy:a') self.mpris.OpenUri('dummy:/test/uri') - self.assertEquals(self.backend.playback.state.get(), PLAYING) - self.assertEquals(self.backend.playback.current_track.get().uri, - 'dummy:/test/uri') + self.assertEqual(self.core.playback.state.get(), PLAYING) + self.assertEqual( + self.core.playback.current_track.get().uri, 'dummy:/test/uri') diff --git a/tests/frontends/mpris/root_interface_test.py b/tests/frontends/mpris/root_interface_test.py index 1e54fc15..79a8b07f 100644 --- a/tests/frontends/mpris/root_interface_test.py +++ b/tests/frontends/mpris/root_interface_test.py @@ -1,13 +1,14 @@ import sys import mock +import pykka -from mopidy import OptionalDependencyError, settings -from mopidy.backends.dummy import DummyBackend +from mopidy import core, exceptions, settings +from mopidy.backends import dummy try: from mopidy.frontends.mpris import objects -except OptionalDependencyError: +except exceptions.OptionalDependencyError: pass from tests import unittest @@ -18,11 +19,12 @@ class RootInterfaceTest(unittest.TestCase): def setUp(self): objects.exit_process = mock.Mock() objects.MprisObject._connect_to_dbus = mock.Mock() - self.backend = DummyBackend.start().proxy() - self.mpris = objects.MprisObject() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() + self.mpris = objects.MprisObject(core=self.core) def tearDown(self): - self.backend.stop() + pykka.ActorRegistry.stop_all() def test_constructor_connects_to_dbus(self): self.assert_(self.mpris._connect_to_dbus.called) diff --git a/tests/models_test.py b/tests/models_test.py index 779d1a4b..004c0a28 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -67,8 +67,8 @@ class ArtistTest(unittest.TestCase): mb_id = u'mb-id' artist = Artist(musicbrainz_id=mb_id) self.assertEqual(artist.musicbrainz_id, mb_id) - self.assertRaises(AttributeError, setattr, artist, - 'musicbrainz_id', None) + self.assertRaises( + AttributeError, setattr, artist, 'musicbrainz_id', None) def test_invalid_kwarg(self): test = lambda: Artist(foo='baz') @@ -164,12 +164,18 @@ class AlbumTest(unittest.TestCase): self.assertEqual(album.num_tracks, num_tracks) self.assertRaises(AttributeError, setattr, album, 'num_tracks', None) + def test_date(self): + date = '1977-01-01' + album = Album(date=date) + self.assertEqual(album.date, date) + self.assertRaises(AttributeError, setattr, album, 'date', None) + def test_musicbrainz_id(self): mb_id = u'mb-id' album = Album(musicbrainz_id=mb_id) self.assertEqual(album.musicbrainz_id, mb_id) - self.assertRaises(AttributeError, setattr, album, - 'musicbrainz_id', None) + self.assertRaises( + AttributeError, setattr, album, 'musicbrainz_id', None) def test_invalid_kwarg(self): test = lambda: Album(foo='baz') @@ -229,6 +235,13 @@ class AlbumTest(unittest.TestCase): self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) + def test_eq_date(self): + date = '1977-01-01' + album1 = Album(date=date) + album2 = Album(date=date) + self.assertEqual(album1, album2) + self.assertEqual(hash(album1), hash(album2)) + def test_eq_musibrainz_id(self): album1 = Album(musicbrainz_id=u'id') album2 = Album(musicbrainz_id=u'id') @@ -237,9 +250,11 @@ class AlbumTest(unittest.TestCase): def test_eq(self): artists = [Artist()] - album1 = Album(name=u'name', uri=u'uri', artists=artists, num_tracks=2, + album1 = Album( + name=u'name', uri=u'uri', artists=artists, num_tracks=2, musicbrainz_id='id') - album2 = Album(name=u'name', uri=u'uri', artists=artists, num_tracks=2, + album2 = Album( + name=u'name', uri=u'uri', artists=artists, num_tracks=2, musicbrainz_id='id') self.assertEqual(album1, album2) self.assertEqual(hash(album1), hash(album2)) @@ -274,6 +289,12 @@ class AlbumTest(unittest.TestCase): self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) + def test_ne_date(self): + album1 = Album(date='1977-01-01') + album2 = Album(date='1977-01-02') + self.assertNotEqual(album1, album2) + self.assertNotEqual(hash(album1), hash(album2)) + def test_ne_musicbrainz_id(self): album1 = Album(musicbrainz_id=u'id1') album2 = Album(musicbrainz_id=u'id2') @@ -281,12 +302,12 @@ class AlbumTest(unittest.TestCase): self.assertNotEqual(hash(album1), hash(album2)) def test_ne(self): - album1 = Album(name=u'name1', uri=u'uri1', - artists=[Artist(name=u'name1')], num_tracks=1, - musicbrainz_id='id1') - album2 = Album(name=u'name2', uri=u'uri2', - artists=[Artist(name=u'name2')], num_tracks=2, - musicbrainz_id='id2') + album1 = Album( + name=u'name1', uri=u'uri1', artists=[Artist(name=u'name1')], + num_tracks=1, musicbrainz_id='id1') + album2 = Album( + name=u'name2', uri=u'uri2', artists=[Artist(name=u'name2')], + num_tracks=2, musicbrainz_id='id2') self.assertNotEqual(album1, album2) self.assertNotEqual(hash(album1), hash(album2)) @@ -359,8 +380,8 @@ class TrackTest(unittest.TestCase): mb_id = u'mb-id' track = Track(musicbrainz_id=mb_id) self.assertEqual(track.musicbrainz_id, mb_id) - self.assertRaises(AttributeError, setattr, track, - 'musicbrainz_id', None) + self.assertRaises( + AttributeError, setattr, track, 'musicbrainz_id', None) def test_invalid_kwarg(self): test = lambda: Track(foo='baz') @@ -462,12 +483,12 @@ class TrackTest(unittest.TestCase): date = '1977-01-01' artists = [Artist()] album = Album() - track1 = Track(uri=u'uri', name=u'name', artists=artists, album=album, - track_no=1, date=date, length=100, bitrate=100, - musicbrainz_id='id') - track2 = Track(uri=u'uri', name=u'name', artists=artists, album=album, - track_no=1, date=date, length=100, bitrate=100, - musicbrainz_id='id') + track1 = Track( + uri=u'uri', name=u'name', artists=artists, album=album, track_no=1, + date=date, length=100, bitrate=100, musicbrainz_id='id') + track2 = Track( + uri=u'uri', name=u'name', artists=artists, album=album, track_no=1, + date=date, length=100, bitrate=100, musicbrainz_id='id') self.assertEqual(track1, track2) self.assertEqual(hash(track1), hash(track2)) @@ -532,14 +553,14 @@ class TrackTest(unittest.TestCase): self.assertNotEqual(hash(track1), hash(track2)) def test_ne(self): - track1 = Track(uri=u'uri1', name=u'name1', - artists=[Artist(name=u'name1')], album=Album(name=u'name1'), - track_no=1, date='1977-01-01', length=100, bitrate=100, - musicbrainz_id='id1') - track2 = Track(uri=u'uri2', name=u'name2', - artists=[Artist(name=u'name2')], album=Album(name=u'name2'), - track_no=2, date='1977-01-02', length=200, bitrate=200, - musicbrainz_id='id2') + track1 = Track( + uri=u'uri1', name=u'name1', artists=[Artist(name=u'name1')], + album=Album(name=u'name1'), track_no=1, date='1977-01-01', + length=100, bitrate=100, musicbrainz_id='id1') + track2 = Track( + uri=u'uri2', name=u'name2', artists=[Artist(name=u'name2')], + album=Album(name=u'name2'), track_no=2, date='1977-01-02', + length=200, bitrate=200, musicbrainz_id='id2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) @@ -572,13 +593,14 @@ class PlaylistTest(unittest.TestCase): last_modified = datetime.datetime.now() playlist = Playlist(last_modified=last_modified) self.assertEqual(playlist.last_modified, last_modified) - self.assertRaises(AttributeError, setattr, playlist, 'last_modified', - None) + self.assertRaises( + AttributeError, setattr, playlist, 'last_modified', None) def test_with_new_uri(self): tracks = [Track()] last_modified = datetime.datetime.now() - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + playlist = Playlist( + uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(uri=u'another uri') self.assertEqual(new_playlist.uri, u'another uri') @@ -589,7 +611,8 @@ class PlaylistTest(unittest.TestCase): def test_with_new_name(self): tracks = [Track()] last_modified = datetime.datetime.now() - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + playlist = Playlist( + uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(name=u'another name') self.assertEqual(new_playlist.uri, u'an uri') @@ -600,7 +623,8 @@ class PlaylistTest(unittest.TestCase): def test_with_new_tracks(self): tracks = [Track()] last_modified = datetime.datetime.now() - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + playlist = Playlist( + uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_tracks = [Track(), Track()] new_playlist = playlist.copy(tracks=new_tracks) @@ -613,7 +637,8 @@ class PlaylistTest(unittest.TestCase): tracks = [Track()] last_modified = datetime.datetime.now() new_last_modified = last_modified + datetime.timedelta(1) - playlist = Playlist(uri=u'an uri', name=u'a name', tracks=tracks, + playlist = Playlist( + uri=u'an uri', name=u'a name', tracks=tracks, last_modified=last_modified) new_playlist = playlist.copy(last_modified=new_last_modified) self.assertEqual(new_playlist.uri, u'an uri') @@ -666,7 +691,7 @@ class PlaylistTest(unittest.TestCase): self.assertEqual(playlist1, playlist2) self.assertEqual(hash(playlist1), hash(playlist2)) - def test_eq_uri(self): + def test_eq_last_modified(self): playlist1 = Playlist(last_modified=1) playlist2 = Playlist(last_modified=1) self.assertEqual(playlist1, playlist2) @@ -674,10 +699,10 @@ class PlaylistTest(unittest.TestCase): def test_eq(self): tracks = [Track()] - playlist1 = Playlist(uri=u'uri', name=u'name', tracks=tracks, - last_modified=1) - playlist2 = Playlist(uri=u'uri', name=u'name', tracks=tracks, - last_modified=1) + playlist1 = Playlist( + uri=u'uri', name=u'name', tracks=tracks, last_modified=1) + playlist2 = Playlist( + uri=u'uri', name=u'name', tracks=tracks, last_modified=1) self.assertEqual(playlist1, playlist2) self.assertEqual(hash(playlist1), hash(playlist2)) @@ -705,17 +730,18 @@ class PlaylistTest(unittest.TestCase): self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) - def test_ne_uri(self): + def test_ne_last_modified(self): playlist1 = Playlist(last_modified=1) playlist2 = Playlist(last_modified=2) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) def test_ne(self): - playlist1 = Playlist(uri=u'uri1', name=u'name2', - tracks=[Track(uri=u'uri1')], last_modified=1) - playlist2 = Playlist(uri=u'uri2', name=u'name2', - tracks=[Track(uri=u'uri2')], last_modified=2) + playlist1 = Playlist( + uri=u'uri1', name=u'name2', tracks=[Track(uri=u'uri1')], + last_modified=1) + playlist2 = Playlist( + uri=u'uri2', name=u'name2', tracks=[Track(uri=u'uri2')], + last_modified=2) self.assertNotEqual(playlist1, playlist2) self.assertNotEqual(hash(playlist1), hash(playlist2)) - diff --git a/tests/scanner_test.py b/tests/scanner_test.py index 91e67e11..6af48bb5 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -134,8 +134,8 @@ class ScannerTest(unittest.TestCase): self.data = {} def scan(self, path): - scanner = Scanner(path_to_data_dir(path), - self.data_callback, self.error_callback) + scanner = Scanner( + path_to_data_dir(path), self.data_callback, self.error_callback) scanner.start() def check(self, name, key, value): @@ -160,8 +160,9 @@ class ScannerTest(unittest.TestCase): def test_uri_is_set(self): self.scan('scanner/simple') - self.check('scanner/simple/song1.mp3', 'uri', 'file://' - + path_to_data_dir('scanner/simple/song1.mp3')) + self.check( + 'scanner/simple/song1.mp3', 'uri', + 'file://%s' % path_to_data_dir('scanner/simple/song1.mp3')) def test_duration_is_set(self): self.scan('scanner/simple') diff --git a/tests/utils/deps_test.py b/tests/utils/deps_test.py index f5aa0b1e..42c8b299 100644 --- a/tests/utils/deps_test.py +++ b/tests/utils/deps_test.py @@ -65,10 +65,12 @@ class DepsTest(unittest.TestCase): result = deps.gstreamer_info() self.assertEquals('GStreamer', result['name']) - self.assertEquals('.'.join(map(str, gst.get_gst_version())), result['version']) + self.assertEquals( + '.'.join(map(str, gst.get_gst_version())), result['version']) self.assertIn('gst', result['path']) self.assertIn('Python wrapper: gst-python', result['other']) - self.assertIn('.'.join(map(str, gst.get_pygst_version())), result['other']) + self.assertIn( + '.'.join(map(str, gst.get_pygst_version())), result['other']) self.assertIn('Relevant elements:', result['other']) def test_pykka_info(self): diff --git a/tests/utils/decode_test.py b/tests/utils/encoding_test.py similarity index 90% rename from tests/utils/decode_test.py rename to tests/utils/encoding_test.py index edbfe651..da50d9be 100644 --- a/tests/utils/decode_test.py +++ b/tests/utils/encoding_test.py @@ -1,11 +1,11 @@ import mock -from mopidy.utils import locale_decode +from mopidy.utils.encoding import locale_decode from tests import unittest -@mock.patch('mopidy.utils.locale.getpreferredencoding') +@mock.patch('mopidy.utils.encoding.locale.getpreferredencoding') class LocaleDecodeTest(unittest.TestCase): def test_can_decode_utf8_strings_with_french_content(self, mock): mock.return_value = 'UTF-8' diff --git a/tests/utils/init_test.py b/tests/utils/importing_test.py similarity index 68% rename from tests/utils/init_test.py rename to tests/utils/importing_test.py index bdd0adc5..271f9dbe 100644 --- a/tests/utils/init_test.py +++ b/tests/utils/importing_test.py @@ -1,4 +1,4 @@ -from mopidy import utils +from mopidy.utils import importing from tests import unittest @@ -6,22 +6,22 @@ from tests import unittest class GetClassTest(unittest.TestCase): def test_loading_module_that_does_not_exist(self): with self.assertRaises(ImportError): - utils.get_class('foo.bar.Baz') + importing.get_class('foo.bar.Baz') def test_loading_class_that_does_not_exist(self): with self.assertRaises(ImportError): - utils.get_class('unittest.FooBarBaz') + importing.get_class('unittest.FooBarBaz') def test_loading_incorrect_class_path(self): with self.assertRaises(ImportError): - utils.get_class('foobarbaz') + importing.get_class('foobarbaz') def test_import_error_message_contains_complete_class_path(self): try: - utils.get_class('foo.bar.Baz') + importing.get_class('foo.bar.Baz') except ImportError as e: self.assertIn('foo.bar.Baz', str(e)) def test_loading_existing_class(self): - cls = utils.get_class('unittest.TestCase') + cls = importing.get_class('unittest.TestCase') self.assertEqual(cls.__name__, 'TestCase') diff --git a/tests/utils/network/connection_test.py b/tests/utils/network/connection_test.py index 96ddb833..c9fe9a05 100644 --- a/tests/utils/network/connection_test.py +++ b/tests/utils/network/connection_test.py @@ -17,44 +17,50 @@ class ConnectionTest(unittest.TestCase): def test_init_ensure_nonblocking_io(self): sock = Mock(spec=socket.SocketType) - network.Connection.__init__(self.mock, Mock(), sock, - (sentinel.host, sentinel.port), sentinel.timeout) + network.Connection.__init__( + self.mock, Mock(), {}, sock, (sentinel.host, sentinel.port), + sentinel.timeout) sock.setblocking.assert_called_once_with(False) def test_init_starts_actor(self): protocol = Mock(spec=network.LineProtocol) - network.Connection.__init__(self.mock, protocol, Mock(), - (sentinel.host, sentinel.port), sentinel.timeout) + network.Connection.__init__( + self.mock, protocol, {}, Mock(), (sentinel.host, sentinel.port), + sentinel.timeout) protocol.start.assert_called_once_with(self.mock) def test_init_enables_recv_and_timeout(self): - network.Connection.__init__(self.mock, Mock(), Mock(), - (sentinel.host, sentinel.port), sentinel.timeout) + network.Connection.__init__( + self.mock, Mock(), {}, Mock(), (sentinel.host, sentinel.port), + sentinel.timeout) self.mock.enable_recv.assert_called_once_with() self.mock.enable_timeout.assert_called_once_with() def test_init_stores_values_in_attributes(self): addr = (sentinel.host, sentinel.port) protocol = Mock(spec=network.LineProtocol) + protocol_kwargs = {} sock = Mock(spec=socket.SocketType) network.Connection.__init__( - self.mock, protocol, sock, addr, sentinel.timeout) + self.mock, protocol, protocol_kwargs, sock, addr, sentinel.timeout) self.assertEqual(sock, self.mock.sock) self.assertEqual(protocol, self.mock.protocol) + self.assertEqual(protocol_kwargs, self.mock.protocol_kwargs) self.assertEqual(sentinel.timeout, self.mock.timeout) self.assertEqual(sentinel.host, self.mock.host) self.assertEqual(sentinel.port, self.mock.port) def test_init_handles_ipv6_addr(self): - addr = (sentinel.host, sentinel.port, - sentinel.flowinfo, sentinel.scopeid) + addr = ( + sentinel.host, sentinel.port, sentinel.flowinfo, sentinel.scopeid) protocol = Mock(spec=network.LineProtocol) + protocol_kwargs = {} sock = Mock(spec=socket.SocketType) network.Connection.__init__( - self.mock, protocol, sock, addr, sentinel.timeout) + self.mock, protocol, protocol_kwargs, sock, addr, sentinel.timeout) self.assertEqual(sentinel.host, self.mock.host) self.assertEqual(sentinel.port, self.mock.port) @@ -135,8 +141,8 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.mock.sock = Mock(spec=socket.SocketType) - network.Connection.stop(self.mock, sentinel.reason, - level=sentinel.level) + network.Connection.stop( + self.mock, sentinel.reason, level=sentinel.level) network.logger.log.assert_called_once_with( sentinel.level, sentinel.reason) @@ -157,7 +163,8 @@ class ConnectionTest(unittest.TestCase): gobject.io_add_watch.return_value = sentinel.tag network.Connection.enable_recv(self.mock) - gobject.io_add_watch.assert_called_once_with(sentinel.fileno, + gobject.io_add_watch.assert_called_once_with( + sentinel.fileno, gobject.IO_IN | gobject.IO_ERR | gobject.IO_HUP, self.mock.recv_callback) self.assertEqual(sentinel.tag, self.mock.recv_id) @@ -210,7 +217,8 @@ class ConnectionTest(unittest.TestCase): gobject.io_add_watch.return_value = sentinel.tag network.Connection.enable_send(self.mock) - gobject.io_add_watch.assert_called_once_with(sentinel.fileno, + gobject.io_add_watch.assert_called_once_with( + sentinel.fileno, gobject.IO_OUT | gobject.IO_ERR | gobject.IO_HUP, self.mock.send_callback) self.assertEqual(sentinel.tag, self.mock.send_id) @@ -267,8 +275,8 @@ class ConnectionTest(unittest.TestCase): gobject.timeout_add_seconds.return_value = sentinel.tag network.Connection.enable_timeout(self.mock) - gobject.timeout_add_seconds.assert_called_once_with(10, - self.mock.timeout_callback) + gobject.timeout_add_seconds.assert_called_once_with( + 10, self.mock.timeout_callback) self.assertEqual(sentinel.tag, self.mock.timeout_id) @patch.object(gobject, 'timeout_add_seconds', new=Mock()) @@ -356,24 +364,25 @@ class ConnectionTest(unittest.TestCase): self.mock.sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() - self.assertTrue(network.Connection.recv_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_respects_io_hup(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() - self.assertTrue(network.Connection.recv_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_respects_io_hup_and_io_err(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.actor_ref = Mock() - self.assertTrue(network.Connection.recv_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) + self.assertTrue(network.Connection.recv_callback( + self.mock, sentinel.fd, + gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_recv_callback_sends_data_to_actor(self): @@ -383,14 +392,14 @@ class ConnectionTest(unittest.TestCase): self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, gobject.IO_IN)) - self.mock.actor_ref.send_one_way.assert_called_once_with( + self.mock.actor_ref.tell.assert_called_once_with( {'received': 'data'}) def test_recv_callback_handles_dead_actors(self): self.mock.sock = Mock(spec=socket.SocketType) self.mock.sock.recv.return_value = 'data' self.mock.actor_ref = Mock() - self.mock.actor_ref.send_one_way.side_effect = pykka.ActorDeadError() + self.mock.actor_ref.tell.side_effect = pykka.ActorDeadError() self.assertTrue(network.Connection.recv_callback( self.mock, sentinel.fd, gobject.IO_IN)) @@ -429,8 +438,8 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.mock.send_buffer = '' - self.assertTrue(network.Connection.send_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_hup(self): @@ -440,8 +449,8 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.mock.send_buffer = '' - self.assertTrue(network.Connection.send_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, gobject.IO_IN | gobject.IO_HUP)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_respects_io_hup_and_io_err(self): @@ -451,8 +460,9 @@ class ConnectionTest(unittest.TestCase): self.mock.actor_ref = Mock() self.mock.send_buffer = '' - self.assertTrue(network.Connection.send_callback(self.mock, - sentinel.fd, gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) + self.assertTrue(network.Connection.send_callback( + self.mock, sentinel.fd, + gobject.IO_IN | gobject.IO_HUP | gobject.IO_ERR)) self.mock.stop.assert_called_once_with(any_unicode) def test_send_callback_acquires_and_releases_lock(self): diff --git a/tests/utils/network/lineprotocol_test.py b/tests/utils/network/lineprotocol_test.py index b323de09..9a19e12e 100644 --- a/tests/utils/network/lineprotocol_test.py +++ b/tests/utils/network/lineprotocol_test.py @@ -14,23 +14,23 @@ class LineProtocolTest(unittest.TestCase): self.mock.terminator = network.LineProtocol.terminator self.mock.encoding = network.LineProtocol.encoding - self.mock.delimeter = network.LineProtocol.delimeter + self.mock.delimiter = network.LineProtocol.delimiter self.mock.prevent_timeout = False def test_init_stores_values_in_attributes(self): - delimeter = re.compile(network.LineProtocol.terminator) + delimiter = re.compile(network.LineProtocol.terminator) network.LineProtocol.__init__(self.mock, sentinel.connection) self.assertEqual(sentinel.connection, self.mock.connection) self.assertEqual('', self.mock.recv_buffer) - self.assertEqual(delimeter, self.mock.delimeter) + self.assertEqual(delimiter, self.mock.delimiter) self.assertFalse(self.mock.prevent_timeout) - def test_init_compiles_delimeter(self): - self.mock.delimeter = '\r?\n' - delimeter = re.compile('\r?\n') + def test_init_compiles_delimiter(self): + self.mock.delimiter = '\r?\n' + delimiter = re.compile('\r?\n') network.LineProtocol.__init__(self.mock, sentinel.connection) - self.assertEqual(delimeter, self.mock.delimeter) + self.assertEqual(delimiter, self.mock.delimiter) def test_on_receive_no_new_lines_adds_to_recv_buffer(self): self.mock.connection = Mock(spec=network.Connection) @@ -103,26 +103,26 @@ class LineProtocolTest(unittest.TestCase): self.mock.parse_lines.return_value = ['line1', 'line2'] self.mock.decode.return_value = sentinel.decoded - network.LineProtocol.on_receive(self.mock, - {'received': 'line1\nline2\n'}) + network.LineProtocol.on_receive( + self.mock, {'received': 'line1\nline2\n'}) self.assertEqual(2, self.mock.on_line_received.call_count) def test_parse_lines_emtpy_buffer(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = '' lines = network.LineProtocol.parse_lines(self.mock) self.assertRaises(StopIteration, lines.next) def test_parse_lines_no_terminator(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data' lines = network.LineProtocol.parse_lines(self.mock) self.assertRaises(StopIteration, lines.next) def test_parse_lines_termintor(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data\n' lines = network.LineProtocol.parse_lines(self.mock) @@ -131,7 +131,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_termintor_with_carriage_return(self): - self.mock.delimeter = re.compile(r'\r?\n') + self.mock.delimiter = re.compile(r'\r?\n') self.mock.recv_buffer = 'data\r\n' lines = network.LineProtocol.parse_lines(self.mock) @@ -140,7 +140,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_no_data_before_terminator(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = '\n' lines = network.LineProtocol.parse_lines(self.mock) @@ -149,7 +149,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_extra_data_after_terminator(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data1\ndata2' lines = network.LineProtocol.parse_lines(self.mock) @@ -158,7 +158,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('data2', self.mock.recv_buffer) def test_parse_lines_unicode(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = u'æøå\n'.encode('utf-8') lines = network.LineProtocol.parse_lines(self.mock) @@ -167,7 +167,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('', self.mock.recv_buffer) def test_parse_lines_multiple_lines(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'abc\ndef\nghi\njkl' lines = network.LineProtocol.parse_lines(self.mock) @@ -178,7 +178,7 @@ class LineProtocolTest(unittest.TestCase): self.assertEqual('jkl', self.mock.recv_buffer) def test_parse_lines_multiple_calls(self): - self.mock.delimeter = re.compile(r'\n') + self.mock.delimiter = re.compile(r'\n') self.mock.recv_buffer = 'data1' lines = network.LineProtocol.parse_lines(self.mock) diff --git a/tests/utils/network/server_test.py b/tests/utils/network/server_test.py index e0399525..6090077d 100644 --- a/tests/utils/network/server_test.py +++ b/tests/utils/network/server_test.py @@ -13,8 +13,8 @@ class ServerTest(unittest.TestCase): self.mock = Mock(spec=network.Server) def test_init_calls_create_server_socket(self): - network.Server.__init__(self.mock, sentinel.host, - sentinel.port, sentinel.protocol) + network.Server.__init__( + self.mock, sentinel.host, sentinel.port, sentinel.protocol) self.mock.create_server_socket.assert_called_once_with( sentinel.host, sentinel.port) @@ -23,8 +23,8 @@ class ServerTest(unittest.TestCase): sock.fileno.return_value = sentinel.fileno self.mock.create_server_socket.return_value = sock - network.Server.__init__(self.mock, sentinel.host, - sentinel.port, sentinel.protocol) + network.Server.__init__( + self.mock, sentinel.host, sentinel.port, sentinel.protocol) self.mock.register_server_socket.assert_called_once_with( sentinel.fileno) @@ -33,17 +33,18 @@ class ServerTest(unittest.TestCase): sock.fileno.side_effect = socket.error self.mock.create_server_socket.return_value = sock - self.assertRaises(socket.error, network.Server.__init__, - self.mock, sentinel.host, sentinel.port, sentinel.protocol) + self.assertRaises( + socket.error, network.Server.__init__, self.mock, sentinel.host, + sentinel.port, sentinel.protocol) def test_init_stores_values_in_attributes(self): # This need to be a mock and no a sentinel as fileno() is called on it sock = Mock(spec=socket.SocketType) self.mock.create_server_socket.return_value = sock - network.Server.__init__(self.mock, sentinel.host, sentinel.port, - sentinel.protocol, max_connections=sentinel.max_connections, - timeout=sentinel.timeout) + network.Server.__init__( + self.mock, sentinel.host, sentinel.port, sentinel.protocol, + max_connections=sentinel.max_connections, timeout=sentinel.timeout) self.assertEqual(sentinel.protocol, self.mock.protocol) self.assertEqual(sentinel.max_connections, self.mock.max_connections) self.assertEqual(sentinel.timeout, self.mock.timeout) @@ -53,8 +54,8 @@ class ServerTest(unittest.TestCase): def test_create_server_socket_sets_up_listener(self, create_socket): sock = create_socket.return_value - network.Server.create_server_socket(self.mock, - sentinel.host, sentinel.port) + network.Server.create_server_socket( + self.mock, sentinel.host, sentinel.port) sock.setblocking.assert_called_once_with(False) sock.bind.assert_called_once_with((sentinel.host, sentinel.port)) sock.listen.assert_called_once_with(any_int) @@ -62,30 +63,33 @@ class ServerTest(unittest.TestCase): @patch.object(network, 'create_socket', new=Mock()) def test_create_server_socket_fails(self): network.create_socket.side_effect = socket.error - self.assertRaises(socket.error, network.Server.create_server_socket, - self.mock, sentinel.host, sentinel.port) + self.assertRaises( + socket.error, network.Server.create_server_socket, self.mock, + sentinel.host, sentinel.port) @patch.object(network, 'create_socket', new=Mock()) def test_create_server_bind_fails(self): sock = network.create_socket.return_value sock.bind.side_effect = socket.error - self.assertRaises(socket.error, network.Server.create_server_socket, - self.mock, sentinel.host, sentinel.port) + self.assertRaises( + socket.error, network.Server.create_server_socket, self.mock, + sentinel.host, sentinel.port) @patch.object(network, 'create_socket', new=Mock()) def test_create_server_listen_fails(self): sock = network.create_socket.return_value sock.listen.side_effect = socket.error - self.assertRaises(socket.error, network.Server.create_server_socket, - self.mock, sentinel.host, sentinel.port) + self.assertRaises( + socket.error, network.Server.create_server_socket, self.mock, + sentinel.host, sentinel.port) @patch.object(gobject, 'io_add_watch', new=Mock()) def test_register_server_socket_sets_up_io_watch(self): network.Server.register_server_socket(self.mock, sentinel.fileno) - gobject.io_add_watch.assert_called_once_with(sentinel.fileno, - gobject.IO_IN, self.mock.handle_connection) + gobject.io_add_watch.assert_called_once_with( + sentinel.fileno, gobject.IO_IN, self.mock.handle_connection) def test_handle_connection(self): self.mock.accept_connection.return_value = ( @@ -128,7 +132,8 @@ class ServerTest(unittest.TestCase): for error in (errno.EAGAIN, errno.EINTR): sock.accept.side_effect = socket.error(error, '') - self.assertRaises(network.ShouldRetrySocketCall, + self.assertRaises( + network.ShouldRetrySocketCall, network.Server.accept_connection, self.mock) # FIXME decide if this should be allowed to propegate @@ -136,8 +141,8 @@ class ServerTest(unittest.TestCase): sock = Mock(spec=socket.SocketType) self.mock.server_socket = sock sock.accept.side_effect = socket.error - self.assertRaises(socket.error, - network.Server.accept_connection, self.mock) + self.assertRaises( + socket.error, network.Server.accept_connection, self.mock) def test_maximum_connections_exceeded(self): self.mock.max_connections = 10 @@ -149,7 +154,8 @@ class ServerTest(unittest.TestCase): self.assertTrue(network.Server.maximum_connections_exceeded(self.mock)) self.mock.number_of_connections.return_value = 9 - self.assertFalse(network.Server.maximum_connections_exceeded(self.mock)) + self.assertFalse( + network.Server.maximum_connections_exceeded(self.mock)) @patch('pykka.registry.ActorRegistry.get_by_class') def test_number_of_connections(self, get_by_class): @@ -164,23 +170,25 @@ class ServerTest(unittest.TestCase): @patch.object(network, 'Connection', new=Mock()) def test_init_connection(self): self.mock.protocol = sentinel.protocol + self.mock.protocol_kwargs = {} self.mock.timeout = sentinel.timeout network.Server.init_connection(self.mock, sentinel.sock, sentinel.addr) - network.Connection.assert_called_once_with(sentinel.protocol, - sentinel.sock, sentinel.addr, sentinel.timeout) + network.Connection.assert_called_once_with( + sentinel.protocol, {}, sentinel.sock, sentinel.addr, + sentinel.timeout) def test_reject_connection(self): sock = Mock(spec=socket.SocketType) - network.Server.reject_connection(self.mock, sock, - (sentinel.host, sentinel.port)) + network.Server.reject_connection( + self.mock, sock, (sentinel.host, sentinel.port)) sock.close.assert_called_once_with() def test_reject_connection_error(self): sock = Mock(spec=socket.SocketType) sock.close.side_effect = socket.error - network.Server.reject_connection(self.mock, sock, - (sentinel.host, sentinel.port)) + network.Server.reject_connection( + self.mock, sock, (sentinel.host, sentinel.port)) sock.close.assert_called_once_with() diff --git a/tests/utils/network/utils_test.py b/tests/utils/network/utils_test.py index 1e11673e..f28aeb4b 100644 --- a/tests/utils/network/utils_test.py +++ b/tests/utils/network/utils_test.py @@ -42,15 +42,15 @@ class CreateSocketTest(unittest.TestCase): @patch('socket.socket') def test_ipv4_socket(self, socket_mock): network.create_socket() - self.assertEqual(socket_mock.call_args[0], - (socket.AF_INET, socket.SOCK_STREAM)) + self.assertEqual( + socket_mock.call_args[0], (socket.AF_INET, socket.SOCK_STREAM)) @patch('mopidy.utils.network.has_ipv6', True) @patch('socket.socket') def test_ipv6_socket(self, socket_mock): network.create_socket() - self.assertEqual(socket_mock.call_args[0], - (socket.AF_INET6, socket.SOCK_STREAM)) + self.assertEqual( + socket_mock.call_args[0], (socket.AF_INET6, socket.SOCK_STREAM)) @unittest.SkipTest def test_ipv6_only_is_set(self): diff --git a/tests/utils/path_test.py b/tests/utils/path_test.py index 19bae375..91951ac7 100644 --- a/tests/utils/path_test.py +++ b/tests/utils/path_test.py @@ -1,12 +1,12 @@ # encoding: utf-8 +import glib import os import shutil import sys import tempfile -from mopidy.utils.path import (get_or_create_folder, mtime, - path_to_uri, uri_to_path, split_path, find_files) +from mopidy.utils import path from tests import unittest, path_to_data_dir @@ -23,7 +23,7 @@ class GetOrCreateFolderTest(unittest.TestCase): folder = os.path.join(self.parent, 'test') self.assert_(not os.path.exists(folder)) self.assert_(not os.path.isdir(folder)) - created = get_or_create_folder(folder) + created = path.get_or_create_folder(folder) self.assert_(os.path.exists(folder)) self.assert_(os.path.isdir(folder)) self.assertEqual(created, folder) @@ -35,7 +35,7 @@ class GetOrCreateFolderTest(unittest.TestCase): self.assert_(not os.path.isdir(level2_folder)) self.assert_(not os.path.exists(level3_folder)) self.assert_(not os.path.isdir(level3_folder)) - created = get_or_create_folder(level3_folder) + created = path.get_or_create_folder(level3_folder) self.assert_(os.path.exists(level2_folder)) self.assert_(os.path.isdir(level2_folder)) self.assert_(os.path.exists(level3_folder)) @@ -43,7 +43,7 @@ class GetOrCreateFolderTest(unittest.TestCase): self.assertEqual(created, level3_folder) def test_creating_existing_folder(self): - created = get_or_create_folder(self.parent) + created = path.get_or_create_folder(self.parent) self.assert_(os.path.exists(self.parent)) self.assert_(os.path.isdir(self.parent)) self.assertEqual(created, self.parent) @@ -52,92 +52,117 @@ class GetOrCreateFolderTest(unittest.TestCase): conflicting_file = os.path.join(self.parent, 'test') open(conflicting_file, 'w').close() folder = os.path.join(self.parent, 'test') - self.assertRaises(OSError, get_or_create_folder, folder) + self.assertRaises(OSError, path.get_or_create_folder, folder) class PathToFileURITest(unittest.TestCase): def test_simple_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/WINDOWS/clock.avi') + result = path.path_to_uri(u'C:/WINDOWS/clock.avi') self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') else: - result = path_to_uri(u'/etc/fstab') + result = path.path_to_uri(u'/etc/fstab') self.assertEqual(result, 'file:///etc/fstab') def test_folder_and_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/WINDOWS/', u'clock.avi') + result = path.path_to_uri(u'C:/WINDOWS/', u'clock.avi') self.assertEqual(result, 'file:///C://WINDOWS/clock.avi') else: - result = path_to_uri(u'/etc', u'fstab') + result = path.path_to_uri(u'/etc', u'fstab') self.assertEqual(result, u'file:///etc/fstab') def test_space_in_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/test this') + result = path.path_to_uri(u'C:/test this') self.assertEqual(result, 'file:///C://test%20this') else: - result = path_to_uri(u'/tmp/test this') + result = path.path_to_uri(u'/tmp/test this') self.assertEqual(result, u'file:///tmp/test%20this') def test_unicode_in_path(self): if sys.platform == 'win32': - result = path_to_uri(u'C:/æøå') + result = path.path_to_uri(u'C:/æøå') self.assertEqual(result, 'file:///C://%C3%A6%C3%B8%C3%A5') else: - result = path_to_uri(u'/tmp/æøå') + result = path.path_to_uri(u'/tmp/æøå') self.assertEqual(result, u'file:///tmp/%C3%A6%C3%B8%C3%A5') class UriToPathTest(unittest.TestCase): def test_simple_uri(self): if sys.platform == 'win32': - result = uri_to_path('file:///C://WINDOWS/clock.avi') + result = path.uri_to_path('file:///C://WINDOWS/clock.avi') self.assertEqual(result, u'C:/WINDOWS/clock.avi') else: - result = uri_to_path('file:///etc/fstab') + result = path.uri_to_path('file:///etc/fstab') self.assertEqual(result, u'/etc/fstab') def test_space_in_uri(self): if sys.platform == 'win32': - result = uri_to_path('file:///C://test%20this') + result = path.uri_to_path('file:///C://test%20this') self.assertEqual(result, u'C:/test this') else: - result = uri_to_path(u'file:///tmp/test%20this') + result = path.uri_to_path(u'file:///tmp/test%20this') self.assertEqual(result, u'/tmp/test this') def test_unicode_in_uri(self): if sys.platform == 'win32': - result = uri_to_path( 'file:///C://%C3%A6%C3%B8%C3%A5') + result = path.uri_to_path('file:///C://%C3%A6%C3%B8%C3%A5') self.assertEqual(result, u'C:/æøå') else: - result = uri_to_path(u'file:///tmp/%C3%A6%C3%B8%C3%A5') + result = path.uri_to_path(u'file:///tmp/%C3%A6%C3%B8%C3%A5') self.assertEqual(result, u'/tmp/æøå') class SplitPathTest(unittest.TestCase): def test_empty_path(self): - self.assertEqual([], split_path('')) + self.assertEqual([], path.split_path('')) def test_single_folder(self): - self.assertEqual(['foo'], split_path('foo')) + self.assertEqual(['foo'], path.split_path('foo')) def test_folders(self): - self.assertEqual(['foo', 'bar', 'baz'], split_path('foo/bar/baz')) - - def test_folders(self): - self.assertEqual(['foo', 'bar', 'baz'], split_path('foo/bar/baz')) + self.assertEqual(['foo', 'bar', 'baz'], path.split_path('foo/bar/baz')) def test_initial_slash_is_ignored(self): - self.assertEqual(['foo', 'bar', 'baz'], split_path('/foo/bar/baz')) + self.assertEqual( + ['foo', 'bar', 'baz'], path.split_path('/foo/bar/baz')) def test_only_slash(self): - self.assertEqual([], split_path('/')) + self.assertEqual([], path.split_path('/')) + + +class ExpandPathTest(unittest.TestCase): + # TODO: test via mocks? + + def test_empty_path(self): + self.assertEqual(os.path.abspath('.'), path.expand_path('')) + + def test_absolute_path(self): + self.assertEqual('/tmp/foo', path.expand_path('/tmp/foo')) + + def test_home_dir_expansion(self): + self.assertEqual( + os.path.expanduser('~/foo'), path.expand_path('~/foo')) + + def test_abspath(self): + self.assertEqual(os.path.abspath('./foo'), path.expand_path('./foo')) + + def test_xdg_subsititution(self): + self.assertEqual( + glib.get_user_data_dir() + '/foo', + path.expand_path('$XDG_DATA_DIR/foo')) + + def test_xdg_subsititution_unknown(self): + self.assertEqual( + '/tmp/$XDG_INVALID_DIR/foo', + path.expand_path('/tmp/$XDG_INVALID_DIR/foo')) class FindFilesTest(unittest.TestCase): - def find(self, path): - return list(find_files(path_to_data_dir(path))) + def find(self, value): + return list(path.find_files(path_to_data_dir(value))) def test_basic_folder(self): self.assert_(self.find('')) @@ -153,18 +178,24 @@ class FindFilesTest(unittest.TestCase): def test_names_are_unicode(self): is_unicode = lambda f: isinstance(f, unicode) for name in self.find(''): - self.assert_(is_unicode(name), - '%s is not unicode object' % repr(name)) + self.assert_( + is_unicode(name), '%s is not unicode object' % repr(name)) + + def test_ignores_hidden_folders(self): + self.assertEqual(self.find('.hidden'), []) + + def test_ignores_hidden_files(self): + self.assertEqual(self.find('.blank.mp3'), []) class MtimeTest(unittest.TestCase): def tearDown(self): - mtime.undo_fake() + path.mtime.undo_fake() def test_mtime_of_current_dir(self): mtime_dir = int(os.stat('.').st_mtime) - self.assertEqual(mtime_dir, mtime('.')) + self.assertEqual(mtime_dir, path.mtime('.')) def test_fake_time_is_returned(self): - mtime.set_fake_time(123456) - self.assertEqual(mtime('.'), 123456) + path.mtime.set_fake_time(123456) + self.assertEqual(path.mtime('.'), 123456) diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index cf476c24..c98527cd 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -1,6 +1,6 @@ import os -import mopidy +from mopidy import exceptions, settings from mopidy.utils import settings as setting_utils from tests import unittest @@ -9,6 +9,8 @@ from tests import unittest class ValidateSettingsTest(unittest.TestCase): def setUp(self): self.defaults = { + 'BACKENDS': ['a'], + 'FRONTENDS': ['a'], 'MPD_SERVER_HOSTNAME': '::', 'MPD_SERVER_PORT': 6600, 'SPOTIFY_BITRATE': 160, @@ -19,41 +21,37 @@ class ValidateSettingsTest(unittest.TestCase): self.assertEqual(result, {}) def test_unknown_setting_returns_error(self): - result = setting_utils.validate_settings(self.defaults, - {'MPD_SERVER_HOSTNMAE': '127.0.0.1'}) - self.assertEqual(result['MPD_SERVER_HOSTNMAE'], + result = setting_utils.validate_settings( + self.defaults, {'MPD_SERVER_HOSTNMAE': '127.0.0.1'}) + self.assertEqual( + result['MPD_SERVER_HOSTNMAE'], u'Unknown setting. Did you mean MPD_SERVER_HOSTNAME?') def test_not_renamed_setting_returns_error(self): - result = setting_utils.validate_settings(self.defaults, - {'SERVER_HOSTNAME': '127.0.0.1'}) - self.assertEqual(result['SERVER_HOSTNAME'], + result = setting_utils.validate_settings( + self.defaults, {'SERVER_HOSTNAME': '127.0.0.1'}) + self.assertEqual( + result['SERVER_HOSTNAME'], u'Deprecated setting. Use MPD_SERVER_HOSTNAME.') def test_unneeded_settings_returns_error(self): - result = setting_utils.validate_settings(self.defaults, - {'SPOTIFY_LIB_APPKEY': '/tmp/foo'}) - self.assertEqual(result['SPOTIFY_LIB_APPKEY'], + result = setting_utils.validate_settings( + self.defaults, {'SPOTIFY_LIB_APPKEY': '/tmp/foo'}) + self.assertEqual( + result['SPOTIFY_LIB_APPKEY'], u'Deprecated setting. It may be removed.') - def test_deprecated_setting_value_returns_error(self): - result = setting_utils.validate_settings(self.defaults, - {'BACKENDS': ('mopidy.backends.despotify.DespotifyBackend',)}) - self.assertEqual(result['BACKENDS'], - u'Deprecated setting value. ' + - '"mopidy.backends.despotify.DespotifyBackend" is no longer ' + - 'available.') - def test_unavailable_bitrate_setting_returns_error(self): - result = setting_utils.validate_settings(self.defaults, - {'SPOTIFY_BITRATE': 50}) - self.assertEqual(result['SPOTIFY_BITRATE'], - u'Unavailable Spotify bitrate. ' + + result = setting_utils.validate_settings( + self.defaults, {'SPOTIFY_BITRATE': 50}) + self.assertEqual( + result['SPOTIFY_BITRATE'], + u'Unavailable Spotify bitrate. ' u'Available bitrates are 96, 160, and 320.') def test_two_errors_are_both_reported(self): - result = setting_utils.validate_settings(self.defaults, - {'FOO': '', 'BAR': ''}) + result = setting_utils.validate_settings( + self.defaults, {'FOO': '', 'BAR': ''}) self.assertEqual(len(result), 2) def test_masks_value_if_secret(self): @@ -61,17 +59,31 @@ class ValidateSettingsTest(unittest.TestCase): self.assertEqual(u'********', secret) def test_does_not_mask_value_if_not_secret(self): - not_secret = setting_utils.mask_value_if_secret('SPOTIFY_USERNAME', 'foo') + not_secret = setting_utils.mask_value_if_secret( + 'SPOTIFY_USERNAME', 'foo') self.assertEqual('foo', not_secret) def test_does_not_mask_value_if_none(self): - not_secret = setting_utils.mask_value_if_secret('SPOTIFY_USERNAME', None) + not_secret = setting_utils.mask_value_if_secret( + 'SPOTIFY_USERNAME', None) self.assertEqual(None, not_secret) + def test_empty_frontends_list_returns_error(self): + result = setting_utils.validate_settings( + self.defaults, {'FRONTENDS': []}) + self.assertEqual( + result['FRONTENDS'], u'Must contain at least one value.') + + def test_empty_backends_list_returns_error(self): + result = setting_utils.validate_settings( + self.defaults, {'BACKENDS': []}) + self.assertEqual( + result['BACKENDS'], u'Must contain at least one value.') + class SettingsProxyTest(unittest.TestCase): def setUp(self): - self.settings = setting_utils.SettingsProxy(mopidy.settings) + self.settings = setting_utils.SettingsProxy(settings) self.settings.local.clear() def test_set_and_get_attr(self): @@ -80,17 +92,17 @@ class SettingsProxyTest(unittest.TestCase): def test_getattr_raises_error_on_missing_setting(self): try: - _ = self.settings.TEST + self.settings.TEST self.fail(u'Should raise exception') - except mopidy.SettingsError as e: + except exceptions.SettingsError as e: self.assertEqual(u'Setting "TEST" is not set.', e.message) def test_getattr_raises_error_on_empty_setting(self): self.settings.TEST = u'' try: - _ = self.settings.TEST + self.settings.TEST self.fail(u'Should raise exception') - except mopidy.SettingsError as e: + except exceptions.SettingsError as e: self.assertEqual(u'Setting "TEST" is empty.', e.message) def test_getattr_does_not_raise_error_if_setting_is_false(self): @@ -176,7 +188,7 @@ class SettingsProxyTest(unittest.TestCase): class FormatSettingListTest(unittest.TestCase): def setUp(self): - self.settings = setting_utils.SettingsProxy(mopidy.settings) + self.settings = setting_utils.SettingsProxy(settings) def test_contains_the_setting_name(self): self.settings.TEST = u'test' @@ -207,15 +219,19 @@ class FormatSettingListTest(unittest.TestCase): def test_short_values_are_not_pretty_printed(self): self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend',) result = setting_utils.format_settings_list(self.settings) - self.assertIn("FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)", result) + self.assertIn( + "FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)", result) def test_long_values_are_pretty_printed(self): - self.settings.FRONTEND = (u'mopidy.frontends.mpd.MpdFrontend', + self.settings.FRONTEND = ( + u'mopidy.frontends.mpd.MpdFrontend', u'mopidy.frontends.lastfm.LastfmFrontend') result = setting_utils.format_settings_list(self.settings) - self.assert_("""FRONTEND: - (u'mopidy.frontends.mpd.MpdFrontend', - u'mopidy.frontends.lastfm.LastfmFrontend')""" in result, result) + self.assertIn( + "FRONTEND: \n" + " (u'mopidy.frontends.mpd.MpdFrontend',\n" + " u'mopidy.frontends.lastfm.LastfmFrontend')", + result) class DidYouMeanTest(unittest.TestCase): diff --git a/tests/version_test.py b/tests/version_test.py index 85b182f0..2689a716 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -1,7 +1,6 @@ from distutils.version import StrictVersion as SV -import platform -from mopidy import __version__, get_platform, get_python +from mopidy import __version__ from tests import unittest @@ -11,30 +10,23 @@ class VersionTest(unittest.TestCase): SV(__version__) def test_versions_can_be_strictly_ordered(self): - self.assert_(SV('0.1.0a0') < SV('0.1.0a1')) - self.assert_(SV('0.1.0a1') < SV('0.1.0a2')) - self.assert_(SV('0.1.0a2') < SV('0.1.0a3')) - self.assert_(SV('0.1.0a3') < SV('0.1.0')) - self.assert_(SV('0.1.0') < SV('0.2.0')) - self.assert_(SV('0.1.0') < SV('1.0.0')) - self.assert_(SV('0.2.0') < SV('0.3.0')) - self.assert_(SV('0.3.0') < SV('0.3.1')) - self.assert_(SV('0.3.1') < SV('0.4.0')) - self.assert_(SV('0.4.0') < SV('0.4.1')) - self.assert_(SV('0.4.1') < SV('0.5.0')) - self.assert_(SV('0.5.0') < SV('0.6.0')) - self.assert_(SV('0.6.0') < SV('0.6.1')) - self.assert_(SV('0.6.1') < SV('0.7.0')) - self.assert_(SV('0.7.0') < SV('0.7.1')) - self.assert_(SV('0.7.1') < SV('0.7.2')) - self.assert_(SV('0.7.2') < SV(__version__)) - self.assert_(SV(__version__) < SV('0.8.0')) - - def test_get_platform_contains_platform(self): - self.assertIn(platform.platform(), get_platform()) - - def test_get_python_contains_python_implementation(self): - self.assertIn(platform.python_implementation(), get_python()) - - def test_get_python_contains_python_version(self): - self.assertIn(platform.python_version(), get_python()) + self.assertLess(SV('0.1.0a0'), SV('0.1.0a1')) + self.assertLess(SV('0.1.0a1'), SV('0.1.0a2')) + self.assertLess(SV('0.1.0a2'), SV('0.1.0a3')) + self.assertLess(SV('0.1.0a3'), SV('0.1.0')) + self.assertLess(SV('0.1.0'), SV('0.2.0')) + self.assertLess(SV('0.1.0'), SV('1.0.0')) + self.assertLess(SV('0.2.0'), SV('0.3.0')) + self.assertLess(SV('0.3.0'), SV('0.3.1')) + self.assertLess(SV('0.3.1'), SV('0.4.0')) + self.assertLess(SV('0.4.0'), SV('0.4.1')) + self.assertLess(SV('0.4.1'), SV('0.5.0')) + self.assertLess(SV('0.5.0'), SV('0.6.0')) + self.assertLess(SV('0.6.0'), SV('0.6.1')) + self.assertLess(SV('0.6.1'), SV('0.7.0')) + self.assertLess(SV('0.7.0'), SV('0.7.1')) + self.assertLess(SV('0.7.1'), SV('0.7.2')) + self.assertLess(SV('0.7.2'), SV('0.7.3')) + self.assertLess(SV('0.7.3'), SV('0.8.0')) + self.assertLess(SV('0.8.0'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.8.2')) diff --git a/tools/debug-proxy.py b/tools/debug-proxy.py index 2f54ea36..4fb39b5b 100755 --- a/tools/debug-proxy.py +++ b/tools/debug-proxy.py @@ -6,7 +6,7 @@ import sys from gevent import select, server, socket -COLORS = ['\033[1;%dm' % (30+i) for i in range(8)] +COLORS = ['\033[1;%dm' % (30 + i) for i in range(8)] BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = COLORS RESET = "\033[0m" BOLD = "\033[1m" @@ -53,7 +53,8 @@ def loop(client, address, reference, actual): # Consume banners from backends responses = dict() - disconnected = read([reference, actual], responses, find_response_end_token) + disconnected = read( + [reference, actual], responses, find_response_end_token) diff(address, '', responses[reference], responses[actual]) # We lost a backend, might as well give up. @@ -78,13 +79,15 @@ def loop(client, address, reference, actual): actual.sendall(responses[client]) # Get the entire resonse from both backends. - disconnected = read([reference, actual], responses, find_response_end_token) + disconnected = read( + [reference, actual], responses, find_response_end_token) # Send the client the complete reference response client.sendall(responses[reference]) # Compare our responses - diff(address, responses[client], responses[reference], responses[actual]) + diff(address, + responses[client], responses[reference], responses[actual]) # Give up if we lost a backend. if disconnected: diff --git a/tools/idle.py b/tools/idle.py index aa56dce2..fc9cb021 100644 --- a/tools/idle.py +++ b/tools/idle.py @@ -17,98 +17,98 @@ data = {'id': None, 'id2': None, 'url': url, 'artist': artist} # Commands to run before test requests to coerce MPD into right state setup_requests = [ - 'clear', - 'add "%(url)s"', - 'add "%(url)s"', - 'add "%(url)s"', - 'play', -# 'pause', # Uncomment to test paused idle behaviour -# 'stop', # Uncomment to test stopped idle behaviour + 'clear', + 'add "%(url)s"', + 'add "%(url)s"', + 'add "%(url)s"', + 'play', + #'pause', # Uncomment to test paused idle behaviour + #'stop', # Uncomment to test stopped idle behaviour ] # List of commands to test for idle behaviour. Ordering of list is important in # order to keep MPD state as intended. Commands that are obviously # informational only or "harmfull" have been excluded. test_requests = [ - 'add "%(url)s"', - 'addid "%(url)s" "1"', - 'clear', -# 'clearerror', -# 'close', -# 'commands', - 'consume "1"', - 'consume "0"', -# 'count', - 'crossfade "1"', - 'crossfade "0"', -# 'currentsong', -# 'delete "1:2"', - 'delete "0"', - 'deleteid "%(id)s"', - 'disableoutput "0"', - 'enableoutput "0"', -# 'find', -# 'findadd "artist" "%(artist)s"', -# 'idle', -# 'kill', -# 'list', -# 'listall', -# 'listallinfo', -# 'listplaylist', -# 'listplaylistinfo', -# 'listplaylists', -# 'lsinfo', - 'move "0:1" "2"', - 'move "0" "1"', - 'moveid "%(id)s" "1"', - 'next', -# 'notcommands', -# 'outputs', -# 'password', - 'pause', -# 'ping', - 'play', - 'playid "%(id)s"', -# 'playlist', - 'playlistadd "foo" "%(url)s"', - 'playlistclear "foo"', - 'playlistadd "foo" "%(url)s"', - 'playlistdelete "foo" "0"', -# 'playlistfind', -# 'playlistid', -# 'playlistinfo', - 'playlistadd "foo" "%(url)s"', - 'playlistadd "foo" "%(url)s"', - 'playlistmove "foo" "0" "1"', -# 'playlistsearch', -# 'plchanges', -# 'plchangesposid', - 'previous', - 'random "1"', - 'random "0"', - 'rm "bar"', - 'rename "foo" "bar"', - 'repeat "0"', - 'rm "bar"', - 'save "bar"', - 'load "bar"', -# 'search', - 'seek "1" "10"', - 'seekid "%(id)s" "10"', -# 'setvol "10"', - 'shuffle', - 'shuffle "0:1"', - 'single "1"', - 'single "0"', -# 'stats', -# 'status', - 'stop', - 'swap "1" "2"', - 'swapid "%(id)s" "%(id2)s"', -# 'tagtypes', -# 'update', -# 'urlhandlers', -# 'volume', + 'add "%(url)s"', + 'addid "%(url)s" "1"', + 'clear', + #'clearerror', + #'close', + #'commands', + 'consume "1"', + 'consume "0"', + # 'count', + 'crossfade "1"', + 'crossfade "0"', + #'currentsong', + #'delete "1:2"', + 'delete "0"', + 'deleteid "%(id)s"', + 'disableoutput "0"', + 'enableoutput "0"', + #'find', + #'findadd "artist" "%(artist)s"', + #'idle', + #'kill', + #'list', + #'listall', + #'listallinfo', + #'listplaylist', + #'listplaylistinfo', + #'listplaylists', + #'lsinfo', + 'move "0:1" "2"', + 'move "0" "1"', + 'moveid "%(id)s" "1"', + 'next', + #'notcommands', + #'outputs', + #'password', + 'pause', + #'ping', + 'play', + 'playid "%(id)s"', + #'playlist', + 'playlistadd "foo" "%(url)s"', + 'playlistclear "foo"', + 'playlistadd "foo" "%(url)s"', + 'playlistdelete "foo" "0"', + #'playlistfind', + #'playlistid', + #'playlistinfo', + 'playlistadd "foo" "%(url)s"', + 'playlistadd "foo" "%(url)s"', + 'playlistmove "foo" "0" "1"', + #'playlistsearch', + #'plchanges', + #'plchangesposid', + 'previous', + 'random "1"', + 'random "0"', + 'rm "bar"', + 'rename "foo" "bar"', + 'repeat "0"', + 'rm "bar"', + 'save "bar"', + 'load "bar"', + #'search', + 'seek "1" "10"', + 'seekid "%(id)s" "10"', + #'setvol "10"', + 'shuffle', + 'shuffle "0:1"', + 'single "1"', + 'single "0"', + #'stats', + #'status', + 'stop', + 'swap "1" "2"', + 'swapid "%(id)s" "%(id2)s"', + #'tagtypes', + #'update', + #'urlhandlers', + #'volume', ] @@ -116,8 +116,8 @@ def create_socketfile(): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect((host, port)) sock.settimeout(0.5) - fd = sock.makefile('rw', 1) # 1 = line buffered - fd.readline() # Read banner + fd = sock.makefile('rw', 1) # 1 = line buffered + fd.readline() # Read banner return fd