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