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
|
.. 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/>`_.
|
||||||
|
|||||||
@ -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`.
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
.. _frontend-api:
|
||||||
|
|
||||||
************
|
************
|
||||||
Frontend API
|
Frontend API
|
||||||
************
|
************
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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``.
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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
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')
|
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())
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user