diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000..15d8f359 --- /dev/null +++ b/.mailmap @@ -0,0 +1,4 @@ +Kristian Klette +Johannes Knutsen +Johannes Knutsen +John Bäckstrand diff --git a/docs/api/audio.rst b/docs/api/audio.rst new file mode 100644 index 00000000..d5fb5dd9 --- /dev/null +++ b/docs/api/audio.rst @@ -0,0 +1,19 @@ +.. _audio-api: + +********* +Audio API +********* + +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: + + +.. autoclass:: mopidy.audio.Audio + :members: diff --git a/docs/api/backends/providers.rst b/docs/api/backends.rst similarity index 56% rename from docs/api/backends/providers.rst rename to docs/api/backends.rst index 61e5f68a..781723d6 100644 --- a/docs/api/backends/providers.rst +++ b/docs/api/backends.rst @@ -1,12 +1,12 @@ -.. _backend-provider-api: +.. _backend-api: -******************** -Backend provider API -******************** +*********** +Backend API +*********** -The backend provider API is the interface that must be implemented when you -create a backend. If you are working on a frontend and need to access the -backend, see the :ref:`backend-controller-api`. +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`. Playback provider @@ -30,8 +30,8 @@ Library provider :members: -Backend provider implementations -================================ +Backend implementations +======================= * :mod:`mopidy.backends.dummy` * :mod:`mopidy.backends.spotify` diff --git a/docs/api/backends/controllers.rst b/docs/api/backends/controllers.rst deleted file mode 100644 index 8d6687e2..00000000 --- a/docs/api/backends/controllers.rst +++ /dev/null @@ -1,54 +0,0 @@ -.. _backend-controller-api: - -********************** -Backend controller API -********************** - - -The backend controller API is the interface that is used by frontends like -:mod:`mopidy.frontends.mpd`. If you want to implement your own backend, see the -:ref:`backend-provider-api`. - - -The backend -=========== - -.. autoclass:: mopidy.backends.base.Backend - :members: - - -Playback controller -=================== - -Manages playback, with actions like play, pause, stop, next, previous, -seek, and volume control. - -.. autoclass:: mopidy.backends.base.PlaybackController - :members: - - -Current playlist controller -=========================== - -Manages everything related to the currently loaded playlist. - -.. autoclass:: mopidy.backends.base.CurrentPlaylistController - :members: - - -Stored playlists controller -=========================== - -Manages stored playlist. - -.. autoclass:: mopidy.backends.base.StoredPlaylistsController - :members: - - -Library controller -================== - -Manages the music library, e.g. searching for tracks to be added to a playlist. - -.. autoclass:: mopidy.backends.base.LibraryController - :members: diff --git a/docs/api/backends/concepts.rst b/docs/api/concepts.rst similarity index 88% rename from docs/api/backends/concepts.rst rename to docs/api/concepts.rst index 371e03bc..ae959237 100644 --- a/docs/api/backends/concepts.rst +++ b/docs/api/concepts.rst @@ -1,4 +1,4 @@ -.. _backend-concepts: +.. _concepts: ********************************************** The backend, controller, and provider concepts @@ -12,11 +12,11 @@ Controllers: functionality. Most, but not all, controllers delegates some work to one or more providers. The controllers are responsible for choosing the right provider for any given task based upon i.e. the track's URI. See - :ref:`backend-controller-api` for more details. + :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-provider-api` for more details. + providers. See :ref:`backend-api` for more details. .. digraph:: backend_relations diff --git a/docs/api/core.rst b/docs/api/core.rst new file mode 100644 index 00000000..e74d9f45 --- /dev/null +++ b/docs/api/core.rst @@ -0,0 +1,50 @@ +.. _core-api: + +******** +Core API +******** + + +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 +backends. + + +Playback controller +=================== + +Manages playback, with actions like play, pause, stop, next, previous, +seek, and volume control. + +.. autoclass:: mopidy.core.PlaybackState + :members: + +.. autoclass:: mopidy.core.PlaybackController + :members: + + +Current playlist controller +=========================== + +Manages everything related to the currently loaded playlist. + +.. autoclass:: mopidy.core.CurrentPlaylistController + :members: + + +Stored playlists controller +=========================== + +Manages stored playlist. + +.. autoclass:: mopidy.core.StoredPlaylistsController + :members: + + +Library controller +================== + +Manages the music library, e.g. searching for tracks to be added to a playlist. + +.. autoclass:: mopidy.core.LibraryController + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index 1f37e9ff..618096ee 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -5,7 +5,10 @@ API reference .. toctree:: :glob: - backends/concepts - backends/controllers - backends/providers - * + concepts + models + backends + core + audio + frontends + listeners diff --git a/docs/authors.rst b/docs/authors.rst index af84f842..822abc15 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -9,6 +9,9 @@ Contributors to Mopidy in the order of appearance: - Thomas Adamcik - Kristian Klette +A complete list of persons with commits accepted into the Mopidy repo can be +found at `GitHub `_. + Showing your appreciation ========================= @@ -17,13 +20,3 @@ If you already enjoy Mopidy, or don't enjoy it and want to help us making Mopidy better, the best way to do so is to contribute back to the community. You can contribute code, documentation, tests, bug reports, or help other users, spreading the word, etc. - -If you want to show your appreciation in a less time consuming way, you can -`flattr us `_, or `donate money -`_ to Mopidy's development. - -We promise that any money donated -- to Pledgie, not Flattr, due to the size of -the amounts -- will be used to cover costs related to Mopidy development, like -service subscriptions (Spotify, Last.fm, etc.) and hardware devices like an -used iPod Touch for testing Mopidy with MPod. - diff --git a/docs/changes.rst b/docs/changes.rst index 57224300..43b930b8 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -7,24 +7,7 @@ This change log is used to track all major changes to Mopidy. v0.8 (in development) ===================== -**Changes** - -- Added tools/debug-proxy.py to tee client requests to two backends and diff - responses. Intended as a developer tool for checking for MPD protocol changes - and various client support. Requires gevent, which currently is not a - dependency of Mopidy. - -- 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. (Fixes: :issue:`162`) - -- Added :option:`--list-deps` option to the `mopidy` command that lists - required and optional dependencies, their current versions, and some other - information useful for debugging. (Fixes: :issue:`74`) - -- When unknown settings are encountered, we now check if it's similar to a - known setting, and suggests to the user what we think the setting should have - been. +**Audio output and mixer changes** - Removed multiple outputs support. Having this feature currently seems to be more trouble than what it is worth. The :attr:`mopidy.settings.OUTPUTS` @@ -35,10 +18,10 @@ v0.8 (in development) :issue:`159`) - Switch to pure GStreamer based mixing. This implies that users setup a - GStreamer bin with a mixer in it in :attr:`mopidy.setting.MIXER`. The default - value is ``autoaudiomixer``, a custom mixer that attempts to find a 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 + GStreamer bin with a mixer in it in :attr:`mopidy.settings.MIXER`. The + default value is ``autoaudiomixer``, a custom mixer that attempts to find a + 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. @@ -46,7 +29,7 @@ v0.8 (in development) - Updated the NAD hardware mixer to work in the new GStreamer based mixing regime. Settings are now passed as GStreamer element properties. In practice - that means that the following old-style config: + that means that the following old-style config:: MIXER = u'mopidy.mixers.nad.NadMixer' MIXER_EXT_PORT = u'/dev/ttyUSB0' @@ -54,7 +37,7 @@ v0.8 (in development) MIXER_EXT_SPEAKERS_A = u'On' MIXER_EXT_SPEAKERS_B = u'Off' - Now is reduced to simply: + Now is reduced to simply:: MIXER = u'nadmixer port=/dev/ttyUSB0 source=aux speakers-a=on speakers-b=off' @@ -62,12 +45,41 @@ v0.8 (in development) properties may be left out if you don't want the mixer to adjust the settings on your NAD amplifier when Mopidy is started. -- Fixed :issue:`150` which caused some clients to block Mopidy completely. Bug - was caused by some clients sending ``close`` and then shutting down the - connection right away. This trigged a situation in which the connection +**Changes** + +- When unknown settings are encountered, we now check if it's similar to a + known setting, and suggests to the user what we think the setting should have + been. + +- Added :option:`--list-deps` option to the ``mopidy`` command that lists + required and optional dependencies, their current versions, and some other + information useful for debugging. (Fixes: :issue:`74`) + +- Added ``tools/debug-proxy.py`` to tee client requests to two backends and + diff responses. Intended as a developer tool for checking for MPD protocol + changes and various client support. Requires gevent, which currently is not a + dependency of Mopidy. + +- Support tracks with only release year, and not a full release date, like e.g. + Spotify tracks. + +**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. +- Fixed crash on lookup of unknown path when using local backend. + v0.7.3 (2012-08-11) =================== @@ -608,9 +620,9 @@ to this problem. :class:`mopidy.models.Album`, and :class:`mopidy.models.Track`. - Prepare for multi-backend support (see :issue:`40`) by introducing the - :ref:`provider concept `. Split the backend API into a - :ref:`backend controller API ` (for frontend use) - and a :ref:`backend provider API ` (for backend + :ref:`provider concept `. Split the backend API into a + :ref:`backend controller API ` (for frontend use) + and a :ref:`backend provider API ` (for backend implementation use), which includes the following changes: - Rename ``BaseBackend`` to :class:`mopidy.backends.base.Backend`. @@ -852,8 +864,8 @@ In the last two months, Mopidy's MPD frontend has gotten lots of stability fixes and error handling improvements, proper support for having the same track multiple times in a playlist, and support for IPv6. We have also fixed the choppy playback on the libspotify backend. For the road ahead of us, we got an -updated :doc:`release roadmap ` with our goals for the 0.1 -to 0.3 releases. +updated :doc:`release roadmap ` with our goals for the 0.1 to 0.3 +releases. Enjoy the best alpha relase of Mopidy ever :-) @@ -946,7 +958,7 @@ Since the previous release Mopidy has seen about 300 commits, more than 200 new tests, a libspotify release, and major feature additions to Spotify. The new releases from Spotify have lead to updates to our dependencies, and also to new bugs in Mopidy. Thus, this is primarily a bugfix release, even though the not -yet finished work on a Gstreamer backend have been merged. +yet finished work on a GStreamer backend have been merged. All users are recommended to upgrade to 0.1.0a1, and should at the same time ensure that they have the latest versions of our dependencies: Despotify r508 @@ -971,7 +983,7 @@ As always, report problems at our IRC channel or our issue tracker. Thanks! - Several new generic features, like shuffle, consume, and playlist repeat. (Fixes: :issue:`3`) - **[Work in Progress]** A new backend for playing music from a local music - archive using the Gstreamer library. + archive using the GStreamer library. - Made :class:`mopidy.mixers.alsa.AlsaMixer` work on machines without a mixer named "Master". diff --git a/docs/clients/mpd.rst b/docs/clients/mpd.rst index 844eaee7..c7dc3799 100644 --- a/docs/clients/mpd.rst +++ b/docs/clients/mpd.rst @@ -30,33 +30,13 @@ ncmpcpp A console client that generally works well with Mopidy, and is regularly used by Mopidy developers. -Search -^^^^^^ - -Search only works for ncmpcpp versions 0.5.1 and higher, and in two of the -three search modes: +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. -If you run Ubuntu 10.04 or older, you can fetch an updated version of ncmpcpp -from `Launchpad `_. - -Communication mode -^^^^^^^^^^^^^^^^^^ - -In newer versions of ncmpcpp, like ncmpcpp 0.5.5 shipped with Ubuntu 11.04, -ncmcpp defaults to "notifications" mode for MPD communications, which Mopidy -did not support before Mopidy 0.6. To workaround this limitation in earlier -versions of Mopidy, edit the ncmpcpp configuration file at -``~/.ncmpcpp/config`` and add the following setting:: - - mpd_communication_mode = "polling" - -If you use Mopidy 0.6 or newer, you don't need to change anything. - Graphical clients ================= @@ -102,8 +82,8 @@ It generally works well with Mopidy. Android clients =============== -We've tested all six MPD clients we could find for Android with Mopidy 0.3 on a -HTC Hero with Android 2.1, using the following test procedure: +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: #. Connect to Mopidy #. Search for ``foo``, with search type "any" if it can be selected @@ -127,152 +107,180 @@ HTC Hero with Android 2.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 -In summary: +We found that all four apps crashed on Android 4.1.1. -- BitMPC lacks finishing touches on its user interface but supports all - features tested. -- Droid MPD Client works well, but got a couple of bugs one can live with and - does not expose stored playlist anywhere. -- IcyBeats is not usable yet. -- MPDroid is working well and looking good, but does not have search - functionality. -- PMix is just a lesser MPDroid, so use MPDroid instead. -- ThreeMPD is too buggy to even get connected to Mopidy. +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: -Our recommendation: +- PMix can be ignored, because it is unmaintained and its fork MPDroid is + better on all fronts. -- If you do not care about looks, use BitMPC. -- If you do not care about stored playlists, use Droid MPD Client. -- If you do not care about searching, use MPDroid. +- 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. + +- BitMPC is in our experience feature complete, but ugly. + +- MPDroid, now that search is in place, is probably feature complete as well, + and looks nicer than BitMPC. + +In conclusion: MPD clients on Android 4.x is a sad affair. If you want to try +anyway, try BitMPC and MPDroid. BitMPC ------ -We tested version 1.0.0, which at the time had 1k-5k downloads, <100 ratings, -3.5 stars. +Test date: + 2012-09-12 +Tested version: + 1.0.0 (released 2010-04-12) +Downloads: + 5,000+ +Rating: + 3.7 stars from about 100 ratings -The user interface lacks some finishing touches. E.g. you can't enter a -hostname for the server. Only IPv4 addresses are allowed. -All features exercised in the test procedure works. BitMPC lacks support for -single mode and consume mode. BitMPC crashes if Mopidy is killed or crash. +- The user interface lacks some finishing touches. E.g. you can't enter a + hostname for the server. Only IPv4 addresses are allowed. + +- When we last tested the same version of BitMPC using Android 2.1: + + - All features exercised in the test procedure worked. + + - BitMPC lacked support for single mode and consume mode. + + - 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. + +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 +it hasn't been maintained for 2.5 years. Droid MPD Client ---------------- -We tested version 0.4.0, which at the time had 5k-10k downloads, >200 ratings, -4 stars. +Test date: + 2012-09-12 +Tested version: + 1.4.0 (released 2011-12-20) +Downloads: + 10,000+ +Rating: + 4.2 stars from 400+ ratings -To find the search functionality, you have to select the menu, then "Playlist -manager", then the search tab. I do not understand why search is hidden inside -"Playlist manager". +- No intutive way to ask the app to connect to the server after adding the + server hostname to the settings. -The user interface have some French remnants, like "Rechercher" in the search -field. +- To find the search functionality, you have to select the menu, + then "Playlist manager", then the search tab. I do not understand why search + is hidden inside "Playlist manager". -When selecting the artist tab, it issues the ``list Artist`` command and -becomes stuck waiting for the results. Same thing happens for the album tab, -which issues ``list Album``, and the folder tab, which issues ``lsinfo``. -Mopidy returned zero hits immediately on all three commands. If Mopidy has -loaded your stored playlists and returns more than zero hits on these commands, -they artist and album tabs do not hang. The folder tab still freezes when -``lsinfo`` returns a list of stored playlists, though zero files. Thus, we've -discovered a couple of bugs in Droid MPD Client. +- The tabs "Artists" and "Albums" did not contain anything, and did not cause + any requests. -Even though ``lsinfo`` returns the stored playlists for the folder tab, they -are not displayed anywhere. Thus, we had to select an album in the album tab to -complete the test procedure. +- The tab "Folders" showed a spinner and said "Updating data..." but did not + send any requests. -At one point, I had problems turning off repeat mode. After I adjusted the -volume and tried again, it worked. +- Searching for "foo" did nothing. No request was sent to the server. -Droid MPD client does not support single mode or consume mode. It does not -detect that the server is killed/crashed. You'll only notice it by no actions -having any effect, e.g. you can't turn the volume knob any more. +- 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`). -In conclusion, some bugs and caveats, but most of the test procedure was -possible to perform. +- Droid MPD client does not support single mode or consume mode. +- Not able to complete the test procedure, due to the above problems. -IcyBeats --------- - -We tested version 0.2, which at the time had 50-100 downloads, no ratings. -The app was still in beta when we tried it. - -IcyBeats successfully connected to Mopidy and I was able to adjust volume. When -I was searching for some tracks, I could not figure out how to actually start -the search, as there was no search button and pressing enter in the input field -just added a new line. I was stuck. In other words, IcyBeats 0.2 is not usable -with Mopidy. - -IcyBeats does have something going for it: IcyBeats uses IPv6 to connect to -Mopidy. The future is just around the corner! +In conclusion, not a client we can recommend. MPDroid ------- -We tested version 0.6.9, which at the time had 5k-10k downloads, <200 ratings, -4.5 stars. MPDroid started out as a fork of PMix. +Test date: + 2012-09-12 +Tested version: + 0.7 (released 2011-06-19) +Downloads: + 10,000+ +Rating: + 4.5 stars from ~500 ratings -First of all, MPDroid's user interface looks nice. +- MPDroid started out as a fork of PMix. -I couldn't find any search functionality, so I added the initial track using -another client. Other than the missing search functionality, everything in the -test procedure worked out flawlessly. Like all other Android clients, MPDroid -does not support single mode or consume mode. When Mopidy is killed, MPDroid -handles it gracefully and asks if you want to try to reconnect. +- First of all, MPDroid's user interface looks nice. -All in all, MPDroid is a good MPD client without search support. +- 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 ---- -We tested version 0.4.0, which at the time had 10k-50k downloads, >200 ratings, -4 stars. +Test date: + 2012-09-12 +Tested version: + 0.4.0 (released 2010-03-06) +Downloads: + 10,000+ +Rating: + 3.8 stars from >200 ratings -Add MPDroid is a fork from PMix, it is no surprise that PMix does not support -search either. In addition, I could not find stored playlists. Other than that, -I was able to complete the test procedure. PMix crashed once during testing, -but handled the killing of Mopidy just as nicely as MPDroid. It does not -support single mode or consume mode. +- Using Android 4.1.1, PMix, which haven't been updated for 2.5 years, crashes + as soon as it connects to Mopidy. + +- Last time we tested the same version of PMix using Android 2.1, we found + that: + + - PMix does not support search. + + - I could not find stored playlists. + + - Other than that, I was able to complete the test procedure. + + - PMix crashed once during testing. + + - 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. -ThreeMPD --------- - -We tested version 0.3.0, which at the time had 1k-5k downloads, <25 ratings, -2.5 average. The developer request users to use MPDroid instead, due to limited -time for maintenance. Does not support password authentication. - -ThreeMPD froze during startup, so we were not able to test it. - - .. _ios_mpd_clients: -iPhone/iPod Touch clients -========================= - -impdclient ----------- - -There's an open source MPD client for iOS called `impdclient -`_ which has not seen any updates since -August 2008. So far, we've not heard of users trying it with Mopidy. Please -notify us of your successes and/or problems if you do try it out. - +iOS clients +=========== MPod ---- -The `MPoD `_ client can be -installed from the `iTunes Store +Test date: + 2011-01-19 +Tested version: + 1.5.1 + +The `MPoD `_ iPhone/iPod Touch +app can be installed from the `iTunes Store `_. Users have reported varying success in using MPoD together with Mopidy. Thus, @@ -316,3 +324,10 @@ we've tested a fresh install of MPoD 1.5.1 with Mopidy as of revision e7ed28d - **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`. + + +MPaD +---- + +The `MPaD `_ iPad app works +with Mopidy. A complete review may appear here in the future. diff --git a/docs/conf.py b/docs/conf.py index d8aa118e..8129adec 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -29,6 +29,8 @@ class Mock(object): def __getattr__(self, name): if name in ('__file__', '__path__'): return '/dev/null' + elif name[0] == name[0].upper() and not name.startswith('MIXER_TRACK'): + return type(name, (), {}) else: return Mock() @@ -51,11 +53,6 @@ MOCK_MODULES = [ for mod_name in MOCK_MODULES: sys.modules[mod_name] = Mock() -def get_version(): - init_py = open('../mopidy/__init__.py').read() - metadata = dict(re.findall("__([a-z]+)__ = '([^']+)'", init_py)) - return metadata['version'] - # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. @@ -94,6 +91,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 release = get_version() # The short X.Y version. version = '.'.join(release.split('.')[:2]) diff --git a/docs/development/contributing.rst b/docs/development.rst similarity index 65% rename from docs/development/contributing.rst rename to docs/development.rst index 373da1a0..c5020bd9 100644 --- a/docs/development/contributing.rst +++ b/docs/development.rst @@ -1,11 +1,42 @@ -***************** -How to contribute -***************** +*********** +Development +*********** Development of Mopidy is coordinated through the IRC channel ``#mopidy`` at ``irc.freenode.net`` and through `GitHub `_. +Release schedule +================ + +We intend to have about one timeboxed feature release every month +in periods of active development. The feature releases are numbered 0.x.0. The +features added is a mix of what we feel is most important/requested of the +missing features, and features we develop just because we find them fun to +make, even though they may be useful for very few users or for a limited use +case. + +Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs +that are too serious to wait for the next feature release. We will only release +bugfix releases for the last feature release. E.g. when 0.3.0 is released, we +will no longer provide bugfix releases for the 0.2 series. In other words, +there will be just a single supported release at any point in time. + + +Feature wishlist +================ + +We maintain our collection of sane or less sane ideas for future Mopidy +features as `issues `_ at GitHub +labeled with `the "wishlist" label +`_. Feel free to vote +up any feature you would love to see in Mopidy, but please refrain from adding +a comment just to say "I want this too!". You are of course free to add +comments if you have suggestions for how the feature should work or be +implemented, and you may add new wishlist issues if your ideas are not already +represented. + + Code style ========== @@ -126,6 +157,49 @@ code. So, if you're out of work, the code coverage and pylint data at the CI server should give you a place to start. +Protocol debugging +================== + +Since the main interface provided to Mopidy is through the MPD protocol, it is +crucial that we try and stay in sync with protocol developments. In an attempt +to make it easier to debug differences Mopidy and MPD protocol handling we have +created ``tools/debug-proxy.py``. + +This tool is proxy that sits in front of two MPD protocol aware servers and +sends all requests to both, returning the primary response to the client and +then printing any diff in the two responses. + +Note that this tool depends on ``gevent`` unlike the rest of Mopidy at the time +of writing. See ``--help`` for available options. Sample session:: + + [127.0.0.1]:59714 + listallinfo + --- Reference response + +++ Actual response + @@ -1,16 +1,1 @@ + -file: uri1 + -Time: 4 + -Artist: artist1 + -Title: track1 + -Album: album1 + -file: uri2 + -Time: 4 + -Artist: artist2 + -Title: track2 + -Album: album2 + -file: uri3 + -Time: 4 + -Artist: artist3 + -Title: track3 + -Album: album3 + -OK + +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. + + Writing documentation ===================== diff --git a/docs/development/index.rst b/docs/development/index.rst deleted file mode 100644 index 321b3242..00000000 --- a/docs/development/index.rst +++ /dev/null @@ -1,9 +0,0 @@ -*********** -Development -*********** - -.. toctree:: - :maxdepth: 3 - - roadmap - contributing diff --git a/docs/development/roadmap.rst b/docs/development/roadmap.rst deleted file mode 100644 index 6280762c..00000000 --- a/docs/development/roadmap.rst +++ /dev/null @@ -1,34 +0,0 @@ -******* -Roadmap -******* - - -Release schedule -================ - -We intend to have about one timeboxed feature release every month -in periods of active development. The feature releases are numbered 0.x.0. The -features added is a mix of what we feel is most important/requested of the -missing features, and features we develop just because we find them fun to -make, even though they may be useful for very few users or for a limited use -case. - -Bugfix releases, numbered 0.x.y, will be released whenever we discover bugs -that are too serious to wait for the next feature release. We will only release -bugfix releases for the last feature release. E.g. when 0.3.0 is released, we -will no longer provide bugfix releases for the 0.2 series. In other words, -there will be just a single supported release at any point in time. - - -Feature wishlist -================ - -We maintain our collection of sane or less sane ideas for future Mopidy -features as `issues `_ at GitHub -labeled with `the "wishlist" label -`_. Feel free to vote -up any feature you would love to see in Mopidy, but please refrain from adding -a comment just to say "I want this too!". You are of course free to add -comments if you have suggestions for how the feature should work or be -implemented, and you may add new wishlist issues if your ideas are not already -represented. diff --git a/docs/index.rst b/docs/index.rst index 7e757de0..0af510d0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -54,7 +54,7 @@ Development documentation .. toctree:: :maxdepth: 3 - development/index + development Indices and tables ================== diff --git a/docs/installation/gstreamer.rst b/docs/installation/gstreamer.rst index 546b53ba..42685ad0 100644 --- a/docs/installation/gstreamer.rst +++ b/docs/installation/gstreamer.rst @@ -2,7 +2,7 @@ GStreamer installation ********************** -To use the Mopidy, you first need to install GStreamer and the GStreamer Python +To use Mopidy, you first need to install GStreamer and the GStreamer Python bindings. @@ -54,15 +54,8 @@ Python bindings on OS X using Homebrew. #. Install `Homebrew `_. -#. Download our Homebrew formulas for ``pycairo``, ``pygobject``, ``pygtk``, - and ``gst-python``:: +#. Download our Homebrew formula for ``gst-python``:: - curl -o $(brew --prefix)/Library/Formula/pycairo.rb \ - https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pycairo.rb - curl -o $(brew --prefix)/Library/Formula/pygobject.rb \ - https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pygobject.rb - curl -o $(brew --prefix)/Library/Formula/pygtk.rb \ - https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/pygtk.rb curl -o $(brew --prefix)/Library/Formula/gst-python.rb \ https://raw.github.com/jodal/homebrew/gst-python/Library/Formula/gst-python.rb @@ -77,13 +70,13 @@ Python bindings on OS X using Homebrew. 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.6/site-packages:$PYTHONPATH + 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.6/site-packages mopidy + PYTHONPATH=$(brew --prefix)/lib/python2.7/site-packages mopidy - Note that you need to replace ``python2.6`` with ``python2.7`` if that's + 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 diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 766616ac..66b920f8 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -173,7 +173,7 @@ If you want to contribute to Mopidy, you should install Mopidy using Git. For an introduction to ``git``, please visit `git-scm.com `_. Also, please read our :doc:`developer documentation -`. +`. From AUR on ArchLinux diff --git a/docs/modules/gstreamer.rst b/docs/modules/gstreamer.rst deleted file mode 100644 index 205b0a3e..00000000 --- a/docs/modules/gstreamer.rst +++ /dev/null @@ -1,7 +0,0 @@ -******************************************** -:mod:`mopidy.gstreamer` -- GStreamer adapter -******************************************** - -.. automodule:: mopidy.gstreamer - :synopsis: GStreamer adapter - :members: diff --git a/docs/settings.rst b/docs/settings.rst index 94f3c63b..0c1a3c7e 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -166,9 +166,9 @@ server simultaneously. To use the SHOUTcast output, do the following: example, to set the username and password, use: ``lame ! shout2send username="foobar" password="s3cret"``. -Other advanced setups are also possible for outputs. Basically anything you can -get a ``gst-lauch`` command to output to can be plugged into -:attr:`mopidy.settings.OUTPUT``. +Other advanced setups are also possible for outputs. Basically, anything you +can use with the ``gst-launch-0.10`` command can be plugged into +:attr:`mopidy.settings.OUTPUT`. Available settings diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 64465c19..49793752 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -30,7 +30,7 @@ sys.path.insert(0, from mopidy import (get_version, settings, OptionalDependencyError, SettingsError, DATA_PATH, SETTINGS_PATH, SETTINGS_FILE) -from mopidy.gstreamer import GStreamer +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 @@ -56,7 +56,7 @@ def main(): setup_logging(options.verbosity_level, options.save_debug_log) check_old_folders() setup_settings(options.interactive) - setup_gstreamer() + setup_audio() setup_backend() setup_frontends() loop.run() @@ -70,7 +70,7 @@ def main(): loop.quit() stop_frontends() stop_backend() - stop_gstreamer() + stop_audio() stop_remaining_actors() @@ -122,12 +122,12 @@ def setup_settings(interactive): sys.exit(1) -def setup_gstreamer(): - GStreamer.start() +def setup_audio(): + Audio.start() -def stop_gstreamer(): - stop_actors_by_class(GStreamer) +def stop_audio(): + stop_actors_by_class(Audio) def setup_backend(): get_class(settings.BACKENDS[0]).start() diff --git a/mopidy/gstreamer.py b/mopidy/audio/__init__.py similarity index 84% rename from mopidy/gstreamer.py rename to mopidy/audio/__init__.py index d9157a02..dd98dfa8 100644 --- a/mopidy/gstreamer.py +++ b/mopidy/audio/__init__.py @@ -1,6 +1,7 @@ import pygst pygst.require('0.10') import gst +import gobject import logging @@ -9,12 +10,15 @@ from pykka.registry import ActorRegistry from mopidy import settings, utils from mopidy.backends.base import Backend -from mopidy import mixers # Trigger install of gst mixer plugins. +from mopidy.utils import process -logger = logging.getLogger('mopidy.gstreamer') +# Trigger install of gst mixer plugins +from mopidy.audio import mixers + +logger = logging.getLogger('mopidy.audio') -class GStreamer(ThreadingActor): +class Audio(ThreadingActor): """ Audio output through `GStreamer `_. @@ -27,7 +31,8 @@ class GStreamer(ThreadingActor): """ def __init__(self): - super(GStreamer, self).__init__() + super(Audio, self).__init__() + self._default_caps = gst.Caps(""" audio/x-raw-int, endianness=(int)1234, @@ -36,16 +41,29 @@ class GStreamer(ThreadingActor): 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._setup_pipeline() - self._setup_output() - self._setup_mixer() - self._setup_message_processor() + 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 @@ -65,10 +83,18 @@ class GStreamer(ThreadingActor): 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): - # This will raise a gobject.GError if the description is bad. - self._output = gst.parse_bin_from_description( - settings.OUTPUT, ghost_unconnected_pads=True) + 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'), @@ -80,8 +106,13 @@ class GStreamer(ThreadingActor): logger.info('Not setting up mixer.') return - # This will raise a gobject.GError if the description is bad. - mixerbin = gst.parse_bin_from_description(settings.MIXER, False) + 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') @@ -113,10 +144,21 @@ class GStreamer(ThreadingActor): 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') @@ -166,6 +208,8 @@ class GStreamer(ThreadingActor): """ 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 @@ -289,9 +333,14 @@ class GStreamer(ThreadingActor): """ Get volume level of the installed mixer. - 0 == muted. - 100 == max volume for given system. - None == no mixer present, i.e. volume unknown. + 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` """ @@ -339,7 +388,7 @@ class GStreamer(ThreadingActor): deliver raw audio data to GStreamer. :param track: the current track - :type track: :class:`mopidy.modes.Track` + :type track: :class:`mopidy.models.Track` """ taglist = gst.TagList() artists = [a for a in (track.artists or []) if a.name] diff --git a/mopidy/mixers/__init__.py b/mopidy/audio/mixers/__init__.py similarity index 88% rename from mopidy/mixers/__init__.py rename to mopidy/audio/mixers/__init__.py index 317188fc..a0247519 100644 --- a/mopidy/mixers/__init__.py +++ b/mopidy/audio/mixers/__init__.py @@ -38,6 +38,6 @@ def create_track(label, initial_volume, min_volume, max_volume, # # Keep these imports at the bottom of the file to avoid cyclic import problems # when mixers use the above code. -from mopidy.mixers.auto import AutoAudioMixer -from mopidy.mixers.fake import FakeMixer -from mopidy.mixers.nad import NadMixer +from .auto import AutoAudioMixer +from .fake import FakeMixer +from .nad import NadMixer diff --git a/mopidy/mixers/auto.py b/mopidy/audio/mixers/auto.py similarity index 97% rename from mopidy/mixers/auto.py rename to mopidy/audio/mixers/auto.py index f4bd0f92..1233afa3 100644 --- a/mopidy/mixers/auto.py +++ b/mopidy/audio/mixers/auto.py @@ -5,7 +5,7 @@ import gst import logging -logger = logging.getLogger('mopidy.mixers.auto') +logger = logging.getLogger('mopidy.audio.mixers.auto') # TODO: we might want to add some ranking to the mixers we know about? diff --git a/mopidy/mixers/fake.py b/mopidy/audio/mixers/fake.py similarity index 96% rename from mopidy/mixers/fake.py rename to mopidy/audio/mixers/fake.py index 3c47ef33..c5faa03f 100644 --- a/mopidy/mixers/fake.py +++ b/mopidy/audio/mixers/fake.py @@ -3,7 +3,7 @@ pygst.require('0.10') import gobject import gst -from mopidy.mixers import create_track +from mopidy.audio.mixers import create_track class FakeMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): diff --git a/mopidy/mixers/nad.py b/mopidy/audio/mixers/nad.py similarity index 98% rename from mopidy/mixers/nad.py rename to mopidy/audio/mixers/nad.py index de959d41..667dee53 100644 --- a/mopidy/mixers/nad.py +++ b/mopidy/audio/mixers/nad.py @@ -12,10 +12,10 @@ except ImportError: from pykka.actor import ThreadingActor -from mopidy.mixers import create_track +from mopidy.audio.mixers import create_track -logger = logging.getLogger('mopidy.mixers.nad') +logger = logging.getLogger('mopidy.audio.mixers.nad') class NadMixer(gst.Element, gst.ImplementsInterface, gst.interfaces.Mixer): diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index 76c7f078..e6c8b70a 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -1,12 +1,7 @@ -import logging +from .library import BaseLibraryProvider +from .playback import BasePlaybackProvider +from .stored_playlists import BaseStoredPlaylistsProvider -from .current_playlist import CurrentPlaylistController -from .library import LibraryController, BaseLibraryProvider -from .playback import PlaybackController, BasePlaybackProvider -from .stored_playlists import (StoredPlaylistsController, - BaseStoredPlaylistsProvider) - -logger = logging.getLogger('mopidy.backends.base') class Backend(object): #: The current playlist controller. An instance of diff --git a/mopidy/backends/base/library.py b/mopidy/backends/base/library.py index 9e3afe9a..837eef49 100644 --- a/mopidy/backends/base/library.py +++ b/mopidy/backends/base/library.py @@ -1,79 +1,3 @@ -import logging - -logger = logging.getLogger('mopidy.backends.base') - -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` - """ - - pykka_traversable = True - - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider - - def find_exact(self, **query): - """ - Search the library for tracks where ``field`` is ``values``. - - Examples:: - - # Returns results matching 'a' - find_exact(any=['a']) - # Returns results matching artist 'xyz' - find_exact(artist=['xyz']) - # Returns results matching 'a' and 'b' and artist 'xyz' - find_exact(any=['a', 'b'], artist=['xyz']) - - :param query: one or more queries to search for - :type query: dict - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.find_exact(**query) - - def lookup(self, uri): - """ - Lookup track with given URI. Returns :class:`None` if not found. - - :param uri: track URI - :type uri: string - :rtype: :class:`mopidy.models.Track` or :class:`None` - """ - return self.provider.lookup(uri) - - def refresh(self, uri=None): - """ - Refresh library. Limit to URI and below if an URI is given. - - :param uri: directory or track URI - :type uri: string - """ - return self.provider.refresh(uri) - - def search(self, **query): - """ - Search the library for tracks where ``field`` contains ``values``. - - Examples:: - - # Returns results matching 'a' - search(any=['a']) - # Returns results matching artist 'xyz' - search(artist=['xyz']) - # Returns results matching 'a' and 'b' and artist 'xyz' - search(any=['a', 'b'], artist=['xyz']) - - :param query: one or more queries to search for - :type query: dict - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.search(**query) - - class BaseLibraryProvider(object): """ :param backend: backend the controller is a part of diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index dfcbe8bb..ae5a4383 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -1,550 +1,3 @@ -import logging -import random -import time - -from mopidy.listeners import BackendListener - -logger = logging.getLogger('mopidy.backends.base') - - -def option_wrapper(name, default): - def get_option(self): - return getattr(self, name, default) - def set_option(self, value): - if getattr(self, name, default) != value: - self._trigger_options_changed() - return setattr(self, name, value) - return property(get_option, set_option) - - -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 - - pykka_traversable = True - - #: Constant representing the paused state. - PAUSED = u'paused' - - #: Constant representing the playing state. - PLAYING = u'playing' - - #: Constant representing the stopped state. - STOPPED = u'stopped' - - #: :class:`True` - #: Tracks are removed from the playlist when they have been played. - #: :class:`False` - #: Tracks are not removed from the playlist. - consume = option_wrapper('_consume', False) - - #: The currently playing or selected track. - #: - #: A two-tuple of (CPID integer, :class:`mopidy.models.Track`) or - #: :class:`None`. - current_cp_track = None - - #: :class:`True` - #: Tracks are selected at random from the playlist. - #: :class:`False` - #: Tracks are played in the order of the playlist. - random = option_wrapper('_random', False) - - #: :class:`True` - #: The current playlist is played repeatedly. To repeat a single track, - #: select both :attr:`repeat` and :attr:`single`. - #: :class:`False` - #: The current playlist is played once. - repeat = option_wrapper('_repeat', False) - - #: :class:`True` - #: Playback is stopped after current song, unless in :attr:`repeat` - #: mode. - #: :class:`False` - #: Playback continues after current song. - single = option_wrapper('_single', False) - - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider - self._state = self.STOPPED - self._shuffled = [] - self._first_shuffle = True - self.play_time_accumulated = 0 - self.play_time_started = None - - def _get_cpid(self, cp_track): - if cp_track is None: - return None - return cp_track.cpid - - def _get_track(self, cp_track): - if cp_track is None: - return None - return cp_track.track - - @property - def current_cpid(self): - """ - The CPID (current playlist ID) of the currently playing or selected - track. - - Read-only. Extracted from :attr:`current_cp_track` for convenience. - """ - return self._get_cpid(self.current_cp_track) - - @property - def current_track(self): - """ - The currently playing or selected :class:`mopidy.models.Track`. - - Read-only. Extracted from :attr:`current_cp_track` for convenience. - """ - return self._get_track(self.current_cp_track) - - @property - def current_playlist_position(self): - """ - The position of the current track in the current playlist. - - Read-only. - """ - if self.current_cp_track is None: - return None - try: - return self.backend.current_playlist.cp_tracks.index( - self.current_cp_track) - except ValueError: - return None - - @property - def track_at_eot(self): - """ - The track that will be played at the end of the current track. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_eot` for convenience. - """ - return self._get_track(self.cp_track_at_eot) - - @property - def cp_track_at_eot(self): - """ - The track that will be played at the end of the current track. - - Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). - - Not necessarily the same track as :attr:`cp_track_at_next`. - """ - # pylint: disable = R0911 - # Too many return statements - - cp_tracks = self.backend.current_playlist.cp_tracks - - if not cp_tracks: - return None - - if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: - logger.debug('Shuffling tracks') - self._shuffled = cp_tracks - random.shuffle(self._shuffled) - self._first_shuffle = False - - if self.random and self._shuffled: - return self._shuffled[0] - - if self.current_cp_track is None: - return cp_tracks[0] - - if self.repeat and self.single: - return cp_tracks[self.current_playlist_position] - - if self.repeat and not self.single: - return cp_tracks[ - (self.current_playlist_position + 1) % len(cp_tracks)] - - try: - return cp_tracks[self.current_playlist_position + 1] - except IndexError: - return None - - @property - def track_at_next(self): - """ - The track that will be played if calling :meth:`next()`. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_next` for convenience. - """ - return self._get_track(self.cp_track_at_next) - - @property - def cp_track_at_next(self): - """ - The track that will be played if calling :meth:`next()`. - - Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). - - For normal playback this is the next track in the playlist. If repeat - is enabled the next track can loop around the playlist. When random is - enabled this should be a random track, all tracks should be played once - before the list repeats. - """ - cp_tracks = self.backend.current_playlist.cp_tracks - - if not cp_tracks: - return None - - if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: - logger.debug('Shuffling tracks') - self._shuffled = cp_tracks - random.shuffle(self._shuffled) - self._first_shuffle = False - - if self.random and self._shuffled: - return self._shuffled[0] - - if self.current_cp_track is None: - return cp_tracks[0] - - if self.repeat: - return cp_tracks[ - (self.current_playlist_position + 1) % len(cp_tracks)] - - try: - return cp_tracks[self.current_playlist_position + 1] - except IndexError: - return None - - @property - def track_at_previous(self): - """ - The track that will be played if calling :meth:`previous()`. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_previous` for convenience. - """ - return self._get_track(self.cp_track_at_previous) - - @property - def cp_track_at_previous(self): - """ - The track that will be played if calling :meth:`previous()`. - - A two-tuple of (CPID integer, :class:`mopidy.models.Track`). - - For normal playback this is the previous track in the playlist. If - random and/or consume is enabled it should return the current track - instead. - """ - if self.repeat or self.consume or self.random: - return self.current_cp_track - - if self.current_playlist_position in (None, 0): - return None - - return self.backend.current_playlist.cp_tracks[ - self.current_playlist_position - 1] - - @property - def state(self): - """ - The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or - :attr:`STOPPED`. - - Possible states and transitions: - - .. digraph:: state_transitions - - "STOPPED" -> "PLAYING" [ label="play" ] - "STOPPED" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "STOPPED" [ label="stop" ] - "PLAYING" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "PLAYING" [ label="play" ] - "PAUSED" -> "PLAYING" [ label="resume" ] - "PAUSED" -> "STOPPED" [ label="stop" ] - """ - return self._state - - @state.setter - def state(self, new_state): - (old_state, self._state) = (self.state, new_state) - logger.debug(u'Changing state: %s -> %s', old_state, new_state) - - 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 (self.PLAYING, self.STOPPED) - and new_state == self.PLAYING): - self._play_time_start() - elif old_state == self.PLAYING and new_state == self.PAUSED: - self._play_time_pause() - elif old_state == self.PAUSED and new_state == self.PLAYING: - self._play_time_resume() - - @property - def time_position(self): - """Time position in milliseconds.""" - if self.state == self.PLAYING: - time_since_started = (self._current_wall_time - - self.play_time_started) - return self.play_time_accumulated + time_since_started - elif self.state == self.PAUSED: - return self.play_time_accumulated - elif self.state == self.STOPPED: - return 0 - - def _play_time_start(self): - self.play_time_accumulated = 0 - self.play_time_started = self._current_wall_time - - def _play_time_pause(self): - time_since_started = self._current_wall_time - self.play_time_started - self.play_time_accumulated += time_since_started - - def _play_time_resume(self): - self.play_time_started = self._current_wall_time - - @property - def _current_wall_time(self): - return int(time.time() * 1000) - - @property - def volume(self): - return self.provider.get_volume() - - @volume.setter - def volume(self, volume): - self.provider.set_volume(volume) - - def change_track(self, cp_track, on_error_step=1): - """ - Change to the given track, keeping the current playback state. - - :param cp_track: track to change to - :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) - or :class:`None` - :param on_error_step: direction to step at play error, 1 for next - track (default), -1 for previous track - :type on_error_step: int, -1 or 1 - - """ - old_state = self.state - self.stop() - self.current_cp_track = cp_track - if old_state == self.PLAYING: - self.play(on_error_step=on_error_step) - elif old_state == self.PAUSED: - self.pause() - - def on_end_of_track(self): - """ - Tell the playback controller that end of track is reached. - """ - if self.state == self.STOPPED: - return - - original_cp_track = self.current_cp_track - - if self.cp_track_at_eot: - self._trigger_track_playback_ended() - self.play(self.cp_track_at_eot) - else: - self.stop(clear_current_track=True) - - if self.consume: - self.backend.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`. - """ - self._first_shuffle = True - self._shuffled = [] - - if (not self.backend.current_playlist.cp_tracks or - self.current_cp_track not in - self.backend.current_playlist.cp_tracks): - self.stop(clear_current_track=True) - - def next(self): - """ - Change to the next track. - - The current playback state will be kept. If it was playing, playing - will continue. If it was paused, it will still be paused, etc. - """ - if self.cp_track_at_next: - self._trigger_track_playback_ended() - self.change_track(self.cp_track_at_next) - else: - self.stop(clear_current_track=True) - - def pause(self): - """Pause playback.""" - if self.provider.pause(): - self.state = self.PAUSED - self._trigger_track_playback_paused() - - def play(self, cp_track=None, on_error_step=1): - """ - Play the given track, or if the given track is :class:`None`, play the - currently active track. - - :param cp_track: track to play - :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) - or :class:`None` - :param on_error_step: direction to step at play error, 1 for next - track (default), -1 for previous track - :type on_error_step: int, -1 or 1 - """ - - if cp_track is not None: - assert cp_track in self.backend.current_playlist.cp_tracks - elif cp_track is None: - if self.state == self.PAUSED: - return self.resume() - elif self.current_cp_track is not None: - cp_track = self.current_cp_track - elif self.current_cp_track is None and on_error_step == 1: - cp_track = self.cp_track_at_next - elif self.current_cp_track is None and on_error_step == -1: - cp_track = self.cp_track_at_previous - - if cp_track is not None: - self.current_cp_track = cp_track - self.state = self.PLAYING - if not self.provider.play(cp_track.track): - # Track is not playable - if self.random and self._shuffled: - self._shuffled.remove(cp_track) - if on_error_step == 1: - self.next() - elif on_error_step == -1: - self.previous() - - if self.random and self.current_cp_track in self._shuffled: - self._shuffled.remove(self.current_cp_track) - - self._trigger_track_playback_started() - - def previous(self): - """ - Change to the previous track. - - The current playback state will be kept. If it was playing, playing - will continue. If it was paused, it will still be paused, etc. - """ - self._trigger_track_playback_ended() - self.change_track(self.cp_track_at_previous, on_error_step=-1) - - def resume(self): - """If paused, resume playing the current track.""" - if self.state == self.PAUSED and self.provider.resume(): - self.state = self.PLAYING - self._trigger_track_playback_resumed() - - def seek(self, time_position): - """ - Seeks to time position given in milliseconds. - - :param time_position: time position in milliseconds - :type time_position: int - :rtype: :class:`True` if successful, else :class:`False` - """ - if not self.backend.current_playlist.tracks: - return False - - if self.state == self.STOPPED: - self.play() - elif self.state == self.PAUSED: - self.resume() - - if time_position < 0: - time_position = 0 - elif time_position > self.current_track.length: - self.next() - return True - - self.play_time_started = self._current_wall_time - self.play_time_accumulated = time_position - - success = self.provider.seek(time_position) - if success: - self._trigger_seeked() - return success - - def stop(self, clear_current_track=False): - """ - Stop playing. - - :param clear_current_track: whether to clear the current track _after_ - stopping - :type clear_current_track: boolean - """ - if self.state != self.STOPPED: - if self.provider.stop(): - self._trigger_track_playback_ended() - self.state = self.STOPPED - if clear_current_track: - self.current_cp_track = None - - def _trigger_track_playback_paused(self): - 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) - - 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) - - 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) - - 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) - - def _trigger_playback_state_changed(self): - logger.debug(u'Triggering playback state change event') - BackendListener.send('playback_state_changed') - - def _trigger_options_changed(self): - logger.debug(u'Triggering options changed event') - BackendListener.send('options_changed') - - def _trigger_seeked(self): - logger.debug(u'Triggering seeked event') - BackendListener.send('seeked') - - class BasePlaybackProvider(object): """ :param backend: the backend @@ -560,73 +13,75 @@ class BasePlaybackProvider(object): """ Pause playback. - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :rtype: :class:`True` if successful, else :class:`False` """ - raise NotImplementedError + return self.backend.audio.pause_playback().get() def play(self, track): """ Play given track. - *MUST be implemented by subclass.* + *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` """ - raise NotImplementedError + 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. - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :rtype: :class:`True` if successful, else :class:`False` """ - raise NotImplementedError + return self.backend.audio.start_playback().get() def seek(self, time_position): """ Seek to a given time position. - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :param time_position: time position in milliseconds :type time_position: int :rtype: :class:`True` if successful, else :class:`False` """ - raise NotImplementedError + return self.backend.audio.set_position(time_position).get() def stop(self): """ Stop playback. - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :rtype: :class:`True` if successful, else :class:`False` """ - raise NotImplementedError + return self.backend.audio.stop_playback().get() def get_volume(self): """ Get current volume - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :rtype: int [0..100] or :class:`None` """ - raise NotImplementedError + return self.backend.audio.get_volume().get() def set_volume(self, volume): """ Get current volume - *MUST be implemented by subclass.* + *MAY be reimplemented by subclass.* :param: volume :type volume: int [0..100] """ - raise NotImplementedError + self.backend.audio.set_volume(volume) diff --git a/mopidy/backends/base/stored_playlists.py b/mopidy/backends/base/stored_playlists.py index 0ce2e196..d1d52c9a 100644 --- a/mopidy/backends/base/stored_playlists.py +++ b/mopidy/backends/base/stored_playlists.py @@ -1,120 +1,4 @@ from copy import copy -import logging - -logger = logging.getLogger('mopidy.backends.base') - -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` - """ - - pykka_traversable = True - - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider - - @property - def playlists(self): - """ - Currently stored playlists. - - Read/write. List of :class:`mopidy.models.Playlist`. - """ - return self.provider.playlists - - @playlists.setter - def playlists(self, playlists): - self.provider.playlists = playlists - - def create(self, name): - """ - Create a new playlist. - - :param name: name of the new playlist - :type name: string - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.create(name) - - def delete(self, playlist): - """ - Delete playlist. - - :param playlist: the playlist to delete - :type playlist: :class:`mopidy.models.Playlist` - """ - return self.provider.delete(playlist) - - def get(self, **criteria): - """ - Get playlist by given criterias from the set of stored playlists. - - Raises :exc:`LookupError` if a unique match is not found. - - Examples:: - - get(name='a') # Returns track with name 'a' - get(uri='xyz') # Returns track with URI 'xyz' - get(name='a', uri='xyz') # Returns track with name 'a' and URI - # 'xyz' - - :param criteria: one or more criteria to match by - :type criteria: dict - :rtype: :class:`mopidy.models.Playlist` - """ - matches = self.playlists - for (key, value) in criteria.iteritems(): - matches = filter(lambda p: getattr(p, key) == value, matches) - if len(matches) == 1: - return matches[0] - criteria_string = ', '.join( - ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) - if len(matches) == 0: - raise LookupError('"%s" match no playlists' % criteria_string) - else: - raise LookupError('"%s" match multiple playlists' % criteria_string) - - def lookup(self, uri): - """ - Lookup playlist with given URI in both the set of stored playlists and - in any other playlist sources. - - :param uri: playlist URI - :type uri: string - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.lookup(uri) - - def refresh(self): - """ - Refresh the stored playlists in - :attr:`mopidy.backends.base.StoredPlaylistsController.playlists`. - """ - return self.provider.refresh() - - def rename(self, playlist, new_name): - """ - Rename playlist. - - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - :param new_name: the new name - :type new_name: string - """ - return self.provider.rename(playlist, new_name) - - def save(self, playlist): - """ - Save the playlist to the set of stored playlists. - - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - """ - return self.provider.save(playlist) class BaseStoredPlaylistsProvider(object): diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 2234242c..3ada0052 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -1,13 +1,11 @@ from pykka.actor import ThreadingActor -from mopidy.backends.base import (Backend, CurrentPlaylistController, - PlaybackController, BasePlaybackProvider, LibraryController, - BaseLibraryProvider, StoredPlaylistsController, - BaseStoredPlaylistsProvider) +from mopidy import core +from mopidy.backends import base from mopidy.models import Playlist -class DummyBackend(ThreadingActor, Backend): +class DummyBackend(ThreadingActor, base.Backend): """ A backend which implements the backend API in the simplest way possible. Used in tests of the frontends. @@ -18,24 +16,24 @@ class DummyBackend(ThreadingActor, Backend): def __init__(self, *args, **kwargs): super(DummyBackend, self).__init__(*args, **kwargs) - self.current_playlist = CurrentPlaylistController(backend=self) + self.current_playlist = core.CurrentPlaylistController(backend=self) library_provider = DummyLibraryProvider(backend=self) - self.library = LibraryController(backend=self, + self.library = core.LibraryController(backend=self, provider=library_provider) playback_provider = DummyPlaybackProvider(backend=self) - self.playback = PlaybackController(backend=self, + self.playback = core.PlaybackController(backend=self, provider=playback_provider) stored_playlists_provider = DummyStoredPlaylistsProvider(backend=self) - self.stored_playlists = StoredPlaylistsController(backend=self, + self.stored_playlists = core.StoredPlaylistsController(backend=self, provider=stored_playlists_provider) self.uri_schemes = [u'dummy'] -class DummyLibraryProvider(BaseLibraryProvider): +class DummyLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] @@ -55,7 +53,7 @@ class DummyLibraryProvider(BaseLibraryProvider): return Playlist() -class DummyPlaybackProvider(BasePlaybackProvider): +class DummyPlaybackProvider(base.BasePlaybackProvider): def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) self._volume = None @@ -83,7 +81,7 @@ class DummyPlaybackProvider(BasePlaybackProvider): self._volume = volume -class DummyStoredPlaylistsProvider(BaseStoredPlaylistsProvider): +class DummyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def create(self, name): playlist = Playlist(name=name) self._playlists.append(playlist) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 1b1f9730..c7126824 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -7,13 +7,9 @@ import shutil from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import settings, DATA_PATH -from mopidy.backends.base import (Backend, CurrentPlaylistController, - LibraryController, BaseLibraryProvider, PlaybackController, - BasePlaybackProvider, StoredPlaylistsController, - BaseStoredPlaylistsProvider) +from mopidy import audio, core, settings, DATA_PATH +from mopidy.backends import base from mopidy.models import Playlist, Track, Album -from mopidy.gstreamer import GStreamer from .translator import parse_m3u, parse_mpd_tag_cache @@ -27,12 +23,10 @@ if not DEFAULT_MUSIC_PATH or DEFAULT_MUSIC_PATH == os.path.expanduser(u'~'): DEFAULT_MUSIC_PATH = os.path.expanduser(u'~/music') -class LocalBackend(ThreadingActor, Backend): +class LocalBackend(ThreadingActor, base.Backend): """ A backend for playing music from a local music archive. - **Issues:** https://github.com/mopidy/mopidy/issues?labels=backend-local - **Dependencies:** - None @@ -47,32 +41,32 @@ class LocalBackend(ThreadingActor, Backend): def __init__(self, *args, **kwargs): super(LocalBackend, self).__init__(*args, **kwargs) - self.current_playlist = CurrentPlaylistController(backend=self) + self.current_playlist = core.CurrentPlaylistController(backend=self) library_provider = LocalLibraryProvider(backend=self) - self.library = LibraryController(backend=self, + self.library = core.LibraryController(backend=self, provider=library_provider) - playback_provider = LocalPlaybackProvider(backend=self) + playback_provider = base.BasePlaybackProvider(backend=self) self.playback = LocalPlaybackController(backend=self, provider=playback_provider) stored_playlists_provider = LocalStoredPlaylistsProvider(backend=self) - self.stored_playlists = StoredPlaylistsController(backend=self, + self.stored_playlists = core.StoredPlaylistsController(backend=self, provider=stored_playlists_provider) self.uri_schemes = [u'file'] - self.gstreamer = None + self.audio = None def on_start(self): - gstreamer_refs = ActorRegistry.get_by_class(GStreamer) - assert len(gstreamer_refs) == 1, \ - 'Expected exactly one running GStreamer.' - self.gstreamer = gstreamer_refs[0].proxy() + 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(PlaybackController): +class LocalPlaybackController(core.PlaybackController): def __init__(self, *args, **kwargs): super(LocalPlaybackController, self).__init__(*args, **kwargs) @@ -81,35 +75,10 @@ class LocalPlaybackController(PlaybackController): @property def time_position(self): - return self.backend.gstreamer.get_position().get() + return self.backend.audio.get_position().get() -class LocalPlaybackProvider(BasePlaybackProvider): - def pause(self): - return self.backend.gstreamer.pause_playback().get() - - def play(self, track): - self.backend.gstreamer.prepare_change() - self.backend.gstreamer.set_uri(track.uri).get() - return self.backend.gstreamer.start_playback().get() - - def resume(self): - return self.backend.gstreamer.start_playback().get() - - def seek(self, time_position): - return self.backend.gstreamer.set_position(time_position).get() - - def stop(self): - return self.backend.gstreamer.stop_playback().get() - - def get_volume(self): - return self.backend.gstreamer.get_volume().get() - - def set_volume(self, volume): - self.backend.gstreamer.set_volume(volume).get() - - -class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): +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 @@ -124,7 +93,7 @@ class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): 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')] + name = os.path.basename(m3u)[:-len('.m3u')] tracks = [] for uri in parse_m3u(m3u): try: @@ -182,7 +151,7 @@ class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): self._playlists.append(playlist) -class LocalLibraryProvider(BaseLibraryProvider): +class LocalLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(LocalLibraryProvider, self).__init__(*args, **kwargs) self._uri_mapping = {} @@ -203,7 +172,8 @@ class LocalLibraryProvider(BaseLibraryProvider): try: return self._uri_mapping[uri] except KeyError: - raise LookupError('%s not found.' % uri) + logger.debug(u'Failed to lookup "%s"', uri) + return None def find_exact(self, **query): self._validate_query(query) diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 56775926..1feb1c65 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -3,16 +3,14 @@ import logging from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import settings -from mopidy.backends.base import (Backend, CurrentPlaylistController, - LibraryController, PlaybackController, StoredPlaylistsController) -from mopidy.gstreamer import GStreamer +from mopidy import audio, core, settings +from mopidy.backends import base logger = logging.getLogger('mopidy.backends.spotify') BITRATES = {96: 2, 160: 0, 320: 1} -class SpotifyBackend(ThreadingActor, Backend): +class SpotifyBackend(ThreadingActor, base.Backend): """ A backend for playing music from the `Spotify `_ music streaming service. The backend uses the official `libspotify @@ -51,24 +49,24 @@ class SpotifyBackend(ThreadingActor, Backend): super(SpotifyBackend, self).__init__(*args, **kwargs) - self.current_playlist = CurrentPlaylistController(backend=self) + self.current_playlist = core.CurrentPlaylistController(backend=self) library_provider = SpotifyLibraryProvider(backend=self) - self.library = LibraryController(backend=self, + self.library = core.LibraryController(backend=self, provider=library_provider) playback_provider = SpotifyPlaybackProvider(backend=self) - self.playback = PlaybackController(backend=self, + self.playback = core.PlaybackController(backend=self, provider=playback_provider) stored_playlists_provider = SpotifyStoredPlaylistsProvider( backend=self) - self.stored_playlists = StoredPlaylistsController(backend=self, + self.stored_playlists = core.StoredPlaylistsController(backend=self, provider=stored_playlists_provider) self.uri_schemes = [u'spotify'] - self.gstreamer = None + self.audio = None self.spotify = None # Fail early if settings are not present @@ -76,10 +74,10 @@ class SpotifyBackend(ThreadingActor, Backend): self.password = settings.SPOTIFY_PASSWORD def on_start(self): - gstreamer_refs = ActorRegistry.get_by_class(GStreamer) - assert len(gstreamer_refs) == 1, \ - 'Expected exactly one running GStreamer.' - self.gstreamer = gstreamer_refs[0].proxy() + 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() diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index a080c7bd..18276ecd 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -5,21 +5,55 @@ from spotify import Link, SpotifyError from mopidy.backends.base import BaseLibraryProvider from mopidy.backends.spotify.translator import SpotifyTranslator -from mopidy.models import Playlist +from mopidy.models import Track, Playlist logger = logging.getLogger('mopidy.backends.spotify.library') + +class SpotifyTrack(Track): + """Proxy object for unloaded Spotify tracks.""" + def __init__(self, uri): + self._spotify_track = Link.from_string(uri).as_track() + self._unloaded_track = Track(uri=uri, name=u'[loading...]') + self._track = None + + @property + def _proxy(self): + if self._track: + return self._track + elif self._spotify_track.is_loaded(): + self._track = SpotifyTranslator.to_mopidy_track(self._spotify_track) + return self._track + else: + return self._unloaded_track + + def __getattribute__(self, name): + if name.startswith('_'): + return super(SpotifyTrack, self).__getattribute__(name) + return self._proxy.__getattribute__(name) + + def __repr__(self): + return self._proxy.__repr__() + + def __hash__(self): + return hash(self._proxy.uri) + + def __eq__(self, other): + if not isinstance(other, Track): + return False + return self._proxy.uri == other.uri + + def copy(self, **values): + return self._proxy.copy(**values) + + class SpotifyLibraryProvider(BaseLibraryProvider): def find_exact(self, **query): return self.search(**query) def lookup(self, uri): try: - spotify_track = Link.from_string(uri).as_track() - # TODO Block until metadata_updated callback is called. Before that - # the track will be unloaded, unless it's already in the stored - # playlists. - return SpotifyTranslator.to_mopidy_track(spotify_track) + return SpotifyTrack(uri) except SpotifyError as e: logger.debug(u'Failed to lookup "%s": %s', uri, e) return None diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 70cc4617..1c20da87 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -3,15 +3,13 @@ import logging from spotify import Link, SpotifyError from mopidy.backends.base import BasePlaybackProvider +from mopidy.core import PlaybackState logger = logging.getLogger('mopidy.backends.spotify.playback') class SpotifyPlaybackProvider(BasePlaybackProvider): - def pause(self): - return self.backend.gstreamer.pause_playback() - def play(self, track): - if self.backend.playback.state == self.backend.playback.PLAYING: + if self.backend.playback.state == PlaybackState.PLAYING: self.backend.spotify.session.play(0) if track.uri is None: return False @@ -19,10 +17,10 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self.backend.gstreamer.prepare_change() - self.backend.gstreamer.set_uri('appsrc://') - self.backend.gstreamer.start_playback() - self.backend.gstreamer.set_metadata(track) + self.backend.audio.prepare_change() + self.backend.audio.set_uri('appsrc://') + self.backend.audio.start_playback() + self.backend.audio.set_metadata(track) return True except SpotifyError as e: logger.info('Playback of %s failed: %s', track.uri, e) @@ -32,18 +30,11 @@ class SpotifyPlaybackProvider(BasePlaybackProvider): return self.seek(self.backend.playback.time_position) def seek(self, time_position): - self.backend.gstreamer.prepare_change() + self.backend.audio.prepare_change() self.backend.spotify.session.seek(time_position) - self.backend.gstreamer.start_playback() + self.backend.audio.start_playback() return True def stop(self): - result = self.backend.gstreamer.stop_playback() self.backend.spotify.session.play(0) - return result - - def get_volume(self): - return self.backend.gstreamer.get_volume().get() - - def set_volume(self, volume): - self.backend.gstreamer.set_volume(volume) + return super(SpotifyPlaybackProvider, self).stop() diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 481f7a94..aa3734ae 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -6,14 +6,13 @@ from spotify.manager import SpotifySessionManager as PyspotifySessionManager from pykka.registry import ActorRegistry -from mopidy import get_version, settings, CACHE_PATH +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.models import Playlist -from mopidy.gstreamer import GStreamer from mopidy.utils.process import BaseThread logger = logging.getLogger('mopidy.backends.spotify.session_manager') @@ -34,7 +33,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): BaseThread.__init__(self) self.name = 'SpotifyThread' - self.gstreamer = None + self.audio = None self.backend = None self.connected = threading.Event() @@ -50,10 +49,10 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): self.connect() def setup(self): - gstreamer_refs = ActorRegistry.get_by_class(GStreamer) - assert len(gstreamer_refs) == 1, \ - 'Expected exactly one running gstreamer.' - self.gstreamer = gstreamer_refs[0].proxy() + 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.' @@ -117,7 +116,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): 'sample_rate': sample_rate, 'channels': channels, } - self.gstreamer.emit_data(capabilites, bytes(frames)) + self.audio.emit_data(capabilites, bytes(frames)) return num_frames def play_token_lost(self, session): @@ -143,7 +142,7 @@ class SpotifySessionManager(BaseThread, PyspotifySessionManager): def end_of_track(self, session): """Callback used by pyspotify""" logger.debug(u'End of data stream reached') - self.gstreamer.emit_end_of_stream() + self.audio.emit_end_of_stream() def refresh_stored_playlists(self): """Refresh the stored playlists in the backend with fresh meta data diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 2f47a42b..1a8f048d 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -1,4 +1,3 @@ -import datetime as dt import logging from spotify import Link, SpotifyError @@ -31,9 +30,8 @@ class SpotifyTranslator(object): 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() - and dt.MINYEAR <= int(spotify_album.year()) <= dt.MAXYEAR): - date = dt.date(spotify_album.year(), 1, 1) + if spotify_album is not None and spotify_album.is_loaded(): + date = spotify_album.year() else: date = None return Track( diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py new file mode 100644 index 00000000..87df96c9 --- /dev/null +++ b/mopidy/core/__init__.py @@ -0,0 +1,4 @@ +from .current_playlist import CurrentPlaylistController +from .library import LibraryController +from .playback import PlaybackController, PlaybackState +from .stored_playlists import StoredPlaylistsController diff --git a/mopidy/backends/base/current_playlist.py b/mopidy/core/current_playlist.py similarity index 99% rename from mopidy/backends/base/current_playlist.py rename to mopidy/core/current_playlist.py index d7e6c331..af06e05e 100644 --- a/mopidy/backends/base/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -5,7 +5,9 @@ import random from mopidy.listeners import BackendListener from mopidy.models import CpTrack -logger = logging.getLogger('mopidy.backends.base') + +logger = logging.getLogger('mopidy.core') + class CurrentPlaylistController(object): """ diff --git a/mopidy/core/library.py b/mopidy/core/library.py new file mode 100644 index 00000000..fc55aaeb --- /dev/null +++ b/mopidy/core/library.py @@ -0,0 +1,70 @@ +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` + """ + + pykka_traversable = True + + def __init__(self, backend, provider): + self.backend = backend + self.provider = provider + + def find_exact(self, **query): + """ + Search the library for tracks where ``field`` is ``values``. + + Examples:: + + # Returns results matching 'a' + find_exact(any=['a']) + # Returns results matching artist 'xyz' + find_exact(artist=['xyz']) + # Returns results matching 'a' and 'b' and artist 'xyz' + find_exact(any=['a', 'b'], artist=['xyz']) + + :param query: one or more queries to search for + :type query: dict + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.find_exact(**query) + + def lookup(self, uri): + """ + Lookup track with given URI. Returns :class:`None` if not found. + + :param uri: track URI + :type uri: string + :rtype: :class:`mopidy.models.Track` or :class:`None` + """ + return self.provider.lookup(uri) + + def refresh(self, uri=None): + """ + Refresh library. Limit to URI and below if an URI is given. + + :param uri: directory or track URI + :type uri: string + """ + return self.provider.refresh(uri) + + def search(self, **query): + """ + Search the library for tracks where ``field`` contains ``values``. + + Examples:: + + # Returns results matching 'a' + search(any=['a']) + # Returns results matching artist 'xyz' + search(artist=['xyz']) + # Returns results matching 'a' and 'b' and artist 'xyz' + search(any=['a', 'b'], artist=['xyz']) + + :param query: one or more queries to search for + :type query: dict + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.search(**query) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py new file mode 100644 index 00000000..dfd1676e --- /dev/null +++ b/mopidy/core/playback.py @@ -0,0 +1,557 @@ +import logging +import random +import time + +from mopidy.listeners import BackendListener + + +logger = logging.getLogger('mopidy.backends.base') + + +def option_wrapper(name, default): + def get_option(self): + return getattr(self, name, default) + + def set_option(self, value): + if getattr(self, name, default) != value: + self._trigger_options_changed() + return setattr(self, name, value) + + return property(get_option, set_option) + + + +class PlaybackState(object): + """ + Enum of playback states. + """ + + #: Constant representing the paused state. + PAUSED = u'paused' + + #: Constant representing the playing state. + PLAYING = u'playing' + + #: Constant representing the stopped state. + STOPPED = u'stopped' + + +class 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 + + pykka_traversable = True + + #: :class:`True` + #: Tracks are removed from the playlist when they have been played. + #: :class:`False` + #: Tracks are not removed from the playlist. + consume = option_wrapper('_consume', False) + + #: The currently playing or selected track. + #: + #: A two-tuple of (CPID integer, :class:`mopidy.models.Track`) or + #: :class:`None`. + current_cp_track = None + + #: :class:`True` + #: Tracks are selected at random from the playlist. + #: :class:`False` + #: Tracks are played in the order of the playlist. + random = option_wrapper('_random', False) + + #: :class:`True` + #: The current playlist is played repeatedly. To repeat a single track, + #: select both :attr:`repeat` and :attr:`single`. + #: :class:`False` + #: The current playlist is played once. + repeat = option_wrapper('_repeat', False) + + #: :class:`True` + #: Playback is stopped after current song, unless in :attr:`repeat` + #: mode. + #: :class:`False` + #: Playback continues after current song. + single = option_wrapper('_single', False) + + def __init__(self, backend, provider): + self.backend = backend + self.provider = provider + self._state = PlaybackState.STOPPED + self._shuffled = [] + self._first_shuffle = True + self.play_time_accumulated = 0 + self.play_time_started = None + + def _get_cpid(self, cp_track): + if cp_track is None: + return None + return cp_track.cpid + + def _get_track(self, cp_track): + if cp_track is None: + return None + return cp_track.track + + @property + def current_cpid(self): + """ + The CPID (current playlist ID) of the currently playing or selected + track. + + Read-only. Extracted from :attr:`current_cp_track` for convenience. + """ + return self._get_cpid(self.current_cp_track) + + @property + def current_track(self): + """ + The currently playing or selected :class:`mopidy.models.Track`. + + Read-only. Extracted from :attr:`current_cp_track` for convenience. + """ + return self._get_track(self.current_cp_track) + + @property + def current_playlist_position(self): + """ + The position of the current track in the current playlist. + + Read-only. + """ + if self.current_cp_track is None: + return None + try: + return self.backend.current_playlist.cp_tracks.index( + self.current_cp_track) + except ValueError: + return None + + @property + def track_at_eot(self): + """ + The track that will be played at the end of the current track. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_eot` for convenience. + """ + return self._get_track(self.cp_track_at_eot) + + @property + def cp_track_at_eot(self): + """ + The track that will be played at the end of the current track. + + Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + + Not necessarily the same track as :attr:`cp_track_at_next`. + """ + # pylint: disable = R0911 + # Too many return statements + + cp_tracks = self.backend.current_playlist.cp_tracks + + if not cp_tracks: + return None + + if self.random and not self._shuffled: + if self.repeat or self._first_shuffle: + logger.debug('Shuffling tracks') + self._shuffled = cp_tracks + random.shuffle(self._shuffled) + self._first_shuffle = False + + if self.random and self._shuffled: + return self._shuffled[0] + + if self.current_cp_track is None: + return cp_tracks[0] + + if self.repeat and self.single: + return cp_tracks[self.current_playlist_position] + + if self.repeat and not self.single: + return cp_tracks[ + (self.current_playlist_position + 1) % len(cp_tracks)] + + try: + return cp_tracks[self.current_playlist_position + 1] + except IndexError: + return None + + @property + def track_at_next(self): + """ + The track that will be played if calling :meth:`next()`. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_next` for convenience. + """ + return self._get_track(self.cp_track_at_next) + + @property + def cp_track_at_next(self): + """ + The track that will be played if calling :meth:`next()`. + + Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + + For normal playback this is the next track in the playlist. If repeat + is enabled the next track can loop around the playlist. When random is + enabled this should be a random track, all tracks should be played once + before the list repeats. + """ + cp_tracks = self.backend.current_playlist.cp_tracks + + if not cp_tracks: + return None + + if self.random and not self._shuffled: + if self.repeat or self._first_shuffle: + logger.debug('Shuffling tracks') + self._shuffled = cp_tracks + random.shuffle(self._shuffled) + self._first_shuffle = False + + if self.random and self._shuffled: + return self._shuffled[0] + + if self.current_cp_track is None: + return cp_tracks[0] + + if self.repeat: + return cp_tracks[ + (self.current_playlist_position + 1) % len(cp_tracks)] + + try: + return cp_tracks[self.current_playlist_position + 1] + except IndexError: + return None + + @property + def track_at_previous(self): + """ + The track that will be played if calling :meth:`previous()`. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_previous` for convenience. + """ + return self._get_track(self.cp_track_at_previous) + + @property + def cp_track_at_previous(self): + """ + The track that will be played if calling :meth:`previous()`. + + A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + + For normal playback this is the previous track in the playlist. If + random and/or consume is enabled it should return the current track + instead. + """ + if self.repeat or self.consume or self.random: + return self.current_cp_track + + if self.current_playlist_position in (None, 0): + return None + + return self.backend.current_playlist.cp_tracks[ + self.current_playlist_position - 1] + + @property + def state(self): + """ + The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or + :attr:`STOPPED`. + + Possible states and transitions: + + .. digraph:: state_transitions + + "STOPPED" -> "PLAYING" [ label="play" ] + "STOPPED" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "STOPPED" [ label="stop" ] + "PLAYING" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "PLAYING" [ label="play" ] + "PAUSED" -> "PLAYING" [ label="resume" ] + "PAUSED" -> "STOPPED" [ label="stop" ] + """ + return self._state + + @state.setter + def state(self, new_state): + (old_state, self._state) = (self.state, new_state) + logger.debug(u'Changing state: %s -> %s', old_state, new_state) + + 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() + + @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: + return 0 + + def _play_time_start(self): + self.play_time_accumulated = 0 + self.play_time_started = self._current_wall_time + + def _play_time_pause(self): + time_since_started = self._current_wall_time - self.play_time_started + self.play_time_accumulated += time_since_started + + def _play_time_resume(self): + self.play_time_started = self._current_wall_time + + @property + def _current_wall_time(self): + return int(time.time() * 1000) + + @property + def volume(self): + return self.provider.get_volume() + + @volume.setter + def volume(self, volume): + self.provider.set_volume(volume) + + def change_track(self, cp_track, on_error_step=1): + """ + Change to the given track, keeping the current playback state. + + :param cp_track: track to change to + :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) + or :class:`None` + :param on_error_step: direction to step at play error, 1 for next + track (default), -1 for previous track + :type on_error_step: int, -1 or 1 + + """ + old_state = self.state + self.stop() + self.current_cp_track = cp_track + if old_state == PlaybackState.PLAYING: + self.play(on_error_step=on_error_step) + elif old_state == PlaybackState.PAUSED: + self.pause() + + def on_end_of_track(self): + """ + Tell the playback controller that end of track is reached. + """ + if self.state == PlaybackState.STOPPED: + return + + original_cp_track = self.current_cp_track + + if self.cp_track_at_eot: + self._trigger_track_playback_ended() + self.play(self.cp_track_at_eot) + else: + self.stop(clear_current_track=True) + + if self.consume: + self.backend.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`. + """ + self._first_shuffle = True + self._shuffled = [] + + if (not self.backend.current_playlist.cp_tracks or + self.current_cp_track not in + self.backend.current_playlist.cp_tracks): + self.stop(clear_current_track=True) + + def next(self): + """ + Change to the next track. + + The current playback state will be kept. If it was playing, playing + will continue. If it was paused, it will still be paused, etc. + """ + if self.cp_track_at_next: + self._trigger_track_playback_ended() + self.change_track(self.cp_track_at_next) + else: + self.stop(clear_current_track=True) + + def pause(self): + """Pause playback.""" + if self.provider.pause(): + self.state = PlaybackState.PAUSED + self._trigger_track_playback_paused() + + def play(self, cp_track=None, on_error_step=1): + """ + Play the given track, or if the given track is :class:`None`, play the + currently active track. + + :param cp_track: track to play + :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) + or :class:`None` + :param on_error_step: direction to step at play error, 1 for next + track (default), -1 for previous track + :type on_error_step: int, -1 or 1 + """ + + if cp_track is not None: + assert cp_track in self.backend.current_playlist.cp_tracks + elif cp_track is None: + if self.state == PlaybackState.PAUSED: + return self.resume() + elif self.current_cp_track is not None: + cp_track = self.current_cp_track + elif self.current_cp_track is None and on_error_step == 1: + cp_track = self.cp_track_at_next + elif self.current_cp_track is None and on_error_step == -1: + cp_track = self.cp_track_at_previous + + if cp_track is not None: + self.current_cp_track = cp_track + self.state = PlaybackState.PLAYING + if not self.provider.play(cp_track.track): + # Track is not playable + if self.random and self._shuffled: + self._shuffled.remove(cp_track) + if on_error_step == 1: + self.next() + elif on_error_step == -1: + self.previous() + + if self.random and self.current_cp_track in self._shuffled: + self._shuffled.remove(self.current_cp_track) + + self._trigger_track_playback_started() + + def previous(self): + """ + Change to the previous track. + + The current playback state will be kept. If it was playing, playing + will continue. If it was paused, it will still be paused, etc. + """ + self._trigger_track_playback_ended() + self.change_track(self.cp_track_at_previous, on_error_step=-1) + + def resume(self): + """If paused, resume playing the current track.""" + if self.state == PlaybackState.PAUSED and self.provider.resume(): + self.state = PlaybackState.PLAYING + self._trigger_track_playback_resumed() + + def seek(self, time_position): + """ + Seeks to time position given in milliseconds. + + :param time_position: time position in milliseconds + :type time_position: int + :rtype: :class:`True` if successful, else :class:`False` + """ + if not self.backend.current_playlist.tracks: + return False + + if self.state == PlaybackState.STOPPED: + self.play() + elif self.state == PlaybackState.PAUSED: + self.resume() + + if time_position < 0: + time_position = 0 + elif time_position > self.current_track.length: + self.next() + return True + + self.play_time_started = self._current_wall_time + self.play_time_accumulated = time_position + + success = self.provider.seek(time_position) + if success: + self._trigger_seeked() + return success + + def stop(self, clear_current_track=False): + """ + Stop playing. + + :param clear_current_track: whether to clear the current track _after_ + stopping + :type clear_current_track: boolean + """ + if self.state != PlaybackState.STOPPED: + if self.provider.stop(): + self._trigger_track_playback_ended() + self.state = PlaybackState.STOPPED + if clear_current_track: + self.current_cp_track = None + + def _trigger_track_playback_paused(self): + 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) + + 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) + + 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) + + 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) + + def _trigger_playback_state_changed(self): + logger.debug(u'Triggering playback state change event') + BackendListener.send('playback_state_changed') + + def _trigger_options_changed(self): + logger.debug(u'Triggering options changed event') + BackendListener.send('options_changed') + + def _trigger_seeked(self): + logger.debug(u'Triggering seeked event') + BackendListener.send('seeked') diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py new file mode 100644 index 00000000..a29e34fc --- /dev/null +++ b/mopidy/core/stored_playlists.py @@ -0,0 +1,113 @@ +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` + """ + + pykka_traversable = True + + def __init__(self, backend, provider): + self.backend = backend + self.provider = provider + + @property + def playlists(self): + """ + Currently stored playlists. + + Read/write. List of :class:`mopidy.models.Playlist`. + """ + return self.provider.playlists + + @playlists.setter + def playlists(self, playlists): + self.provider.playlists = playlists + + def create(self, name): + """ + Create a new playlist. + + :param name: name of the new playlist + :type name: string + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.create(name) + + def delete(self, playlist): + """ + Delete playlist. + + :param playlist: the playlist to delete + :type playlist: :class:`mopidy.models.Playlist` + """ + return self.provider.delete(playlist) + + def get(self, **criteria): + """ + Get playlist by given criterias from the set of stored playlists. + + Raises :exc:`LookupError` if a unique match is not found. + + Examples:: + + get(name='a') # Returns track with name 'a' + get(uri='xyz') # Returns track with URI 'xyz' + get(name='a', uri='xyz') # Returns track with name 'a' and URI + # 'xyz' + + :param criteria: one or more criteria to match by + :type criteria: dict + :rtype: :class:`mopidy.models.Playlist` + """ + matches = self.playlists + for (key, value) in criteria.iteritems(): + matches = filter(lambda p: getattr(p, key) == value, matches) + if len(matches) == 1: + return matches[0] + criteria_string = ', '.join( + ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) + if len(matches) == 0: + raise LookupError('"%s" match no playlists' % criteria_string) + else: + raise LookupError('"%s" match multiple playlists' + % criteria_string) + + def lookup(self, uri): + """ + Lookup playlist with given URI in both the set of stored playlists and + in any other playlist sources. + + :param uri: playlist URI + :type uri: string + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.lookup(uri) + + def refresh(self): + """ + Refresh the stored playlists in + :attr:`mopidy.backends.base.StoredPlaylistsController.playlists`. + """ + return self.provider.refresh() + + def rename(self, playlist, new_name): + """ + Rename playlist. + + :param playlist: the playlist + :type playlist: :class:`mopidy.models.Playlist` + :param new_name: the new name + :type new_name: string + """ + return self.provider.rename(playlist, new_name) + + def save(self, playlist): + """ + Save the playlist to the set of stored playlists. + + :param playlist: the playlist + :type playlist: :class:`mopidy.models.Playlist` + """ + return self.provider.save(playlist) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py index 3cf20c5d..d0128a1e 100644 --- a/mopidy/frontends/mpd/protocol/music_db.py +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -242,7 +242,7 @@ def _list_date(context, query): playlist = context.backend.library.find_exact(**query).get() for track in playlist.tracks: if track.date is not None: - dates.add((u'Date', track.date.strftime('%Y-%m-%d'))) + dates.add((u'Date', track.date)) return dates @handle_request(r'^listall "(?P[^"]+)"') diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 4cf33266..b0c299c8 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -1,4 +1,4 @@ -from mopidy.backends.base import PlaybackController +from mopidy.core import PlaybackState from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, MpdNotImplemented) @@ -104,11 +104,9 @@ def pause(context, state=None): - Calls ``pause`` without any arguments to toogle pause. """ if state is None: - if (context.backend.playback.state.get() == - PlaybackController.PLAYING): + if (context.backend.playback.state.get() == PlaybackState.PLAYING): context.backend.playback.pause() - elif (context.backend.playback.state.get() == - PlaybackController.PAUSED): + elif (context.backend.playback.state.get() == PlaybackState.PAUSED): context.backend.playback.resume() elif int(state): context.backend.playback.pause() @@ -185,9 +183,9 @@ def playpos(context, songpos): raise MpdArgError(u'Bad song index', command=u'play') def _play_minus_one(context): - if (context.backend.playback.state.get() == PlaybackController.PLAYING): + if (context.backend.playback.state.get() == PlaybackState.PLAYING): return # Nothing to do - elif (context.backend.playback.state.get() == PlaybackController.PAUSED): + 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() diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index 4a9ad9a1..fc24e1e1 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -1,6 +1,6 @@ import pykka.future -from mopidy.backends.base import PlaybackController +from mopidy.core import PlaybackState from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import track_to_mpd_format @@ -194,8 +194,8 @@ 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 (PlaybackController.PLAYING, - PlaybackController.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))) @@ -239,11 +239,11 @@ def _status_songpos(futures): def _status_state(futures): state = futures['playback.state'].get() - if state == PlaybackController.PLAYING: + if state == PlaybackState.PLAYING: return u'play' - elif state == PlaybackController.STOPPED: + elif state == PlaybackState.STOPPED: return u'stop' - elif state == PlaybackController.PAUSED: + elif state == PlaybackState.PAUSED: return u'pause' def _status_time(futures): diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 6815c0d2..93669977 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -16,7 +16,7 @@ from pykka.registry import ActorRegistry from mopidy import settings from mopidy.backends.base import Backend -from mopidy.backends.base.playback import PlaybackController +from mopidy.core import PlaybackState from mopidy.utils.process import exit_process # Must be done before dbus.SessionBus() is called @@ -198,11 +198,11 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.PlayPause not allowed', PLAYER_IFACE) return state = self.backend.playback.state.get() - if state == PlaybackController.PLAYING: + if state == PlaybackState.PLAYING: self.backend.playback.pause().get() - elif state == PlaybackController.PAUSED: + elif state == PlaybackState.PAUSED: self.backend.playback.resume().get() - elif state == PlaybackController.STOPPED: + elif state == PlaybackState.STOPPED: self.backend.playback.play().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) @@ -220,7 +220,7 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.Play not allowed', PLAYER_IFACE) return state = self.backend.playback.state.get() - if state == PlaybackController.PAUSED: + if state == PlaybackState.PAUSED: self.backend.playback.resume().get() else: self.backend.playback.play().get() @@ -287,11 +287,11 @@ class MprisObject(dbus.service.Object): def get_PlaybackStatus(self): state = self.backend.playback.state.get() - if state == PlaybackController.PLAYING: + if state == PlaybackState.PLAYING: return 'Playing' - elif state == PlaybackController.PAUSED: + elif state == PlaybackState.PAUSED: return 'Paused' - elif state == PlaybackController.STOPPED: + elif state == PlaybackState.STOPPED: return 'Stopped' def get_LoopStatus(self): diff --git a/mopidy/models.py b/mopidy/models.py index 3363a429..507ca088 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,5 +1,6 @@ from collections import namedtuple + class ImmutableObject(object): """ Superclass for immutable objects whose fields can only be modified via the @@ -157,8 +158,8 @@ class Track(ImmutableObject): :type album: :class:`Album` :param track_no: track number in album :type track_no: integer - :param date: track release date - :type date: :class:`datetime.date` + :param date: track release date (YYYY or YYYY-MM-DD) + :type date: string :param length: track length in milliseconds :type length: integer :param bitrate: bitrate in kbit/s diff --git a/mopidy/settings.py b/mopidy/settings.py index 72e805bf..0612fc24 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -1,5 +1,5 @@ """ -Available settings and their default values. +All available settings and their default values. .. warning:: @@ -14,6 +14,10 @@ Available settings and their default values. #: #: 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 = ( @@ -106,9 +110,9 @@ LOCAL_TAG_CACHE_FILE = None #: Sound mixer to use. #: #: Expects a GStreamer mixer to use, typical values are: -#: alsamixer, pulsemixer, oss4mixer, ossmixer. +#: ``alsamixer``, ``pulsemixer``, ``ossmixer``, and ``oss4mixer``. #: -#: Setting this to ``None`` means no volume control. +#: Setting this to :class:`None` turns off volume control. #: #: Default:: #: @@ -118,7 +122,7 @@ MIXER = u'autoaudiomixer' #: Sound mixer track to use. #: #: Name of the mixer track to use. If this is not set we will try to find the -#: output track with master set. As an example, using ``alsamixer`` you would +#: master output track. As an example, using ``alsamixer`` you would #: typically set this to ``Master`` or ``PCM``. #: #: Default:: @@ -128,7 +132,9 @@ MIXER_TRACK = None #: Which address Mopidy's MPD server should bind to. #: -#:Examples: +#: Used by :mod:`mopidy.frontends.mpd`. +#: +#: Examples: #: #: ``127.0.0.1`` #: Listens only on the IPv4 loopback interface. Default. @@ -142,16 +148,22 @@ MPD_SERVER_HOSTNAME = u'127.0.0.1' #: Which TCP port Mopidy's MPD server should listen to. #: +#: Used by :mod:`mopidy.frontends.mpd`. +#: #: Default: 6600 MPD_SERVER_PORT = 6600 #: The password required for connecting to the MPD server. #: +#: Used by :mod:`mopidy.frontends.mpd`. +#: #: Default: :class:`None`, which means no password required. MPD_SERVER_PASSWORD = None #: The maximum number of concurrent connections the MPD server will accept. #: +#: Used by :mod:`mopidy.frontends.mpd`. +#: #: Default: 20 MPD_SERVER_MAX_CONNECTIONS = 20 diff --git a/tests/gstreamer_test.py b/tests/audio_test.py similarity index 67% rename from tests/gstreamer_test.py rename to tests/audio_test.py index 790394f5..fcafa75f 100644 --- a/tests/gstreamer_test.py +++ b/tests/audio_test.py @@ -1,7 +1,6 @@ import sys -from mopidy import settings -from mopidy.gstreamer import GStreamer +from mopidy import audio, settings from mopidy.utils.path import path_to_uri from tests import unittest, path_to_data_dir @@ -9,37 +8,38 @@ from tests import unittest, path_to_data_dir @unittest.skipIf(sys.platform == 'win32', 'Our Windows build server does not support GStreamer yet') -class GStreamerTest(unittest.TestCase): +class AudioTest(unittest.TestCase): def setUp(self): settings.MIXER = 'fakemixer track_max_volume=65536' settings.OUTPUT = 'fakesink' self.song_uri = path_to_uri(path_to_data_dir('song1.wav')) - self.gstreamer = GStreamer() + self.audio = audio.Audio.start().proxy() def tearDown(self): + self.audio.stop() settings.runtime.clear() def prepare_uri(self, uri): - self.gstreamer.prepare_change() - self.gstreamer.set_uri(uri) + self.audio.prepare_change() + self.audio.set_uri(uri) def test_start_playback_existing_file(self): self.prepare_uri(self.song_uri) - self.assertTrue(self.gstreamer.start_playback()) + self.assertTrue(self.audio.start_playback().get()) def test_start_playback_non_existing_file(self): self.prepare_uri(self.song_uri + 'bogus') - self.assertFalse(self.gstreamer.start_playback()) + self.assertFalse(self.audio.start_playback().get()) def test_pause_playback_while_playing(self): self.prepare_uri(self.song_uri) - self.gstreamer.start_playback() - self.assertTrue(self.gstreamer.pause_playback()) + self.audio.start_playback() + self.assertTrue(self.audio.pause_playback().get()) def test_stop_playback_while_playing(self): self.prepare_uri(self.song_uri) - self.gstreamer.start_playback() - self.assertTrue(self.gstreamer.stop_playback()) + self.audio.start_playback() + self.assertTrue(self.audio.stop_playback().get()) @unittest.SkipTest def test_deliver_data(self): @@ -51,8 +51,8 @@ class GStreamerTest(unittest.TestCase): def test_set_volume(self): for value in range(0, 101): - self.assertTrue(self.gstreamer.set_volume(value)) - self.assertEqual(value, self.gstreamer.get_volume()) + self.assertTrue(self.audio.set_volume(value).get()) + self.assertEqual(value, self.audio.get_volume().get()) @unittest.SkipTest def test_set_state_encapsulation(self): @@ -65,4 +65,3 @@ class GStreamerTest(unittest.TestCase): @unittest.SkipTest def test_invalid_output_raises_error(self): pass # TODO - diff --git a/tests/backends/base/current_playlist.py b/tests/backends/base/current_playlist.py index e99cd56c..430e4c40 100644 --- a/tests/backends/base/current_playlist.py +++ b/tests/backends/base/current_playlist.py @@ -1,8 +1,9 @@ import mock import random +from mopidy import audio +from mopidy.core import PlaybackState from mopidy.models import CpTrack, Playlist, Track -from mopidy.gstreamer import GStreamer from tests.backends.base import populate_playlist @@ -12,7 +13,7 @@ class CurrentPlaylistControllerTest(object): def setUp(self): self.backend = self.backend_class() - self.backend.gstreamer = mock.Mock(spec=GStreamer) + self.backend.audio = mock.Mock(spec=audio.Audio) self.controller = self.backend.current_playlist self.playback = self.backend.playback @@ -71,9 +72,9 @@ class CurrentPlaylistControllerTest(object): @populate_playlist def test_clear_when_playing(self): self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.controller.clear() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_get_by_uri_returns_unique_match(self): track = Track(uri='a') @@ -134,13 +135,13 @@ class CurrentPlaylistControllerTest(object): self.playback.play() track = self.playback.current_track self.controller.append(self.controller.tracks[1:2]) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) @populate_playlist def test_append_preserves_stopped_state(self): self.controller.append(self.controller.tracks[1:2]) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) def test_index_returns_index_of_track(self): @@ -205,7 +206,7 @@ class CurrentPlaylistControllerTest(object): version = self.controller.version self.controller.remove(uri=track1.uri) self.assert_(version < self.controller.version) - self.assert_(track1 not in self.controller.tracks) + self.assertNotIn(track1, self.controller.tracks) self.assertEqual(track2, self.controller.tracks[1]) @populate_playlist diff --git a/tests/backends/base/library.py b/tests/backends/base/library.py index 4b3ef5c0..f76d9d75 100644 --- a/tests/backends/base/library.py +++ b/tests/backends/base/library.py @@ -34,8 +34,8 @@ class LibraryControllerTest(object): self.assertEqual(track, self.tracks[0]) def test_lookup_unknown_track(self): - test = lambda: self.library.lookup('fake uri') - self.assertRaises(LookupError, test) + track = self.library.lookup('fake uri') + self.assertEquals(track, None) def test_find_exact_no_hits(self): result = self.library.find_exact(track=['unknown track']) diff --git a/tests/backends/base/playback.py b/tests/backends/base/playback.py index 40c49709..1e434e35 100644 --- a/tests/backends/base/playback.py +++ b/tests/backends/base/playback.py @@ -2,8 +2,9 @@ import mock import random import time +from mopidy import audio +from mopidy.core import PlaybackState from mopidy.models import Track -from mopidy.gstreamer import GStreamer from tests import unittest from tests.backends.base import populate_playlist @@ -16,7 +17,7 @@ class PlaybackControllerTest(object): def setUp(self): self.backend = self.backend_class() - self.backend.gstreamer = mock.Mock(spec=GStreamer) + self.backend.audio = mock.Mock(spec=audio.Audio) self.playback = self.backend.playback self.current_playlist = self.backend.current_playlist @@ -26,21 +27,21 @@ class PlaybackControllerTest(object): 'First song needs to be at least 2000 miliseconds' def test_initial_state_is_stopped(self): - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_play_with_empty_playlist(self): - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_play_with_empty_playlist_return_value(self): self.assertEqual(self.playback.play(), None) @populate_playlist def test_play_state(self): - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_play_return_value(self): @@ -48,9 +49,9 @@ class PlaybackControllerTest(object): @populate_playlist def test_play_track_state(self): - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play(self.current_playlist.cp_tracks[-1]) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_play_track_return_value(self): @@ -70,7 +71,7 @@ class PlaybackControllerTest(object): track = self.playback.current_track self.playback.pause() self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(track, self.playback.current_track) @populate_playlist @@ -81,7 +82,7 @@ class PlaybackControllerTest(object): track = self.playback.current_track self.playback.pause() self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(track, self.playback.current_track) @populate_playlist @@ -106,12 +107,12 @@ class PlaybackControllerTest(object): def test_current_track_after_completed_playlist(self): self.playback.play(self.current_playlist.cp_tracks[-1]) self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) self.playback.play(self.current_playlist.cp_tracks[-1]) self.playback.next() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @populate_playlist @@ -141,17 +142,17 @@ class PlaybackControllerTest(object): self.playback.next() self.playback.stop() self.playback.previous() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_previous_at_start_of_playlist(self): self.playback.previous() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) def test_previous_for_empty_playlist(self): self.playback.previous() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @populate_playlist @@ -185,20 +186,20 @@ class PlaybackControllerTest(object): @populate_playlist def test_next_does_not_trigger_playback(self): self.playback.next() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_next_at_end_of_playlist(self): self.playback.play() for i, track in enumerate(self.tracks): - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) self.assertEqual(self.playback.current_playlist_position, i) self.playback.next() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_next_until_end_of_playlist_and_play_from_start(self): @@ -208,15 +209,15 @@ class PlaybackControllerTest(object): self.playback.next() self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, self.tracks[0]) def test_next_for_empty_playlist(self): self.playback.next() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_next_skips_to_next_track_on_failure(self): @@ -273,7 +274,7 @@ class PlaybackControllerTest(object): self.playback.consume = True self.playback.play() self.playback.next() - self.assert_(self.tracks[0] in self.backend.current_playlist.tracks) + self.assertIn(self.tracks[0], self.backend.current_playlist.tracks) @populate_playlist def test_next_with_single_and_repeat(self): @@ -321,20 +322,20 @@ class PlaybackControllerTest(object): @populate_playlist def test_end_of_track_does_not_trigger_playback(self): self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_end_of_track_at_end_of_playlist(self): self.playback.play() for i, track in enumerate(self.tracks): - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, track) self.assertEqual(self.playback.current_playlist_position, i) self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_end_of_track_until_end_of_playlist_and_play_from_start(self): @@ -344,15 +345,15 @@ class PlaybackControllerTest(object): self.playback.on_end_of_track() self.assertEqual(self.playback.current_track, None) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) self.assertEqual(self.playback.current_track, self.tracks[0]) def test_end_of_track_for_empty_playlist(self): self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_end_of_track_skips_to_next_track_on_failure(self): @@ -410,7 +411,7 @@ class PlaybackControllerTest(object): self.playback.consume = True self.playback.play() self.playback.on_end_of_track() - self.assert_(self.tracks[0] not in self.backend.current_playlist.tracks) + self.assertNotIn(self.tracks[0], self.backend.current_playlist.tracks) @populate_playlist def test_end_of_track_with_random(self): @@ -534,13 +535,13 @@ class PlaybackControllerTest(object): self.playback.play() current_track = self.playback.current_track self.backend.current_playlist.append([self.tracks[2]]) - self.assertEqual(self.playback.state, self.playback.PLAYING) + 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.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.assertEqual(self.playback.current_track, None) @populate_playlist @@ -549,26 +550,26 @@ class PlaybackControllerTest(object): self.playback.pause() current_track = self.playback.current_track self.backend.current_playlist.append([self.tracks[2]]) - self.assertEqual(self.playback.state, self.backend.playback.PAUSED) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) self.assertEqual(self.playback.current_track, current_track) @populate_playlist def test_pause_when_stopped(self): self.playback.pause() - self.assertEqual(self.playback.state, self.playback.PAUSED) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) @populate_playlist def test_pause_when_playing(self): self.playback.play() self.playback.pause() - self.assertEqual(self.playback.state, self.playback.PAUSED) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) @populate_playlist def test_pause_when_paused(self): self.playback.play() self.playback.pause() self.playback.pause() - self.assertEqual(self.playback.state, self.playback.PAUSED) + self.assertEqual(self.playback.state, PlaybackState.PAUSED) @populate_playlist def test_pause_return_value(self): @@ -578,20 +579,20 @@ class PlaybackControllerTest(object): @populate_playlist def test_resume_when_stopped(self): self.playback.resume() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_resume_when_playing(self): self.playback.play() self.playback.resume() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_resume_when_paused(self): self.playback.play() self.playback.pause() self.playback.resume() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_resume_return_value(self): @@ -624,12 +625,12 @@ class PlaybackControllerTest(object): def test_seek_on_empty_playlist_updates_position(self): self.playback.seek(0) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_seek_when_stopped_triggers_play(self): self.playback.seek(0) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_seek_when_playing(self): @@ -666,7 +667,7 @@ class PlaybackControllerTest(object): self.playback.play() self.playback.pause() self.playback.seek(0) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @unittest.SkipTest @populate_playlist @@ -686,7 +687,7 @@ class PlaybackControllerTest(object): def test_seek_beyond_end_of_song_for_last_track(self): self.playback.play(self.current_playlist.cp_tracks[-1]) self.playback.seek(self.current_playlist.tracks[-1].length * 100) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @unittest.SkipTest @populate_playlist @@ -702,25 +703,25 @@ class PlaybackControllerTest(object): self.playback.seek(-1000) position = self.playback.time_position self.assert_(position >= 0, position) - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_stop_when_stopped(self): self.playback.stop() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_stop_when_playing(self): self.playback.play() self.playback.stop() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) @populate_playlist def test_stop_when_paused(self): self.playback.play() self.playback.pause() self.playback.stop() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_stop_return_value(self): self.playback.play() @@ -729,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.gstreamer.get_position = mock.Mock(return_value=future) + self.backend.audio.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) @@ -737,7 +738,7 @@ class PlaybackControllerTest(object): def test_time_position_when_stopped_with_playlist(self): future = mock.Mock() future.get = mock.Mock(return_value=0) - self.backend.gstreamer.get_position = mock.Mock(return_value=future) + self.backend.audio.get_position = mock.Mock(return_value=future) self.assertEqual(self.playback.time_position, 0) @@ -810,7 +811,7 @@ class PlaybackControllerTest(object): def test_end_of_playlist_stops(self): self.playback.play(self.current_playlist.cp_tracks[-1]) self.playback.on_end_of_track() - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) def test_repeat_off_by_default(self): self.assertEqual(self.playback.repeat, False) @@ -835,9 +836,9 @@ class PlaybackControllerTest(object): for _ in self.tracks: self.playback.next() self.assertNotEqual(self.playback.track_at_next, None) - self.assertEqual(self.playback.state, self.playback.STOPPED) + self.assertEqual(self.playback.state, PlaybackState.STOPPED) self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) @populate_playlist def test_random_until_end_of_playlist_with_repeat(self): @@ -854,7 +855,7 @@ class PlaybackControllerTest(object): self.playback.play() played = [] for _ in self.tracks: - self.assert_(self.playback.current_track not in played) + self.assertNotIn(self.playback.current_track, played) played.append(self.playback.current_track) self.playback.next() diff --git a/tests/backends/base/stored_playlists.py b/tests/backends/base/stored_playlists.py index 54315e62..1e575b9e 100644 --- a/tests/backends/base/stored_playlists.py +++ b/tests/backends/base/stored_playlists.py @@ -30,7 +30,7 @@ class StoredPlaylistsControllerTest(object): def test_create_in_playlists(self): playlist = self.stored.create('test') self.assert_(self.stored.playlists) - self.assert_(playlist in self.stored.playlists) + self.assertIn(playlist, self.stored.playlists) def test_playlists_empty_to_start_with(self): self.assert_(not self.stored.playlists) @@ -101,7 +101,7 @@ class StoredPlaylistsControllerTest(object): # FIXME should we handle playlists without names? playlist = Playlist(name='test') self.stored.save(playlist) - self.assert_(playlist in self.stored.playlists) + self.assertIn(playlist, self.stored.playlists) @unittest.SkipTest def test_playlist_with_unknown_track(self): diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 788fe33c..c167fbcc 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -2,6 +2,7 @@ import sys from mopidy import settings from mopidy.backends.local import LocalBackend +from mopidy.core import PlaybackState from mopidy.models import Track from mopidy.utils.path import path_to_uri @@ -34,19 +35,19 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): self.backend.current_playlist.add(track) def test_uri_scheme(self): - self.assert_('file' in self.backend.uri_schemes) + self.assertIn('file', self.backend.uri_schemes) def test_play_mp3(self): self.add_track('blank.mp3') self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) def test_play_ogg(self): self.add_track('blank.ogg') self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) def test_play_flac(self): self.add_track('blank.flac') self.playback.play() - self.assertEqual(self.playback.state, self.playback.PLAYING) + self.assertEqual(self.playback.state, PlaybackState.PLAYING) diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/frontends/mpd/dispatcher_test.py b/tests/frontends/mpd/dispatcher_test.py index 63f6d299..9f05d7dd 100644 --- a/tests/frontends/mpd/dispatcher_test.py +++ b/tests/frontends/mpd/dispatcher_test.py @@ -37,7 +37,7 @@ class MpdDispatcherTest(unittest.TestCase): expected_handler (handler, kwargs) = self.dispatcher._find_handler('known_command an_arg') self.assertEqual(handler, expected_handler) - self.assert_('arg1' in kwargs) + self.assertIn('arg1', kwargs) self.assertEqual(kwargs['arg1'], 'an_arg') def test_handling_unknown_request_yields_error(self): @@ -48,5 +48,5 @@ class MpdDispatcherTest(unittest.TestCase): expected = 'magic' request_handlers['known request'] = lambda x: expected result = self.dispatcher.handle_request('known request') - self.assert_(u'OK' in result) - self.assert_(expected in result) + self.assertIn(u'OK', result) + self.assertIn(expected, result) diff --git a/tests/frontends/mpd/protocol/__init__.py b/tests/frontends/mpd/protocol/__init__.py index b39ded01..3b8fbe33 100644 --- a/tests/frontends/mpd/protocol/__init__.py +++ b/tests/frontends/mpd/protocol/__init__.py @@ -42,7 +42,7 @@ class BaseTestCase(unittest.TestCase): self.assertEqual([], self.connection.response) def assertInResponse(self, value): - self.assert_(value in self.connection.response, u'Did not find %s ' + self.assertIn(value, self.connection.response, u'Did not find %s ' 'in %s' % (repr(value), repr(self.connection.response))) def assertOnceInResponse(self, value): @@ -51,7 +51,7 @@ class BaseTestCase(unittest.TestCase): (repr(value), repr(self.connection.response))) def assertNotInResponse(self, value): - self.assert_(value not in self.connection.response, u'Found %s in %s' % + self.assertNotIn(value, self.connection.response, u'Found %s in %s' % (repr(value), repr(self.connection.response))) def assertEqualResponse(self, value): diff --git a/tests/frontends/mpd/protocol/command_list_test.py b/tests/frontends/mpd/protocol/command_list_test.py index a81725ad..65b051d3 100644 --- a/tests/frontends/mpd/protocol/command_list_test.py +++ b/tests/frontends/mpd/protocol/command_list_test.py @@ -21,7 +21,7 @@ class CommandListsTest(protocol.BaseTestCase): self.assertEqual([], self.dispatcher.command_list) self.assertEqual(False, self.dispatcher.command_list_ok) self.sendRequest(u'ping') - self.assert_(u'ping' in self.dispatcher.command_list) + 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) @@ -42,7 +42,7 @@ class CommandListsTest(protocol.BaseTestCase): self.assertEqual([], self.dispatcher.command_list) self.assertEqual(True, self.dispatcher.command_list_ok) self.sendRequest(u'ping') - self.assert_(u'ping' in self.dispatcher.command_list) + self.assertIn(u'ping', self.dispatcher.command_list) self.sendRequest(u'command_list_end') self.assertInResponse(u'list_OK') self.assertInResponse(u'OK') diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 87c9bbb8..88452d3d 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -1,12 +1,13 @@ -from mopidy.backends import base as backend +from mopidy.core import PlaybackState from mopidy.models import Track from tests import unittest from tests.frontends.mpd import protocol -PAUSED = backend.PlaybackController.PAUSED -PLAYING = backend.PlaybackController.PLAYING -STOPPED = backend.PlaybackController.STOPPED + +PAUSED = PlaybackState.PAUSED +PLAYING = PlaybackState.PLAYING +STOPPED = PlaybackState.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py index a20abaed..e6cd80e2 100644 --- a/tests/frontends/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -31,66 +31,66 @@ class TrackMpdFormatTest(unittest.TestCase): def test_track_to_mpd_format_for_empty_track(self): result = translator.track_to_mpd_format(Track()) - self.assert_(('file', '') in result) - self.assert_(('Time', 0) in result) - self.assert_(('Artist', '') in result) - self.assert_(('Title', '') in result) - self.assert_(('Album', '') in result) - self.assert_(('Track', 0) in result) - self.assert_(('Date', '') in result) + self.assertIn(('file', ''), result) + self.assertIn(('Time', 0), result) + self.assertIn(('Artist', ''), result) + self.assertIn(('Title', ''), result) + self.assertIn(('Album', ''), result) + self.assertIn(('Track', 0), result) + self.assertIn(('Date', ''), result) self.assertEqual(len(result), 7) def test_track_to_mpd_format_with_position(self): result = translator.track_to_mpd_format(Track(), position=1) - self.assert_(('Pos', 1) not in result) + self.assertNotIn(('Pos', 1), result) def test_track_to_mpd_format_with_cpid(self): result = translator.track_to_mpd_format(CpTrack(1, Track())) - self.assert_(('Id', 1) not in result) + 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) - self.assert_(('Pos', 1) in result) - self.assert_(('Id', 2) in result) + self.assertIn(('Pos', 1), result) + self.assertIn(('Id', 2), result) def test_track_to_mpd_format_for_nonempty_track(self): result = translator.track_to_mpd_format( CpTrack(122, self.track), position=9) - self.assert_(('file', 'a uri') in result) - self.assert_(('Time', 137) in result) - self.assert_(('Artist', 'an artist') in result) - self.assert_(('Title', 'a name') in result) - self.assert_(('Album', 'an album') in result) - self.assert_(('AlbumArtist', 'an other artist') in result) - self.assert_(('Track', '7/13') in result) - self.assert_(('Date', datetime.date(1977, 1, 1)) in result) - self.assert_(('Pos', 9) in result) - self.assert_(('Id', 122) in result) + self.assertIn(('file', 'a uri'), result) + self.assertIn(('Time', 137), result) + self.assertIn(('Artist', 'an artist'), result) + self.assertIn(('Title', 'a name'), result) + self.assertIn(('Album', 'an album'), result) + self.assertIn(('AlbumArtist', 'an other artist'), result) + self.assertIn(('Track', '7/13'), result) + self.assertIn(('Date', datetime.date(1977, 1, 1)), result) + self.assertIn(('Pos', 9), result) + self.assertIn(('Id', 122), result) self.assertEqual(len(result), 10) def test_track_to_mpd_format_musicbrainz_trackid(self): track = self.track.copy(musicbrainz_id='foo') result = translator.track_to_mpd_format(track) - self.assert_(('MUSICBRAINZ_TRACKID', 'foo') in result) + self.assertIn(('MUSICBRAINZ_TRACKID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_albumid(self): album = self.track.album.copy(musicbrainz_id='foo') track = self.track.copy(album=album) result = translator.track_to_mpd_format(track) - self.assert_(('MUSICBRAINZ_ALBUMID', 'foo') in result) + self.assertIn(('MUSICBRAINZ_ALBUMID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_albumid(self): artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') album = self.track.album.copy(artists=[artist]) track = self.track.copy(album=album) result = translator.track_to_mpd_format(track) - self.assert_(('MUSICBRAINZ_ALBUMARTISTID', 'foo') in result) + self.assertIn(('MUSICBRAINZ_ALBUMARTISTID', 'foo'), result) def test_track_to_mpd_format_musicbrainz_artistid(self): artist = list(self.track.artists)[0].copy(musicbrainz_id='foo') track = self.track.copy(artists=[artist]) result = translator.track_to_mpd_format(track) - self.assert_(('MUSICBRAINZ_ARTISTID', 'foo') in result) + self.assertIn(('MUSICBRAINZ_ARTISTID', 'foo'), result) def test_artists_to_mpd_format(self): artists = [Artist(name=u'ABBA'), Artist(name=u'Beatles')] diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 3701faaf..2bc3488b 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,13 +1,15 @@ -from mopidy.backends import dummy as backend +from mopidy.backends import dummy +from mopidy.core import PlaybackState from mopidy.frontends.mpd import dispatcher from mopidy.frontends.mpd.protocol import status from mopidy.models import Track from tests import unittest -PAUSED = backend.PlaybackController.PAUSED -PLAYING = backend.PlaybackController.PLAYING -STOPPED = backend.PlaybackController.STOPPED + +PAUSED = PlaybackState.PAUSED +PLAYING = PlaybackState.PLAYING +STOPPED = PlaybackState.STOPPED # FIXME migrate to using protocol.BaseTestCase instead of status.stats # directly? @@ -15,7 +17,7 @@ STOPPED = backend.PlaybackController.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.backend = backend.DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start().proxy() self.dispatcher = dispatcher.MpdDispatcher() self.context = self.dispatcher.context @@ -24,123 +26,123 @@ class StatusHandlerTest(unittest.TestCase): def test_stats_method(self): result = status.stats(self.context) - self.assert_('artists' in result) + self.assertIn('artists', result) self.assert_(int(result['artists']) >= 0) - self.assert_('albums' in result) + self.assertIn('albums', result) self.assert_(int(result['albums']) >= 0) - self.assert_('songs' in result) + self.assertIn('songs', result) self.assert_(int(result['songs']) >= 0) - self.assert_('uptime' in result) + self.assertIn('uptime', result) self.assert_(int(result['uptime']) >= 0) - self.assert_('db_playtime' in result) + self.assertIn('db_playtime', result) self.assert_(int(result['db_playtime']) >= 0) - self.assert_('db_update' in result) + self.assertIn('db_update', result) self.assert_(int(result['db_update']) >= 0) - self.assert_('playtime' in result) + self.assertIn('playtime', result) self.assert_(int(result['playtime']) >= 0) def test_status_method_contains_volume_with_na_value(self): result = dict(status.status(self.context)) - self.assert_('volume' in result) + self.assertIn('volume', result) self.assertEqual(int(result['volume']), -1) def test_status_method_contains_volume(self): self.backend.playback.volume = 17 result = dict(status.status(self.context)) - self.assert_('volume' in result) + self.assertIn('volume', result) self.assertEqual(int(result['volume']), 17) def test_status_method_contains_repeat_is_0(self): result = dict(status.status(self.context)) - self.assert_('repeat' in result) + self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): self.backend.playback.repeat = 1 result = dict(status.status(self.context)) - self.assert_('repeat' in result) + self.assertIn('repeat', result) self.assertEqual(int(result['repeat']), 1) def test_status_method_contains_random_is_0(self): result = dict(status.status(self.context)) - self.assert_('random' in result) + self.assertIn('random', result) self.assertEqual(int(result['random']), 0) def test_status_method_contains_random_is_1(self): self.backend.playback.random = 1 result = dict(status.status(self.context)) - self.assert_('random' in result) + self.assertIn('random', result) self.assertEqual(int(result['random']), 1) def test_status_method_contains_single(self): result = dict(status.status(self.context)) - self.assert_('single' in result) - self.assert_(int(result['single']) in (0, 1)) + self.assertIn('single', result) + self.assertIn(int(result['single']), (0, 1)) def test_status_method_contains_consume_is_0(self): result = dict(status.status(self.context)) - self.assert_('consume' in result) + self.assertIn('consume', result) self.assertEqual(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): self.backend.playback.consume = 1 result = dict(status.status(self.context)) - self.assert_('consume' in result) + self.assertIn('consume', result) self.assertEqual(int(result['consume']), 1) def test_status_method_contains_playlist(self): result = dict(status.status(self.context)) - self.assert_('playlist' in result) - self.assert_(int(result['playlist']) in xrange(0, 2**31 - 1)) + self.assertIn('playlist', result) + self.assertIn(int(result['playlist']), xrange(0, 2**31 - 1)) def test_status_method_contains_playlistlength(self): result = dict(status.status(self.context)) - self.assert_('playlistlength' in result) + self.assertIn('playlistlength', result) self.assert_(int(result['playlistlength']) >= 0) def test_status_method_contains_xfade(self): result = dict(status.status(self.context)) - self.assert_('xfade' in result) + self.assertIn('xfade', result) self.assert_(int(result['xfade']) >= 0) def test_status_method_contains_state_is_play(self): self.backend.playback.state = PLAYING result = dict(status.status(self.context)) - self.assert_('state' in result) + self.assertIn('state', result) self.assertEqual(result['state'], 'play') def test_status_method_contains_state_is_stop(self): self.backend.playback.state = STOPPED result = dict(status.status(self.context)) - self.assert_('state' in result) + 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 result = dict(status.status(self.context)) - self.assert_('state' in result) + 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() result = dict(status.status(self.context)) - self.assert_('song' in result) + self.assertIn('song', result) self.assert_(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() result = dict(status.status(self.context)) - self.assert_('songid' in result) + 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() result = dict(status.status(self.context)) - self.assert_('time' in result) + self.assertIn('time', result) (position, total) = result['time'].split(':') position = int(position) total = int(total) @@ -150,7 +152,7 @@ class StatusHandlerTest(unittest.TestCase): self.backend.current_playlist.append([Track(length=10000)]) self.backend.playback.play() result = dict(status.status(self.context)) - self.assert_('time' in result) + self.assertIn('time', result) (position, total) = result['time'].split(':') position = int(position) total = int(total) @@ -160,19 +162,19 @@ class StatusHandlerTest(unittest.TestCase): self.backend.playback.state = PAUSED self.backend.playback.play_time_accumulated = 59123 result = dict(status.status(self.context)) - self.assert_('elapsed' in result) + 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 result = dict(status.status(self.context)) - self.assert_('elapsed' in result) + self.assertIn('elapsed', result) self.assertEqual(result['elapsed'], '0.123') def test_status_method_when_playing_contains_bitrate(self): self.backend.current_playlist.append([Track(bitrate=320)]) self.backend.playback.play() result = dict(status.status(self.context)) - self.assert_('bitrate' in result) + self.assertIn('bitrate', result) self.assertEqual(int(result['bitrate']), 320) diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index b7ad1b60..db7f9265 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -4,7 +4,7 @@ import mock from mopidy import OptionalDependencyError from mopidy.backends.dummy import DummyBackend -from mopidy.backends.base.playback import PlaybackController +from mopidy.core import PlaybackState from mopidy.models import Album, Artist, Track try: @@ -14,9 +14,9 @@ except OptionalDependencyError: from tests import unittest -PLAYING = PlaybackController.PLAYING -PAUSED = PlaybackController.PAUSED -STOPPED = PlaybackController.STOPPED +PLAYING = PlaybackState.PLAYING +PAUSED = PlaybackState.PAUSED +STOPPED = PlaybackState.STOPPED @unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') @@ -141,7 +141,7 @@ class PlayerInterfaceTest(unittest.TestCase): def test_get_metadata_has_trackid_even_when_no_current_track(self): result = self.mpris.Get(objects.PLAYER_IFACE, 'Metadata') - self.assert_('mpris:trackid' in result.keys()) + self.assertIn('mpris:trackid', result.keys()) self.assertEquals(result['mpris:trackid'], '') def test_get_metadata_has_trackid_based_on_cpid(self): diff --git a/tests/help_test.py b/tests/help_test.py index 1fa22c2f..a2803b72 100644 --- a/tests/help_test.py +++ b/tests/help_test.py @@ -13,18 +13,18 @@ class HelpTest(unittest.TestCase): args = [sys.executable, mopidy_dir, '--help'] process = subprocess.Popen(args, stdout=subprocess.PIPE) output = process.communicate()[0] - self.assert_('--version' in output) - self.assert_('--help' in output) - self.assert_('--help-gst' in output) - self.assert_('--interactive' in output) - self.assert_('--quiet' in output) - self.assert_('--verbose' in output) - self.assert_('--save-debug-log' in output) - self.assert_('--list-settings' in output) + self.assertIn('--version', output) + self.assertIn('--help', output) + self.assertIn('--help-gst', output) + self.assertIn('--interactive', output) + self.assertIn('--quiet', output) + self.assertIn('--verbose', output) + self.assertIn('--save-debug-log', output) + self.assertIn('--list-settings', output) def test_help_gst_has_gstreamer_options(self): mopidy_dir = os.path.dirname(mopidy.__file__) args = [sys.executable, mopidy_dir, '--help-gst'] process = subprocess.Popen(args, stdout=subprocess.PIPE) output = process.communicate()[0] - self.assert_('--gst-version' in output) + self.assertIn('--gst-version', output) diff --git a/tests/models_test.py b/tests/models_test.py index 231587e4..779d1a4b 100644 --- a/tests/models_test.py +++ b/tests/models_test.py @@ -43,7 +43,7 @@ class GenericCopyTets(unittest.TestCase): artist2 = Artist(name='bar') track = Track(artists=[artist1]) copy = track.copy(artists=[artist2]) - self.assert_(artist2 in copy.artists) + self.assertIn(artist2, copy.artists) def test_copying_track_with_invalid_key(self): test = lambda: Track().copy(invalid_key=True) @@ -155,7 +155,7 @@ class AlbumTest(unittest.TestCase): def test_artists(self): artist = Artist() album = Album(artists=[artist]) - self.assert_(artist in album.artists) + self.assertIn(artist, album.artists) self.assertRaises(AttributeError, setattr, album, 'artists', None) def test_num_tracks(self): @@ -338,7 +338,7 @@ class TrackTest(unittest.TestCase): self.assertRaises(AttributeError, setattr, track, 'track_no', None) def test_date(self): - date = datetime.date(1977, 1, 1) + date = '1977-01-01' track = Track(date=date) self.assertEqual(track.date, date) self.assertRaises(AttributeError, setattr, track, 'date', None) @@ -434,7 +434,7 @@ class TrackTest(unittest.TestCase): self.assertEqual(hash(track1), hash(track2)) def test_eq_date(self): - date = datetime.date.today() + date = '1977-01-01' track1 = Track(date=date) track2 = Track(date=date) self.assertEqual(track1, track2) @@ -459,7 +459,7 @@ class TrackTest(unittest.TestCase): self.assertEqual(hash(track1), hash(track2)) def test_eq(self): - date = datetime.date.today() + date = '1977-01-01' artists = [Artist()] album = Album() track1 = Track(uri=u'uri', name=u'name', artists=artists, album=album, @@ -508,8 +508,8 @@ class TrackTest(unittest.TestCase): self.assertNotEqual(hash(track1), hash(track2)) def test_ne_date(self): - track1 = Track(date=datetime.date.today()) - track2 = Track(date=datetime.date.today()-datetime.timedelta(days=1)) + track1 = Track(date='1977-01-01') + track2 = Track(date='1977-01-02') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) @@ -534,12 +534,12 @@ class TrackTest(unittest.TestCase): 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=datetime.date.today(), length=100, bitrate=100, + 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=datetime.date.today()-datetime.timedelta(days=1), - length=200, bitrate=200, musicbrainz_id='id2') + track_no=2, date='1977-01-02', length=200, bitrate=200, + musicbrainz_id='id2') self.assertNotEqual(track1, track2) self.assertNotEqual(hash(track1), hash(track2)) diff --git a/tests/utils/init_test.py b/tests/utils/init_test.py index f232e2ef..bdd0adc5 100644 --- a/tests/utils/init_test.py +++ b/tests/utils/init_test.py @@ -20,7 +20,7 @@ class GetClassTest(unittest.TestCase): try: utils.get_class('foo.bar.Baz') except ImportError as e: - self.assert_('foo.bar.Baz' in str(e)) + self.assertIn('foo.bar.Baz', str(e)) def test_loading_existing_class(self): cls = utils.get_class('unittest.TestCase') diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 7d104969..cf476c24 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -107,7 +107,7 @@ class SettingsProxyTest(unittest.TestCase): def test_setattr_updates_runtime_settings(self): self.settings.TEST = 'test' - self.assert_('TEST' in self.settings.runtime) + self.assertIn('TEST', self.settings.runtime) def test_setattr_updates_runtime_with_value(self): self.settings.TEST = 'test' @@ -181,34 +181,33 @@ class FormatSettingListTest(unittest.TestCase): def test_contains_the_setting_name(self): self.settings.TEST = u'test' result = setting_utils.format_settings_list(self.settings) - self.assert_('TEST:' in result, result) + self.assertIn('TEST:', result, result) def test_repr_of_a_string_value(self): self.settings.TEST = u'test' result = setting_utils.format_settings_list(self.settings) - self.assert_("TEST: u'test'" in result, result) + self.assertIn("TEST: u'test'", result, result) def test_repr_of_an_int_value(self): self.settings.TEST = 123 result = setting_utils.format_settings_list(self.settings) - self.assert_("TEST: 123" in result, result) + self.assertIn("TEST: 123", result, result) def test_repr_of_a_tuple_value(self): self.settings.TEST = (123, u'abc') result = setting_utils.format_settings_list(self.settings) - self.assert_("TEST: (123, u'abc')" in result, result) + self.assertIn("TEST: (123, u'abc')", result, result) def test_passwords_are_masked(self): self.settings.TEST_PASSWORD = u'secret' result = setting_utils.format_settings_list(self.settings) - self.assert_("TEST_PASSWORD: u'secret'" not in result, result) - self.assert_("TEST_PASSWORD: u'********'" in result, result) + self.assertNotIn("TEST_PASSWORD: u'secret'", result, result) + self.assertIn("TEST_PASSWORD: u'********'", result, result) 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.assert_("FRONTEND: (u'mopidy.frontends.mpd.MpdFrontend',)" in result, - 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', diff --git a/tests/version_test.py b/tests/version_test.py index 26045ac1..85b182f0 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -31,10 +31,10 @@ class VersionTest(unittest.TestCase): self.assert_(SV(__version__) < SV('0.8.0')) def test_get_platform_contains_platform(self): - self.assert_(platform.platform() in get_platform()) + self.assertIn(platform.platform(), get_platform()) def test_get_python_contains_python_implementation(self): - self.assert_(platform.python_implementation() in get_python()) + self.assertIn(platform.python_implementation(), get_python()) def test_get_python_contains_python_version(self): - self.assert_(platform.python_version() in get_python()) + self.assertIn(platform.python_version(), get_python())