diff --git a/docs/api/backends/controllers.rst b/docs/api/backends/controllers.rst index 28112cf7..20dc2d61 100644 --- a/docs/api/backends/controllers.rst +++ b/docs/api/backends/controllers.rst @@ -15,7 +15,6 @@ The backend .. autoclass:: mopidy.backends.base.Backend :members: - :undoc-members: Playback controller @@ -26,7 +25,6 @@ seek. .. autoclass:: mopidy.backends.base.PlaybackController :members: - :undoc-members: Mixer controller @@ -42,7 +40,6 @@ Manages everything related to the currently loaded playlist. .. autoclass:: mopidy.backends.base.CurrentPlaylistController :members: - :undoc-members: Stored playlists controller @@ -52,7 +49,6 @@ Manages stored playlist. .. autoclass:: mopidy.backends.base.StoredPlaylistsController :members: - :undoc-members: Library controller @@ -62,4 +58,3 @@ Manages the music library, e.g. searching for tracks to be added to a playlist. .. autoclass:: mopidy.backends.base.LibraryController :members: - :undoc-members: diff --git a/docs/api/backends/providers.rst b/docs/api/backends/providers.rst index 903e220b..61e5f68a 100644 --- a/docs/api/backends/providers.rst +++ b/docs/api/backends/providers.rst @@ -14,7 +14,6 @@ Playback provider .. autoclass:: mopidy.backends.base.BasePlaybackProvider :members: - :undoc-members: Stored playlists provider @@ -22,7 +21,6 @@ Stored playlists provider .. autoclass:: mopidy.backends.base.BaseStoredPlaylistsProvider :members: - :undoc-members: Library provider @@ -30,7 +28,6 @@ Library provider .. autoclass:: mopidy.backends.base.BaseLibraryProvider :members: - :undoc-members: Backend provider implementations diff --git a/docs/api/frontends.rst b/docs/api/frontends.rst index 0c1e32a3..792e4bc9 100644 --- a/docs/api/frontends.rst +++ b/docs/api/frontends.rst @@ -2,22 +2,26 @@ Frontend API ************ -A frontend may do whatever it wants to, including creating threads, opening TCP -ports and exposing Mopidy for a type of clients. - -Frontends got one main limitation: they are restricted to passing messages -through the ``core_queue`` for all communication with the rest of Mopidy. Thus, -the frontend API is very small and reveals little of what a frontend may do. - -.. warning:: - - A stable frontend API is not available yet, as we've only implemented a - couple of frontend modules. - -.. automodule:: mopidy.frontends.base - :synopsis: Base class for frontends - :members: +The following requirements applies to any frontend implementation: +- A frontend MAY do mostly whatever it wants to, including creating threads, + opening TCP ports and exposing Mopidy for a group of clients. +- A frontend MUST implement at least one `Pykka + `_ actor, called the "main actor" from here + on. +- It MAY use additional actors to implement whatever it does, and using actors + in frontend implementations is encouraged. +- The frontend is activated by including its main actor in the + :attr:`mopidy.settings.FRONTENDS` setting. +- The main actor MUST be able to start and stop the frontend when the main + actor is started and stopped. +- The frontend MAY require additional settings to be set for it to + work. +- Such settings MUST be documented. +- The main actor MUST stop itself if the defined settings are not adequate for + the frontend to work properly. +- Any actor which is part of the frontend MAY implement any listener interface + from :mod:`mopidy.listeners` to receive notification of the specified events. Frontend implementations ======================== diff --git a/docs/api/listeners.rst b/docs/api/listeners.rst new file mode 100644 index 00000000..609dc3c7 --- /dev/null +++ b/docs/api/listeners.rst @@ -0,0 +1,7 @@ +************ +Listener API +************ + +.. automodule:: mopidy.listeners + :synopsis: Listener API + :members: diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst index 6daa7a4e..2459db8c 100644 --- a/docs/api/mixers.rst +++ b/docs/api/mixers.rst @@ -30,7 +30,6 @@ methods as described below. .. automodule:: mopidy.mixers.base :synopsis: Mixer API :members: - :undoc-members: Mixer implementations diff --git a/docs/api/models.rst b/docs/api/models.rst index ef11547e..5833e58c 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -25,4 +25,3 @@ Data model API .. automodule:: mopidy.models :synopsis: Data model API :members: - :undoc-members: diff --git a/docs/changes.rst b/docs/changes.rst index 4125b788..5d2ab57d 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -8,9 +8,18 @@ This change log is used to track all major changes to Mopidy. v0.6.0 (in development) ======================= +**Important changes** + +- Pykka 0.12.3 or greater is required. + **Changes** -- None yet +- Replace :attr:`mopidy.backends.base.Backend.uri_handlers` with + :attr:`mopidy.backends.base.Backend.uri_schemes`, which just takes the part + up to the colon of an URI, and not any prefix. +- Add Listener API, :mod:`mopidy.listeners`, to be implemented by actors + wanting to receive events from the backend. This is a formalization of the + ad hoc events the Last.fm scrobbler has already been using for some time. v0.5.0 (2011-06-15) diff --git a/docs/installation/index.rst b/docs/installation/index.rst index 5101cc84..198ac9e8 100644 --- a/docs/installation/index.rst +++ b/docs/installation/index.rst @@ -25,7 +25,7 @@ Otherwise, make sure you got the required dependencies installed. - Python >= 2.6, < 3 -- `Pykka `_ >= 0.12 +- `Pykka `_ >= 0.12.3 - GStreamer >= 0.10, with Python bindings. See :doc:`gstreamer`. diff --git a/docs/modules/frontends/mpd.rst b/docs/modules/frontends/mpd.rst index 6120c2a6..6f69b2a9 100644 --- a/docs/modules/frontends/mpd.rst +++ b/docs/modules/frontends/mpd.rst @@ -5,9 +5,8 @@ .. inheritance-diagram:: mopidy.frontends.mpd .. automodule:: mopidy.frontends.mpd - :synopsis: MPD frontend + :synopsis: MPD server frontend :members: - :undoc-members: MPD server @@ -18,7 +17,6 @@ MPD server .. automodule:: mopidy.frontends.mpd.server :synopsis: MPD server :members: - :undoc-members: MPD session @@ -29,7 +27,6 @@ MPD session .. automodule:: mopidy.frontends.mpd.session :synopsis: MPD client session :members: - :undoc-members: MPD dispatcher @@ -40,7 +37,6 @@ MPD dispatcher .. automodule:: mopidy.frontends.mpd.dispatcher :synopsis: MPD request dispatcher :members: - :undoc-members: MPD protocol diff --git a/docs/settings.rst b/docs/settings.rst index f0888670..68adfd55 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -151,4 +151,3 @@ Available settings .. automodule:: mopidy.settings :synopsis: Available settings and their default values :members: - :undoc-members: diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index 038e2d7b..76c7f078 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -25,5 +25,5 @@ class Backend(object): #: :class:`mopidy.backends.base.StoredPlaylistsController`. stored_playlists = None - #: List of URI prefixes this backend can handle. - uri_handlers = [] + #: List of URI schemes this backend can handle. + uri_schemes = [] diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index 530c4840..088a5ad4 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -4,7 +4,7 @@ import time from pykka.registry import ActorRegistry -from mopidy.frontends.base import BaseFrontend +from mopidy.listeners import BackendListener logger = logging.getLogger('mopidy.backends.base') @@ -461,38 +461,30 @@ class PlaybackController(object): self.current_cp_track = None def _trigger_started_playing_event(self): - """ - Notifies frontends that a track has started playing. - - For internal use only. Should be called by the backend directly after a - track has started playing. - """ + logger.debug(u'Triggering started playing event') if self.current_track is None: return - frontend_refs = ActorRegistry.get_by_class(BaseFrontend) - for frontend_ref in frontend_refs: - frontend_ref.send_one_way({ - 'command': 'started_playing', - 'track': self.current_track, - }) + ActorRegistry.broadcast({ + 'command': 'pykka_call', + 'attr_path': ('started_playing',), + 'args': [], + 'kwargs': {'track': self.current_track}, + }, target_class=BackendListener) def _trigger_stopped_playing_event(self): - """ - Notifies frontends that a track has stopped playing. - - For internal use only. Should be called by the backend before a track - is stopped playing, e.g. at the next, previous, and stop actions and at - end-of-track. - """ + # TODO Test that this is called on next/prev/end-of-track + logger.debug(u'Triggering stopped playing event') if self.current_track is None: return - frontend_refs = ActorRegistry.get_by_class(BaseFrontend) - for frontend_ref in frontend_refs: - frontend_ref.send_one_way({ - 'command': 'stopped_playing', + ActorRegistry.broadcast({ + 'command': 'pykka_call', + 'attr_path': ('stopped_playing',), + 'args': [], + 'kwargs': { 'track': self.current_track, - 'stop_position': self.time_position, - }) + 'time_position': self.time_position, + }, + }, target_class=BackendListener) class BasePlaybackProvider(object): diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 90c87dac..70efb028 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -32,7 +32,7 @@ class DummyBackend(ThreadingActor, Backend): self.stored_playlists = StoredPlaylistsController(backend=self, provider=stored_playlists_provider) - self.uri_handlers = [u'dummy:'] + self.uri_schemes = [u'dummy'] class DummyLibraryProvider(BaseLibraryProvider): diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 93cf3534..af80a8eb 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -52,7 +52,7 @@ class LocalBackend(ThreadingActor, Backend): self.stored_playlists = StoredPlaylistsController(backend=self, provider=stored_playlists_provider) - self.uri_handlers = [u'file://'] + self.uri_schemes = [u'file'] self.gstreamer = None diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 66bcffd4..02ccd802 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -67,7 +67,7 @@ class SpotifyBackend(ThreadingActor, Backend): self.stored_playlists = StoredPlaylistsController(backend=self, provider=stored_playlists_provider) - self.uri_handlers = [u'spotify:', u'http://open.spotify.com/'] + self.uri_schemes = [u'spotify'] self.gstreamer = None self.spotify = None diff --git a/mopidy/backends/spotify/translator.py b/mopidy/backends/spotify/translator.py index 1bf7e5aa..95287d77 100644 --- a/mopidy/backends/spotify/translator.py +++ b/mopidy/backends/spotify/translator.py @@ -4,7 +4,7 @@ import logging from spotify import Link, SpotifyError from mopidy import settings -from mopidy.backends.spotify import ENCODING, BITRATES +from mopidy.backends.spotify import ENCODING from mopidy.models import Artist, Album, Track, Playlist logger = logging.getLogger('mopidy.backends.spotify.translator') @@ -44,7 +44,7 @@ class SpotifyTranslator(object): track_no=spotify_track.index(), date=date, length=spotify_track.duration(), - bitrate=BITRATES[settings.SPOTIFY_BITRATE], + bitrate=settings.SPOTIFY_BITRATE, ) @classmethod diff --git a/mopidy/core.py b/mopidy/core.py index 4420c319..e831fc55 100644 --- a/mopidy/core.py +++ b/mopidy/core.py @@ -25,7 +25,8 @@ from mopidy.gstreamer import GStreamer from mopidy.utils import get_class from mopidy.utils.log import setup_logging from mopidy.utils.path import get_or_create_folder, get_or_create_file -from mopidy.utils.process import exit_handler, stop_all_actors +from mopidy.utils.process import (exit_handler, stop_remaining_actors, + stop_actors_by_class) from mopidy.utils.settings import list_settings_optparse_callback logger = logging.getLogger('mopidy.core') @@ -50,7 +51,11 @@ def main(): logger.exception(e) finally: loop.quit() - stop_all_actors() + stop_frontends() + stop_backend() + stop_mixer() + stop_gstreamer() + stop_remaining_actors() def parse_options(): parser = optparse.OptionParser(version=u'Mopidy %s' % get_version()) @@ -59,7 +64,7 @@ def parse_options(): help='show GStreamer help options') parser.add_option('-i', '--interactive', action='store_true', dest='interactive', - help='ask interactively for required settings which is missing') + help='ask interactively for required settings which are missing') parser.add_option('-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') @@ -86,15 +91,31 @@ def setup_settings(interactive): def setup_gstreamer(): GStreamer.start() +def stop_gstreamer(): + stop_actors_by_class(GStreamer) + def setup_mixer(): get_class(settings.MIXER).start() +def stop_mixer(): + stop_actors_by_class(get_class(settings.MIXER)) + def setup_backend(): get_class(settings.BACKENDS[0]).start() +def stop_backend(): + stop_actors_by_class(get_class(settings.BACKENDS[0])) + def setup_frontends(): for frontend_class_name in settings.FRONTENDS: try: get_class(frontend_class_name).start() except OptionalDependencyError as e: logger.info(u'Disabled: %s (%s)', frontend_class_name, e) + +def stop_frontends(): + for frontend_class_name in settings.FRONTENDS: + try: + stop_actors_by_class(get_class(frontend_class_name)) + except OptionalDependencyError: + pass diff --git a/mopidy/frontends/base.py b/mopidy/frontends/base.py deleted file mode 100644 index 811644b1..00000000 --- a/mopidy/frontends/base.py +++ /dev/null @@ -1,5 +0,0 @@ -class BaseFrontend(object): - """ - Base class for frontends. - """ - pass diff --git a/mopidy/frontends/lastfm.py b/mopidy/frontends/lastfm.py index 04716c61..33e724c0 100644 --- a/mopidy/frontends/lastfm.py +++ b/mopidy/frontends/lastfm.py @@ -10,14 +10,14 @@ except ImportError as import_error: from pykka.actor import ThreadingActor from mopidy import settings, SettingsError -from mopidy.frontends.base import BaseFrontend +from mopidy.listeners import BackendListener logger = logging.getLogger('mopidy.frontends.lastfm') API_KEY = '2236babefa8ebb3d93ea467560d00d04' API_SECRET = '94d9a09c0cd5be955c4afaeaffcaefcd' -class LastfmFrontend(ThreadingActor, BaseFrontend): +class LastfmFrontend(ThreadingActor, BackendListener): """ Frontend which scrobbles the music you play to your `Last.fm `_ profile. @@ -57,14 +57,6 @@ class LastfmFrontend(ThreadingActor, BaseFrontend): logger.error(u'Error during Last.fm setup: %s', e) self.stop() - def on_receive(self, message): - if message.get('command') == 'started_playing': - self.started_playing(message['track']) - elif message.get('command') == 'stopped_playing': - self.stopped_playing(message['track'], message['stop_position']) - else: - pass # Ignore any other messages - def started_playing(self, track): artists = ', '.join([a.name for a in track.artists]) duration = track.length and track.length // 1000 or 0 diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 0432850d..1e18f6e2 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -3,7 +3,6 @@ import sys from pykka.actor import ThreadingActor -from mopidy.frontends.base import BaseFrontend from mopidy import settings from mopidy.utils import network from mopidy.frontends.mpd.dispatcher import MpdDispatcher @@ -11,8 +10,7 @@ from mopidy.frontends.mpd.protocol import ENCODING, VERSION, LINE_TERMINATOR logger = logging.getLogger('mopidy.frontends.mpd') -# FIXME no real need for frontend to be threading actor -class MpdFrontend(ThreadingActor, BaseFrontend): +class MpdFrontend(ThreadingActor): """ The MPD frontend. diff --git a/mopidy/frontends/mpd/dispatcher.py b/mopidy/frontends/mpd/dispatcher.py index 91cdc5e7..18f994de 100644 --- a/mopidy/frontends/mpd/dispatcher.py +++ b/mopidy/frontends/mpd/dispatcher.py @@ -128,7 +128,7 @@ class MpdDispatcher(object): return self._call_next_filter(request, response, filter_chain) except ActorDeadError as e: logger.warning(u'Tried to communicate with dead actor.') - raise exceptions.MpdSystemError(e.message) + raise exceptions.MpdSystemError(e) def _call_handler(self, request): (handler, kwargs) = self._find_handler(request) diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py index 8e26013d..c7136804 100644 --- a/mopidy/frontends/mpd/protocol/current_playlist.py +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -19,8 +19,8 @@ def add(context, uri): """ if not uri: return - for handler_prefix in context.backend.uri_handlers.get(): - if uri.startswith(handler_prefix): + for uri_scheme in context.backend.uri_schemes.get(): + if uri.startswith(uri_scheme): track = context.backend.library.lookup(uri).get() if track is not None: context.backend.current_playlist.add(track) diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py index 920f48a5..3618f5e1 100644 --- a/mopidy/frontends/mpd/protocol/reflection.py +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -95,4 +95,5 @@ def urlhandlers(context): Gets a list of available URL handlers. """ - return [(u'handler', uri) for uri in context.backend.uri_handlers.get()] + return [(u'handler', uri_scheme) + for uri_scheme in context.backend.uri_schemes.get()] diff --git a/mopidy/listeners.py b/mopidy/listeners.py new file mode 100644 index 00000000..dfc5c60b --- /dev/null +++ b/mopidy/listeners.py @@ -0,0 +1,34 @@ +class BackendListener(object): + """ + Marker interface for recipients of events sent by the backend. + + Any Pykka actor that mixes in this class will receive calls to the methods + defined here when the corresponding events happen in the backend. This + interface is used both for looking up what actors to notify of the events, + and for providing default implementations for those listeners that are not + interested in all events. + """ + + def started_playing(self, track): + """ + Called whenever a new track starts playing. + + *MAY* be implemented by actor. + + :param track: the track that just started playing + :type track: :class:`mopidy.models.Track` + """ + pass + + def stopped_playing(self, track, time_position): + """ + Called whenever playback is stopped. + + *MAY* be implemented by actor. + + :param track: the track that was played before playback stopped + :type track: :class:`mopidy.models.Track` + :param time_position: the time position in milliseconds + :type time_position: int + """ + pass diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index f9577496..80d850fe 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -22,9 +22,17 @@ def exit_handler(signum, frame): logger.info(u'Got %s signal', signals[signum]) exit_process() -def stop_all_actors(): +def stop_actors_by_class(klass): + actors = ActorRegistry.get_by_class(klass) + logger.debug(u'Stopping %d instance(s) of %s', len(actors), klass.__name__) + for actor in actors: + actor.stop() + +def stop_remaining_actors(): num_actors = len(ActorRegistry.get_all()) while num_actors: + logger.error( + u'There are actor threads still running, this is probably a bug') logger.debug(u'Seeing %d actor and %d non-actor thread(s): %s', num_actors, threading.active_count() - num_actors, ', '.join([t.name for t in threading.enumerate()])) diff --git a/requirements/core.txt b/requirements/core.txt index aaae84f8..8f9da622 100644 --- a/requirements/core.txt +++ b/requirements/core.txt @@ -1 +1 @@ -Pykka >= 0.12 +Pykka >= 0.12.3 diff --git a/tests/backends/events_test.py b/tests/backends/events_test.py new file mode 100644 index 00000000..44529e90 --- /dev/null +++ b/tests/backends/events_test.py @@ -0,0 +1,45 @@ +import threading +import unittest + +from pykka.actor import ThreadingActor +from pykka.registry import ActorRegistry + +from mopidy.backends.dummy import DummyBackend +from mopidy.listeners import BackendListener +from mopidy.models import Track + +class BackendEventsTest(unittest.TestCase): + def setUp(self): + self.events = { + 'started_playing': threading.Event(), + 'stopped_playing': threading.Event(), + } + self.backend = DummyBackend.start().proxy() + self.listener = DummyBackendListener.start(self.events).proxy() + + def tearDown(self): + ActorRegistry.stop_all() + + def test_play_sends_started_playing_event(self): + self.backend.current_playlist.add([Track(uri='a')]) + self.backend.playback.play() + self.events['started_playing'].wait(timeout=1) + self.assertTrue(self.events['started_playing'].is_set()) + + def test_stop_sends_stopped_playing_event(self): + self.backend.current_playlist.add([Track(uri='a')]) + self.backend.playback.play() + self.backend.playback.stop() + self.events['stopped_playing'].wait(timeout=1) + self.assertTrue(self.events['stopped_playing'].is_set()) + + +class DummyBackendListener(ThreadingActor, BackendListener): + def __init__(self, events): + self.events = events + + def started_playing(self, track): + self.events['started_playing'].set() + + def stopped_playing(self, track, time_position): + self.events['stopped_playing'].set() diff --git a/tests/backends/local/playback_test.py b/tests/backends/local/playback_test.py index 2cdeadb9..5f80e691 100644 --- a/tests/backends/local/playback_test.py +++ b/tests/backends/local/playback_test.py @@ -36,8 +36,8 @@ class LocalPlaybackControllerTest(PlaybackControllerTest, unittest.TestCase): track = Track(uri=uri, length=4464) self.backend.current_playlist.add(track) - def test_uri_handler(self): - self.assert_('file://' in self.backend.uri_handlers) + def test_uri_scheme(self): + self.assertIn('file', self.backend.uri_schemes) def test_play_mp3(self): self.add_track('blank.mp3') diff --git a/tests/frontends/mpd/reflection_test.py b/tests/frontends/mpd/reflection_test.py index 2abf5acc..c4fd632a 100644 --- a/tests/frontends/mpd/reflection_test.py +++ b/tests/frontends/mpd/reflection_test.py @@ -76,4 +76,4 @@ class ReflectionHandlerTest(unittest.TestCase): def test_urlhandlers(self): result = self.dispatcher.handle_request(u'urlhandlers') self.assert_(u'OK' in result) - self.assert_(u'handler: dummy:' in result) + self.assert_(u'handler: dummy' in result) diff --git a/tests/listeners_test.py b/tests/listeners_test.py new file mode 100644 index 00000000..761aff4f --- /dev/null +++ b/tests/listeners_test.py @@ -0,0 +1,14 @@ +import unittest + +from mopidy.listeners import BackendListener +from mopidy.models import Track + +class BackendListenerTest(unittest.TestCase): + def setUp(self): + self.listener = BackendListener() + + def test_listener_has_default_impl_for_the_started_playing_event(self): + self.listener.started_playing(Track()) + + def test_listener_has_default_impl_for_the_stopped_playing_event(self): + self.listener.stopped_playing(Track(), 0)