diff --git a/README.rst b/README.rst index e7ecd614..a7df7692 100644 --- a/README.rst +++ b/README.rst @@ -4,11 +4,17 @@ Mopidy .. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop -Mopidy is a music server which can play music from `Spotify -`_ or from your local hard drive. To search for music -in Spotify's vast archive, manage playlists, and play music, you can use any -`MPD client `_. MPD clients are available for most -platforms, including Windows, Mac OS X, Linux, Android and iOS. +Mopidy is a music server which can play music both from your local hard drive +and from `Spotify `_. Searches returns results from +both your local hard drive and from Spotify, and you can mix tracks from both +sources in your play queue. Your Spotify playlists are also available for use, +though we don't support modifying them yet. + +To control your music server, you can use the Ubuntu Sound Menu on the machine +running Mopidy, any device on the same network which supports the DLNA media +controller spec (with the help of Rygel in addition to Mopidy), or any `MPD +client `_. MPD clients are available for most platforms, +including Windows, Mac OS X, Linux, Android and iOS. To install Mopidy, check out `the installation docs `_. diff --git a/docs/api/concepts.rst b/docs/api/concepts.rst index ae959237..203418de 100644 --- a/docs/api/concepts.rst +++ b/docs/api/concepts.rst @@ -1,29 +1,99 @@ .. _concepts: -********************************************** -The backend, controller, and provider concepts -********************************************** +************************* +Architecture and concepts +************************* -Backend: - The backend is mostly for convenience. It is a container that holds - references to all the controllers. -Controllers: - Each controller has responsibility for a given part of the backend - functionality. Most, but not all, controllers delegates some work to one or - more providers. The controllers are responsible for choosing the right - provider for any given task based upon i.e. the track's URI. See - :ref:`core-api` for more details. -Providers: - Anything specific to i.e. Spotify integration or local storage is contained - in the providers. To integrate with new music sources, you just add new - providers. See :ref:`backend-api` for more details. +The overall architecture of Mopidy is organized around multiple frontends and +backends. The frontends use the core API. The core actor makes multiple backends +work as one. The backends connect to various music sources. Both the core actor +and the backends use the audio actor to play audio and control audio volume. -.. digraph:: backend_relations +.. digraph:: overall_architecture - Backend -> "Current\nplaylist\ncontroller" - Backend -> "Library\ncontroller" - "Library\ncontroller" -> "Library\nproviders" - Backend -> "Playback\ncontroller" - "Playback\ncontroller" -> "Playback\nproviders" - Backend -> "Stored\nplaylists\ncontroller" - "Stored\nplaylists\ncontroller" -> "Stored\nplaylist\nproviders" + "Multiple frontends" -> Core + Core -> "Multiple backends" + Core -> Audio + "Multiple backends" -> Audio + + +Frontends +========= + +Frontends expose Mopidy to the external world. They can implement servers for +protocols like MPD and MPRIS, and they can be used to update other services +when something happens in Mopidy, like the Last.fm scrobbler frontend does. See +:ref:`frontend-api` for more details. + +.. digraph:: frontend_architecture + + "MPD\nfrontend" -> Core + "MPRIS\nfrontend" -> Core + "Last.fm\nfrontend" -> Core + + +Core +==== + +The core is organized as a set of controllers with responsiblity for separate +sets of functionality. + +The core is the single actor that the frontends send their requests to. For +every request from a frontend it calls out to one or more backends which does +the real work, and when the backends respond, the core actor is responsible for +combining the responses into a single response to the requesting frontend. + +The core actor also keeps track of the current playlist, since it doesn't +belong to a specific backend. + +See :ref:`core-api` for more details. + +.. digraph:: core_architecture + + Core -> "Current\nplaylist\ncontroller" + Core -> "Library\ncontroller" + Core -> "Playback\ncontroller" + Core -> "Stored\nplaylists\ncontroller" + + "Library\ncontroller" -> "Local backend" + "Library\ncontroller" -> "Spotify backend" + + "Playback\ncontroller" -> "Local backend" + "Playback\ncontroller" -> "Spotify backend" + "Playback\ncontroller" -> Audio + + "Stored\nplaylists\ncontroller" -> "Local backend" + "Stored\nplaylists\ncontroller" -> "Spotify backend" + + +Backends +======== + +The backends are organized as a set of providers with responsiblity for +separate sets of functionality, similar to the core actor. + +Anything specific to i.e. Spotify integration or local storage is contained in +the backends. To integrate with new music sources, you just add a new backend. +See :ref:`backend-api` for more details. + +.. digraph:: backend_architecture + + "Local backend" -> "Local\nlibrary\nprovider" -> "Local disk" + "Local backend" -> "Local\nplayback\nprovider" -> "Local disk" + "Local backend" -> "Local\nstored\nplaylists\nprovider" -> "Local disk" + "Local\nplayback\nprovider" -> Audio + + "Spotify backend" -> "Spotify\nlibrary\nprovider" -> "Spotify service" + "Spotify backend" -> "Spotify\nplayback\nprovider" -> "Spotify service" + "Spotify backend" -> "Spotify\nstored\nplaylists\nprovider" -> "Spotify service" + "Spotify\nplayback\nprovider" -> Audio + + +Audio +===== + +The audio actor is a thin wrapper around the parts of the GStreamer library we +use. In addition to playback, it's responsible for volume control through both +GStreamer's own volume mixers, and mixers we've created ourselves. If you +implement an advanced backend, you may need to implement your own playback +provider using the :ref:`audio-api`. diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index fc54a8a2..2237b4e7 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -1,3 +1,5 @@ +.. _frontend-api: + ************ Frontend API ************ diff --git a/docs/changes.rst b/docs/changes.rst index 7d608086..025ed71e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -12,6 +12,54 @@ v0.9.0 (in development) - Pykka >= 1.0 is now required. +**Multiple backends support** + +Support for using the local and Spotify backends simultaneously have for a very +long time been our most requested feature. Finally, it's here! + +- Both the local backend and the Spotify backend are now turned on by default. + The local backend is listed first in the :attr:`mopidy.settings.BACKENDS` + setting, and are thus given the highest priority in e.g. search results, + meaning that we're listing search hits from the local backend first. If you + want to prioritize the backends in another way, simply set ``BACKENDS`` in + your own settings file and reorder the backends. + + There are no other setting changes related to the local and Spotify backends. + As always, see :mod:`mopidy.settings` for the full list of available + settings. + +Internally, Mopidy have seen a lot of changes to pave the way for multiple +backends: + +- A new layer and actor, "core", has been added to our stack, inbetween the + frontends and the backends. The responsibility of the core layer and actor is + to take requests from the frontends, pass them on to one or more backends, + and combining the response from the backends into a single response to the + requesting frontend. + + Frontends no longer know anything about the backends. They just use the + :ref:`core-api`. + +- The base playback provider has been updated with sane default behavior + instead of empty functions. By default, the playback provider now lets + GStreamer keep track of the current track's time position. The local backend + simply uses the base playback provider without any changes. The same applies + to any future backend that just needs GStreamer to play an URI for it. + +- The dependency graph between the core controllers and the backend providers + have been straightened out, so that we don't have any circular dependencies. + The frontend, core, backend, and audio layers are now strictly separate. The + frontend layer calls on the core layer, and the core layer calls on the + backend layer. Both the core layer and the backends are allowed to call on + the audio layer. Any data flow in the opposite direction is done by + broadcasting of events to listeners, through e.g. + :class:`mopidy.core.CoreListener` and :class:`mopidy.audio.AudioListener`. + +- All dependencies are now explicitly passed to the constructors of the + frontends, core, and the backends. This makes testing each layer with + dummy/mocked lower layers easier than with the old variant, where + dependencies where looked up in Pykka's actor registry. + **Bug fixes** - :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors diff --git a/docs/settings.rst b/docs/settings.rst index 37e1d8ed..99064b60 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -47,20 +47,10 @@ Music from local storage ======================== If you want use Mopidy to play music you have locally at your machine instead -of using Spotify, you need to change the backend from the default to -:mod:`mopidy.backends.local` by adding the following line to your settings -file:: - - BACKENDS = (u'mopidy.backends.local.LocalBackend',) - -You may also want to change some of the ``LOCAL_*`` settings. See -:mod:`mopidy.settings`, for a full list of available settings. - -.. note:: - - Currently, Mopidy supports using Spotify *or* local storage as a music - source. We're working on using both sources simultaneously, and will - have support for this in a future release. +of or in addition to using Spotify, you need to review and maybe change some of +the ``LOCAL_*`` settings. See :mod:`mopidy.settings`, for a full list of +available settings. Then you need to generate a tag cache for your local +music... .. _generating-a-tag-cache: @@ -70,7 +60,7 @@ Generating a tag cache Before Mopidy 0.3 the local storage backend relied purely on ``tag_cache`` files generated by the original MPD server. To remedy this the command -:command:`mopidy-scan` has been created. The program will scan your current +:command:`mopidy-scan` was created. The program will scan your current :attr:`mopidy.settings.LOCAL_MUSIC_PATH` and build a MPD compatible ``tag_cache``. diff --git a/mopidy/core/actor.py b/mopidy/core/actor.py index ea360055..482868ad 100644 --- a/mopidy/core/actor.py +++ b/mopidy/core/actor.py @@ -1,3 +1,5 @@ +import itertools + import pykka from mopidy.audio import AudioListener @@ -28,22 +30,42 @@ class Core(pykka.ThreadingActor, AudioListener): def __init__(self, audio=None, backends=None): super(Core, self).__init__() - self._backends = backends + self.backends = Backends(backends) self.current_playlist = CurrentPlaylistController(core=self) - self.library = LibraryController(backends=backends, core=self) + self.library = LibraryController(backends=self.backends, core=self) self.playback = PlaybackController( - audio=audio, backends=backends, core=self) + audio=audio, backends=self.backends, core=self) self.stored_playlists = StoredPlaylistsController( - backends=backends, core=self) + backends=self.backends, core=self) @property def uri_schemes(self): """List of URI schemes we can handle""" - return self._backends[0].uri_schemes.get() + futures = [b.uri_schemes for b in self.backends] + results = pykka.get_all(futures) + uri_schemes = itertools.chain(*results) + return sorted(uri_schemes) def reached_end_of_stream(self): self.playback.on_end_of_track() + + +class Backends(list): + def __init__(self, backends): + super(Backends, self).__init__(backends) + + self.by_uri_scheme = {} + for backend in backends: + uri_schemes = backend.uri_schemes.get() + for uri_scheme in uri_schemes: + assert uri_scheme not in self.by_uri_scheme, ( + 'Cannot add URI scheme %s for %s, ' + 'it is already handled by %s' + ) % ( + uri_scheme, backend.__class__.__name__, + self.by_uri_scheme[uri_scheme].__class__.__name__) + self.by_uri_scheme[uri_scheme] = backend diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 469b6160..f7514fd8 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -1,3 +1,11 @@ +import itertools +import urlparse + +import pykka + +from mopidy.models import Playlist + + class LibraryController(object): pykka_traversable = True @@ -5,6 +13,10 @@ class LibraryController(object): self.backends = backends self.core = core + def _get_backend(self, uri): + uri_scheme = urlparse.urlparse(uri).scheme + return self.backends.by_uri_scheme.get(uri_scheme, None) + def find_exact(self, **query): """ Search the library for tracks where ``field`` is ``values``. @@ -22,7 +34,10 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.backends[0].library.find_exact(**query).get() + futures = [b.library.find_exact(**query) for b in self.backends] + results = pykka.get_all(futures) + return Playlist(tracks=[ + track for playlist in results for track in playlist.tracks]) def lookup(self, uri): """ @@ -32,7 +47,11 @@ class LibraryController(object): :type uri: string :rtype: :class:`mopidy.models.Track` or :class:`None` """ - return self.backends[0].library.lookup(uri).get() + backend = self._get_backend(uri) + if backend: + return backend.library.lookup(uri).get() + else: + return None def refresh(self, uri=None): """ @@ -41,7 +60,13 @@ class LibraryController(object): :param uri: directory or track URI :type uri: string """ - return self.backends[0].library.refresh(uri).get() + if uri is not None: + backend = self._get_backend(uri) + if backend: + backend.library.refresh(uri).get() + else: + futures = [b.library.refresh(uri) for b in self.backends] + pykka.get_all(futures) def search(self, **query): """ @@ -60,4 +85,8 @@ class LibraryController(object): :type query: dict :rtype: :class:`mopidy.models.Playlist` """ - return self.backends[0].library.search(**query).get() + futures = [b.library.search(**query) for b in self.backends] + results = pykka.get_all(futures) + track_lists = [playlist.tracks for playlist in results] + tracks = list(itertools.chain(*track_lists)) + return Playlist(tracks=tracks) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py index 85faaa13..74f4bebd 100644 --- a/mopidy/core/playback.py +++ b/mopidy/core/playback.py @@ -1,5 +1,6 @@ import logging import random +import urlparse from . import listener @@ -76,9 +77,7 @@ class PlaybackController(object): def __init__(self, audio, backends, core): self.audio = audio - self.backends = backends - self.core = core self._state = PlaybackState.STOPPED @@ -86,6 +85,13 @@ class PlaybackController(object): self._first_shuffle = True self._volume = None + def _get_backend(self): + if self.current_cp_track is None: + return None + uri = self.current_cp_track.track.uri + uri_scheme = urlparse.urlparse(uri).scheme + return self.backends.by_uri_scheme[uri_scheme] + def _get_cpid(self, cp_track): if cp_track is None: return None @@ -291,7 +297,10 @@ class PlaybackController(object): @property def time_position(self): """Time position in milliseconds.""" - return self.backends[0].playback.get_time_position().get() + backend = self._get_backend() + if backend is None: + return 0 + return backend.playback.get_time_position().get() @property def volume(self): @@ -377,7 +386,8 @@ class PlaybackController(object): def pause(self): """Pause playback.""" - if self.backends[0].playback.pause().get(): + backend = self._get_backend() + if backend is None or backend.playback.pause().get(): self.state = PlaybackState.PAUSED self._trigger_track_playback_paused() @@ -409,7 +419,7 @@ class PlaybackController(object): if cp_track is not None: self.current_cp_track = cp_track self.state = PlaybackState.PLAYING - if not self.backends[0].playback.play(cp_track.track).get(): + if not self._get_backend().playback.play(cp_track.track).get(): # Track is not playable if self.random and self._shuffled: self._shuffled.remove(cp_track) @@ -436,7 +446,7 @@ class PlaybackController(object): def resume(self): """If paused, resume playing the current track.""" if (self.state == PlaybackState.PAUSED and - self.backends[0].playback.resume().get()): + self._get_backend().playback.resume().get()): self.state = PlaybackState.PLAYING self._trigger_track_playback_resumed() @@ -462,7 +472,7 @@ class PlaybackController(object): self.next() return True - success = self.backends[0].playback.seek(time_position).get() + success = self._get_backend().playback.seek(time_position).get() if success: self._trigger_seeked(time_position) return success @@ -476,7 +486,7 @@ class PlaybackController(object): :type clear_current_track: boolean """ if self.state != PlaybackState.STOPPED: - if self.backends[0].playback.stop().get(): + if self._get_backend().playback.stop().get(): self._trigger_track_playback_ended() self.state = PlaybackState.STOPPED if clear_current_track: diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py index d7bcbd0c..9de1545f 100644 --- a/mopidy/core/stored_playlists.py +++ b/mopidy/core/stored_playlists.py @@ -1,3 +1,8 @@ +import itertools + +import pykka + + class StoredPlaylistsController(object): pykka_traversable = True @@ -12,10 +17,13 @@ class StoredPlaylistsController(object): Read/write. List of :class:`mopidy.models.Playlist`. """ - return self.backends[0].stored_playlists.playlists.get() + futures = [b.stored_playlists.playlists for b in self.backends] + results = pykka.get_all(futures) + return list(itertools.chain(*results)) @playlists.setter # noqa def playlists(self, playlists): + # TODO Support multiple backends self.backends[0].stored_playlists.playlists = playlists def create(self, name): @@ -26,6 +34,7 @@ class StoredPlaylistsController(object): :type name: string :rtype: :class:`mopidy.models.Playlist` """ + # TODO Support multiple backends return self.backends[0].stored_playlists.create(name).get() def delete(self, playlist): @@ -35,6 +44,7 @@ class StoredPlaylistsController(object): :param playlist: the playlist to delete :type playlist: :class:`mopidy.models.Playlist` """ + # TODO Support multiple backends return self.backends[0].stored_playlists.delete(playlist).get() def get(self, **criteria): @@ -76,12 +86,14 @@ class StoredPlaylistsController(object): :type uri: string :rtype: :class:`mopidy.models.Playlist` """ + # TODO Support multiple backends return self.backends[0].stored_playlists.lookup(uri).get() def refresh(self): """ Refresh the stored playlists in :attr:`playlists`. """ + # TODO Support multiple backends return self.backends[0].stored_playlists.refresh().get() def rename(self, playlist, new_name): @@ -93,6 +105,7 @@ class StoredPlaylistsController(object): :param new_name: the new name :type new_name: string """ + # TODO Support multiple backends return self.backends[0].stored_playlists.rename( playlist, new_name).get() @@ -103,4 +116,5 @@ class StoredPlaylistsController(object): :param playlist: the playlist :type playlist: :class:`mopidy.models.Playlist` """ + # TODO Support multiple backends return self.backends[0].stored_playlists.save(playlist).get() diff --git a/mopidy/settings.py b/mopidy/settings.py index 31de4a6e..c1f35887 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -10,17 +10,17 @@ All available settings and their default values. #: List of playback backends to use. See :ref:`backend-implementations` for all #: available backends. #: +#: When results from multiple backends are combined, they are combined in the +#: order the backends are listed here. +#: #: Default:: #: -#: BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) -#: -#: Other typical values:: -#: -#: BACKENDS = (u'mopidy.backends.local.LocalBackend',) -#: -#: .. note:: -#: Currently only the first backend in the list is used. +#: BACKENDS = ( +#: u'mopidy.backends.local.LocalBackend', +#: u'mopidy.backends.spotify.SpotifyBackend', +#: ) BACKENDS = ( + u'mopidy.backends.local.LocalBackend', u'mopidy.backends.spotify.SpotifyBackend', ) diff --git a/tests/core/actor_test.py b/tests/core/actor_test.py new file mode 100644 index 00000000..8212c1da --- /dev/null +++ b/tests/core/actor_test.py @@ -0,0 +1,35 @@ +import mock +import pykka + +from mopidy.core import Core + +from tests import unittest + + +class CoreActorTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + def test_uri_schemes_has_uris_from_all_backends(self): + result = self.core.uri_schemes + + self.assertIn('dummy1', result) + self.assertIn('dummy2', result) + + def test_backends_with_colliding_uri_schemes_fails(self): + self.backend1.__class__.__name__ = 'B1' + self.backend2.__class__.__name__ = 'B2' + self.backend2.uri_schemes.get.return_value = ['dummy1', 'dummy2'] + self.assertRaisesRegexp( + AssertionError, + 'Cannot add URI scheme dummy1 for B2, it is already handled by B1', + Core, audio=None, backends=[self.backend1, self.backend2]) diff --git a/tests/core/library_test.py b/tests/core/library_test.py new file mode 100644 index 00000000..04f19909 --- /dev/null +++ b/tests/core/library_test.py @@ -0,0 +1,82 @@ +import mock + +from mopidy.backends import base +from mopidy.core import Core +from mopidy.models import Playlist, Track + +from tests import unittest + + +class CoreLibraryTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.library1 = mock.Mock(spec=base.BaseLibraryProvider) + self.backend1.library = self.library1 + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.library2 = mock.Mock(spec=base.BaseLibraryProvider) + self.backend2.library = self.library2 + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + + def test_lookup_selects_dummy1_backend(self): + self.core.library.lookup('dummy1:a') + + self.library1.lookup.assert_called_once_with('dummy1:a') + self.assertFalse(self.library2.lookup.called) + + def test_lookup_selects_dummy2_backend(self): + self.core.library.lookup('dummy2:a') + + self.assertFalse(self.library1.lookup.called) + self.library2.lookup.assert_called_once_with('dummy2:a') + + def test_refresh_with_uri_selects_dummy1_backend(self): + self.core.library.refresh('dummy1:a') + + self.library1.refresh.assert_called_once_with('dummy1:a') + self.assertFalse(self.library2.refresh.called) + + def test_refresh_with_uri_selects_dummy2_backend(self): + self.core.library.refresh('dummy2:a') + + self.assertFalse(self.library1.refresh.called) + self.library2.refresh.assert_called_once_with('dummy2:a') + + def test_refresh_without_uri_calls_all_backends(self): + self.core.library.refresh() + + self.library1.refresh.assert_called_once_with(None) + self.library2.refresh.assert_called_once_with(None) + + def test_find_exact_combines_results_from_all_backends(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + self.library1.find_exact().get.return_value = Playlist(tracks=[track1]) + self.library1.find_exact.reset_mock() + self.library2.find_exact().get.return_value = Playlist(tracks=[track2]) + self.library2.find_exact.reset_mock() + + result = self.core.library.find_exact(any=['a']) + + self.assertIn(track1, result.tracks) + self.assertIn(track2, result.tracks) + self.library1.find_exact.assert_called_once_with(any=['a']) + self.library2.find_exact.assert_called_once_with(any=['a']) + + def test_search_combines_results_from_all_backends(self): + track1 = Track(uri='dummy1:a') + track2 = Track(uri='dummy2:a') + self.library1.search().get.return_value = Playlist(tracks=[track1]) + self.library1.search.reset_mock() + self.library2.search().get.return_value = Playlist(tracks=[track2]) + self.library2.search.reset_mock() + + result = self.core.library.search(any=['a']) + + self.assertIn(track1, result.tracks) + self.assertIn(track2, result.tracks) + self.library1.search.assert_called_once_with(any=['a']) + self.library2.search.assert_called_once_with(any=['a']) diff --git a/tests/core/playback_test.py b/tests/core/playback_test.py new file mode 100644 index 00000000..b3a75773 --- /dev/null +++ b/tests/core/playback_test.py @@ -0,0 +1,118 @@ +import mock + +from mopidy.backends import base +from mopidy.core import Core +from mopidy.models import Track + +from tests import unittest + + +class CorePlaybackTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.playback1 = mock.Mock(spec=base.BasePlaybackProvider) + self.backend1.playback = self.playback1 + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.playback2 = mock.Mock(spec=base.BasePlaybackProvider) + self.backend2.playback = self.playback2 + + self.tracks = [ + Track(uri='dummy1://foo', length=40000), + Track(uri='dummy1://bar', length=40000), + Track(uri='dummy2://foo', length=40000), + Track(uri='dummy2://bar', length=40000), + ] + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + self.core.current_playlist.append(self.tracks) + + self.cp_tracks = self.core.current_playlist.cp_tracks + + def test_play_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + + self.playback1.play.assert_called_once_with(self.tracks[0]) + self.assertFalse(self.playback2.play.called) + + def test_play_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + + self.assertFalse(self.playback1.play.called) + self.playback2.play.assert_called_once_with(self.tracks[2]) + + def test_pause_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.pause() + + self.playback1.pause.assert_called_once_with() + self.assertFalse(self.playback2.pause.called) + + def test_pause_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.pause() + + self.assertFalse(self.playback1.pause.called) + self.playback2.pause.assert_called_once_with() + + def test_resume_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.pause() + self.core.playback.resume() + + self.playback1.resume.assert_called_once_with() + self.assertFalse(self.playback2.resume.called) + + def test_resume_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.pause() + self.core.playback.resume() + + self.assertFalse(self.playback1.resume.called) + self.playback2.resume.assert_called_once_with() + + def test_stop_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.stop() + + self.playback1.stop.assert_called_once_with() + self.assertFalse(self.playback2.stop.called) + + def test_stop_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.stop() + + self.assertFalse(self.playback1.stop.called) + self.playback2.stop.assert_called_once_with() + + def test_seek_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.seek(10000) + + self.playback1.seek.assert_called_once_with(10000) + self.assertFalse(self.playback2.seek.called) + + def test_seek_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.seek(10000) + + self.assertFalse(self.playback1.seek.called) + self.playback2.seek.assert_called_once_with(10000) + + def test_time_position_selects_dummy1_backend(self): + self.core.playback.play(self.cp_tracks[0]) + self.core.playback.seek(10000) + self.core.playback.time_position + + self.playback1.get_time_position.assert_called_once_with() + self.assertFalse(self.playback2.get_time_position.called) + + def test_time_position_selects_dummy2_backend(self): + self.core.playback.play(self.cp_tracks[2]) + self.core.playback.seek(10000) + self.core.playback.time_position + + self.assertFalse(self.playback1.get_time_position.called) + self.playback2.get_time_position.assert_called_once_with() diff --git a/tests/core/stored_playlists_test.py b/tests/core/stored_playlists_test.py new file mode 100644 index 00000000..d92b89c0 --- /dev/null +++ b/tests/core/stored_playlists_test.py @@ -0,0 +1,41 @@ +import mock + +from mopidy.backends import base +from mopidy.core import Core +from mopidy.models import Playlist, Track + +from tests import unittest + + +class StoredPlaylistsTest(unittest.TestCase): + def setUp(self): + self.backend1 = mock.Mock() + self.backend1.uri_schemes.get.return_value = ['dummy1'] + self.sp1 = mock.Mock(spec=base.BaseStoredPlaylistsProvider) + self.backend1.stored_playlists = self.sp1 + + self.backend2 = mock.Mock() + self.backend2.uri_schemes.get.return_value = ['dummy2'] + self.sp2 = mock.Mock(spec=base.BaseStoredPlaylistsProvider) + self.backend2.stored_playlists = self.sp2 + + self.pl1a = Playlist(tracks=[Track(uri='dummy1:a')]) + self.pl1b = Playlist(tracks=[Track(uri='dummy1:b')]) + self.sp1.playlists.get.return_value = [self.pl1a, self.pl1b] + + self.pl2a = Playlist(tracks=[Track(uri='dummy2:a')]) + self.pl2b = Playlist(tracks=[Track(uri='dummy2:b')]) + self.sp2.playlists.get.return_value = [self.pl2a, self.pl2b] + + self.core = Core(audio=None, backends=[self.backend1, self.backend2]) + + def test_get_playlists_combines_result_from_backends(self): + result = self.core.stored_playlists.playlists + + self.assertIn(self.pl1a, result) + self.assertIn(self.pl1b, result) + self.assertIn(self.pl2a, result) + self.assertIn(self.pl2b, result) + + # TODO The rest of the stored playlists API is pending redesign before + # we'll update it to support multiple backends. diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index ab254bdf..202ac649 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -392,9 +392,9 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seek_with_songpos(self): - seek_track = Track(uri='dummy:2', length=40000) + seek_track = Track(uri='dummy:b', length=40000) self.core.current_playlist.append( - [Track(uri='dummy:1', length=40000), seek_track]) + [Track(uri='dummy:a', length=40000), seek_track]) self.sendRequest(u'seek "1" "30"') self.assertEqual(self.core.playback.current_track.get(), seek_track) @@ -417,9 +417,9 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.assertInResponse(u'OK') def test_seekid_with_cpid(self): - seek_track = Track(uri='dummy:2', length=40000) + seek_track = Track(uri='dummy:b', length=40000) self.core.current_playlist.append( - [Track(uri='dummy:1', length=40000), seek_track]) + [Track(uri='dummy:a', length=40000), seek_track]) self.sendRequest(u'seekid "1" "30"') self.assertEqual(1, self.core.playback.current_cpid.get())