Merge pull request #215 from jodal/feature/multi-backend
Initial support for multiple backends
This commit is contained in:
commit
2011f64049
16
README.rst
16
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
|
||||
<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/>`_.
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
.. _frontend-api:
|
||||
|
||||
************
|
||||
Frontend API
|
||||
************
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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``.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
35
tests/core/actor_test.py
Normal 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])
|
||||
82
tests/core/library_test.py
Normal file
82
tests/core/library_test.py
Normal 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
118
tests/core/playback_test.py
Normal 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()
|
||||
41
tests/core/stored_playlists_test.py
Normal file
41
tests/core/stored_playlists_test.py
Normal 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.
|
||||
@ -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())
|
||||
|
||||
Loading…
Reference in New Issue
Block a user