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
Mopidy is a music server which can play music from `Spotify
<http://www.spotify.com/>`_ 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 <http://mpd.wikia.com/>`_. 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 <http://www.spotify.com/>`_. 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 <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
`the installation docs <http://docs.mopidy.com/en/latest/installation/>`_.

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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
#: 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',
)

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