diff --git a/docs/api/backends/providers.rst b/docs/api/backends.rst similarity index 56% rename from docs/api/backends/providers.rst rename to docs/api/backends.rst index 61e5f68a..781723d6 100644 --- a/docs/api/backends/providers.rst +++ b/docs/api/backends.rst @@ -1,12 +1,12 @@ -.. _backend-provider-api: +.. _backend-api: -******************** -Backend provider API -******************** +*********** +Backend API +*********** -The backend provider API is the interface that must be implemented when you -create a backend. If you are working on a frontend and need to access the -backend, see the :ref:`backend-controller-api`. +The backend API is the interface that must be implemented when you create a +backend. If you are working on a frontend and need to access the backend, see +the :ref:`core-api`. Playback provider @@ -30,8 +30,8 @@ Library provider :members: -Backend provider implementations -================================ +Backend implementations +======================= * :mod:`mopidy.backends.dummy` * :mod:`mopidy.backends.spotify` diff --git a/docs/api/backends/controllers.rst b/docs/api/backends/controllers.rst deleted file mode 100644 index 8d6687e2..00000000 --- a/docs/api/backends/controllers.rst +++ /dev/null @@ -1,54 +0,0 @@ -.. _backend-controller-api: - -********************** -Backend controller API -********************** - - -The backend controller API is the interface that is used by frontends like -:mod:`mopidy.frontends.mpd`. If you want to implement your own backend, see the -:ref:`backend-provider-api`. - - -The backend -=========== - -.. autoclass:: mopidy.backends.base.Backend - :members: - - -Playback controller -=================== - -Manages playback, with actions like play, pause, stop, next, previous, -seek, and volume control. - -.. autoclass:: mopidy.backends.base.PlaybackController - :members: - - -Current playlist controller -=========================== - -Manages everything related to the currently loaded playlist. - -.. autoclass:: mopidy.backends.base.CurrentPlaylistController - :members: - - -Stored playlists controller -=========================== - -Manages stored playlist. - -.. autoclass:: mopidy.backends.base.StoredPlaylistsController - :members: - - -Library controller -================== - -Manages the music library, e.g. searching for tracks to be added to a playlist. - -.. autoclass:: mopidy.backends.base.LibraryController - :members: diff --git a/docs/api/backends/concepts.rst b/docs/api/concepts.rst similarity index 88% rename from docs/api/backends/concepts.rst rename to docs/api/concepts.rst index 371e03bc..ae959237 100644 --- a/docs/api/backends/concepts.rst +++ b/docs/api/concepts.rst @@ -1,4 +1,4 @@ -.. _backend-concepts: +.. _concepts: ********************************************** The backend, controller, and provider concepts @@ -12,11 +12,11 @@ Controllers: 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:`backend-controller-api` for more details. + :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-provider-api` for more details. + providers. See :ref:`backend-api` for more details. .. digraph:: backend_relations diff --git a/docs/api/core.rst b/docs/api/core.rst new file mode 100644 index 00000000..b37c8730 --- /dev/null +++ b/docs/api/core.rst @@ -0,0 +1,47 @@ +.. _core-api: + +******** +Core API +******** + + +The core API is the interface that is used by frontends like +:mod:`mopidy.frontends.mpd`. The core layer is inbetween the frontends and the +backends. + + +Playback controller +=================== + +Manages playback, with actions like play, pause, stop, next, previous, +seek, and volume control. + +.. autoclass:: mopidy.core.PlaybackController + :members: + + +Current playlist controller +=========================== + +Manages everything related to the currently loaded playlist. + +.. autoclass:: mopidy.core.CurrentPlaylistController + :members: + + +Stored playlists controller +=========================== + +Manages stored playlist. + +.. autoclass:: mopidy.core.StoredPlaylistsController + :members: + + +Library controller +================== + +Manages the music library, e.g. searching for tracks to be added to a playlist. + +.. autoclass:: mopidy.core.LibraryController + :members: diff --git a/docs/api/index.rst b/docs/api/index.rst index 1f37e9ff..b5be8ed4 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -5,7 +5,9 @@ API reference .. toctree:: :glob: - backends/concepts - backends/controllers - backends/providers - * + concepts + models + backends + core + frontends + listeners diff --git a/docs/changes.rst b/docs/changes.rst index c6b7e0ac..3b77f61a 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -616,9 +616,9 @@ to this problem. :class:`mopidy.models.Album`, and :class:`mopidy.models.Track`. - Prepare for multi-backend support (see :issue:`40`) by introducing the - :ref:`provider concept `. Split the backend API into a - :ref:`backend controller API ` (for frontend use) - and a :ref:`backend provider API ` (for backend + :ref:`provider concept `. Split the backend API into a + :ref:`backend controller API ` (for frontend use) + and a :ref:`backend provider API ` (for backend implementation use), which includes the following changes: - Rename ``BaseBackend`` to :class:`mopidy.backends.base.Backend`. diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py index 76c7f078..e6c8b70a 100644 --- a/mopidy/backends/base/__init__.py +++ b/mopidy/backends/base/__init__.py @@ -1,12 +1,7 @@ -import logging +from .library import BaseLibraryProvider +from .playback import BasePlaybackProvider +from .stored_playlists import BaseStoredPlaylistsProvider -from .current_playlist import CurrentPlaylistController -from .library import LibraryController, BaseLibraryProvider -from .playback import PlaybackController, BasePlaybackProvider -from .stored_playlists import (StoredPlaylistsController, - BaseStoredPlaylistsProvider) - -logger = logging.getLogger('mopidy.backends.base') class Backend(object): #: The current playlist controller. An instance of diff --git a/mopidy/backends/base/library.py b/mopidy/backends/base/library.py index 9e3afe9a..837eef49 100644 --- a/mopidy/backends/base/library.py +++ b/mopidy/backends/base/library.py @@ -1,79 +1,3 @@ -import logging - -logger = logging.getLogger('mopidy.backends.base') - -class LibraryController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BaseLibraryProvider` - """ - - pykka_traversable = True - - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider - - def find_exact(self, **query): - """ - Search the library for tracks where ``field`` is ``values``. - - Examples:: - - # Returns results matching 'a' - find_exact(any=['a']) - # Returns results matching artist 'xyz' - find_exact(artist=['xyz']) - # Returns results matching 'a' and 'b' and artist 'xyz' - find_exact(any=['a', 'b'], artist=['xyz']) - - :param query: one or more queries to search for - :type query: dict - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.find_exact(**query) - - def lookup(self, uri): - """ - Lookup track with given URI. Returns :class:`None` if not found. - - :param uri: track URI - :type uri: string - :rtype: :class:`mopidy.models.Track` or :class:`None` - """ - return self.provider.lookup(uri) - - def refresh(self, uri=None): - """ - Refresh library. Limit to URI and below if an URI is given. - - :param uri: directory or track URI - :type uri: string - """ - return self.provider.refresh(uri) - - def search(self, **query): - """ - Search the library for tracks where ``field`` contains ``values``. - - Examples:: - - # Returns results matching 'a' - search(any=['a']) - # Returns results matching artist 'xyz' - search(artist=['xyz']) - # Returns results matching 'a' and 'b' and artist 'xyz' - search(any=['a', 'b'], artist=['xyz']) - - :param query: one or more queries to search for - :type query: dict - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.search(**query) - - class BaseLibraryProvider(object): """ :param backend: backend the controller is a part of diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py index dfcbe8bb..d2b9edd9 100644 --- a/mopidy/backends/base/playback.py +++ b/mopidy/backends/base/playback.py @@ -1,550 +1,3 @@ -import logging -import random -import time - -from mopidy.listeners import BackendListener - -logger = logging.getLogger('mopidy.backends.base') - - -def option_wrapper(name, default): - def get_option(self): - return getattr(self, name, default) - def set_option(self, value): - if getattr(self, name, default) != value: - self._trigger_options_changed() - return setattr(self, name, value) - return property(get_option, set_option) - - -class PlaybackController(object): - """ - :param backend: the backend - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BasePlaybackProvider` - """ - - # pylint: disable = R0902 - # Too many instance attributes - - pykka_traversable = True - - #: Constant representing the paused state. - PAUSED = u'paused' - - #: Constant representing the playing state. - PLAYING = u'playing' - - #: Constant representing the stopped state. - STOPPED = u'stopped' - - #: :class:`True` - #: Tracks are removed from the playlist when they have been played. - #: :class:`False` - #: Tracks are not removed from the playlist. - consume = option_wrapper('_consume', False) - - #: The currently playing or selected track. - #: - #: A two-tuple of (CPID integer, :class:`mopidy.models.Track`) or - #: :class:`None`. - current_cp_track = None - - #: :class:`True` - #: Tracks are selected at random from the playlist. - #: :class:`False` - #: Tracks are played in the order of the playlist. - random = option_wrapper('_random', False) - - #: :class:`True` - #: The current playlist is played repeatedly. To repeat a single track, - #: select both :attr:`repeat` and :attr:`single`. - #: :class:`False` - #: The current playlist is played once. - repeat = option_wrapper('_repeat', False) - - #: :class:`True` - #: Playback is stopped after current song, unless in :attr:`repeat` - #: mode. - #: :class:`False` - #: Playback continues after current song. - single = option_wrapper('_single', False) - - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider - self._state = self.STOPPED - self._shuffled = [] - self._first_shuffle = True - self.play_time_accumulated = 0 - self.play_time_started = None - - def _get_cpid(self, cp_track): - if cp_track is None: - return None - return cp_track.cpid - - def _get_track(self, cp_track): - if cp_track is None: - return None - return cp_track.track - - @property - def current_cpid(self): - """ - The CPID (current playlist ID) of the currently playing or selected - track. - - Read-only. Extracted from :attr:`current_cp_track` for convenience. - """ - return self._get_cpid(self.current_cp_track) - - @property - def current_track(self): - """ - The currently playing or selected :class:`mopidy.models.Track`. - - Read-only. Extracted from :attr:`current_cp_track` for convenience. - """ - return self._get_track(self.current_cp_track) - - @property - def current_playlist_position(self): - """ - The position of the current track in the current playlist. - - Read-only. - """ - if self.current_cp_track is None: - return None - try: - return self.backend.current_playlist.cp_tracks.index( - self.current_cp_track) - except ValueError: - return None - - @property - def track_at_eot(self): - """ - The track that will be played at the end of the current track. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_eot` for convenience. - """ - return self._get_track(self.cp_track_at_eot) - - @property - def cp_track_at_eot(self): - """ - The track that will be played at the end of the current track. - - Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). - - Not necessarily the same track as :attr:`cp_track_at_next`. - """ - # pylint: disable = R0911 - # Too many return statements - - cp_tracks = self.backend.current_playlist.cp_tracks - - if not cp_tracks: - return None - - if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: - logger.debug('Shuffling tracks') - self._shuffled = cp_tracks - random.shuffle(self._shuffled) - self._first_shuffle = False - - if self.random and self._shuffled: - return self._shuffled[0] - - if self.current_cp_track is None: - return cp_tracks[0] - - if self.repeat and self.single: - return cp_tracks[self.current_playlist_position] - - if self.repeat and not self.single: - return cp_tracks[ - (self.current_playlist_position + 1) % len(cp_tracks)] - - try: - return cp_tracks[self.current_playlist_position + 1] - except IndexError: - return None - - @property - def track_at_next(self): - """ - The track that will be played if calling :meth:`next()`. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_next` for convenience. - """ - return self._get_track(self.cp_track_at_next) - - @property - def cp_track_at_next(self): - """ - The track that will be played if calling :meth:`next()`. - - Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). - - For normal playback this is the next track in the playlist. If repeat - is enabled the next track can loop around the playlist. When random is - enabled this should be a random track, all tracks should be played once - before the list repeats. - """ - cp_tracks = self.backend.current_playlist.cp_tracks - - if not cp_tracks: - return None - - if self.random and not self._shuffled: - if self.repeat or self._first_shuffle: - logger.debug('Shuffling tracks') - self._shuffled = cp_tracks - random.shuffle(self._shuffled) - self._first_shuffle = False - - if self.random and self._shuffled: - return self._shuffled[0] - - if self.current_cp_track is None: - return cp_tracks[0] - - if self.repeat: - return cp_tracks[ - (self.current_playlist_position + 1) % len(cp_tracks)] - - try: - return cp_tracks[self.current_playlist_position + 1] - except IndexError: - return None - - @property - def track_at_previous(self): - """ - The track that will be played if calling :meth:`previous()`. - - Read-only. A :class:`mopidy.models.Track` extracted from - :attr:`cp_track_at_previous` for convenience. - """ - return self._get_track(self.cp_track_at_previous) - - @property - def cp_track_at_previous(self): - """ - The track that will be played if calling :meth:`previous()`. - - A two-tuple of (CPID integer, :class:`mopidy.models.Track`). - - For normal playback this is the previous track in the playlist. If - random and/or consume is enabled it should return the current track - instead. - """ - if self.repeat or self.consume or self.random: - return self.current_cp_track - - if self.current_playlist_position in (None, 0): - return None - - return self.backend.current_playlist.cp_tracks[ - self.current_playlist_position - 1] - - @property - def state(self): - """ - The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or - :attr:`STOPPED`. - - Possible states and transitions: - - .. digraph:: state_transitions - - "STOPPED" -> "PLAYING" [ label="play" ] - "STOPPED" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "STOPPED" [ label="stop" ] - "PLAYING" -> "PAUSED" [ label="pause" ] - "PLAYING" -> "PLAYING" [ label="play" ] - "PAUSED" -> "PLAYING" [ label="resume" ] - "PAUSED" -> "STOPPED" [ label="stop" ] - """ - return self._state - - @state.setter - def state(self, new_state): - (old_state, self._state) = (self.state, new_state) - logger.debug(u'Changing state: %s -> %s', old_state, new_state) - - self._trigger_playback_state_changed() - - # FIXME play_time stuff assumes backend does not have a better way of - # handeling this stuff :/ - if (old_state in (self.PLAYING, self.STOPPED) - and new_state == self.PLAYING): - self._play_time_start() - elif old_state == self.PLAYING and new_state == self.PAUSED: - self._play_time_pause() - elif old_state == self.PAUSED and new_state == self.PLAYING: - self._play_time_resume() - - @property - def time_position(self): - """Time position in milliseconds.""" - if self.state == self.PLAYING: - time_since_started = (self._current_wall_time - - self.play_time_started) - return self.play_time_accumulated + time_since_started - elif self.state == self.PAUSED: - return self.play_time_accumulated - elif self.state == self.STOPPED: - return 0 - - def _play_time_start(self): - self.play_time_accumulated = 0 - self.play_time_started = self._current_wall_time - - def _play_time_pause(self): - time_since_started = self._current_wall_time - self.play_time_started - self.play_time_accumulated += time_since_started - - def _play_time_resume(self): - self.play_time_started = self._current_wall_time - - @property - def _current_wall_time(self): - return int(time.time() * 1000) - - @property - def volume(self): - return self.provider.get_volume() - - @volume.setter - def volume(self, volume): - self.provider.set_volume(volume) - - def change_track(self, cp_track, on_error_step=1): - """ - Change to the given track, keeping the current playback state. - - :param cp_track: track to change to - :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) - or :class:`None` - :param on_error_step: direction to step at play error, 1 for next - track (default), -1 for previous track - :type on_error_step: int, -1 or 1 - - """ - old_state = self.state - self.stop() - self.current_cp_track = cp_track - if old_state == self.PLAYING: - self.play(on_error_step=on_error_step) - elif old_state == self.PAUSED: - self.pause() - - def on_end_of_track(self): - """ - Tell the playback controller that end of track is reached. - """ - if self.state == self.STOPPED: - return - - original_cp_track = self.current_cp_track - - if self.cp_track_at_eot: - self._trigger_track_playback_ended() - self.play(self.cp_track_at_eot) - else: - self.stop(clear_current_track=True) - - if self.consume: - self.backend.current_playlist.remove(cpid=original_cp_track.cpid) - - def on_current_playlist_change(self): - """ - Tell the playback controller that the current playlist has changed. - - Used by :class:`mopidy.backends.base.CurrentPlaylistController`. - """ - self._first_shuffle = True - self._shuffled = [] - - if (not self.backend.current_playlist.cp_tracks or - self.current_cp_track not in - self.backend.current_playlist.cp_tracks): - self.stop(clear_current_track=True) - - def next(self): - """ - Change to the next track. - - The current playback state will be kept. If it was playing, playing - will continue. If it was paused, it will still be paused, etc. - """ - if self.cp_track_at_next: - self._trigger_track_playback_ended() - self.change_track(self.cp_track_at_next) - else: - self.stop(clear_current_track=True) - - def pause(self): - """Pause playback.""" - if self.provider.pause(): - self.state = self.PAUSED - self._trigger_track_playback_paused() - - def play(self, cp_track=None, on_error_step=1): - """ - Play the given track, or if the given track is :class:`None`, play the - currently active track. - - :param cp_track: track to play - :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) - or :class:`None` - :param on_error_step: direction to step at play error, 1 for next - track (default), -1 for previous track - :type on_error_step: int, -1 or 1 - """ - - if cp_track is not None: - assert cp_track in self.backend.current_playlist.cp_tracks - elif cp_track is None: - if self.state == self.PAUSED: - return self.resume() - elif self.current_cp_track is not None: - cp_track = self.current_cp_track - elif self.current_cp_track is None and on_error_step == 1: - cp_track = self.cp_track_at_next - elif self.current_cp_track is None and on_error_step == -1: - cp_track = self.cp_track_at_previous - - if cp_track is not None: - self.current_cp_track = cp_track - self.state = self.PLAYING - if not self.provider.play(cp_track.track): - # Track is not playable - if self.random and self._shuffled: - self._shuffled.remove(cp_track) - if on_error_step == 1: - self.next() - elif on_error_step == -1: - self.previous() - - if self.random and self.current_cp_track in self._shuffled: - self._shuffled.remove(self.current_cp_track) - - self._trigger_track_playback_started() - - def previous(self): - """ - Change to the previous track. - - The current playback state will be kept. If it was playing, playing - will continue. If it was paused, it will still be paused, etc. - """ - self._trigger_track_playback_ended() - self.change_track(self.cp_track_at_previous, on_error_step=-1) - - def resume(self): - """If paused, resume playing the current track.""" - if self.state == self.PAUSED and self.provider.resume(): - self.state = self.PLAYING - self._trigger_track_playback_resumed() - - def seek(self, time_position): - """ - Seeks to time position given in milliseconds. - - :param time_position: time position in milliseconds - :type time_position: int - :rtype: :class:`True` if successful, else :class:`False` - """ - if not self.backend.current_playlist.tracks: - return False - - if self.state == self.STOPPED: - self.play() - elif self.state == self.PAUSED: - self.resume() - - if time_position < 0: - time_position = 0 - elif time_position > self.current_track.length: - self.next() - return True - - self.play_time_started = self._current_wall_time - self.play_time_accumulated = time_position - - success = self.provider.seek(time_position) - if success: - self._trigger_seeked() - return success - - def stop(self, clear_current_track=False): - """ - Stop playing. - - :param clear_current_track: whether to clear the current track _after_ - stopping - :type clear_current_track: boolean - """ - if self.state != self.STOPPED: - if self.provider.stop(): - self._trigger_track_playback_ended() - self.state = self.STOPPED - if clear_current_track: - self.current_cp_track = None - - def _trigger_track_playback_paused(self): - logger.debug(u'Triggering track playback paused event') - if self.current_track is None: - return - BackendListener.send('track_playback_paused', - track=self.current_track, - time_position=self.time_position) - - def _trigger_track_playback_resumed(self): - logger.debug(u'Triggering track playback resumed event') - if self.current_track is None: - return - BackendListener.send('track_playback_resumed', - track=self.current_track, - time_position=self.time_position) - - def _trigger_track_playback_started(self): - logger.debug(u'Triggering track playback started event') - if self.current_track is None: - return - BackendListener.send('track_playback_started', - track=self.current_track) - - def _trigger_track_playback_ended(self): - logger.debug(u'Triggering track playback ended event') - if self.current_track is None: - return - BackendListener.send('track_playback_ended', - track=self.current_track, - time_position=self.time_position) - - def _trigger_playback_state_changed(self): - logger.debug(u'Triggering playback state change event') - BackendListener.send('playback_state_changed') - - def _trigger_options_changed(self): - logger.debug(u'Triggering options changed event') - BackendListener.send('options_changed') - - def _trigger_seeked(self): - logger.debug(u'Triggering seeked event') - BackendListener.send('seeked') - - class BasePlaybackProvider(object): """ :param backend: the backend diff --git a/mopidy/backends/base/stored_playlists.py b/mopidy/backends/base/stored_playlists.py index 0ce2e196..d1d52c9a 100644 --- a/mopidy/backends/base/stored_playlists.py +++ b/mopidy/backends/base/stored_playlists.py @@ -1,120 +1,4 @@ from copy import copy -import logging - -logger = logging.getLogger('mopidy.backends.base') - -class StoredPlaylistsController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`mopidy.backends.base.Backend` - :param provider: provider the controller should use - :type provider: instance of :class:`BaseStoredPlaylistsProvider` - """ - - pykka_traversable = True - - def __init__(self, backend, provider): - self.backend = backend - self.provider = provider - - @property - def playlists(self): - """ - Currently stored playlists. - - Read/write. List of :class:`mopidy.models.Playlist`. - """ - return self.provider.playlists - - @playlists.setter - def playlists(self, playlists): - self.provider.playlists = playlists - - def create(self, name): - """ - Create a new playlist. - - :param name: name of the new playlist - :type name: string - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.create(name) - - def delete(self, playlist): - """ - Delete playlist. - - :param playlist: the playlist to delete - :type playlist: :class:`mopidy.models.Playlist` - """ - return self.provider.delete(playlist) - - def get(self, **criteria): - """ - Get playlist by given criterias from the set of stored playlists. - - Raises :exc:`LookupError` if a unique match is not found. - - Examples:: - - get(name='a') # Returns track with name 'a' - get(uri='xyz') # Returns track with URI 'xyz' - get(name='a', uri='xyz') # Returns track with name 'a' and URI - # 'xyz' - - :param criteria: one or more criteria to match by - :type criteria: dict - :rtype: :class:`mopidy.models.Playlist` - """ - matches = self.playlists - for (key, value) in criteria.iteritems(): - matches = filter(lambda p: getattr(p, key) == value, matches) - if len(matches) == 1: - return matches[0] - criteria_string = ', '.join( - ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) - if len(matches) == 0: - raise LookupError('"%s" match no playlists' % criteria_string) - else: - raise LookupError('"%s" match multiple playlists' % criteria_string) - - def lookup(self, uri): - """ - Lookup playlist with given URI in both the set of stored playlists and - in any other playlist sources. - - :param uri: playlist URI - :type uri: string - :rtype: :class:`mopidy.models.Playlist` - """ - return self.provider.lookup(uri) - - def refresh(self): - """ - Refresh the stored playlists in - :attr:`mopidy.backends.base.StoredPlaylistsController.playlists`. - """ - return self.provider.refresh() - - def rename(self, playlist, new_name): - """ - Rename playlist. - - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - :param new_name: the new name - :type new_name: string - """ - return self.provider.rename(playlist, new_name) - - def save(self, playlist): - """ - Save the playlist to the set of stored playlists. - - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - """ - return self.provider.save(playlist) class BaseStoredPlaylistsProvider(object): diff --git a/mopidy/backends/dummy/__init__.py b/mopidy/backends/dummy/__init__.py index 2234242c..3ada0052 100644 --- a/mopidy/backends/dummy/__init__.py +++ b/mopidy/backends/dummy/__init__.py @@ -1,13 +1,11 @@ from pykka.actor import ThreadingActor -from mopidy.backends.base import (Backend, CurrentPlaylistController, - PlaybackController, BasePlaybackProvider, LibraryController, - BaseLibraryProvider, StoredPlaylistsController, - BaseStoredPlaylistsProvider) +from mopidy import core +from mopidy.backends import base from mopidy.models import Playlist -class DummyBackend(ThreadingActor, Backend): +class DummyBackend(ThreadingActor, base.Backend): """ A backend which implements the backend API in the simplest way possible. Used in tests of the frontends. @@ -18,24 +16,24 @@ class DummyBackend(ThreadingActor, Backend): def __init__(self, *args, **kwargs): super(DummyBackend, self).__init__(*args, **kwargs) - self.current_playlist = CurrentPlaylistController(backend=self) + self.current_playlist = core.CurrentPlaylistController(backend=self) library_provider = DummyLibraryProvider(backend=self) - self.library = LibraryController(backend=self, + self.library = core.LibraryController(backend=self, provider=library_provider) playback_provider = DummyPlaybackProvider(backend=self) - self.playback = PlaybackController(backend=self, + self.playback = core.PlaybackController(backend=self, provider=playback_provider) stored_playlists_provider = DummyStoredPlaylistsProvider(backend=self) - self.stored_playlists = StoredPlaylistsController(backend=self, + self.stored_playlists = core.StoredPlaylistsController(backend=self, provider=stored_playlists_provider) self.uri_schemes = [u'dummy'] -class DummyLibraryProvider(BaseLibraryProvider): +class DummyLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(DummyLibraryProvider, self).__init__(*args, **kwargs) self.dummy_library = [] @@ -55,7 +53,7 @@ class DummyLibraryProvider(BaseLibraryProvider): return Playlist() -class DummyPlaybackProvider(BasePlaybackProvider): +class DummyPlaybackProvider(base.BasePlaybackProvider): def __init__(self, *args, **kwargs): super(DummyPlaybackProvider, self).__init__(*args, **kwargs) self._volume = None @@ -83,7 +81,7 @@ class DummyPlaybackProvider(BasePlaybackProvider): self._volume = volume -class DummyStoredPlaylistsProvider(BaseStoredPlaylistsProvider): +class DummyStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def create(self, name): playlist = Playlist(name=name) self._playlists.append(playlist) diff --git a/mopidy/backends/local/__init__.py b/mopidy/backends/local/__init__.py index 263d2fc2..e8d918b0 100644 --- a/mopidy/backends/local/__init__.py +++ b/mopidy/backends/local/__init__.py @@ -7,11 +7,8 @@ import shutil from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import settings, DATA_PATH -from mopidy.backends.base import (Backend, CurrentPlaylistController, - LibraryController, BaseLibraryProvider, PlaybackController, - BasePlaybackProvider, StoredPlaylistsController, - BaseStoredPlaylistsProvider) +from mopidy import core, settings, DATA_PATH +from mopidy.backends import base from mopidy.models import Playlist, Track, Album from mopidy.gstreamer import GStreamer @@ -27,12 +24,10 @@ if not DEFAULT_MUSIC_PATH or DEFAULT_MUSIC_PATH == os.path.expanduser(u'~'): DEFAULT_MUSIC_PATH = os.path.expanduser(u'~/music') -class LocalBackend(ThreadingActor, Backend): +class LocalBackend(ThreadingActor, base.Backend): """ A backend for playing music from a local music archive. - **Issues:** https://github.com/mopidy/mopidy/issues?labels=backend-local - **Dependencies:** - None @@ -47,10 +42,10 @@ class LocalBackend(ThreadingActor, Backend): def __init__(self, *args, **kwargs): super(LocalBackend, self).__init__(*args, **kwargs) - self.current_playlist = CurrentPlaylistController(backend=self) + self.current_playlist = core.CurrentPlaylistController(backend=self) library_provider = LocalLibraryProvider(backend=self) - self.library = LibraryController(backend=self, + self.library = core.LibraryController(backend=self, provider=library_provider) playback_provider = LocalPlaybackProvider(backend=self) @@ -58,7 +53,7 @@ class LocalBackend(ThreadingActor, Backend): provider=playback_provider) stored_playlists_provider = LocalStoredPlaylistsProvider(backend=self) - self.stored_playlists = StoredPlaylistsController(backend=self, + self.stored_playlists = core.StoredPlaylistsController(backend=self, provider=stored_playlists_provider) self.uri_schemes = [u'file'] @@ -72,7 +67,7 @@ class LocalBackend(ThreadingActor, Backend): self.gstreamer = gstreamer_refs[0].proxy() -class LocalPlaybackController(PlaybackController): +class LocalPlaybackController(core.PlaybackController): def __init__(self, *args, **kwargs): super(LocalPlaybackController, self).__init__(*args, **kwargs) @@ -84,7 +79,7 @@ class LocalPlaybackController(PlaybackController): return self.backend.gstreamer.get_position().get() -class LocalPlaybackProvider(BasePlaybackProvider): +class LocalPlaybackProvider(base.BasePlaybackProvider): def pause(self): return self.backend.gstreamer.pause_playback().get() @@ -109,7 +104,7 @@ class LocalPlaybackProvider(BasePlaybackProvider): self.backend.gstreamer.set_volume(volume).get() -class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): +class LocalStoredPlaylistsProvider(base.BaseStoredPlaylistsProvider): def __init__(self, *args, **kwargs): super(LocalStoredPlaylistsProvider, self).__init__(*args, **kwargs) self._folder = settings.LOCAL_PLAYLIST_PATH or DEFAULT_PLAYLIST_PATH @@ -182,7 +177,7 @@ class LocalStoredPlaylistsProvider(BaseStoredPlaylistsProvider): self._playlists.append(playlist) -class LocalLibraryProvider(BaseLibraryProvider): +class LocalLibraryProvider(base.BaseLibraryProvider): def __init__(self, *args, **kwargs): super(LocalLibraryProvider, self).__init__(*args, **kwargs) self._uri_mapping = {} diff --git a/mopidy/backends/spotify/__init__.py b/mopidy/backends/spotify/__init__.py index 56775926..fef86280 100644 --- a/mopidy/backends/spotify/__init__.py +++ b/mopidy/backends/spotify/__init__.py @@ -3,16 +3,15 @@ import logging from pykka.actor import ThreadingActor from pykka.registry import ActorRegistry -from mopidy import settings -from mopidy.backends.base import (Backend, CurrentPlaylistController, - LibraryController, PlaybackController, StoredPlaylistsController) +from mopidy import core, settings +from mopidy.backends import base from mopidy.gstreamer import GStreamer logger = logging.getLogger('mopidy.backends.spotify') BITRATES = {96: 2, 160: 0, 320: 1} -class SpotifyBackend(ThreadingActor, Backend): +class SpotifyBackend(ThreadingActor, base.Backend): """ A backend for playing music from the `Spotify `_ music streaming service. The backend uses the official `libspotify @@ -51,19 +50,19 @@ class SpotifyBackend(ThreadingActor, Backend): super(SpotifyBackend, self).__init__(*args, **kwargs) - self.current_playlist = CurrentPlaylistController(backend=self) + self.current_playlist = core.CurrentPlaylistController(backend=self) library_provider = SpotifyLibraryProvider(backend=self) - self.library = LibraryController(backend=self, + self.library = core.LibraryController(backend=self, provider=library_provider) playback_provider = SpotifyPlaybackProvider(backend=self) - self.playback = PlaybackController(backend=self, + self.playback = core.PlaybackController(backend=self, provider=playback_provider) stored_playlists_provider = SpotifyStoredPlaylistsProvider( backend=self) - self.stored_playlists = StoredPlaylistsController(backend=self, + self.stored_playlists = core.StoredPlaylistsController(backend=self, provider=stored_playlists_provider) self.uri_schemes = [u'spotify'] diff --git a/mopidy/core/__init__.py b/mopidy/core/__init__.py new file mode 100644 index 00000000..16c09665 --- /dev/null +++ b/mopidy/core/__init__.py @@ -0,0 +1,4 @@ +from .current_playlist import CurrentPlaylistController +from .library import LibraryController +from .playback import PlaybackController +from .stored_playlists import StoredPlaylistsController diff --git a/mopidy/backends/base/current_playlist.py b/mopidy/core/current_playlist.py similarity index 99% rename from mopidy/backends/base/current_playlist.py rename to mopidy/core/current_playlist.py index d7e6c331..af06e05e 100644 --- a/mopidy/backends/base/current_playlist.py +++ b/mopidy/core/current_playlist.py @@ -5,7 +5,9 @@ import random from mopidy.listeners import BackendListener from mopidy.models import CpTrack -logger = logging.getLogger('mopidy.backends.base') + +logger = logging.getLogger('mopidy.core') + class CurrentPlaylistController(object): """ diff --git a/mopidy/core/library.py b/mopidy/core/library.py new file mode 100644 index 00000000..fc55aaeb --- /dev/null +++ b/mopidy/core/library.py @@ -0,0 +1,70 @@ +class LibraryController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backends.base.Backend` + :param provider: provider the controller should use + :type provider: instance of :class:`BaseLibraryProvider` + """ + + pykka_traversable = True + + def __init__(self, backend, provider): + self.backend = backend + self.provider = provider + + def find_exact(self, **query): + """ + Search the library for tracks where ``field`` is ``values``. + + Examples:: + + # Returns results matching 'a' + find_exact(any=['a']) + # Returns results matching artist 'xyz' + find_exact(artist=['xyz']) + # Returns results matching 'a' and 'b' and artist 'xyz' + find_exact(any=['a', 'b'], artist=['xyz']) + + :param query: one or more queries to search for + :type query: dict + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.find_exact(**query) + + def lookup(self, uri): + """ + Lookup track with given URI. Returns :class:`None` if not found. + + :param uri: track URI + :type uri: string + :rtype: :class:`mopidy.models.Track` or :class:`None` + """ + return self.provider.lookup(uri) + + def refresh(self, uri=None): + """ + Refresh library. Limit to URI and below if an URI is given. + + :param uri: directory or track URI + :type uri: string + """ + return self.provider.refresh(uri) + + def search(self, **query): + """ + Search the library for tracks where ``field`` contains ``values``. + + Examples:: + + # Returns results matching 'a' + search(any=['a']) + # Returns results matching artist 'xyz' + search(artist=['xyz']) + # Returns results matching 'a' and 'b' and artist 'xyz' + search(any=['a', 'b'], artist=['xyz']) + + :param query: one or more queries to search for + :type query: dict + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.search(**query) diff --git a/mopidy/core/playback.py b/mopidy/core/playback.py new file mode 100644 index 00000000..a0c3ef30 --- /dev/null +++ b/mopidy/core/playback.py @@ -0,0 +1,548 @@ +import logging +import random +import time + +from mopidy.listeners import BackendListener + + +logger = logging.getLogger('mopidy.backends.base') + + +def option_wrapper(name, default): + def get_option(self): + return getattr(self, name, default) + + def set_option(self, value): + if getattr(self, name, default) != value: + self._trigger_options_changed() + return setattr(self, name, value) + + return property(get_option, set_option) + + +class PlaybackController(object): + """ + :param backend: the backend + :type backend: :class:`mopidy.backends.base.Backend` + :param provider: provider the controller should use + :type provider: instance of :class:`BasePlaybackProvider` + """ + + # pylint: disable = R0902 + # Too many instance attributes + + pykka_traversable = True + + #: Constant representing the paused state. + PAUSED = u'paused' + + #: Constant representing the playing state. + PLAYING = u'playing' + + #: Constant representing the stopped state. + STOPPED = u'stopped' + + #: :class:`True` + #: Tracks are removed from the playlist when they have been played. + #: :class:`False` + #: Tracks are not removed from the playlist. + consume = option_wrapper('_consume', False) + + #: The currently playing or selected track. + #: + #: A two-tuple of (CPID integer, :class:`mopidy.models.Track`) or + #: :class:`None`. + current_cp_track = None + + #: :class:`True` + #: Tracks are selected at random from the playlist. + #: :class:`False` + #: Tracks are played in the order of the playlist. + random = option_wrapper('_random', False) + + #: :class:`True` + #: The current playlist is played repeatedly. To repeat a single track, + #: select both :attr:`repeat` and :attr:`single`. + #: :class:`False` + #: The current playlist is played once. + repeat = option_wrapper('_repeat', False) + + #: :class:`True` + #: Playback is stopped after current song, unless in :attr:`repeat` + #: mode. + #: :class:`False` + #: Playback continues after current song. + single = option_wrapper('_single', False) + + def __init__(self, backend, provider): + self.backend = backend + self.provider = provider + self._state = self.STOPPED + self._shuffled = [] + self._first_shuffle = True + self.play_time_accumulated = 0 + self.play_time_started = None + + def _get_cpid(self, cp_track): + if cp_track is None: + return None + return cp_track.cpid + + def _get_track(self, cp_track): + if cp_track is None: + return None + return cp_track.track + + @property + def current_cpid(self): + """ + The CPID (current playlist ID) of the currently playing or selected + track. + + Read-only. Extracted from :attr:`current_cp_track` for convenience. + """ + return self._get_cpid(self.current_cp_track) + + @property + def current_track(self): + """ + The currently playing or selected :class:`mopidy.models.Track`. + + Read-only. Extracted from :attr:`current_cp_track` for convenience. + """ + return self._get_track(self.current_cp_track) + + @property + def current_playlist_position(self): + """ + The position of the current track in the current playlist. + + Read-only. + """ + if self.current_cp_track is None: + return None + try: + return self.backend.current_playlist.cp_tracks.index( + self.current_cp_track) + except ValueError: + return None + + @property + def track_at_eot(self): + """ + The track that will be played at the end of the current track. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_eot` for convenience. + """ + return self._get_track(self.cp_track_at_eot) + + @property + def cp_track_at_eot(self): + """ + The track that will be played at the end of the current track. + + Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + + Not necessarily the same track as :attr:`cp_track_at_next`. + """ + # pylint: disable = R0911 + # Too many return statements + + cp_tracks = self.backend.current_playlist.cp_tracks + + if not cp_tracks: + return None + + if self.random and not self._shuffled: + if self.repeat or self._first_shuffle: + logger.debug('Shuffling tracks') + self._shuffled = cp_tracks + random.shuffle(self._shuffled) + self._first_shuffle = False + + if self.random and self._shuffled: + return self._shuffled[0] + + if self.current_cp_track is None: + return cp_tracks[0] + + if self.repeat and self.single: + return cp_tracks[self.current_playlist_position] + + if self.repeat and not self.single: + return cp_tracks[ + (self.current_playlist_position + 1) % len(cp_tracks)] + + try: + return cp_tracks[self.current_playlist_position + 1] + except IndexError: + return None + + @property + def track_at_next(self): + """ + The track that will be played if calling :meth:`next()`. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_next` for convenience. + """ + return self._get_track(self.cp_track_at_next) + + @property + def cp_track_at_next(self): + """ + The track that will be played if calling :meth:`next()`. + + Read-only. A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + + For normal playback this is the next track in the playlist. If repeat + is enabled the next track can loop around the playlist. When random is + enabled this should be a random track, all tracks should be played once + before the list repeats. + """ + cp_tracks = self.backend.current_playlist.cp_tracks + + if not cp_tracks: + return None + + if self.random and not self._shuffled: + if self.repeat or self._first_shuffle: + logger.debug('Shuffling tracks') + self._shuffled = cp_tracks + random.shuffle(self._shuffled) + self._first_shuffle = False + + if self.random and self._shuffled: + return self._shuffled[0] + + if self.current_cp_track is None: + return cp_tracks[0] + + if self.repeat: + return cp_tracks[ + (self.current_playlist_position + 1) % len(cp_tracks)] + + try: + return cp_tracks[self.current_playlist_position + 1] + except IndexError: + return None + + @property + def track_at_previous(self): + """ + The track that will be played if calling :meth:`previous()`. + + Read-only. A :class:`mopidy.models.Track` extracted from + :attr:`cp_track_at_previous` for convenience. + """ + return self._get_track(self.cp_track_at_previous) + + @property + def cp_track_at_previous(self): + """ + The track that will be played if calling :meth:`previous()`. + + A two-tuple of (CPID integer, :class:`mopidy.models.Track`). + + For normal playback this is the previous track in the playlist. If + random and/or consume is enabled it should return the current track + instead. + """ + if self.repeat or self.consume or self.random: + return self.current_cp_track + + if self.current_playlist_position in (None, 0): + return None + + return self.backend.current_playlist.cp_tracks[ + self.current_playlist_position - 1] + + @property + def state(self): + """ + The playback state. Must be :attr:`PLAYING`, :attr:`PAUSED`, or + :attr:`STOPPED`. + + Possible states and transitions: + + .. digraph:: state_transitions + + "STOPPED" -> "PLAYING" [ label="play" ] + "STOPPED" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "STOPPED" [ label="stop" ] + "PLAYING" -> "PAUSED" [ label="pause" ] + "PLAYING" -> "PLAYING" [ label="play" ] + "PAUSED" -> "PLAYING" [ label="resume" ] + "PAUSED" -> "STOPPED" [ label="stop" ] + """ + return self._state + + @state.setter + def state(self, new_state): + (old_state, self._state) = (self.state, new_state) + logger.debug(u'Changing state: %s -> %s', old_state, new_state) + + self._trigger_playback_state_changed() + + # FIXME play_time stuff assumes backend does not have a better way of + # handeling this stuff :/ + if (old_state in (self.PLAYING, self.STOPPED) + and new_state == self.PLAYING): + self._play_time_start() + elif old_state == self.PLAYING and new_state == self.PAUSED: + self._play_time_pause() + elif old_state == self.PAUSED and new_state == self.PLAYING: + self._play_time_resume() + + @property + def time_position(self): + """Time position in milliseconds.""" + if self.state == self.PLAYING: + time_since_started = (self._current_wall_time - + self.play_time_started) + return self.play_time_accumulated + time_since_started + elif self.state == self.PAUSED: + return self.play_time_accumulated + elif self.state == self.STOPPED: + return 0 + + def _play_time_start(self): + self.play_time_accumulated = 0 + self.play_time_started = self._current_wall_time + + def _play_time_pause(self): + time_since_started = self._current_wall_time - self.play_time_started + self.play_time_accumulated += time_since_started + + def _play_time_resume(self): + self.play_time_started = self._current_wall_time + + @property + def _current_wall_time(self): + return int(time.time() * 1000) + + @property + def volume(self): + return self.provider.get_volume() + + @volume.setter + def volume(self, volume): + self.provider.set_volume(volume) + + def change_track(self, cp_track, on_error_step=1): + """ + Change to the given track, keeping the current playback state. + + :param cp_track: track to change to + :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) + or :class:`None` + :param on_error_step: direction to step at play error, 1 for next + track (default), -1 for previous track + :type on_error_step: int, -1 or 1 + + """ + old_state = self.state + self.stop() + self.current_cp_track = cp_track + if old_state == self.PLAYING: + self.play(on_error_step=on_error_step) + elif old_state == self.PAUSED: + self.pause() + + def on_end_of_track(self): + """ + Tell the playback controller that end of track is reached. + """ + if self.state == self.STOPPED: + return + + original_cp_track = self.current_cp_track + + if self.cp_track_at_eot: + self._trigger_track_playback_ended() + self.play(self.cp_track_at_eot) + else: + self.stop(clear_current_track=True) + + if self.consume: + self.backend.current_playlist.remove(cpid=original_cp_track.cpid) + + def on_current_playlist_change(self): + """ + Tell the playback controller that the current playlist has changed. + + Used by :class:`mopidy.backends.base.CurrentPlaylistController`. + """ + self._first_shuffle = True + self._shuffled = [] + + if (not self.backend.current_playlist.cp_tracks or + self.current_cp_track not in + self.backend.current_playlist.cp_tracks): + self.stop(clear_current_track=True) + + def next(self): + """ + Change to the next track. + + The current playback state will be kept. If it was playing, playing + will continue. If it was paused, it will still be paused, etc. + """ + if self.cp_track_at_next: + self._trigger_track_playback_ended() + self.change_track(self.cp_track_at_next) + else: + self.stop(clear_current_track=True) + + def pause(self): + """Pause playback.""" + if self.provider.pause(): + self.state = self.PAUSED + self._trigger_track_playback_paused() + + def play(self, cp_track=None, on_error_step=1): + """ + Play the given track, or if the given track is :class:`None`, play the + currently active track. + + :param cp_track: track to play + :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) + or :class:`None` + :param on_error_step: direction to step at play error, 1 for next + track (default), -1 for previous track + :type on_error_step: int, -1 or 1 + """ + + if cp_track is not None: + assert cp_track in self.backend.current_playlist.cp_tracks + elif cp_track is None: + if self.state == self.PAUSED: + return self.resume() + elif self.current_cp_track is not None: + cp_track = self.current_cp_track + elif self.current_cp_track is None and on_error_step == 1: + cp_track = self.cp_track_at_next + elif self.current_cp_track is None and on_error_step == -1: + cp_track = self.cp_track_at_previous + + if cp_track is not None: + self.current_cp_track = cp_track + self.state = self.PLAYING + if not self.provider.play(cp_track.track): + # Track is not playable + if self.random and self._shuffled: + self._shuffled.remove(cp_track) + if on_error_step == 1: + self.next() + elif on_error_step == -1: + self.previous() + + if self.random and self.current_cp_track in self._shuffled: + self._shuffled.remove(self.current_cp_track) + + self._trigger_track_playback_started() + + def previous(self): + """ + Change to the previous track. + + The current playback state will be kept. If it was playing, playing + will continue. If it was paused, it will still be paused, etc. + """ + self._trigger_track_playback_ended() + self.change_track(self.cp_track_at_previous, on_error_step=-1) + + def resume(self): + """If paused, resume playing the current track.""" + if self.state == self.PAUSED and self.provider.resume(): + self.state = self.PLAYING + self._trigger_track_playback_resumed() + + def seek(self, time_position): + """ + Seeks to time position given in milliseconds. + + :param time_position: time position in milliseconds + :type time_position: int + :rtype: :class:`True` if successful, else :class:`False` + """ + if not self.backend.current_playlist.tracks: + return False + + if self.state == self.STOPPED: + self.play() + elif self.state == self.PAUSED: + self.resume() + + if time_position < 0: + time_position = 0 + elif time_position > self.current_track.length: + self.next() + return True + + self.play_time_started = self._current_wall_time + self.play_time_accumulated = time_position + + success = self.provider.seek(time_position) + if success: + self._trigger_seeked() + return success + + def stop(self, clear_current_track=False): + """ + Stop playing. + + :param clear_current_track: whether to clear the current track _after_ + stopping + :type clear_current_track: boolean + """ + if self.state != self.STOPPED: + if self.provider.stop(): + self._trigger_track_playback_ended() + self.state = self.STOPPED + if clear_current_track: + self.current_cp_track = None + + def _trigger_track_playback_paused(self): + logger.debug(u'Triggering track playback paused event') + if self.current_track is None: + return + BackendListener.send('track_playback_paused', + track=self.current_track, + time_position=self.time_position) + + def _trigger_track_playback_resumed(self): + logger.debug(u'Triggering track playback resumed event') + if self.current_track is None: + return + BackendListener.send('track_playback_resumed', + track=self.current_track, + time_position=self.time_position) + + def _trigger_track_playback_started(self): + logger.debug(u'Triggering track playback started event') + if self.current_track is None: + return + BackendListener.send('track_playback_started', + track=self.current_track) + + def _trigger_track_playback_ended(self): + logger.debug(u'Triggering track playback ended event') + if self.current_track is None: + return + BackendListener.send('track_playback_ended', + track=self.current_track, + time_position=self.time_position) + + def _trigger_playback_state_changed(self): + logger.debug(u'Triggering playback state change event') + BackendListener.send('playback_state_changed') + + def _trigger_options_changed(self): + logger.debug(u'Triggering options changed event') + BackendListener.send('options_changed') + + def _trigger_seeked(self): + logger.debug(u'Triggering seeked event') + BackendListener.send('seeked') diff --git a/mopidy/core/stored_playlists.py b/mopidy/core/stored_playlists.py new file mode 100644 index 00000000..a29e34fc --- /dev/null +++ b/mopidy/core/stored_playlists.py @@ -0,0 +1,113 @@ +class StoredPlaylistsController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`mopidy.backends.base.Backend` + :param provider: provider the controller should use + :type provider: instance of :class:`BaseStoredPlaylistsProvider` + """ + + pykka_traversable = True + + def __init__(self, backend, provider): + self.backend = backend + self.provider = provider + + @property + def playlists(self): + """ + Currently stored playlists. + + Read/write. List of :class:`mopidy.models.Playlist`. + """ + return self.provider.playlists + + @playlists.setter + def playlists(self, playlists): + self.provider.playlists = playlists + + def create(self, name): + """ + Create a new playlist. + + :param name: name of the new playlist + :type name: string + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.create(name) + + def delete(self, playlist): + """ + Delete playlist. + + :param playlist: the playlist to delete + :type playlist: :class:`mopidy.models.Playlist` + """ + return self.provider.delete(playlist) + + def get(self, **criteria): + """ + Get playlist by given criterias from the set of stored playlists. + + Raises :exc:`LookupError` if a unique match is not found. + + Examples:: + + get(name='a') # Returns track with name 'a' + get(uri='xyz') # Returns track with URI 'xyz' + get(name='a', uri='xyz') # Returns track with name 'a' and URI + # 'xyz' + + :param criteria: one or more criteria to match by + :type criteria: dict + :rtype: :class:`mopidy.models.Playlist` + """ + matches = self.playlists + for (key, value) in criteria.iteritems(): + matches = filter(lambda p: getattr(p, key) == value, matches) + if len(matches) == 1: + return matches[0] + criteria_string = ', '.join( + ['%s=%s' % (k, v) for (k, v) in criteria.iteritems()]) + if len(matches) == 0: + raise LookupError('"%s" match no playlists' % criteria_string) + else: + raise LookupError('"%s" match multiple playlists' + % criteria_string) + + def lookup(self, uri): + """ + Lookup playlist with given URI in both the set of stored playlists and + in any other playlist sources. + + :param uri: playlist URI + :type uri: string + :rtype: :class:`mopidy.models.Playlist` + """ + return self.provider.lookup(uri) + + def refresh(self): + """ + Refresh the stored playlists in + :attr:`mopidy.backends.base.StoredPlaylistsController.playlists`. + """ + return self.provider.refresh() + + def rename(self, playlist, new_name): + """ + Rename playlist. + + :param playlist: the playlist + :type playlist: :class:`mopidy.models.Playlist` + :param new_name: the new name + :type new_name: string + """ + return self.provider.rename(playlist, new_name) + + def save(self, playlist): + """ + Save the playlist to the set of stored playlists. + + :param playlist: the playlist + :type playlist: :class:`mopidy.models.Playlist` + """ + return self.provider.save(playlist) diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py index 4cf33266..e6bb6478 100644 --- a/mopidy/frontends/mpd/protocol/playback.py +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -1,4 +1,4 @@ -from mopidy.backends.base import PlaybackController +from mopidy import core from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.exceptions import (MpdArgError, MpdNoExistError, MpdNotImplemented) @@ -105,10 +105,10 @@ def pause(context, state=None): """ if state is None: if (context.backend.playback.state.get() == - PlaybackController.PLAYING): + core.PlaybackController.PLAYING): context.backend.playback.pause() elif (context.backend.playback.state.get() == - PlaybackController.PAUSED): + core.PlaybackController.PAUSED): context.backend.playback.resume() elif int(state): context.backend.playback.pause() @@ -185,9 +185,11 @@ def playpos(context, songpos): raise MpdArgError(u'Bad song index', command=u'play') def _play_minus_one(context): - if (context.backend.playback.state.get() == PlaybackController.PLAYING): + if (context.backend.playback.state.get() == + core.PlaybackController.PLAYING): return # Nothing to do - elif (context.backend.playback.state.get() == PlaybackController.PAUSED): + elif (context.backend.playback.state.get() == + core.PlaybackController.PAUSED): return context.backend.playback.resume().get() elif context.backend.playback.current_cp_track.get() is not None: cp_track = context.backend.playback.current_cp_track.get() diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py index 4a9ad9a1..279978aa 100644 --- a/mopidy/frontends/mpd/protocol/status.py +++ b/mopidy/frontends/mpd/protocol/status.py @@ -1,6 +1,6 @@ import pykka.future -from mopidy.backends.base import PlaybackController +from mopidy import core from mopidy.frontends.mpd.exceptions import MpdNotImplemented from mopidy.frontends.mpd.protocol import handle_request from mopidy.frontends.mpd.translator import track_to_mpd_format @@ -194,8 +194,8 @@ def status(context): if futures['playback.current_cp_track'].get() is not None: result.append(('song', _status_songpos(futures))) result.append(('songid', _status_songid(futures))) - if futures['playback.state'].get() in (PlaybackController.PLAYING, - PlaybackController.PAUSED): + if futures['playback.state'].get() in (core.PlaybackController.PLAYING, + core.PlaybackController.PAUSED): result.append(('time', _status_time(futures))) result.append(('elapsed', _status_time_elapsed(futures))) result.append(('bitrate', _status_bitrate(futures))) @@ -239,11 +239,11 @@ def _status_songpos(futures): def _status_state(futures): state = futures['playback.state'].get() - if state == PlaybackController.PLAYING: + if state == core.PlaybackController.PLAYING: return u'play' - elif state == PlaybackController.STOPPED: + elif state == core.PlaybackController.STOPPED: return u'stop' - elif state == PlaybackController.PAUSED: + elif state == core.PlaybackController.PAUSED: return u'pause' def _status_time(futures): diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index 6815c0d2..bcd3de5c 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -14,9 +14,8 @@ except ImportError as import_error: from pykka.registry import ActorRegistry -from mopidy import settings +from mopidy import core, settings from mopidy.backends.base import Backend -from mopidy.backends.base.playback import PlaybackController from mopidy.utils.process import exit_process # Must be done before dbus.SessionBus() is called @@ -198,11 +197,11 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.PlayPause not allowed', PLAYER_IFACE) return state = self.backend.playback.state.get() - if state == PlaybackController.PLAYING: + if state == core.PlaybackController.PLAYING: self.backend.playback.pause().get() - elif state == PlaybackController.PAUSED: + elif state == core.PlaybackController.PAUSED: self.backend.playback.resume().get() - elif state == PlaybackController.STOPPED: + elif state == core.PlaybackController.STOPPED: self.backend.playback.play().get() @dbus.service.method(dbus_interface=PLAYER_IFACE) @@ -220,7 +219,7 @@ class MprisObject(dbus.service.Object): logger.debug(u'%s.Play not allowed', PLAYER_IFACE) return state = self.backend.playback.state.get() - if state == PlaybackController.PAUSED: + if state == core.PlaybackController.PAUSED: self.backend.playback.resume().get() else: self.backend.playback.play().get() @@ -287,11 +286,11 @@ class MprisObject(dbus.service.Object): def get_PlaybackStatus(self): state = self.backend.playback.state.get() - if state == PlaybackController.PLAYING: + if state == core.PlaybackController.PLAYING: return 'Playing' - elif state == PlaybackController.PAUSED: + elif state == core.PlaybackController.PAUSED: return 'Paused' - elif state == PlaybackController.STOPPED: + elif state == core.PlaybackController.STOPPED: return 'Stopped' def get_LoopStatus(self): diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/frontends/mpd/protocol/playback_test.py b/tests/frontends/mpd/protocol/playback_test.py index 87c9bbb8..514c1599 100644 --- a/tests/frontends/mpd/protocol/playback_test.py +++ b/tests/frontends/mpd/protocol/playback_test.py @@ -1,12 +1,12 @@ -from mopidy.backends import base as backend +from mopidy import core from mopidy.models import Track from tests import unittest from tests.frontends.mpd import protocol -PAUSED = backend.PlaybackController.PAUSED -PLAYING = backend.PlaybackController.PLAYING -STOPPED = backend.PlaybackController.STOPPED +PAUSED = core.PlaybackController.PAUSED +PLAYING = core.PlaybackController.PLAYING +STOPPED = core.PlaybackController.STOPPED class PlaybackOptionsHandlerTest(protocol.BaseTestCase): diff --git a/tests/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 3701faaf..8fd8895d 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,13 +1,14 @@ -from mopidy.backends import dummy as backend +from mopidy import core +from mopidy.backends import dummy from mopidy.frontends.mpd import dispatcher from mopidy.frontends.mpd.protocol import status from mopidy.models import Track from tests import unittest -PAUSED = backend.PlaybackController.PAUSED -PLAYING = backend.PlaybackController.PLAYING -STOPPED = backend.PlaybackController.STOPPED +PAUSED = core.PlaybackController.PAUSED +PLAYING = core.PlaybackController.PLAYING +STOPPED = core.PlaybackController.STOPPED # FIXME migrate to using protocol.BaseTestCase instead of status.stats # directly? @@ -15,7 +16,7 @@ STOPPED = backend.PlaybackController.STOPPED class StatusHandlerTest(unittest.TestCase): def setUp(self): - self.backend = backend.DummyBackend.start().proxy() + self.backend = dummy.DummyBackend.start().proxy() self.dispatcher = dispatcher.MpdDispatcher() self.context = self.dispatcher.context diff --git a/tests/frontends/mpris/player_interface_test.py b/tests/frontends/mpris/player_interface_test.py index b7ad1b60..48be504f 100644 --- a/tests/frontends/mpris/player_interface_test.py +++ b/tests/frontends/mpris/player_interface_test.py @@ -2,9 +2,8 @@ import sys import mock -from mopidy import OptionalDependencyError +from mopidy import core, OptionalDependencyError from mopidy.backends.dummy import DummyBackend -from mopidy.backends.base.playback import PlaybackController from mopidy.models import Album, Artist, Track try: @@ -14,9 +13,9 @@ except OptionalDependencyError: from tests import unittest -PLAYING = PlaybackController.PLAYING -PAUSED = PlaybackController.PAUSED -STOPPED = PlaybackController.STOPPED +PLAYING = core.PlaybackController.PLAYING +PAUSED = core.PlaybackController.PAUSED +STOPPED = core.PlaybackController.STOPPED @unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux')