Merge pull request #215 from jodal/feature/multi-backend

Initial support for multiple backends
This commit is contained in:
Thomas Adamcik 2012-10-29 03:01:34 -07:00
commit 2011f64049
15 changed files with 541 additions and 74 deletions

View File

@ -4,11 +4,17 @@ Mopidy
.. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop .. image:: https://secure.travis-ci.org/mopidy/mopidy.png?branch=develop
Mopidy is a music server which can play music from `Spotify Mopidy is a music server which can play music both from your local hard drive
<http://www.spotify.com/>`_ or from your local hard drive. To search for music and from `Spotify <http://www.spotify.com/>`_. Searches returns results from
in Spotify's vast archive, manage playlists, and play music, you can use any both your local hard drive and from Spotify, and you can mix tracks from both
`MPD client <http://mpd.wikia.com/>`_. MPD clients are available for most sources in your play queue. Your Spotify playlists are also available for use,
platforms, including Windows, Mac OS X, Linux, Android and iOS. 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 <http://mpd.wikia.com/>`_. MPD clients are available for most platforms,
including Windows, Mac OS X, Linux, Android and iOS.
To install Mopidy, check out To install Mopidy, check out
`the installation docs <http://docs.mopidy.com/en/latest/installation/>`_. `the installation docs <http://docs.mopidy.com/en/latest/installation/>`_.

View File

@ -1,29 +1,99 @@
.. _concepts: .. _concepts:
********************************************** *************************
The backend, controller, and provider concepts Architecture and concepts
********************************************** *************************
Backend: The overall architecture of Mopidy is organized around multiple frontends and
The backend is mostly for convenience. It is a container that holds backends. The frontends use the core API. The core actor makes multiple backends
references to all the controllers. work as one. The backends connect to various music sources. Both the core actor
Controllers: and the backends use the audio actor to play audio and control audio volume.
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.
.. digraph:: backend_relations .. digraph:: overall_architecture
Backend -> "Current\nplaylist\ncontroller" "Multiple frontends" -> Core
Backend -> "Library\ncontroller" Core -> "Multiple backends"
"Library\ncontroller" -> "Library\nproviders" Core -> Audio
Backend -> "Playback\ncontroller" "Multiple backends" -> Audio
"Playback\ncontroller" -> "Playback\nproviders"
Backend -> "Stored\nplaylists\ncontroller"
"Stored\nplaylists\ncontroller" -> "Stored\nplaylist\nproviders" 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`.

View File

@ -1,3 +1,5 @@
.. _frontend-api:
************ ************
Frontend API Frontend API
************ ************

View File

@ -12,6 +12,54 @@ v0.9.0 (in development)
- Pykka >= 1.0 is now required. - 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** **Bug fixes**
- :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors - :issue:`213`: Fix "streaming task paused, reason not-negotiated" errors

View File

@ -47,20 +47,10 @@ Music from local storage
======================== ========================
If you want use Mopidy to play music you have locally at your machine instead 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 of or in addition to using Spotify, you need to review and maybe change some of
:mod:`mopidy.backends.local` by adding the following line to your settings the ``LOCAL_*`` settings. See :mod:`mopidy.settings`, for a full list of
file:: available settings. Then you need to generate a tag cache for your local
music...
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.
.. _generating-a-tag-cache: .. _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`` 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 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 :attr:`mopidy.settings.LOCAL_MUSIC_PATH` and build a MPD compatible
``tag_cache``. ``tag_cache``.

View File

@ -1,3 +1,5 @@
import itertools
import pykka import pykka
from mopidy.audio import AudioListener from mopidy.audio import AudioListener
@ -28,22 +30,42 @@ class Core(pykka.ThreadingActor, AudioListener):
def __init__(self, audio=None, backends=None): def __init__(self, audio=None, backends=None):
super(Core, self).__init__() super(Core, self).__init__()
self._backends = backends self.backends = Backends(backends)
self.current_playlist = CurrentPlaylistController(core=self) self.current_playlist = CurrentPlaylistController(core=self)
self.library = LibraryController(backends=backends, core=self) self.library = LibraryController(backends=self.backends, core=self)
self.playback = PlaybackController( self.playback = PlaybackController(
audio=audio, backends=backends, core=self) audio=audio, backends=self.backends, core=self)
self.stored_playlists = StoredPlaylistsController( self.stored_playlists = StoredPlaylistsController(
backends=backends, core=self) backends=self.backends, core=self)
@property @property
def uri_schemes(self): def uri_schemes(self):
"""List of URI schemes we can handle""" """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): def reached_end_of_stream(self):
self.playback.on_end_of_track() 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

View File

@ -1,3 +1,11 @@
import itertools
import urlparse
import pykka
from mopidy.models import Playlist
class LibraryController(object): class LibraryController(object):
pykka_traversable = True pykka_traversable = True
@ -5,6 +13,10 @@ class LibraryController(object):
self.backends = backends self.backends = backends
self.core = core 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): def find_exact(self, **query):
""" """
Search the library for tracks where ``field`` is ``values``. Search the library for tracks where ``field`` is ``values``.
@ -22,7 +34,10 @@ class LibraryController(object):
:type query: dict :type query: dict
:rtype: :class:`mopidy.models.Playlist` :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): def lookup(self, uri):
""" """
@ -32,7 +47,11 @@ class LibraryController(object):
:type uri: string :type uri: string
:rtype: :class:`mopidy.models.Track` or :class:`None` :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): def refresh(self, uri=None):
""" """
@ -41,7 +60,13 @@ class LibraryController(object):
:param uri: directory or track URI :param uri: directory or track URI
:type uri: string :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): def search(self, **query):
""" """
@ -60,4 +85,8 @@ class LibraryController(object):
:type query: dict :type query: dict
:rtype: :class:`mopidy.models.Playlist` :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)

View File

@ -1,5 +1,6 @@
import logging import logging
import random import random
import urlparse
from . import listener from . import listener
@ -76,9 +77,7 @@ class PlaybackController(object):
def __init__(self, audio, backends, core): def __init__(self, audio, backends, core):
self.audio = audio self.audio = audio
self.backends = backends self.backends = backends
self.core = core self.core = core
self._state = PlaybackState.STOPPED self._state = PlaybackState.STOPPED
@ -86,6 +85,13 @@ class PlaybackController(object):
self._first_shuffle = True self._first_shuffle = True
self._volume = None self._volume = None
def _get_backend(self):
if self.current_cp_track is None:
return None
uri = self.current_cp_track.track.uri
uri_scheme = urlparse.urlparse(uri).scheme
return self.backends.by_uri_scheme[uri_scheme]
def _get_cpid(self, cp_track): def _get_cpid(self, cp_track):
if cp_track is None: if cp_track is None:
return None return None
@ -291,7 +297,10 @@ class PlaybackController(object):
@property @property
def time_position(self): def time_position(self):
"""Time position in milliseconds.""" """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 @property
def volume(self): def volume(self):
@ -377,7 +386,8 @@ class PlaybackController(object):
def pause(self): def pause(self):
"""Pause playback.""" """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.state = PlaybackState.PAUSED
self._trigger_track_playback_paused() self._trigger_track_playback_paused()
@ -409,7 +419,7 @@ class PlaybackController(object):
if cp_track is not None: if cp_track is not None:
self.current_cp_track = cp_track self.current_cp_track = cp_track
self.state = PlaybackState.PLAYING 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 # Track is not playable
if self.random and self._shuffled: if self.random and self._shuffled:
self._shuffled.remove(cp_track) self._shuffled.remove(cp_track)
@ -436,7 +446,7 @@ class PlaybackController(object):
def resume(self): def resume(self):
"""If paused, resume playing the current track.""" """If paused, resume playing the current track."""
if (self.state == PlaybackState.PAUSED and if (self.state == PlaybackState.PAUSED and
self.backends[0].playback.resume().get()): self._get_backend().playback.resume().get()):
self.state = PlaybackState.PLAYING self.state = PlaybackState.PLAYING
self._trigger_track_playback_resumed() self._trigger_track_playback_resumed()
@ -462,7 +472,7 @@ class PlaybackController(object):
self.next() self.next()
return True return True
success = self.backends[0].playback.seek(time_position).get() success = self._get_backend().playback.seek(time_position).get()
if success: if success:
self._trigger_seeked(time_position) self._trigger_seeked(time_position)
return success return success
@ -476,7 +486,7 @@ class PlaybackController(object):
:type clear_current_track: boolean :type clear_current_track: boolean
""" """
if self.state != PlaybackState.STOPPED: 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._trigger_track_playback_ended()
self.state = PlaybackState.STOPPED self.state = PlaybackState.STOPPED
if clear_current_track: if clear_current_track:

View File

@ -1,3 +1,8 @@
import itertools
import pykka
class StoredPlaylistsController(object): class StoredPlaylistsController(object):
pykka_traversable = True pykka_traversable = True
@ -12,10 +17,13 @@ class StoredPlaylistsController(object):
Read/write. List of :class:`mopidy.models.Playlist`. 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 @playlists.setter # noqa
def playlists(self, playlists): def playlists(self, playlists):
# TODO Support multiple backends
self.backends[0].stored_playlists.playlists = playlists self.backends[0].stored_playlists.playlists = playlists
def create(self, name): def create(self, name):
@ -26,6 +34,7 @@ class StoredPlaylistsController(object):
:type name: string :type name: string
:rtype: :class:`mopidy.models.Playlist` :rtype: :class:`mopidy.models.Playlist`
""" """
# TODO Support multiple backends
return self.backends[0].stored_playlists.create(name).get() return self.backends[0].stored_playlists.create(name).get()
def delete(self, playlist): def delete(self, playlist):
@ -35,6 +44,7 @@ class StoredPlaylistsController(object):
:param playlist: the playlist to delete :param playlist: the playlist to delete
:type playlist: :class:`mopidy.models.Playlist` :type playlist: :class:`mopidy.models.Playlist`
""" """
# TODO Support multiple backends
return self.backends[0].stored_playlists.delete(playlist).get() return self.backends[0].stored_playlists.delete(playlist).get()
def get(self, **criteria): def get(self, **criteria):
@ -76,12 +86,14 @@ class StoredPlaylistsController(object):
:type uri: string :type uri: string
:rtype: :class:`mopidy.models.Playlist` :rtype: :class:`mopidy.models.Playlist`
""" """
# TODO Support multiple backends
return self.backends[0].stored_playlists.lookup(uri).get() return self.backends[0].stored_playlists.lookup(uri).get()
def refresh(self): def refresh(self):
""" """
Refresh the stored playlists in :attr:`playlists`. Refresh the stored playlists in :attr:`playlists`.
""" """
# TODO Support multiple backends
return self.backends[0].stored_playlists.refresh().get() return self.backends[0].stored_playlists.refresh().get()
def rename(self, playlist, new_name): def rename(self, playlist, new_name):
@ -93,6 +105,7 @@ class StoredPlaylistsController(object):
:param new_name: the new name :param new_name: the new name
:type new_name: string :type new_name: string
""" """
# TODO Support multiple backends
return self.backends[0].stored_playlists.rename( return self.backends[0].stored_playlists.rename(
playlist, new_name).get() playlist, new_name).get()
@ -103,4 +116,5 @@ class StoredPlaylistsController(object):
:param playlist: the playlist :param playlist: the playlist
:type playlist: :class:`mopidy.models.Playlist` :type playlist: :class:`mopidy.models.Playlist`
""" """
# TODO Support multiple backends
return self.backends[0].stored_playlists.save(playlist).get() return self.backends[0].stored_playlists.save(playlist).get()

View File

@ -10,17 +10,17 @@ All available settings and their default values.
#: List of playback backends to use. See :ref:`backend-implementations` for all #: List of playback backends to use. See :ref:`backend-implementations` for all
#: available backends. #: available backends.
#: #:
#: When results from multiple backends are combined, they are combined in the
#: order the backends are listed here.
#:
#: Default:: #: Default::
#: #:
#: BACKENDS = (u'mopidy.backends.spotify.SpotifyBackend',) #: BACKENDS = (
#: #: u'mopidy.backends.local.LocalBackend',
#: Other typical values:: #: u'mopidy.backends.spotify.SpotifyBackend',
#: #: )
#: BACKENDS = (u'mopidy.backends.local.LocalBackend',)
#:
#: .. note::
#: Currently only the first backend in the list is used.
BACKENDS = ( BACKENDS = (
u'mopidy.backends.local.LocalBackend',
u'mopidy.backends.spotify.SpotifyBackend', u'mopidy.backends.spotify.SpotifyBackend',
) )

35
tests/core/actor_test.py Normal file
View File

@ -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])

View File

@ -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'])

118
tests/core/playback_test.py Normal file
View File

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

View File

@ -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.

View File

@ -392,9 +392,9 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase):
self.assertInResponse(u'OK') self.assertInResponse(u'OK')
def test_seek_with_songpos(self): 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( 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.sendRequest(u'seek "1" "30"')
self.assertEqual(self.core.playback.current_track.get(), seek_track) self.assertEqual(self.core.playback.current_track.get(), seek_track)
@ -417,9 +417,9 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase):
self.assertInResponse(u'OK') self.assertInResponse(u'OK')
def test_seekid_with_cpid(self): 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( 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.sendRequest(u'seekid "1" "30"')
self.assertEqual(1, self.core.playback.current_cpid.get()) self.assertEqual(1, self.core.playback.current_cpid.get())