diff --git a/docs/api/backends.rst b/docs/api/backends.rst index c59b5a30..6760fc54 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -2,6 +2,10 @@ :mod:`mopidy.backends` ********************** +.. automodule:: mopidy.backends + :synopsis: Backend API + + The backend and its controllers =============================== @@ -16,20 +20,17 @@ The backend and its controllers Backend API =========== -.. automodule:: mopidy.backends - :synopsis: Backend interface. - .. note:: Currently this only documents the API that is available for use by - frontends like :class:`mopidy.mpd.handler`, and not what is required to - implement your own backend. :class:`mopidy.backends.BaseBackend` and its - controllers implements many of these methods in a matter that should be + frontends like :mod:`mopidy.frontends.mpd`, and not what is required to + implement your own backend. :class:`mopidy.backends.base.BaseBackend` and + its controllers implements many of these methods in a matter that should be independent of most concrete backend implementations, so you should generally just implement or override a few of these methods yourself to create a new backend with a complete feature set. -.. autoclass:: mopidy.backends.BaseBackend +.. autoclass:: mopidy.backends.base.BaseBackend :members: :undoc-members: @@ -40,7 +41,7 @@ Playback controller Manages playback, with actions like play, pause, stop, next, previous, and seek. -.. autoclass:: mopidy.backends.BasePlaybackController +.. autoclass:: mopidy.backends.base.BasePlaybackController :members: :undoc-members: @@ -56,7 +57,7 @@ Current playlist controller Manages everything related to the currently loaded playlist. -.. autoclass:: mopidy.backends.BaseCurrentPlaylistController +.. autoclass:: mopidy.backends.base.BaseCurrentPlaylistController :members: :undoc-members: @@ -66,7 +67,7 @@ Stored playlists controller Manages stored playlist. -.. autoclass:: mopidy.backends.BaseStoredPlaylistsController +.. autoclass:: mopidy.backends.base.BaseStoredPlaylistsController :members: :undoc-members: @@ -76,45 +77,39 @@ Library controller Manages the music library, e.g. searching for tracks to be added to a playlist. -.. autoclass:: mopidy.backends.BaseLibraryController +.. autoclass:: mopidy.backends.base.BaseLibraryController :members: :undoc-members: -Spotify backends -================ - :mod:`mopidy.backends.despotify` -- Despotify backend ------------------------------------------------------ +===================================================== .. automodule:: mopidy.backends.despotify - :synopsis: Spotify backend using the despotify library. + :synopsis: Spotify backend using the Despotify library :members: -:mod:`mopidy.backends.libspotify` -- Libspotify backend -------------------------------------------------------- - -.. automodule:: mopidy.backends.libspotify - :synopsis: Spotify backend using the libspotify library. - :members: - - -Other backends -============== - -:mod:`mopidy.backends.dummy` -- Dummy backend ---------------------------------------------- +:mod:`mopidy.backends.dummy` -- Dummy backend for testing +========================================================= .. automodule:: mopidy.backends.dummy - :synopsis: Dummy backend used for testing. + :synopsis: Dummy backend used for testing :members: :mod:`mopidy.backends.gstreamer` -- GStreamer backend ------------------------------------------------------ +===================================================== .. automodule:: mopidy.backends.gstreamer :synopsis: Backend for playing music from a local music archive using the - GStreamer library. + GStreamer library + :members: + + +:mod:`mopidy.backends.libspotify` -- Libspotify backend +======================================================= + +.. automodule:: mopidy.backends.libspotify + :synopsis: Spotify backend using the libspotify library :members: diff --git a/docs/api/mixers.rst b/docs/api/mixers.rst index 26f4dc40..70ac450a 100644 --- a/docs/api/mixers.rst +++ b/docs/api/mixers.rst @@ -13,6 +13,17 @@ simply instantiate a mixer and read/write to the ``volume`` attribute:: >>> mixer.volume 80 +Most users will use one of the internal mixers which controls the volume on the +computer running Mopidy. If you do not specify which mixer you want to use in +the settings, Mopidy will choose one for you based upon what OS you run. See +:attr:`mopidy.settings.MIXER` for the defaults. + +Mopidy also supports controlling volume on other hardware devices instead of on +the computer running Mopidy through the use of custom mixer implementations. To +enable one of the hardware device mixers, you must the set +:attr:`mopidy.settings.MIXER` setting to point to one of the classes found +below, and possibly add some extra settings required by the mixer you choose. + Mixer API ========= @@ -21,76 +32,56 @@ All mixers should subclass :class:`mopidy.mixers.BaseMixer` and override methods as described below. .. automodule:: mopidy.mixers - :synopsis: Sound mixer interface. + :synopsis: Mixer API :members: :undoc-members: -Internal mixers -=============== - -Most users will use one of these internal mixers which controls the volume on -the computer running Mopidy. If you do not specify which mixer you want to use -in the settings, Mopidy will choose one for you based upon what OS you run. See -:attr:`mopidy.settings.MIXER` for the defaults. - - -:mod:`mopidy.mixers.alsa` -- ALSA mixer ---------------------------------------- +:mod:`mopidy.mixers.alsa` -- ALSA mixer for Linux +================================================= .. automodule:: mopidy.mixers.alsa - :synopsis: ALSA mixer for Linux. + :synopsis: ALSA mixer for Linux :members: .. inheritance-diagram:: mopidy.mixers.alsa.AlsaMixer -:mod:`mopidy.mixers.dummy` -- Dummy mixer ------------------------------------------ - -.. automodule:: mopidy.mixers.dummy - :synopsis: Dummy mixer for testing. - :members: - -.. inheritance-diagram:: mopidy.mixers.dummy - - -:mod:`mopidy.mixers.osa` -- Osa mixer -------------------------------------- - -.. automodule:: mopidy.mixers.osa - :synopsis: Osa mixer for OS X. - :members: - -.. inheritance-diagram:: mopidy.mixers.osa - - -External device mixers -====================== - -Mopidy supports controlling volume on external devices instead of on the -computer running Mopidy through the use of custom mixer implementations. To -enable one of the following mixers, you must the set -:attr:`mopidy.settings.MIXER` setting to point to one of the classes -found below, and possibly add some extra settings required by the mixer you -choose. - - -:mod:`mopidy.mixers.denon` -- Denon amplifier mixer ---------------------------------------------------- +:mod:`mopidy.mixers.denon` -- Hardware mixer for Denon amplifiers +================================================================= .. automodule:: mopidy.mixers.denon - :synopsis: Denon amplifier mixer. + :synopsis: Hardware mixer for Denon amplifiers :members: .. inheritance-diagram:: mopidy.mixers.denon -:mod:`mopidy.mixers.nad` -- NAD amplifier mixer ------------------------------------------------ +:mod:`mopidy.mixers.dummy` -- Dummy mixer for testing +===================================================== + +.. automodule:: mopidy.mixers.dummy + :synopsis: Dummy mixer for testing + :members: + +.. inheritance-diagram:: mopidy.mixers.dummy + + +:mod:`mopidy.mixers.osa` -- Osa mixer for OS X +============================================== + +.. automodule:: mopidy.mixers.osa + :synopsis: Osa mixer for OS X + :members: + +.. inheritance-diagram:: mopidy.mixers.osa + + +:mod:`mopidy.mixers.nad` -- Hardware mixer for NAD amplifiers +============================================================= .. automodule:: mopidy.mixers.nad - :synopsis: NAD amplifier mixer. + :synopsis: Hardware mixer for NAD amplifiers :members: .. inheritance-diagram:: mopidy.mixers.nad diff --git a/docs/api/models.rst b/docs/api/models.rst index 8be375ef..62e6f75a 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -23,6 +23,6 @@ Data model API ============== .. automodule:: mopidy.models - :synopsis: Immutable data models. + :synopsis: Data model API :members: :undoc-members: diff --git a/docs/api/mpd.rst b/docs/api/mpd.rst index 021f5dcd..7bf7fe7b 100644 --- a/docs/api/mpd.rst +++ b/docs/api/mpd.rst @@ -1,22 +1,103 @@ -***************** -:mod:`mopidy.mpd` -***************** +*************************** +:mod:`mopidy.frontends.mpd` +*************************** -MPD protocol implementation -=========================== +.. automodule:: mopidy.frontends.mpd + :synopsis: MPD frontend -.. automodule:: mopidy.mpd.frontend - :synopsis: Our MPD protocol implementation. + +MPD server +========== + +.. automodule:: mopidy.frontends.mpd.server + :synopsis: MPD server + :members: + :undoc-members: + +.. inheritance-diagram:: mopidy.frontends.mpd.server + + +MPD frontend +============ + +.. automodule:: mopidy.frontends.mpd.frontend + :synopsis: MPD request dispatcher :members: :undoc-members: -MPD server implementation -========================= +MPD protocol +============ -.. automodule:: mopidy.mpd.server - :synopsis: Our MPD server implementation. +.. automodule:: mopidy.frontends.mpd.protocol + :synopsis: MPD protocol :members: - :undoc-members: -.. inheritance-diagram:: mopidy.mpd.server + +Audio output +------------ + +.. automodule:: mopidy.frontends.mpd.protocol.audio_output + :members: + + +Command list +------------ + +.. automodule:: mopidy.frontends.mpd.protocol.command_list + :members: + + +Connection +---------- + +.. automodule:: mopidy.frontends.mpd.protocol.connection + :members: + + +Current playlist +---------------- + +.. automodule:: mopidy.frontends.mpd.protocol.current_playlist + :members: + +Music database +-------------- + +.. automodule:: mopidy.frontends.mpd.protocol.music_db + :members: + + +Playback +-------- + +.. automodule:: mopidy.frontends.mpd.protocol.playback + :members: + + +Reflection +---------- + +.. automodule:: mopidy.frontends.mpd.protocol.reflection + :members: + + +Status +------ + +.. automodule:: mopidy.frontends.mpd.protocol.status + :members: + + +Stickers +-------- + +.. automodule:: mopidy.frontends.mpd.protocol.stickers + :members: + + +Stored playlists +---------------- + +.. automodule:: mopidy.frontends.mpd.protocol.stored_playlists + :members: diff --git a/docs/api/settings.rst b/docs/api/settings.rst index ac071f23..a8ff6446 100644 --- a/docs/api/settings.rst +++ b/docs/api/settings.rst @@ -22,6 +22,6 @@ Available settings ================== .. automodule:: mopidy.settings - :synopsis: Available settings and their default values. + :synopsis: Available settings and their default values :members: :undoc-members: diff --git a/docs/changes.rst b/docs/changes.rst index 724d75bb..2e765176 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -17,16 +17,20 @@ Another great release. the packages created by ``setup.py`` for i.e. PyPI. - MPD frontend: + - Relocate from :mod:`mopidy.mpd` to :mod:`mopidy.frontends.mpd`. + - Split gigantic protocol implementation into eleven modules. - Search improvements, including support for multi-word search. + - Fixed ``play "-1"`` and ``playid "-1"`` behaviour when playlist is empty. - Backend API: + - Relocate from :mod:`mopidy.backends` to :mod:`mopidy.backends.base`. - The ``id`` field of :class:`mopidy.models.Track` has been removed, as it is no longer needed after the CPID refactoring. - - :meth:`mopidy.backends.BaseLibraryController.find_exact()` now accepts + - :meth:`mopidy.backends.base.BaseLibraryController.find_exact()` now accepts keyword arguments of the form ``find_exact(artist=['foo'], album=['bar'])``. - - :meth:`mopidy.backends.BaseLibraryController.search()` now accepts + - :meth:`mopidy.backends.base.BaseLibraryController.search()` now accepts keyword arguments of the form ``search(artist=['foo', 'fighters'], album=['bar', 'grooves'])``. diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 13ce67c8..09d72b26 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -11,8 +11,9 @@ def get_mpd_protocol_version(): return u'0.16.0' class MopidyException(Exception): - def __init__(self, message): - self.message = message + def __init__(self, message, *args, **kwargs): + super(MopidyException, self).__init__(message, *args, **kwargs) + self._message = message @property def message(self): diff --git a/mopidy/backends/__init__.py b/mopidy/backends/__init__.py index e9f6b4c5..e69de29b 100644 --- a/mopidy/backends/__init__.py +++ b/mopidy/backends/__init__.py @@ -1,837 +0,0 @@ -from copy import copy -import logging -import random -import time - -from mopidy import settings -from mopidy.models import Playlist -from mopidy.mpd import serializer -from mopidy.utils import get_class - -logger = logging.getLogger('mopidy.backends.base') - -__all__ = ['BaseBackend', 'BasePlaybackController', - 'BaseCurrentPlaylistController', 'BaseStoredPlaylistsController', - 'BaseLibraryController'] - -class BaseBackend(object): - """ - :param core_queue: a queue for sending messages to - :class:`mopidy.process.CoreProcess` - :type core_queue: :class:`multiprocessing.Queue` - :param mixer: either a mixer instance, or :class:`None` to use the mixer - defined in settings - :type mixer: :class:`mopidy.mixers.BaseMixer` or :class:`None` - """ - - def __init__(self, core_queue=None, mixer=None): - self.core_queue = core_queue - if mixer is not None: - self.mixer = mixer - else: - self.mixer = get_class(settings.MIXER)() - - #: A :class:`multiprocessing.Queue` which can be used by e.g. library - #: callbacks executing in other threads to send messages to the core - #: thread, so that action may be taken in the correct thread. - core_queue = None - - #: The current playlist controller. An instance of - #: :class:`BaseCurrentPlaylistController`. - current_playlist = None - - #: The library controller. An instance of :class:`BaseLibraryController`. - library = None - - #: The sound mixer. An instance of :class:`mopidy.mixers.BaseMixer`. - mixer = None - - #: The playback controller. An instance of :class:`BasePlaybackController`. - playback = None - - #: The stored playlists controller. An instance of - #: :class:`BaseStoredPlaylistsController`. - stored_playlists = None - - #: List of URI prefixes this backend can handle. - uri_handlers = [] - - def destroy(self): - """ - Call destroy on all sub-components in backend so that they can cleanup - after themselves. - """ - - if self.current_playlist: - self.current_playlist.destroy() - - if self.library: - self.library.destroy() - - if self.mixer: - self.mixer.destroy() - - if self.playback: - self.playback.destroy() - - if self.stored_playlists: - self.stored_playlists.destroy() - -class BaseCurrentPlaylistController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`BaseBackend` - """ - - #: The current playlist version. Integer which is increased every time the - #: current playlist is changed. Is not reset before Mopidy is restarted. - version = 0 - - def __init__(self, backend): - self.backend = backend - self._cp_tracks = [] - - def destroy(self): - """Cleanup after component.""" - pass - - @property - def cp_tracks(self): - """ - List of two-tuples of (CPID integer, :class:`mopidy.models.Track`). - - Read-only. - """ - return [copy(ct) for ct in self._cp_tracks] - - @property - def tracks(self): - """ - List of :class:`mopidy.models.Track` in the current playlist. - - Read-only. - """ - return [ct[1] for ct in self._cp_tracks] - - def add(self, track, at_position=None): - """ - Add the track to the end of, or at the given position in the current - playlist. - - :param track: track to add - :type track: :class:`mopidy.models.Track` - :param at_position: position in current playlist to add track - :type at_position: int or :class:`None` - :rtype: two-tuple of (CPID integer, :class:`mopidy.models.Track`) that - was added to the current playlist playlist - """ - assert at_position <= len(self._cp_tracks), \ - u'at_position can not be greater than playlist length' - cp_track = (self.version, track) - if at_position is not None: - self._cp_tracks.insert(at_position, cp_track) - else: - self._cp_tracks.append(cp_track) - self.version += 1 - return cp_track - - def clear(self): - """Clear the current playlist.""" - self.backend.playback.stop() - self.backend.playback.current_cp_track = None - self._cp_tracks = [] - self.version += 1 - - def get(self, **criteria): - """ - Get track by given criterias from current playlist. - - Raises :exc:`LookupError` if a unique match is not found. - - Examples:: - - get(cpid=7) # Returns track with CPID 7 - # (current playlist ID) - get(id=1) # Returns track with ID 1 - get(uri='xyz') # Returns track with URI 'xyz' - get(id=1, uri='xyz') # Returns track with ID 1 and URI 'xyz' - - :param criteria: on or more criteria to match by - :type criteria: dict - :rtype: two-tuple (CPID integer, :class:`mopidy.models.Track`) - """ - matches = self._cp_tracks - for (key, value) in criteria.iteritems(): - if key == 'cpid': - matches = filter(lambda ct: ct[0] == value, matches) - else: - matches = filter(lambda ct: getattr(ct[1], 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(u'"%s" match no tracks' % criteria_string) - else: - raise LookupError(u'"%s" match multiple tracks' % criteria_string) - - def load(self, tracks): - """ - Replace the tracks in the current playlist with the given tracks. - - :param tracks: tracks to load - :type tracks: list of :class:`mopidy.models.Track` - """ - self._cp_tracks = [] - self.version += 1 - for track in tracks: - self.add(track) - self.backend.playback.new_playlist_loaded_callback() - - def move(self, start, end, to_position): - """ - Move the tracks in the slice ``[start:end]`` to ``to_position``. - - :param start: position of first track to move - :type start: int - :param end: position after last track to move - :type end: int - :param to_position: new position for the tracks - :type to_position: int - """ - if start == end: - end += 1 - - cp_tracks = self._cp_tracks - - assert start < end, 'start must be smaller than end' - assert start >= 0, 'start must be at least zero' - assert end <= len(cp_tracks), \ - 'end can not be larger than playlist length' - assert to_position >= 0, 'to_position must be at least zero' - assert to_position <= len(cp_tracks), \ - 'to_position can not be larger than playlist length' - - new_cp_tracks = cp_tracks[:start] + cp_tracks[end:] - for cp_track in cp_tracks[start:end]: - new_cp_tracks.insert(to_position, cp_track) - to_position += 1 - self._cp_tracks = new_cp_tracks - self.version += 1 - - def remove(self, **criteria): - """ - Remove the track from the current playlist. - - Uses :meth:`get()` to lookup the track to remove. - - :param criteria: on or more criteria to match by - :type criteria: dict - :type track: :class:`mopidy.models.Track` - """ - cp_track = self.get(**criteria) - position = self._cp_tracks.index(cp_track) - del self._cp_tracks[position] - self.version += 1 - - def shuffle(self, start=None, end=None): - """ - Shuffles the entire playlist. If ``start`` and ``end`` is given only - shuffles the slice ``[start:end]``. - - :param start: position of first track to shuffle - :type start: int or :class:`None` - :param end: position after last track to shuffle - :type end: int or :class:`None` - """ - cp_tracks = self._cp_tracks - - if start is not None and end is not None: - assert start < end, 'start must be smaller than end' - - if start is not None: - assert start >= 0, 'start must be at least zero' - - if end is not None: - assert end <= len(cp_tracks), 'end can not be larger than ' + \ - 'playlist length' - - before = cp_tracks[:start or 0] - shuffled = cp_tracks[start:end] - after = cp_tracks[end or len(cp_tracks):] - random.shuffle(shuffled) - self._cp_tracks = before + shuffled + after - self.version += 1 - - def mpd_format(self, *args, **kwargs): - """Not a part of the generic backend API.""" - kwargs['cpids'] = [ct[0] for ct in self._cp_tracks] - return serializer.tracks_to_mpd_format(self.tracks, *args, **kwargs) - - -class BaseLibraryController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`BaseBackend` - """ - - def __init__(self, backend): - self.backend = backend - - def destroy(self): - """Cleanup after component.""" - pass - - 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` - """ - raise NotImplementedError - - 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` - """ - raise NotImplementedError - - 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 - """ - raise NotImplementedError - - 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` - """ - raise NotImplementedError - - -class BasePlaybackController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`BaseBackend` - """ - - #: 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 = 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 = 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 = False - - #: :class:`True` - #: Playback is stopped after current song, unless in repeat mode. - #: :class:`False` - #: Playback continues after current song. - single = False - - def __init__(self, backend): - self.backend = backend - self._state = self.STOPPED - self._shuffled = [] - self._first_shuffle = True - self._play_time_accumulated = 0 - self._play_time_started = None - - def destroy(self): - """Cleanup after component.""" - pass - - @property - def current_cpid(self): - """ - The CPID (current playlist ID) of :attr:`current_track`. - - Read-only. Extracted from :attr:`current_cp_track` for convenience. - """ - if self.current_cp_track is None: - return None - return self.current_cp_track[0] - - @property - def current_track(self): - """ - The currently playing or selected :class:`mopidy.models.Track`. - - Read-only. Extracted from :attr:`current_cp_track` for convenience. - """ - if self.current_cp_track is None: - return None - return self.current_cp_track[1] - - @property - def current_playlist_position(self): - """The position of the current track in the current playlist.""" - 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 next_track(self): - """ - The next track in the playlist. - - A :class:`mopidy.models.Track` extracted from :attr:`next_cp_track` for - convenience. - """ - next_cp_track = self.next_cp_track - if next_cp_track is None: - return None - return next_cp_track[1] - - @property - def next_cp_track(self): - """ - The next track in the playlist. - - 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._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 previous_track(self): - """ - The previous track in the playlist. - - A :class:`mopidy.models.Track` extracted from :attr:`previous_cp_track` - for convenience. - """ - previous_cp_track = self.previous_cp_track - if previous_cp_track is None: - return None - return previous_cp_track[1] - - @property - def previous_cp_track(self): - """ - The previous track in the playlist. - - 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_cp_track is None or self.current_playlist_position == 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" ] - "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) - # 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) - - def end_of_track_callback(self): - """ - Tell the playback controller that end of track is reached. - - Typically called by :class:`mopidy.process.CoreProcess` after a message - from a library thread is received. - """ - if self.next_cp_track is not None: - self.next() - else: - self.stop() - self.current_cp_track = None - - def new_playlist_loaded_callback(self): - """ - Tell the playback controller that a new playlist has been loaded. - - Typically called by :class:`mopidy.process.CoreProcess` after a message - from a library thread is received. - """ - self.current_cp_track = None - self._first_shuffle = True - self._shuffled = [] - - if self.state == self.PLAYING: - if len(self.backend.current_playlist.tracks) > 0: - self.play() - else: - self.stop() - elif self.state == self.PAUSED: - self.stop() - - def next(self): - """Play the next track.""" - original_cp_track = self.current_cp_track - - if self.state == self.STOPPED: - return - elif self.next_cp_track is not None and self._next(self.next_track): - self.current_cp_track = self.next_cp_track - self.state = self.PLAYING - elif self.next_cp_track is None: - self.stop() - self.current_cp_track = None - - # FIXME handle in play aswell? - if self.consume: - self.backend.current_playlist.remove(cpid=original_cp_track[0]) - - if self.random and self.current_cp_track in self._shuffled: - self._shuffled.remove(self.current_cp_track) - - def _next(self, track): - return self._play(track) - - def pause(self): - """Pause playback.""" - if self.state == self.PLAYING and self._pause(): - self.state = self.PAUSED - - def _pause(self): - raise NotImplementedError - - def play(self, cp_track=None): - """ - Play the given track or the currently active track. - - :param cp_track: track to play - :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) - or :class:`None` - """ - - if cp_track is not None: - assert cp_track in self.backend.current_playlist.cp_tracks - elif not self.current_cp_track: - cp_track = self.next_cp_track - - if self.state == self.PAUSED and cp_track is None: - self.resume() - elif cp_track is not None and self._play(cp_track[1]): - self.current_cp_track = cp_track - self.state = self.PLAYING - - # TODO Do something sensible when _play() returns False, like calling - # next(). Adding this todo instead of just implementing it as I want a - # test case first. - - if self.random and self.current_cp_track in self._shuffled: - self._shuffled.remove(self.current_cp_track) - - def _play(self, track): - raise NotImplementedError - - def previous(self): - """Play the previous track.""" - if (self.previous_cp_track is not None - and self.state != self.STOPPED - and self._previous(self.previous_track)): - self.current_cp_track = self.previous_cp_track - self.state = self.PLAYING - - def _previous(self, track): - return self._play(track) - - def resume(self): - """If paused, resume playing the current track.""" - if self.state == self.PAUSED and self._resume(): - self.state = self.PLAYING - - def _resume(self): - raise NotImplementedError - - def seek(self, time_position): - """ - Seeks to time position given in milliseconds. - - :param time_position: time position in milliseconds - :type time_position: int - """ - if self.state == self.STOPPED: - self.play() - elif self.state == self.PAUSED: - self.resume() - - if time_position < 0: - time_position = 0 - elif self.current_track and time_position > self.current_track.length: - self.next() - return - - self._seek(time_position) - - def _seek(self, time_position): - raise NotImplementedError - - def stop(self): - """Stop playing.""" - if self.state != self.STOPPED and self._stop(): - self.state = self.STOPPED - - def _stop(self): - raise NotImplementedError - - -class BaseStoredPlaylistsController(object): - """ - :param backend: backend the controller is a part of - :type backend: :class:`BaseBackend` - """ - - def __init__(self, backend): - self.backend = backend - self._playlists = [] - - def destroy(self): - """Cleanup after component.""" - pass - - @property - def playlists(self): - """List of :class:`mopidy.models.Playlist`.""" - return copy(self._playlists) - - @playlists.setter - def playlists(self, playlists): - self._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` - """ - raise NotImplementedError - - def delete(self, playlist): - """ - Delete playlist. - - :param playlist: the playlist to delete - :type playlist: :class:`mopidy.models.Playlist` - """ - raise NotImplementedError - - 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` - """ - raise NotImplementedError - - def refresh(self): - """Refresh stored playlists.""" - raise NotImplementedError - - 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 - """ - raise NotImplementedError - - def save(self, playlist): - """ - Save the playlist to the set of stored playlists. - - :param playlist: the playlist - :type playlist: :class:`mopidy.models.Playlist` - """ - raise NotImplementedError - - def search(self, query): - """ - Search for playlists whose name contains ``query``. - - :param query: query to search for - :type query: string - :rtype: list of :class:`mopidy.models.Playlist` - """ - return filter(lambda p: query in p.name, self._playlists) diff --git a/mopidy/backends/base/__init__.py b/mopidy/backends/base/__init__.py new file mode 100644 index 00000000..e79aceae --- /dev/null +++ b/mopidy/backends/base/__init__.py @@ -0,0 +1,84 @@ +from copy import copy +import logging +import random +import time + +from mopidy import settings +from mopidy.backends.base.current_playlist import BaseCurrentPlaylistController +from mopidy.backends.base.library import BaseLibraryController +from mopidy.backends.base.playback import BasePlaybackController +from mopidy.backends.base.stored_playlists import BaseStoredPlaylistsController +from mopidy.frontends.mpd import serializer +from mopidy.models import Playlist +from mopidy.utils import get_class + +logger = logging.getLogger('mopidy.backends.base') + +__all__ = ['BaseBackend', 'BasePlaybackController', + 'BaseCurrentPlaylistController', 'BaseStoredPlaylistsController', + 'BaseLibraryController'] + +class BaseBackend(object): + """ + :param core_queue: a queue for sending messages to + :class:`mopidy.process.CoreProcess` + :type core_queue: :class:`multiprocessing.Queue` + :param mixer: either a mixer instance, or :class:`None` to use the mixer + defined in settings + :type mixer: :class:`mopidy.mixers.BaseMixer` or :class:`None` + """ + + def __init__(self, core_queue=None, mixer=None): + self.core_queue = core_queue + if mixer is not None: + self.mixer = mixer + else: + self.mixer = get_class(settings.MIXER)() + + #: A :class:`multiprocessing.Queue` which can be used by e.g. library + #: callbacks executing in other threads to send messages to the core + #: thread, so that action may be taken in the correct thread. + core_queue = None + + #: The current playlist controller. An instance of + #: :class:`mopidy.backends.base.BaseCurrentPlaylistController`. + current_playlist = None + + #: The library controller. An instance of + # :class:`mopidy.backends.base.BaseLibraryController`. + library = None + + #: The sound mixer. An instance of :class:`mopidy.mixers.BaseMixer`. + mixer = None + + #: The playback controller. An instance of + #: :class:`mopidy.backends.base.BasePlaybackController`. + playback = None + + #: The stored playlists controller. An instance of + #: :class:`mopidy.backends.base.BaseStoredPlaylistsController`. + stored_playlists = None + + #: List of URI prefixes this backend can handle. + uri_handlers = [] + + def destroy(self): + """ + Call destroy on all sub-components in backend so that they can cleanup + after themselves. + """ + + if self.current_playlist: + self.current_playlist.destroy() + + if self.library: + self.library.destroy() + + if self.mixer: + self.mixer.destroy() + + if self.playback: + self.playback.destroy() + + if self.stored_playlists: + self.stored_playlists.destroy() diff --git a/mopidy/backends/base/current_playlist.py b/mopidy/backends/base/current_playlist.py new file mode 100644 index 00000000..1eee8d0c --- /dev/null +++ b/mopidy/backends/base/current_playlist.py @@ -0,0 +1,199 @@ +from copy import copy +import logging +import random + +from mopidy.frontends.mpd import serializer + +logger = logging.getLogger('mopidy.backends.base') + +class BaseCurrentPlaylistController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`BaseBackend` + """ + + #: The current playlist version. Integer which is increased every time the + #: current playlist is changed. Is not reset before Mopidy is restarted. + version = 0 + + def __init__(self, backend): + self.backend = backend + self._cp_tracks = [] + + def destroy(self): + """Cleanup after component.""" + pass + + @property + def cp_tracks(self): + """ + List of two-tuples of (CPID integer, :class:`mopidy.models.Track`). + + Read-only. + """ + return [copy(ct) for ct in self._cp_tracks] + + @property + def tracks(self): + """ + List of :class:`mopidy.models.Track` in the current playlist. + + Read-only. + """ + return [ct[1] for ct in self._cp_tracks] + + def add(self, track, at_position=None): + """ + Add the track to the end of, or at the given position in the current + playlist. + + :param track: track to add + :type track: :class:`mopidy.models.Track` + :param at_position: position in current playlist to add track + :type at_position: int or :class:`None` + :rtype: two-tuple of (CPID integer, :class:`mopidy.models.Track`) that + was added to the current playlist playlist + """ + assert at_position <= len(self._cp_tracks), \ + u'at_position can not be greater than playlist length' + cp_track = (self.version, track) + if at_position is not None: + self._cp_tracks.insert(at_position, cp_track) + else: + self._cp_tracks.append(cp_track) + self.version += 1 + return cp_track + + def clear(self): + """Clear the current playlist.""" + self.backend.playback.stop() + self.backend.playback.current_cp_track = None + self._cp_tracks = [] + self.version += 1 + + def get(self, **criteria): + """ + Get track by given criterias from current playlist. + + Raises :exc:`LookupError` if a unique match is not found. + + Examples:: + + get(cpid=7) # Returns track with CPID 7 + # (current playlist ID) + get(id=1) # Returns track with ID 1 + get(uri='xyz') # Returns track with URI 'xyz' + get(id=1, uri='xyz') # Returns track with ID 1 and URI 'xyz' + + :param criteria: on or more criteria to match by + :type criteria: dict + :rtype: two-tuple (CPID integer, :class:`mopidy.models.Track`) + """ + matches = self._cp_tracks + for (key, value) in criteria.iteritems(): + if key == 'cpid': + matches = filter(lambda ct: ct[0] == value, matches) + else: + matches = filter(lambda ct: getattr(ct[1], 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(u'"%s" match no tracks' % criteria_string) + else: + raise LookupError(u'"%s" match multiple tracks' % criteria_string) + + def load(self, tracks): + """ + Replace the tracks in the current playlist with the given tracks. + + :param tracks: tracks to load + :type tracks: list of :class:`mopidy.models.Track` + """ + self._cp_tracks = [] + self.version += 1 + for track in tracks: + self.add(track) + self.backend.playback.new_playlist_loaded_callback() + + def move(self, start, end, to_position): + """ + Move the tracks in the slice ``[start:end]`` to ``to_position``. + + :param start: position of first track to move + :type start: int + :param end: position after last track to move + :type end: int + :param to_position: new position for the tracks + :type to_position: int + """ + if start == end: + end += 1 + + cp_tracks = self._cp_tracks + + assert start < end, 'start must be smaller than end' + assert start >= 0, 'start must be at least zero' + assert end <= len(cp_tracks), \ + 'end can not be larger than playlist length' + assert to_position >= 0, 'to_position must be at least zero' + assert to_position <= len(cp_tracks), \ + 'to_position can not be larger than playlist length' + + new_cp_tracks = cp_tracks[:start] + cp_tracks[end:] + for cp_track in cp_tracks[start:end]: + new_cp_tracks.insert(to_position, cp_track) + to_position += 1 + self._cp_tracks = new_cp_tracks + self.version += 1 + + def remove(self, **criteria): + """ + Remove the track from the current playlist. + + Uses :meth:`get()` to lookup the track to remove. + + :param criteria: on or more criteria to match by + :type criteria: dict + :type track: :class:`mopidy.models.Track` + """ + cp_track = self.get(**criteria) + position = self._cp_tracks.index(cp_track) + del self._cp_tracks[position] + self.version += 1 + + def shuffle(self, start=None, end=None): + """ + Shuffles the entire playlist. If ``start`` and ``end`` is given only + shuffles the slice ``[start:end]``. + + :param start: position of first track to shuffle + :type start: int or :class:`None` + :param end: position after last track to shuffle + :type end: int or :class:`None` + """ + cp_tracks = self._cp_tracks + + if start is not None and end is not None: + assert start < end, 'start must be smaller than end' + + if start is not None: + assert start >= 0, 'start must be at least zero' + + if end is not None: + assert end <= len(cp_tracks), 'end can not be larger than ' + \ + 'playlist length' + + before = cp_tracks[:start or 0] + shuffled = cp_tracks[start:end] + after = cp_tracks[end or len(cp_tracks):] + random.shuffle(shuffled) + self._cp_tracks = before + shuffled + after + self.version += 1 + + def mpd_format(self, *args, **kwargs): + """Not a part of the generic backend API.""" + kwargs['cpids'] = [ct[0] for ct in self._cp_tracks] + return serializer.tracks_to_mpd_format(self.tracks, *args, **kwargs) diff --git a/mopidy/backends/base/library.py b/mopidy/backends/base/library.py new file mode 100644 index 00000000..94f40863 --- /dev/null +++ b/mopidy/backends/base/library.py @@ -0,0 +1,73 @@ +import logging + +logger = logging.getLogger('mopidy.backends.base') + +class BaseLibraryController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`BaseBackend` + """ + + def __init__(self, backend): + self.backend = backend + + def destroy(self): + """Cleanup after component.""" + pass + + 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` + """ + raise NotImplementedError + + 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` + """ + raise NotImplementedError + + 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 + """ + raise NotImplementedError + + 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` + """ + raise NotImplementedError diff --git a/mopidy/backends/base/playback.py b/mopidy/backends/base/playback.py new file mode 100644 index 00000000..88617d83 --- /dev/null +++ b/mopidy/backends/base/playback.py @@ -0,0 +1,384 @@ +import logging +import random +import time + +logger = logging.getLogger('mopidy.backends.base') + +class BasePlaybackController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`BaseBackend` + """ + + #: 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 = 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 = 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 = False + + #: :class:`True` + #: Playback is stopped after current song, unless in repeat mode. + #: :class:`False` + #: Playback continues after current song. + single = False + + def __init__(self, backend): + self.backend = backend + self._state = self.STOPPED + self._shuffled = [] + self._first_shuffle = True + self._play_time_accumulated = 0 + self._play_time_started = None + + def destroy(self): + """Cleanup after component.""" + pass + + @property + def current_cpid(self): + """ + The CPID (current playlist ID) of :attr:`current_track`. + + Read-only. Extracted from :attr:`current_cp_track` for convenience. + """ + if self.current_cp_track is None: + return None + return self.current_cp_track[0] + + @property + def current_track(self): + """ + The currently playing or selected :class:`mopidy.models.Track`. + + Read-only. Extracted from :attr:`current_cp_track` for convenience. + """ + if self.current_cp_track is None: + return None + return self.current_cp_track[1] + + @property + def current_playlist_position(self): + """The position of the current track in the current playlist.""" + 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 next_track(self): + """ + The next track in the playlist. + + A :class:`mopidy.models.Track` extracted from :attr:`next_cp_track` for + convenience. + """ + next_cp_track = self.next_cp_track + if next_cp_track is None: + return None + return next_cp_track[1] + + @property + def next_cp_track(self): + """ + The next track in the playlist. + + 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._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 previous_track(self): + """ + The previous track in the playlist. + + A :class:`mopidy.models.Track` extracted from :attr:`previous_cp_track` + for convenience. + """ + previous_cp_track = self.previous_cp_track + if previous_cp_track is None: + return None + return previous_cp_track[1] + + @property + def previous_cp_track(self): + """ + The previous track in the playlist. + + 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_cp_track is None or self.current_playlist_position == 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" ] + "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) + # 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) + + def end_of_track_callback(self): + """ + Tell the playback controller that end of track is reached. + + Typically called by :class:`mopidy.process.CoreProcess` after a message + from a library thread is received. + """ + if self.next_cp_track is not None: + self.next() + else: + self.stop() + self.current_cp_track = None + + def new_playlist_loaded_callback(self): + """ + Tell the playback controller that a new playlist has been loaded. + + Typically called by :class:`mopidy.process.CoreProcess` after a message + from a library thread is received. + """ + self.current_cp_track = None + self._first_shuffle = True + self._shuffled = [] + + if self.state == self.PLAYING: + if len(self.backend.current_playlist.tracks) > 0: + self.play() + else: + self.stop() + elif self.state == self.PAUSED: + self.stop() + + def next(self): + """Play the next track.""" + original_cp_track = self.current_cp_track + + if self.state == self.STOPPED: + return + elif self.next_cp_track is not None and self._next(self.next_track): + self.current_cp_track = self.next_cp_track + self.state = self.PLAYING + elif self.next_cp_track is None: + self.stop() + self.current_cp_track = None + + # FIXME handle in play aswell? + if self.consume: + self.backend.current_playlist.remove(cpid=original_cp_track[0]) + + if self.random and self.current_cp_track in self._shuffled: + self._shuffled.remove(self.current_cp_track) + + def _next(self, track): + return self._play(track) + + def pause(self): + """Pause playback.""" + if self.state == self.PLAYING and self._pause(): + self.state = self.PAUSED + + def _pause(self): + raise NotImplementedError + + def play(self, cp_track=None): + """ + Play the given track or the currently active track. + + :param cp_track: track to play + :type cp_track: two-tuple (CPID integer, :class:`mopidy.models.Track`) + or :class:`None` + """ + + if cp_track is not None: + assert cp_track in self.backend.current_playlist.cp_tracks + elif not self.current_cp_track: + cp_track = self.next_cp_track + + if self.state == self.PAUSED and cp_track is None: + self.resume() + elif cp_track is not None and self._play(cp_track[1]): + self.current_cp_track = cp_track + self.state = self.PLAYING + + # TODO Do something sensible when _play() returns False, like calling + # next(). Adding this todo instead of just implementing it as I want a + # test case first. + + if self.random and self.current_cp_track in self._shuffled: + self._shuffled.remove(self.current_cp_track) + + def _play(self, track): + raise NotImplementedError + + def previous(self): + """Play the previous track.""" + if (self.previous_cp_track is not None + and self.state != self.STOPPED + and self._previous(self.previous_track)): + self.current_cp_track = self.previous_cp_track + self.state = self.PLAYING + + def _previous(self, track): + return self._play(track) + + def resume(self): + """If paused, resume playing the current track.""" + if self.state == self.PAUSED and self._resume(): + self.state = self.PLAYING + + def _resume(self): + raise NotImplementedError + + def seek(self, time_position): + """ + Seeks to time position given in milliseconds. + + :param time_position: time position in milliseconds + :type time_position: int + """ + if self.state == self.STOPPED: + self.play() + elif self.state == self.PAUSED: + self.resume() + + if time_position < 0: + time_position = 0 + elif self.current_track and time_position > self.current_track.length: + self.next() + return + + self._seek(time_position) + + def _seek(self, time_position): + raise NotImplementedError + + def stop(self): + """Stop playing.""" + if self.state != self.STOPPED and self._stop(): + self.state = self.STOPPED + + def _stop(self): + raise NotImplementedError diff --git a/mopidy/backends/base/stored_playlists.py b/mopidy/backends/base/stored_playlists.py new file mode 100644 index 00000000..31185cd4 --- /dev/null +++ b/mopidy/backends/base/stored_playlists.py @@ -0,0 +1,119 @@ +from copy import copy +import logging + +logger = logging.getLogger('mopidy.backends.base') + +class BaseStoredPlaylistsController(object): + """ + :param backend: backend the controller is a part of + :type backend: :class:`BaseBackend` + """ + + def __init__(self, backend): + self.backend = backend + self._playlists = [] + + def destroy(self): + """Cleanup after component.""" + pass + + @property + def playlists(self): + """List of :class:`mopidy.models.Playlist`.""" + return copy(self._playlists) + + @playlists.setter + def playlists(self, playlists): + self._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` + """ + raise NotImplementedError + + def delete(self, playlist): + """ + Delete playlist. + + :param playlist: the playlist to delete + :type playlist: :class:`mopidy.models.Playlist` + """ + raise NotImplementedError + + 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` + """ + raise NotImplementedError + + def refresh(self): + """Refresh stored playlists.""" + raise NotImplementedError + + 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 + """ + raise NotImplementedError + + def save(self, playlist): + """ + Save the playlist to the set of stored playlists. + + :param playlist: the playlist + :type playlist: :class:`mopidy.models.Playlist` + """ + raise NotImplementedError + + def search(self, query): + """ + Search for playlists whose name contains ``query``. + + :param query: query to search for + :type query: string + :rtype: list of :class:`mopidy.models.Playlist` + """ + return filter(lambda p: query in p.name, self._playlists) diff --git a/mopidy/backends/despotify.py b/mopidy/backends/despotify/__init__.py similarity index 93% rename from mopidy/backends/despotify.py rename to mopidy/backends/despotify/__init__.py index b41c54cf..78c7f774 100644 --- a/mopidy/backends/despotify.py +++ b/mopidy/backends/despotify/__init__.py @@ -5,7 +5,7 @@ import sys import spytify from mopidy import settings -from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, +from mopidy.backends.base import (BaseBackend, BaseCurrentPlaylistController, BaseLibraryController, BasePlaybackController, BaseStoredPlaylistsController) from mopidy.models import Artist, Album, Track, Playlist @@ -60,6 +60,9 @@ class DespotifyLibraryController(BaseLibraryController): track = self.backend.spotify.lookup(uri.encode(ENCODING)) return DespotifyTranslator.to_mopidy_track(track) + def refresh(self, uri=None): + pass # TODO + def search(self, **query): spotify_query = [] for (field, values) in query.iteritems(): @@ -106,6 +109,9 @@ class DespotifyPlaybackController(BasePlaybackController): logger.error(e) return False + def _seek(self, time_position): + pass # TODO + def _stop(self): try: self.backend.spotify.stop() @@ -116,6 +122,15 @@ class DespotifyPlaybackController(BasePlaybackController): class DespotifyStoredPlaylistsController(BaseStoredPlaylistsController): + def create(self, name): + pass # TODO + + def delete(self, playlist): + pass # TODO + + def lookup(self, uri): + pass # TODO + def refresh(self): logger.info(u'Caching stored playlists') playlists = [] @@ -127,6 +142,12 @@ class DespotifyStoredPlaylistsController(BaseStoredPlaylistsController): u', '.join([u'<%s>' % p.name for p in self.playlists])) logger.info(u'Done caching stored playlists') + def rename(self, playlist, new_name): + pass # TODO + + def save(self, playlist): + pass # TODO + class DespotifyTranslator(object): @classmethod diff --git a/mopidy/backends/dummy.py b/mopidy/backends/dummy/__init__.py similarity index 63% rename from mopidy/backends/dummy.py rename to mopidy/backends/dummy/__init__.py index 6d8b74f6..98257f18 100644 --- a/mopidy/backends/dummy.py +++ b/mopidy/backends/dummy/__init__.py @@ -1,4 +1,4 @@ -from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, +from mopidy.backends.base import (BaseBackend, BaseCurrentPlaylistController, BasePlaybackController, BaseLibraryController, BaseStoredPlaylistsController) from mopidy.models import Playlist @@ -19,21 +19,28 @@ class DummyBackend(BaseBackend): self.stored_playlists = DummyStoredPlaylistsController(backend=self) self.uri_handlers = [u'dummy:'] + class DummyCurrentPlaylistController(BaseCurrentPlaylistController): pass + class DummyLibraryController(BaseLibraryController): _library = [] + def find_exact(self, **query): + return Playlist() + def lookup(self, uri): matches = filter(lambda t: uri == t.uri, self._library) if matches: return matches[0] + def refresh(self, uri=None): + pass + def search(self, **query): return Playlist() - find_exact = search class DummyPlaybackController(BasePlaybackController): def _next(self, track): @@ -51,6 +58,33 @@ class DummyPlaybackController(BasePlaybackController): def _resume(self): return True + def _seek(self, time_position): + pass + + def _stop(self): + return True + + class DummyStoredPlaylistsController(BaseStoredPlaylistsController): - def search(self, query): - return [Playlist(name=query)] + _playlists = [] + + def create(self, name): + playlist = Playlist(name=name) + self._playlists.append(playlist) + return playlist + + def delete(self, playlist): + self._playlists.remove(playlist) + + def lookup(self, uri): + return filter(lambda p: p.uri == uri, self._playlists) + + def refresh(self): + pass + + def rename(self, playlist, new_name): + self._playlists[self._playlists.index(playlist)] = \ + playlist.with_(name=new_name) + + def save(self, playlist): + self._playlists.append(playlist) diff --git a/mopidy/backends/gstreamer.py b/mopidy/backends/gstreamer/__init__.py similarity index 97% rename from mopidy/backends/gstreamer.py rename to mopidy/backends/gstreamer/__init__.py index 3bb089e5..74dd8332 100644 --- a/mopidy/backends/gstreamer.py +++ b/mopidy/backends/gstreamer/__init__.py @@ -1,7 +1,8 @@ import gobject gobject.threads_init() # FIXME make sure we don't get hit by -# http://jameswestby.net/weblog/tech/14-caution-python-multiprocessing-and-glib-dont-mix.html +# http://jameswestby.net/ +# weblog/tech/14-caution-python-multiprocessing-and-glib-dont-mix.html import pygst pygst.require('0.10') @@ -13,7 +14,7 @@ import glob import shutil import threading -from mopidy.backends import * +from mopidy.backends.base import * from mopidy.models import Playlist, Track, Album from mopidy import settings from mopidy.utils import parse_m3u, parse_mpd_tag_cache @@ -71,9 +72,7 @@ class GStreamerPlaybackController(BasePlaybackController): def _set_state(self, state): self._bin.set_state(state) - - result, new, old = self._bin.get_state() - + (_, new, _) = self._bin.get_state() return new == state def _message(self, bus, message): @@ -131,6 +130,9 @@ class GStreamerStoredPlaylistsController(BaseStoredPlaylistsController): self._folder = os.path.expanduser(settings.LOCAL_PLAYLIST_FOLDER) self.refresh() + def lookup(self, uri): + pass # TODO + def refresh(self): playlists = [] @@ -285,7 +287,7 @@ class GStreamerLibraryController(BaseLibraryController): return Playlist(tracks=result_tracks) def _validate_query(self, query): - for (field, values) in query.iteritems(): + for (_, values) in query.iteritems(): if not values: raise LookupError('Missing query') for value in values: diff --git a/mopidy/backends/libspotify.py b/mopidy/backends/libspotify/__init__.py similarity index 99% rename from mopidy/backends/libspotify.py rename to mopidy/backends/libspotify/__init__.py index 63cb62e4..5afaa293 100644 --- a/mopidy/backends/libspotify.py +++ b/mopidy/backends/libspotify/__init__.py @@ -10,7 +10,7 @@ from spotify.manager import SpotifySessionManager from spotify.alsahelper import AlsaController from mopidy import get_version, settings -from mopidy.backends import (BaseBackend, BaseCurrentPlaylistController, +from mopidy.backends.base import (BaseBackend, BaseCurrentPlaylistController, BaseLibraryController, BasePlaybackController, BaseStoredPlaylistsController) from mopidy.models import Artist, Album, Track, Playlist diff --git a/tests/mpd/__init__.py b/mopidy/frontends/__init__.py similarity index 100% rename from tests/mpd/__init__.py rename to mopidy/frontends/__init__.py diff --git a/mopidy/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py similarity index 60% rename from mopidy/mpd/__init__.py rename to mopidy/frontends/mpd/__init__.py index 4b984fc8..83d6ce4c 100644 --- a/mopidy/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,3 +1,5 @@ +import re + from mopidy import MopidyException class MpdAckError(MopidyException): @@ -19,6 +21,7 @@ class MpdAckError(MopidyException): """ def __init__(self, message=u'', error_code=0, index=0, command=u''): + super(MpdAckError, self).__init__(message, error_code, index, command) self.message = message self.error_code = error_code self.index = index @@ -54,3 +57,37 @@ class MpdNotImplemented(MpdAckError): def __init__(self, *args, **kwargs): super(MpdNotImplemented, self).__init__(*args, **kwargs) self.message = u'Not implemented' + +mpd_commands = set() +request_handlers = {} + +def handle_pattern(pattern): + """ + Decorator for connecting command handlers to command patterns. + + If you use named groups in the pattern, the decorated method will get the + groups as keyword arguments. If the group is optional, remember to give the + argument a default value. + + For example, if the command is ``do that thing`` the ``what`` argument will + be ``this thing``:: + + @handle_pattern('^do (?P.+)$') + def do(what): + ... + + :param pattern: regexp pattern for matching commands + :type pattern: string + """ + def decorator(func): + match = re.search('([a-z_]+)', pattern) + if match is not None: + mpd_commands.add(match.group()) + if pattern in request_handlers: + raise ValueError(u'Tried to redefine handler for %s with %s' % ( + pattern, func)) + request_handlers[pattern] = func + func.__doc__ = ' - *Pattern:* ``%s``\n\n%s' % ( + pattern, func.__doc__ or '') + return func + return decorator diff --git a/mopidy/frontends/mpd/frontend.py b/mopidy/frontends/mpd/frontend.py new file mode 100644 index 00000000..9a0251eb --- /dev/null +++ b/mopidy/frontends/mpd/frontend.py @@ -0,0 +1,79 @@ +import logging +import re + +from mopidy.frontends.mpd import (mpd_commands, request_handlers, + handle_pattern, MpdAckError, MpdArgError, MpdUnknownCommand) +# Do not remove the following import. The protocol modules must be imported to +# get them registered as request handlers. +from mopidy.frontends.mpd.protocol import (audio_output, command_list, + connection, current_playlist, music_db, playback, reflection, status, + stickers, stored_playlists) +from mopidy.utils import flatten + +logger = logging.getLogger('mopidy.frontends.mpd.frontend') + +class MpdFrontend(object): + """ + The MPD frontend dispatches MPD requests to the correct handler. + """ + + def __init__(self, backend=None): + self.backend = backend + self.command_list = False + self.command_list_ok = False + + def handle_request(self, request, command_list_index=None): + """Dispatch incoming requests to the correct handler.""" + if self.command_list is not False and request != u'command_list_end': + self.command_list.append(request) + return None + try: + (handler, kwargs) = self.find_handler(request) + result = handler(self, **kwargs) + except MpdAckError as e: + if command_list_index is not None: + e.index = command_list_index + return self.handle_response(e.get_mpd_ack(), add_ok=False) + if request in (u'command_list_begin', u'command_list_ok_begin'): + return None + if command_list_index is not None: + return self.handle_response(result, add_ok=False) + return self.handle_response(result) + + def find_handler(self, request): + """Find the correct handler for a request.""" + for pattern in request_handlers: + matches = re.match(pattern, request) + if matches is not None: + return (request_handlers[pattern], matches.groupdict()) + command = request.split(' ')[0] + if command in mpd_commands: + raise MpdArgError(u'incorrect arguments', command=command) + raise MpdUnknownCommand(command=command) + + def handle_response(self, result, add_ok=True): + """Format the response from a request handler.""" + response = [] + if result is None: + result = [] + elif isinstance(result, set): + result = list(result) + elif not isinstance(result, list): + result = [result] + for line in flatten(result): + if isinstance(line, dict): + for (key, value) in line.items(): + response.append(u'%s: %s' % (key, value)) + elif isinstance(line, tuple): + (key, value) = line + response.append(u'%s: %s' % (key, value)) + else: + response.append(line) + if add_ok and (not response or not response[-1].startswith(u'ACK')): + response.append(u'OK') + return response + +@handle_pattern(r'^$') +def empty(frontend): + """The original MPD server returns ``OK`` on an empty request.""" + pass diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py new file mode 100644 index 00000000..00932e90 --- /dev/null +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -0,0 +1,17 @@ +""" +This is Mopidy's MPD protocol implementation. + +This is partly based upon the `MPD protocol documentation +`_, which is a useful resource, but it is +rather incomplete with regards to data formats, both for requests and +responses. Thus, we have had to talk a great deal with the the original `MPD +server `_ using telnet to get the details we need to +implement our own MPD server which is compatible with the numerous existing +`MPD clients `_. +""" + +#: The MPD protocol uses UTF-8 for encoding all data. +ENCODING = u'utf-8' + +#: The MPD protocol uses ``\n`` as line terminator. +LINE_TERMINATOR = u'\n' diff --git a/mopidy/frontends/mpd/protocol/audio_output.py b/mopidy/frontends/mpd/protocol/audio_output.py new file mode 100644 index 00000000..e659b162 --- /dev/null +++ b/mopidy/frontends/mpd/protocol/audio_output.py @@ -0,0 +1,38 @@ +from mopidy.frontends.mpd import handle_pattern, MpdNotImplemented + +@handle_pattern(r'^disableoutput "(?P\d+)"$') +def disableoutput(frontend, outputid): + """ + *musicpd.org, audio output section:* + + ``disableoutput`` + + Turns an output off. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^enableoutput "(?P\d+)"$') +def enableoutput(frontend, outputid): + """ + *musicpd.org, audio output section:* + + ``enableoutput`` + + Turns an output on. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^outputs$') +def outputs(frontend): + """ + *musicpd.org, audio output section:* + + ``outputs`` + + Shows information about all outputs. + """ + return [ + ('outputid', 0), + ('outputname', frontend.backend.__class__.__name__), + ('outputenabled', 1), + ] diff --git a/mopidy/frontends/mpd/protocol/command_list.py b/mopidy/frontends/mpd/protocol/command_list.py new file mode 100644 index 00000000..900c26b0 --- /dev/null +++ b/mopidy/frontends/mpd/protocol/command_list.py @@ -0,0 +1,47 @@ +from mopidy.frontends.mpd import handle_pattern, MpdUnknownCommand + +@handle_pattern(r'^command_list_begin$') +def command_list_begin(frontend): + """ + *musicpd.org, command list section:* + + To facilitate faster adding of files etc. you can pass a list of + commands all at once using a command list. The command list begins + with ``command_list_begin`` or ``command_list_ok_begin`` and ends + with ``command_list_end``. + + It does not execute any commands until the list has ended. The + return value is whatever the return for a list of commands is. On + success for all commands, ``OK`` is returned. If a command fails, + no more commands are executed and the appropriate ``ACK`` error is + returned. If ``command_list_ok_begin`` is used, ``list_OK`` is + returned for each successful command executed in the command list. + """ + frontend.command_list = [] + frontend.command_list_ok = False + +@handle_pattern(r'^command_list_end$') +def command_list_end(frontend): + """See :meth:`command_list_begin()`.""" + if frontend.command_list is False: + # Test for False exactly, and not e.g. empty list + raise MpdUnknownCommand(command='command_list_end') + (command_list, frontend.command_list) = (frontend.command_list, False) + (command_list_ok, frontend.command_list_ok) = ( + frontend.command_list_ok, False) + result = [] + for i, command in enumerate(command_list): + response = frontend.handle_request(command, command_list_index=i) + if response is not None: + result.append(response) + if response and response[-1].startswith(u'ACK'): + return result + if command_list_ok: + response.append(u'list_OK') + return result + +@handle_pattern(r'^command_list_ok_begin$') +def command_list_ok_begin(frontend): + """See :meth:`command_list_begin()`.""" + frontend.command_list = [] + frontend.command_list_ok = True diff --git a/mopidy/frontends/mpd/protocol/connection.py b/mopidy/frontends/mpd/protocol/connection.py new file mode 100644 index 00000000..4312ded5 --- /dev/null +++ b/mopidy/frontends/mpd/protocol/connection.py @@ -0,0 +1,48 @@ +from mopidy.frontends.mpd import handle_pattern, MpdNotImplemented + +@handle_pattern(r'^close$') +def close(frontend): + """ + *musicpd.org, connection section:* + + ``close`` + + Closes the connection to MPD. + """ + # TODO Does not work after multiprocessing branch merge + #frontend.session.do_close() + +@handle_pattern(r'^kill$') +def kill(frontend): + """ + *musicpd.org, connection section:* + + ``kill`` + + Kills MPD. + """ + # TODO Does not work after multiprocessing branch merge + #frontend.session.do_kill() + +@handle_pattern(r'^password "(?P[^"]+)"$') +def password_(frontend, password): + """ + *musicpd.org, connection section:* + + ``password {PASSWORD}`` + + This is used for authentication with the server. ``PASSWORD`` is + simply the plaintext password. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^ping$') +def ping(frontend): + """ + *musicpd.org, connection section:* + + ``ping`` + + Does nothing but return ``OK``. + """ + pass diff --git a/mopidy/frontends/mpd/protocol/current_playlist.py b/mopidy/frontends/mpd/protocol/current_playlist.py new file mode 100644 index 00000000..da052fff --- /dev/null +++ b/mopidy/frontends/mpd/protocol/current_playlist.py @@ -0,0 +1,351 @@ +from mopidy.frontends.mpd import (handle_pattern, MpdArgError, MpdNoExistError, + MpdNotImplemented) + +@handle_pattern(r'^add "(?P[^"]*)"$') +def add(frontend, uri): + """ + *musicpd.org, current playlist section:* + + ``add {URI}`` + + Adds the file ``URI`` to the playlist (directories add recursively). + ``URI`` can also be a single file. + """ + track = frontend.backend.library.lookup(uri) + if track is None: + raise MpdNoExistError( + u'directory or file not found', command=u'add') + frontend.backend.current_playlist.add(track) + +@handle_pattern(r'^addid "(?P[^"]*)"( "(?P\d+)")*$') +def addid(frontend, uri, songpos=None): + """ + *musicpd.org, current playlist section:* + + ``addid {URI} [POSITION]`` + + Adds a song to the playlist (non-recursive) and returns the song id. + + ``URI`` is always a single file or URL. For example:: + + addid "foo.mp3" + Id: 999 + OK + """ + if songpos is not None: + songpos = int(songpos) + track = frontend.backend.library.lookup(uri) + if track is None: + raise MpdNoExistError(u'No such song', command=u'addid') + if songpos and songpos > len(frontend.backend.current_playlist.tracks): + raise MpdArgError(u'Bad song index', command=u'addid') + cp_track = frontend.backend.current_playlist.add(track, at_position=songpos) + return ('Id', cp_track[0]) + +@handle_pattern(r'^delete "(?P\d+):(?P\d+)*"$') +def delete_range(frontend, start, end=None): + """ + *musicpd.org, current playlist section:* + + ``delete [{POS} | {START:END}]`` + + Deletes a song from the playlist. + """ + start = int(start) + if end is not None: + end = int(end) + else: + end = len(frontend.backend.current_playlist.tracks) + cp_tracks = frontend.backend.current_playlist.cp_tracks[start:end] + if not cp_tracks: + raise MpdArgError(u'Bad song index', command=u'delete') + for (cpid, _) in cp_tracks: + frontend.backend.current_playlist.remove(cpid=cpid) + +@handle_pattern(r'^delete "(?P\d+)"$') +def delete_songpos(frontend, songpos): + """See :meth:`delete_range`""" + try: + songpos = int(songpos) + (cpid, _) = frontend.backend.current_playlist.cp_tracks[songpos] + frontend.backend.current_playlist.remove(cpid=cpid) + except IndexError: + raise MpdArgError(u'Bad song index', command=u'delete') + +@handle_pattern(r'^deleteid "(?P\d+)"$') +def deleteid(frontend, cpid): + """ + *musicpd.org, current playlist section:* + + ``deleteid {SONGID}`` + + Deletes the song ``SONGID`` from the playlist + """ + try: + cpid = int(cpid) + return frontend.backend.current_playlist.remove(cpid=cpid) + except LookupError: + raise MpdNoExistError(u'No such song', command=u'deleteid') + +@handle_pattern(r'^clear$') +def clear(frontend): + """ + *musicpd.org, current playlist section:* + + ``clear`` + + Clears the current playlist. + """ + frontend.backend.current_playlist.clear() + +@handle_pattern(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') +def move_range(frontend, start, to, end=None): + """ + *musicpd.org, current playlist section:* + + ``move [{FROM} | {START:END}] {TO}`` + + Moves the song at ``FROM`` or range of songs at ``START:END`` to + ``TO`` in the playlist. + """ + if end is None: + end = len(frontend.backend.current_playlist.tracks) + start = int(start) + end = int(end) + to = int(to) + frontend.backend.current_playlist.move(start, end, to) + +@handle_pattern(r'^move "(?P\d+)" "(?P\d+)"$') +def move_songpos(frontend, songpos, to): + """See :meth:`move_range`.""" + songpos = int(songpos) + to = int(to) + frontend.backend.current_playlist.move(songpos, songpos + 1, to) + +@handle_pattern(r'^moveid "(?P\d+)" "(?P\d+)"$') +def moveid(frontend, cpid, to): + """ + *musicpd.org, current playlist section:* + + ``moveid {FROM} {TO}`` + + Moves the song with ``FROM`` (songid) to ``TO`` (playlist index) in + the playlist. If ``TO`` is negative, it is relative to the current + song in the playlist (if there is one). + """ + cpid = int(cpid) + to = int(to) + cp_track = frontend.backend.current_playlist.get(cpid=cpid) + position = frontend.backend.current_playlist.cp_tracks.index(cp_track) + frontend.backend.current_playlist.move(position, position + 1, to) + +@handle_pattern(r'^playlist$') +def playlist(frontend): + """ + *musicpd.org, current playlist section:* + + ``playlist`` + + Displays the current playlist. + + .. note:: + + Do not use this, instead use ``playlistinfo``. + """ + return playlistinfo(frontend) + +@handle_pattern(r'^playlistfind (?P[^"]+) "(?P[^"]+)"$') +@handle_pattern(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') +def playlistfind(frontend, tag, needle): + """ + *musicpd.org, current playlist section:* + + ``playlistfind {TAG} {NEEDLE}`` + + Finds songs in the current playlist with strict matching. + + *GMPC:* + + - does not add quotes around the tag. + """ + if tag == 'filename': + try: + cp_track = frontend.backend.current_playlist.get(uri=needle) + return cp_track[1].mpd_format() + except LookupError: + return None + raise MpdNotImplemented # TODO + +@handle_pattern(r'^playlistid( "(?P\d+)")*$') +def playlistid(frontend, cpid=None): + """ + *musicpd.org, current playlist section:* + + ``playlistid {SONGID}`` + + Displays a list of songs in the playlist. ``SONGID`` is optional + and specifies a single song to display info for. + """ + if cpid is not None: + try: + cpid = int(cpid) + cp_track = frontend.backend.current_playlist.get(cpid=cpid) + position = frontend.backend.current_playlist.cp_tracks.index( + cp_track) + return cp_track[1].mpd_format(position=position, cpid=cpid) + except LookupError: + raise MpdNoExistError(u'No such song', command=u'playlistid') + else: + return frontend.backend.current_playlist.mpd_format() + +@handle_pattern(r'^playlistinfo$') +@handle_pattern(r'^playlistinfo "(?P-?\d+)"$') +@handle_pattern(r'^playlistinfo "(?P\d+):(?P\d+)*"$') +def playlistinfo(frontend, songpos=None, + start=None, end=None): + """ + *musicpd.org, current playlist section:* + + ``playlistinfo [[SONGPOS] | [START:END]]`` + + Displays a list of all songs in the playlist, or if the optional + argument is given, displays information only for the song + ``SONGPOS`` or the range of songs ``START:END``. + + *ncmpc and mpc:* + + - uses negative indexes, like ``playlistinfo "-1"``, to request + the entire playlist + """ + if songpos == "-1": + songpos = None + + if songpos is not None: + songpos = int(songpos) + start = songpos + end = songpos + 1 + if start == -1: + end = None + return frontend.backend.current_playlist.mpd_format(start, end) + else: + if start is None: + start = 0 + start = int(start) + if not (0 <= start <= len(frontend.backend.current_playlist.tracks)): + raise MpdArgError(u'Bad song index', command=u'playlistinfo') + if end is not None: + end = int(end) + if end > len(frontend.backend.current_playlist.tracks): + end = None + return frontend.backend.current_playlist.mpd_format(start, end) + +@handle_pattern(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') +@handle_pattern(r'^playlistsearch (?P\S+) "(?P[^"]+)"$') +def playlistsearch(frontend, tag, needle): + """ + *musicpd.org, current playlist section:* + + ``playlistsearch {TAG} {NEEDLE}`` + + Searches case-sensitively for partial matches in the current + playlist. + + *GMPC:* + + - does not add quotes around the tag + - uses ``filename`` and ``any`` as tags + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^plchanges "(?P\d+)"$') +def plchanges(frontend, version): + """ + *musicpd.org, current playlist section:* + + ``plchanges {VERSION}`` + + Displays changed songs currently in the playlist since ``VERSION``. + + To detect songs that were deleted at the end of the playlist, use + ``playlistlength`` returned by status command. + """ + # XXX Naive implementation that returns all tracks as changed + if int(version) < frontend.backend.current_playlist.version: + return frontend.backend.current_playlist.mpd_format() + +@handle_pattern(r'^plchangesposid "(?P\d+)"$') +def plchangesposid(frontend, version): + """ + *musicpd.org, current playlist section:* + + ``plchangesposid {VERSION}`` + + Displays changed songs currently in the playlist since ``VERSION``. + This function only returns the position and the id of the changed + song, not the complete metadata. This is more bandwidth efficient. + + To detect songs that were deleted at the end of the playlist, use + ``playlistlength`` returned by status command. + """ + # XXX Naive implementation that returns all tracks as changed + if int(version) != frontend.backend.current_playlist.version: + result = [] + for (position, (cpid, _)) in enumerate( + frontend.backend.current_playlist.cp_tracks): + result.append((u'cpos', position)) + result.append((u'Id', cpid)) + return result + +@handle_pattern(r'^shuffle$') +@handle_pattern(r'^shuffle "(?P\d+):(?P\d+)*"$') +def shuffle(frontend, start=None, end=None): + """ + *musicpd.org, current playlist section:* + + ``shuffle [START:END]`` + + Shuffles the current playlist. ``START:END`` is optional and + specifies a range of songs. + """ + if start is not None: + start = int(start) + if end is not None: + end = int(end) + frontend.backend.current_playlist.shuffle(start, end) + +@handle_pattern(r'^swap "(?P\d+)" "(?P\d+)"$') +def swap(frontend, songpos1, songpos2): + """ + *musicpd.org, current playlist section:* + + ``swap {SONG1} {SONG2}`` + + Swaps the positions of ``SONG1`` and ``SONG2``. + """ + songpos1 = int(songpos1) + songpos2 = int(songpos2) + tracks = frontend.backend.current_playlist.tracks + song1 = tracks[songpos1] + song2 = tracks[songpos2] + del tracks[songpos1] + tracks.insert(songpos1, song2) + del tracks[songpos2] + tracks.insert(songpos2, song1) + frontend.backend.current_playlist.load(tracks) + +@handle_pattern(r'^swapid "(?P\d+)" "(?P\d+)"$') +def swapid(frontend, cpid1, cpid2): + """ + *musicpd.org, current playlist section:* + + ``swapid {SONG1} {SONG2}`` + + Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). + """ + cpid1 = int(cpid1) + cpid2 = int(cpid2) + cp_track1 = frontend.backend.current_playlist.get(cpid=cpid1) + cp_track2 = frontend.backend.current_playlist.get(cpid=cpid2) + position1 = frontend.backend.current_playlist.cp_tracks.index(cp_track1) + position2 = frontend.backend.current_playlist.cp_tracks.index(cp_track2) + swap(frontend, position1, position2) diff --git a/mopidy/frontends/mpd/protocol/music_db.py b/mopidy/frontends/mpd/protocol/music_db.py new file mode 100644 index 00000000..5aec6eae --- /dev/null +++ b/mopidy/frontends/mpd/protocol/music_db.py @@ -0,0 +1,254 @@ +import re + +from mopidy.frontends.mpd import handle_pattern, MpdNotImplemented +from mopidy.frontends.mpd.protocol import stored_playlists + +def _build_query(mpd_query): + """ + Parses a MPD query string and converts it to the Mopidy query format. + """ + query_pattern = ( + r'"?(?:[Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? "[^"]+"') + query_parts = re.findall(query_pattern, mpd_query) + query_part_pattern = ( + r'"?(?P([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny))"? ' + r'"(?P[^"]+)"') + query = {} + for query_part in query_parts: + m = re.match(query_part_pattern, query_part) + field = m.groupdict()['field'].lower() + if field == u'title': + field = u'track' + field = str(field) # Needed for kwargs keys on OS X and Windows + what = m.groupdict()['what'].lower() + if field in query: + query[field].append(what) + else: + query[field] = [what] + return query + +@handle_pattern(r'^count "(?P[^"]+)" "(?P[^"]*)"$') +def count(frontend, tag, needle): + """ + *musicpd.org, music database section:* + + ``count {TAG} {NEEDLE}`` + + Counts the number of songs and their total playtime in the db + matching ``TAG`` exactly. + """ + return [('songs', 0), ('playtime', 0)] # TODO + +@handle_pattern(r'^find ' + r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"?' + ' "[^"]+"\s?)+)$') +def find(frontend, mpd_query): + """ + *musicpd.org, music database section:* + + ``find {TYPE} {WHAT}`` + + Finds songs in the db that are exactly ``WHAT``. ``TYPE`` should be + ``album``, ``artist``, or ``title``. ``WHAT`` is what to find. + + *GMPC:* + + - does not add quotes around the field argument. + - also uses ``find album "[ALBUM]" artist "[ARTIST]"`` to list album + tracks. + + *ncmpc:* + + - does not add quotes around the field argument. + - capitalizes the type argument. + """ + query = _build_query(mpd_query) + return frontend.backend.library.find_exact(**query).mpd_format() + +@handle_pattern(r'^findadd ' + r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? ' + '"[^"]+"\s?)+)$') +def findadd(frontend, query): + """ + *musicpd.org, music database section:* + + ``findadd {TYPE} {WHAT}`` + + Finds songs in the db that are exactly ``WHAT`` and adds them to + current playlist. ``TYPE`` can be any tag supported by MPD. + ``WHAT`` is what to find. + """ + # TODO Add result to current playlist + #result = frontend.find(query) + +@handle_pattern(r'^list (?P[Aa]rtist)$') +@handle_pattern(r'^list "(?P[Aa]rtist)"$') +@handle_pattern(r'^list (?Palbum( artist)?)' + '( "(?P[^"]+)")*$') +@handle_pattern(r'^list "(?Palbum(" "artist)?)"' + '( "(?P[^"]+)")*$') +def list_(frontend, field, artist=None): + """ + *musicpd.org, music database section:* + + ``list {TYPE} [ARTIST]`` + + Lists all tags of the specified type. ``TYPE`` should be ``album``, + ``artist``, ``date``, or ``genre``. + + ``ARTIST`` is an optional parameter when type is ``album``, + ``date``, or ``genre``. + + This filters the result list by an artist. + + *GMPC:* + + - does not add quotes around the field argument. + - asks for "list artist" to get available artists and will not query + for artist/album information if this is not retrived + - asks for multiple fields, i.e.:: + + list album artist "an artist name" + + returns the albums available for the asked artist:: + + list album artist "Tiesto" + Album: Radio Trance Vol 4-Promo-CD + Album: Ur A Tear in the Open CDR + Album: Simple Trance 2004 Step One + Album: In Concert 05-10-2003 + + *ncmpc:* + + - does not add quotes around the field argument. + - capitalizes the field argument. + """ + field = field.lower() + if field == u'artist': + return _list_artist(frontend) + elif field == u'album artist': + return _list_album_artist(frontend, artist) + # TODO More to implement + +def _list_artist(frontend): + """ + Since we don't know exactly all available artists, we respond with + the artists we know for sure, which is all artists in our stored playlists. + """ + artists = set() + for playlist in frontend.backend.stored_playlists.playlists: + for track in playlist.tracks: + for artist in track.artists: + artists.add((u'Artist', artist.name)) + return artists + +def _list_album_artist(frontend, artist): + playlist = frontend.backend.library.find_exact(artist=[artist]) + albums = set() + for track in playlist.tracks: + albums.add((u'Album', track.album.name)) + return albums + +@handle_pattern(r'^listall "(?P[^"]+)"') +def listall(frontend, uri): + """ + *musicpd.org, music database section:* + + ``listall [URI]`` + + Lists all songs and directories in ``URI``. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^listallinfo "(?P[^"]+)"') +def listallinfo(frontend, uri): + """ + *musicpd.org, music database section:* + + ``listallinfo [URI]`` + + Same as ``listall``, except it also returns metadata info in the + same format as ``lsinfo``. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^lsinfo$') +@handle_pattern(r'^lsinfo "(?P[^"]*)"$') +def lsinfo(frontend, uri=None): + """ + *musicpd.org, music database section:* + + ``lsinfo [URI]`` + + Lists the contents of the directory ``URI``. + + When listing the root directory, this currently returns the list of + stored playlists. This behavior is deprecated; use + ``listplaylists`` instead. + + MPD returns the same result, including both playlists and the files and + directories located at the root level, for both ``lsinfo``, ``lsinfo + ""``, and ``lsinfo "/"``. + """ + if uri is None or uri == u'/' or uri == u'': + return stored_playlists.listplaylists(frontend) + raise MpdNotImplemented # TODO + +@handle_pattern(r'^rescan( "(?P[^"]+)")*$') +def rescan(frontend, uri=None): + """ + *musicpd.org, music database section:* + + ``rescan [URI]`` + + Same as ``update``, but also rescans unmodified files. + """ + return update(frontend, uri, rescan_unmodified_files=True) + +@handle_pattern(r'^search ' + r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"?' + ' "[^"]+"\s?)+)$') +def search(frontend, mpd_query): + """ + *musicpd.org, music database section:* + + ``search {TYPE} {WHAT}`` + + Searches for any song that contains ``WHAT``. ``TYPE`` can be + ``title``, ``artist``, ``album`` or ``filename``. Search is not + case sensitive. + + *GMPC:* + + - does not add quotes around the field argument. + - uses the undocumented field ``any``. + - searches for multiple words like this:: + + search any "foo" any "bar" any "baz" + + *ncmpc:* + + - does not add quotes around the field argument. + - capitalizes the field argument. + """ + query = _build_query(mpd_query) + return frontend.backend.library.search(**query).mpd_format() + +@handle_pattern(r'^update( "(?P[^"]+)")*$') +def update(frontend, uri=None, rescan_unmodified_files=False): + """ + *musicpd.org, music database section:* + + ``update [URI]`` + + Updates the music database: find new files, remove deleted files, + update modified files. + + ``URI`` is a particular directory or song/file to update. If you do + not specify it, everything is updated. + + Prints ``updating_db: JOBID`` where ``JOBID`` is a positive number + identifying the update job. You can read the current job id in the + ``status`` response. + """ + return {'updating_db': 0} # TODO diff --git a/mopidy/frontends/mpd/protocol/playback.py b/mopidy/frontends/mpd/protocol/playback.py new file mode 100644 index 00000000..719bd8b5 --- /dev/null +++ b/mopidy/frontends/mpd/protocol/playback.py @@ -0,0 +1,331 @@ +from mopidy.frontends.mpd import (handle_pattern, MpdArgError, MpdNoExistError, + MpdNotImplemented) + +@handle_pattern(r'^consume "(?P[01])"$') +def consume(frontend, state): + """ + *musicpd.org, playback section:* + + ``consume {STATE}`` + + Sets consume state to ``STATE``, ``STATE`` should be 0 or + 1. When consume is activated, each song played is removed from + playlist. + """ + if int(state): + frontend.backend.playback.consume = True + else: + frontend.backend.playback.consume = False + +@handle_pattern(r'^crossfade "(?P\d+)"$') +def crossfade(frontend, seconds): + """ + *musicpd.org, playback section:* + + ``crossfade {SECONDS}`` + + Sets crossfading between songs. + """ + seconds = int(seconds) + raise MpdNotImplemented # TODO + +@handle_pattern(r'^next$') +def next_(frontend): + """ + *musicpd.org, playback section:* + + ``next`` + + Plays next song in the playlist. + + *MPD's behaviour when affected by repeat/random/single/consume:* + + Given a playlist of three tracks numbered 1, 2, 3, and a currently + playing track ``c``. ``next_track`` is defined at the track that + will be played upon calls to ``next``. + + Tests performed on MPD 0.15.4-1ubuntu3. + + ====== ====== ====== ======= ===== ===== ===== ===== + Inputs next_track + ------------------------------- ------------------- ----- + repeat random single consume c = 1 c = 2 c = 3 Notes + ====== ====== ====== ======= ===== ===== ===== ===== + T T T T 2 3 EOPL + T T T . Rand Rand Rand [1] + T T . T Rand Rand Rand [4] + T T . . Rand Rand Rand [4] + T . T T 2 3 EOPL + T . T . 2 3 1 + T . . T 3 3 EOPL + T . . . 2 3 1 + . T T T Rand Rand Rand [3] + . T T . Rand Rand Rand [3] + . T . T Rand Rand Rand [2] + . T . . Rand Rand Rand [2] + . . T T 2 3 EOPL + . . T . 2 3 EOPL + . . . T 2 3 EOPL + . . . . 2 3 EOPL + ====== ====== ====== ======= ===== ===== ===== ===== + + - When end of playlist (EOPL) is reached, the current track is + unset. + - [1] When *random* and *single* is combined, ``next`` selects + a track randomly at each invocation, and not just the next track + in an internal prerandomized playlist. + - [2] When *random* is active, ``next`` will skip through + all tracks in the playlist in random order, and finally EOPL is + reached. + - [3] *single* has no effect in combination with *random* + alone, or *random* and *consume*. + - [4] When *random* and *repeat* is active, EOPL is never + reached, but the playlist is played again, in the same random + order as the first time. + + """ + return frontend.backend.playback.next() + +@handle_pattern(r'^pause "(?P[01])"$') +def pause(frontend, state): + """ + *musicpd.org, playback section:* + + ``pause {PAUSE}`` + + Toggles pause/resumes playing, ``PAUSE`` is 0 or 1. + """ + if int(state): + frontend.backend.playback.pause() + else: + frontend.backend.playback.resume() + +@handle_pattern(r'^play$') +def play(frontend): + """ + The original MPD server resumes from the paused state on ``play`` + without arguments. + """ + return frontend.backend.playback.play() + +@handle_pattern(r'^playid "(?P\d+)"$') +@handle_pattern(r'^playid "(?P-1)"$') +def playid(frontend, cpid): + """ + *musicpd.org, playback section:* + + ``playid [SONGID]`` + + Begins playing the playlist at song ``SONGID``. + + *GMPC:* + + - issues ``playid "-1"`` after playlist replacement to start playback + at the first track. + """ + cpid = int(cpid) + try: + if cpid == -1: + if not frontend.backend.current_playlist.cp_tracks: + return # Fail silently + cp_track = frontend.backend.current_playlist.cp_tracks[0] + else: + cp_track = frontend.backend.current_playlist.get(cpid=cpid) + return frontend.backend.playback.play(cp_track) + except LookupError: + raise MpdNoExistError(u'No such song', command=u'playid') + +@handle_pattern(r'^play "(?P\d+)"$') +@handle_pattern(r'^play "(?P-1)"$') +def playpos(frontend, songpos): + """ + *musicpd.org, playback section:* + + ``play [SONGPOS]`` + + Begins playing the playlist at song number ``SONGPOS``. + + *MPoD:* + + - issues ``play "-1"`` after playlist replacement to start playback at + the first track. + """ + songpos = int(songpos) + try: + if songpos == -1: + if not frontend.backend.current_playlist.cp_tracks: + return # Fail silently + cp_track = frontend.backend.current_playlist.cp_tracks[0] + else: + cp_track = frontend.backend.current_playlist.cp_tracks[songpos] + return frontend.backend.playback.play(cp_track) + except IndexError: + raise MpdArgError(u'Bad song index', command=u'play') + +@handle_pattern(r'^previous$') +def previous(frontend): + """ + *musicpd.org, playback section:* + + ``previous`` + + Plays previous song in the playlist. + + *MPD's behaviour when affected by repeat/random/single/consume:* + + Given a playlist of three tracks numbered 1, 2, 3, and a currently + playing track ``c``. ``previous_track`` is defined at the track + that will be played upon ``previous`` calls. + + Tests performed on MPD 0.15.4-1ubuntu3. + + ====== ====== ====== ======= ===== ===== ===== + Inputs previous_track + ------------------------------- ------------------- + repeat random single consume c = 1 c = 2 c = 3 + ====== ====== ====== ======= ===== ===== ===== + T T T T Rand? Rand? Rand? + T T T . 3 1 2 + T T . T Rand? Rand? Rand? + T T . . 3 1 2 + T . T T 3 1 2 + T . T . 3 1 2 + T . . T 3 1 2 + T . . . 3 1 2 + . T T T c c c + . T T . c c c + . T . T c c c + . T . . c c c + . . T T 1 1 2 + . . T . 1 1 2 + . . . T 1 1 2 + . . . . 1 1 2 + ====== ====== ====== ======= ===== ===== ===== + + - If :attr:`time_position` of the current track is 15s or more, + ``previous`` should do a seek to time position 0. + + """ + return frontend.backend.playback.previous() + +@handle_pattern(r'^random "(?P[01])"$') +def random(frontend, state): + """ + *musicpd.org, playback section:* + + ``random {STATE}`` + + Sets random state to ``STATE``, ``STATE`` should be 0 or 1. + """ + if int(state): + frontend.backend.playback.random = True + else: + frontend.backend.playback.random = False + +@handle_pattern(r'^repeat "(?P[01])"$') +def repeat(frontend, state): + """ + *musicpd.org, playback section:* + + ``repeat {STATE}`` + + Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. + """ + if int(state): + frontend.backend.playback.repeat = True + else: + frontend.backend.playback.repeat = False + +@handle_pattern(r'^replay_gain_mode "(?P(off|track|album))"$') +def replay_gain_mode(frontend, mode): + """ + *musicpd.org, playback section:* + + ``replay_gain_mode {MODE}`` + + Sets the replay gain mode. One of ``off``, ``track``, ``album``. + + Changing the mode during playback may take several seconds, because + the new settings does not affect the buffered data. + + This command triggers the options idle event. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^replay_gain_status$') +def replay_gain_status(frontend): + """ + *musicpd.org, playback section:* + + ``replay_gain_status`` + + Prints replay gain options. Currently, only the variable + ``replay_gain_mode`` is returned. + """ + return u'off' # TODO + +@handle_pattern(r'^seek "(?P\d+)" "(?P\d+)"$') +def seek(frontend, songpos, seconds): + """ + *musicpd.org, playback section:* + + ``seek {SONGPOS} {TIME}`` + + Seeks to the position ``TIME`` (in seconds) of entry ``SONGPOS`` in + the playlist. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^seekid "(?P\d+)" "(?P\d+)"$') +def seekid(frontend, cpid, seconds): + """ + *musicpd.org, playback section:* + + ``seekid {SONGID} {TIME}`` + + Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^setvol "(?P[-+]*\d+)"$') +def setvol(frontend, volume): + """ + *musicpd.org, playback section:* + + ``setvol {VOL}`` + + Sets volume to ``VOL``, the range of volume is 0-100. + """ + volume = int(volume) + if volume < 0: + volume = 0 + if volume > 100: + volume = 100 + frontend.backend.mixer.volume = volume + +@handle_pattern(r'^single "(?P[01])"$') +def single(frontend, state): + """ + *musicpd.org, playback section:* + + ``single {STATE}`` + + Sets single state to ``STATE``, ``STATE`` should be 0 or 1. When + single is activated, playback is stopped after current song, or + song is repeated if the ``repeat`` mode is enabled. + """ + if int(state): + frontend.backend.playback.single = True + else: + frontend.backend.playback.single = False + +@handle_pattern(r'^stop$') +def stop(frontend): + """ + *musicpd.org, playback section:* + + ``stop`` + + Stops playing. + """ + frontend.backend.playback.stop() diff --git a/mopidy/frontends/mpd/protocol/reflection.py b/mopidy/frontends/mpd/protocol/reflection.py new file mode 100644 index 00000000..0c349746 --- /dev/null +++ b/mopidy/frontends/mpd/protocol/reflection.py @@ -0,0 +1,79 @@ +from mopidy.frontends.mpd import (handle_pattern, mpd_commands, + MpdNotImplemented) + +@handle_pattern(r'^commands$') +def commands(frontend): + """ + *musicpd.org, reflection section:* + + ``commands`` + + Shows which commands the current user has access to. + + As permissions is not implemented, any user has access to all commands. + """ + sorted_commands = sorted(list(mpd_commands)) + + # Not shown by MPD in its command list + sorted_commands.remove('command_list_begin') + sorted_commands.remove('command_list_ok_begin') + sorted_commands.remove('command_list_end') + sorted_commands.remove('idle') + sorted_commands.remove('noidle') + sorted_commands.remove('sticker') + + return [('command', c) for c in sorted_commands] + +@handle_pattern(r'^decoders$') +def decoders(frontend): + """ + *musicpd.org, reflection section:* + + ``decoders`` + + Print a list of decoder plugins, followed by their supported + suffixes and MIME types. Example response:: + + plugin: mad + suffix: mp3 + suffix: mp2 + mime_type: audio/mpeg + plugin: mpcdec + suffix: mpc + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^notcommands$') +def notcommands(frontend): + """ + *musicpd.org, reflection section:* + + ``notcommands`` + + Shows which commands the current user does not have access to. + + As permissions is not implemented, any user has access to all commands. + """ + pass + +@handle_pattern(r'^tagtypes$') +def tagtypes(frontend): + """ + *musicpd.org, reflection section:* + + ``tagtypes`` + + Shows a list of available song metadata. + """ + pass # TODO + +@handle_pattern(r'^urlhandlers$') +def urlhandlers(frontend): + """ + *musicpd.org, reflection section:* + + ``urlhandlers`` + + Gets a list of available URL handlers. + """ + return [(u'handler', uri) for uri in frontend.backend.uri_handlers] diff --git a/mopidy/frontends/mpd/protocol/status.py b/mopidy/frontends/mpd/protocol/status.py new file mode 100644 index 00000000..16e73dea --- /dev/null +++ b/mopidy/frontends/mpd/protocol/status.py @@ -0,0 +1,216 @@ +from mopidy.frontends.mpd import handle_pattern, MpdNotImplemented + +@handle_pattern(r'^clearerror$') +def clearerror(frontend): + """ + *musicpd.org, status section:* + + ``clearerror`` + + Clears the current error message in status (this is also + accomplished by any command that starts playback). + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^currentsong$') +def currentsong(frontend): + """ + *musicpd.org, status section:* + + ``currentsong`` + + Displays the song info of the current song (same song that is + identified in status). + """ + if frontend.backend.playback.current_track is not None: + return frontend.backend.playback.current_track.mpd_format( + position=frontend.backend.playback.current_playlist_position, + cpid=frontend.backend.playback.current_cpid) + +@handle_pattern(r'^idle$') +@handle_pattern(r'^idle (?P.+)$') +def idle(frontend, subsystems=None): + """ + *musicpd.org, status section:* + + ``idle [SUBSYSTEMS...]`` + + Waits until there is a noteworthy change in one or more of MPD's + subsystems. As soon as there is one, it lists all changed systems + in a line in the format ``changed: SUBSYSTEM``, where ``SUBSYSTEM`` + is one of the following: + + - ``database``: the song database has been modified after update. + - ``update``: a database update has started or finished. If the + database was modified during the update, the database event is + also emitted. + - ``stored_playlist``: a stored playlist has been modified, + renamed, created or deleted + - ``playlist``: the current playlist has been modified + - ``player``: the player has been started, stopped or seeked + - ``mixer``: the volume has been changed + - ``output``: an audio output has been enabled or disabled + - ``options``: options like repeat, random, crossfade, replay gain + + While a client is waiting for idle results, the server disables + timeouts, allowing a client to wait for events as long as MPD runs. + The idle command can be canceled by sending the command ``noidle`` + (no other commands are allowed). MPD will then leave idle mode and + print results immediately; might be empty at this time. + + If the optional ``SUBSYSTEMS`` argument is used, MPD will only send + notifications when something changed in one of the specified + subsystems. + """ + pass # TODO + +@handle_pattern(r'^noidle$') +def noidle(frontend): + """See :meth:`_status_idle`.""" + pass # TODO + +@handle_pattern(r'^stats$') +def stats(frontend): + """ + *musicpd.org, status section:* + + ``stats`` + + Displays statistics. + + - ``artists``: number of artists + - ``songs``: number of albums + - ``uptime``: daemon uptime in seconds + - ``db_playtime``: sum of all song times in the db + - ``db_update``: last db update in UNIX time + - ``playtime``: time length of music played + """ + return { + 'artists': 0, # TODO + 'albums': 0, # TODO + 'songs': 0, # TODO + # TODO Does not work after multiprocessing branch merge + 'uptime': 0, # frontend.session.stats_uptime(), + 'db_playtime': 0, # TODO + 'db_update': 0, # TODO + 'playtime': 0, # TODO + } + +@handle_pattern(r'^status$') +def status(frontend): + """ + *musicpd.org, status section:* + + ``status`` + + Reports the current status of the player and the volume level. + + - ``volume``: 0-100 + - ``repeat``: 0 or 1 + - ``single``: 0 or 1 + - ``consume``: 0 or 1 + - ``playlist``: 31-bit unsigned integer, the playlist version + number + - ``playlistlength``: integer, the length of the playlist + - ``state``: play, stop, or pause + - ``song``: playlist song number of the current song stopped on or + playing + - ``songid``: playlist songid of the current song stopped on or + playing + - ``nextsong``: playlist song number of the next song to be played + - ``nextsongid``: playlist songid of the next song to be played + - ``time``: total time elapsed (of current playing/paused song) + - ``elapsed``: Total time elapsed within the current song, but with + higher resolution. + - ``bitrate``: instantaneous bitrate in kbps + - ``xfade``: crossfade in seconds + - ``audio``: sampleRate``:bits``:channels + - ``updatings_db``: job id + - ``error``: if there is an error, returns message here + """ + result = [ + ('volume', _status_volume(frontend)), + ('repeat', _status_repeat(frontend)), + ('random', _status_random(frontend)), + ('single', _status_single(frontend)), + ('consume', _status_consume(frontend)), + ('playlist', _status_playlist_version(frontend)), + ('playlistlength', _status_playlist_length(frontend)), + ('xfade', _status_xfade(frontend)), + ('state', _status_state(frontend)), + ] + if frontend.backend.playback.current_track is not None: + result.append(('song', _status_songpos(frontend))) + result.append(('songid', _status_songid(frontend))) + if frontend.backend.playback.state in (frontend.backend.playback.PLAYING, + frontend.backend.playback.PAUSED): + result.append(('time', _status_time(frontend))) + result.append(('elapsed', _status_time_elapsed(frontend))) + result.append(('bitrate', _status_bitrate(frontend))) + return result + +def _status_bitrate(frontend): + if frontend.backend.playback.current_track is not None: + return frontend.backend.playback.current_track.bitrate + +def _status_consume(frontend): + if frontend.backend.playback.consume: + return 1 + else: + return 0 + +def _status_playlist_length(frontend): + return len(frontend.backend.current_playlist.tracks) + +def _status_playlist_version(frontend): + return frontend.backend.current_playlist.version + +def _status_random(frontend): + return int(frontend.backend.playback.random) + +def _status_repeat(frontend): + return int(frontend.backend.playback.repeat) + +def _status_single(frontend): + return int(frontend.backend.playback.single) + +def _status_songid(frontend): + if frontend.backend.playback.current_cpid is not None: + return frontend.backend.playback.current_cpid + else: + return _status_songpos(frontend) + +def _status_songpos(frontend): + return frontend.backend.playback.current_playlist_position + +def _status_state(frontend): + if frontend.backend.playback.state == frontend.backend.playback.PLAYING: + return u'play' + elif frontend.backend.playback.state == frontend.backend.playback.STOPPED: + return u'stop' + elif frontend.backend.playback.state == frontend.backend.playback.PAUSED: + return u'pause' + +def _status_time(frontend): + return u'%s:%s' % (_status_time_elapsed(frontend) // 1000, + _status_time_total(frontend) // 1000) + +def _status_time_elapsed(frontend): + return frontend.backend.playback.time_position + +def _status_time_total(frontend): + if frontend.backend.playback.current_track is None: + return 0 + elif frontend.backend.playback.current_track.length is None: + return 0 + else: + return frontend.backend.playback.current_track.length + +def _status_volume(frontend): + if frontend.backend.mixer.volume is not None: + return frontend.backend.mixer.volume + else: + return 0 + +def _status_xfade(frontend): + return 0 # TODO diff --git a/mopidy/frontends/mpd/protocol/stickers.py b/mopidy/frontends/mpd/protocol/stickers.py new file mode 100644 index 00000000..c184d1f9 --- /dev/null +++ b/mopidy/frontends/mpd/protocol/stickers.py @@ -0,0 +1,64 @@ +from mopidy.frontends.mpd import handle_pattern, MpdNotImplemented + +@handle_pattern(r'^sticker delete "(?P[^"]+)" ' + r'"(?P[^"]+)"( "(?P[^"]+)")*$') +def sticker_delete(frontend, field, uri, name=None): + """ + *musicpd.org, sticker section:* + + ``sticker delete {TYPE} {URI} [NAME]`` + + Deletes a sticker value from the specified object. If you do not + specify a sticker name, all sticker values are deleted. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" ' + r'"(?P[^"]+)"$') +def sticker_find(frontend, field, uri, name): + """ + *musicpd.org, sticker section:* + + ``sticker find {TYPE} {URI} {NAME}`` + + Searches the sticker database for stickers with the specified name, + below the specified directory (``URI``). For each matching song, it + prints the ``URI`` and that one sticker's value. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" ' + r'"(?P[^"]+)"$') +def sticker_get(frontend, field, uri, name): + """ + *musicpd.org, sticker section:* + + ``sticker get {TYPE} {URI} {NAME}`` + + Reads a sticker value for the specified object. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^sticker list "(?P[^"]+)" "(?P[^"]+)"$') +def sticker_list(frontend, field, uri): + """ + *musicpd.org, sticker section:* + + ``sticker list {TYPE} {URI}`` + + Lists the stickers for the specified object. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" ' + r'"(?P[^"]+)" "(?P[^"]+)"$') +def sticker_set(frontend, field, uri, name, value): + """ + *musicpd.org, sticker section:* + + ``sticker set {TYPE} {URI} {NAME} {VALUE}`` + + Adds a sticker value to the specified object. If a sticker item + with that name already exists, it is replaced. + """ + raise MpdNotImplemented # TODO diff --git a/mopidy/frontends/mpd/protocol/stored_playlists.py b/mopidy/frontends/mpd/protocol/stored_playlists.py new file mode 100644 index 00000000..ecd8b321 --- /dev/null +++ b/mopidy/frontends/mpd/protocol/stored_playlists.py @@ -0,0 +1,180 @@ +import datetime as dt + +from mopidy.frontends.mpd import (handle_pattern, MpdNoExistError, + MpdNotImplemented) + +@handle_pattern(r'^listplaylist "(?P[^"]+)"$') +def listplaylist(frontend, name): + """ + *musicpd.org, stored playlists section:* + + ``listplaylist {NAME}`` + + Lists the files in the playlist ``NAME.m3u``. + + Output format:: + + file: relative/path/to/file1.flac + file: relative/path/to/file2.ogg + file: relative/path/to/file3.mp3 + """ + try: + return ['file: %s' % t.uri + for t in frontend.backend.stored_playlists.get(name=name).tracks] + except LookupError: + raise MpdNoExistError(u'No such playlist', command=u'listplaylist') + +@handle_pattern(r'^listplaylistinfo "(?P[^"]+)"$') +def listplaylistinfo(frontend, name): + """ + *musicpd.org, stored playlists section:* + + ``listplaylistinfo {NAME}`` + + Lists songs in the playlist ``NAME.m3u``. + + Output format: + + Standard track listing, with fields: file, Time, Title, Date, + Album, Artist, Track + """ + try: + return frontend.backend.stored_playlists.get(name=name).mpd_format() + except LookupError: + raise MpdNoExistError( + u'No such playlist', command=u'listplaylistinfo') + +@handle_pattern(r'^listplaylists$') +def listplaylists(frontend): + """ + *musicpd.org, stored playlists section:* + + ``listplaylists`` + + Prints a list of the playlist directory. + + After each playlist name the server sends its last modification + time as attribute ``Last-Modified`` in ISO 8601 format. To avoid + problems due to clock differences between clients and the server, + clients should not compare this value with their local clock. + + Output format:: + + playlist: a + Last-Modified: 2010-02-06T02:10:25Z + playlist: b + Last-Modified: 2010-02-06T02:11:08Z + """ + result = [] + for playlist in frontend.backend.stored_playlists.playlists: + result.append((u'playlist', playlist.name)) + last_modified = (playlist.last_modified or + dt.datetime.now()).isoformat() + # Remove microseconds + last_modified = last_modified.split('.')[0] + # Add time zone information + # TODO Convert to UTC before adding Z + last_modified = last_modified + 'Z' + result.append((u'Last-Modified', last_modified)) + return result + +@handle_pattern(r'^load "(?P[^"]+)"$') +def load(frontend, name): + """ + *musicpd.org, stored playlists section:* + + ``load {NAME}`` + + Loads the playlist ``NAME.m3u`` from the playlist directory. + """ + matches = frontend.backend.stored_playlists.search(name) + if matches: + frontend.backend.current_playlist.load(matches[0].tracks) + +@handle_pattern(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') +def playlistadd(frontend, name, uri): + """ + *musicpd.org, stored playlists section:* + + ``playlistadd {NAME} {URI}`` + + Adds ``URI`` to the playlist ``NAME.m3u``. + + ``NAME.m3u`` will be created if it does not exist. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^playlistclear "(?P[^"]+)"$') +def playlistclear(frontend, name): + """ + *musicpd.org, stored playlists section:* + + ``playlistclear {NAME}`` + + Clears the playlist ``NAME.m3u``. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') +def playlistdelete(frontend, name, songpos): + """ + *musicpd.org, stored playlists section:* + + ``playlistdelete {NAME} {SONGPOS}`` + + Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^playlistmove "(?P[^"]+)" ' + r'"(?P\d+)" "(?P\d+)"$') +def playlistmove(frontend, name, from_pos, to_pos): + """ + *musicpd.org, stored playlists section:* + + ``playlistmove {NAME} {SONGID} {SONGPOS}`` + + Moves ``SONGID`` in the playlist ``NAME.m3u`` to the position + ``SONGPOS``. + + *Clarifications:* + + - The second argument is not a ``SONGID`` as used elsewhere in the + protocol documentation, but just the ``SONGPOS`` to move *from*, + i.e. ``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') +def rename(frontend, old_name, new_name): + """ + *musicpd.org, stored playlists section:* + + ``rename {NAME} {NEW_NAME}`` + + Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^rm "(?P[^"]+)"$') +def rm(frontend, name): + """ + *musicpd.org, stored playlists section:* + + ``rm {NAME}`` + + Removes the playlist ``NAME.m3u`` from the playlist directory. + """ + raise MpdNotImplemented # TODO + +@handle_pattern(r'^save "(?P[^"]+)"$') +def save(frontend, name): + """ + *musicpd.org, stored playlists section:* + + ``save {NAME}`` + + Saves the current playlist to ``NAME.m3u`` in the playlist + directory. + """ + raise MpdNotImplemented # TODO diff --git a/mopidy/mpd/serializer.py b/mopidy/frontends/mpd/serializer.py similarity index 97% rename from mopidy/mpd/serializer.py rename to mopidy/frontends/mpd/serializer.py index b8b6550b..07a58dd3 100644 --- a/mopidy/mpd/serializer.py +++ b/mopidy/frontends/mpd/serializer.py @@ -58,7 +58,7 @@ def tracks_to_mpd_format(tracks, start=0, end=None, cpids=None): end = len(tracks) tracks = tracks[start:end] positions = range(start, end) - cpids = cpids and cpids[start:end] or [None for t in tracks] + cpids = cpids and cpids[start:end] or [None for _ in tracks] assert len(tracks) == len(positions) == len(cpids) result = [] for track, position, cpid in zip(tracks, positions, cpids): diff --git a/mopidy/mpd/server.py b/mopidy/frontends/mpd/server.py similarity index 83% rename from mopidy/mpd/server.py rename to mopidy/frontends/mpd/server.py index 4dc8058e..57b6211f 100644 --- a/mopidy/mpd/server.py +++ b/mopidy/frontends/mpd/server.py @@ -1,7 +1,3 @@ -""" -This is our MPD server implementation. -""" - import asynchat import asyncore import logging @@ -11,15 +7,10 @@ import socket import sys from mopidy import get_mpd_protocol_version, settings +from mopidy.frontends.mpd.protocol import ENCODING, LINE_TERMINATOR from mopidy.utils import indent, pickle_connection -logger = logging.getLogger('mopidy.mpd.server') - -#: The MPD protocol uses UTF-8 for encoding all data. -ENCODING = u'utf-8' - -#: The MPD protocol uses ``\n`` as line terminator. -LINE_TERMINATOR = u'\n' +logger = logging.getLogger('mopidy.frontends.mpd.server') class MpdServer(asyncore.dispatcher): """ @@ -31,6 +22,7 @@ class MpdServer(asyncore.dispatcher): self.core_queue = core_queue def start(self): + """Start MPD server.""" try: if socket.has_ipv6: self.create_socket(socket.AF_INET6, socket.SOCK_STREAM) @@ -49,6 +41,7 @@ class MpdServer(asyncore.dispatcher): sys.exit('MPD server startup failed: %s' % e) def handle_accept(self): + """Handle new client connection.""" (client_socket, client_socket_address) = self.accept() logger.info(u'MPD client connection from [%s]:%s', client_socket_address[0], client_socket_address[1]) @@ -56,6 +49,7 @@ class MpdServer(asyncore.dispatcher): self.core_queue).start() def handle_close(self): + """Handle end of client connection.""" self.close() def _format_hostname(self, hostname): @@ -67,7 +61,8 @@ class MpdServer(asyncore.dispatcher): class MpdSession(asynchat.async_chat): """ - The MPD client session. Dispatches MPD requests to the frontend. + The MPD client session. Keeps track of a single client and dispatches its + MPD requests to the frontend. """ def __init__(self, server, client_socket, client_socket_address, @@ -81,12 +76,15 @@ class MpdSession(asynchat.async_chat): self.set_terminator(LINE_TERMINATOR.encode(ENCODING)) def start(self): + """Start a new client session.""" self.send_response(u'OK MPD %s' % get_mpd_protocol_version()) def collect_incoming_data(self, data): + """Collect incoming data into buffer until a terminator is found.""" self.input_buffer.append(data) def found_terminator(self): + """Handle request when a terminator is found.""" data = ''.join(self.input_buffer).strip() self.input_buffer = [] try: @@ -98,6 +96,7 @@ class MpdSession(asynchat.async_chat): logger.warning(u'Received invalid data: %s', e) def handle_request(self, request): + """Handle request by sending it to the MPD frontend.""" my_end, other_end = multiprocessing.Pipe() self.core_queue.put({ 'command': 'mpd_request', @@ -110,9 +109,11 @@ class MpdSession(asynchat.async_chat): self.handle_response(response) def handle_response(self, response): + """Handle response from the MPD frontend.""" self.send_response(LINE_TERMINATOR.join(response)) def send_response(self, output): + """Send a response to the client.""" logger.debug(u'Output to [%s]:%s: %s', self.client_address, self.client_port, indent(output)) output = u'%s%s' % (output, LINE_TERMINATOR) diff --git a/mopidy/mixers/alsa.py b/mopidy/mixers/alsa.py index bbb99772..efcb1e98 100644 --- a/mopidy/mixers/alsa.py +++ b/mopidy/mixers/alsa.py @@ -26,7 +26,8 @@ class AlsaMixer(BaseMixer): return control else: logger.debug(u'Mixer control not found, skipping: %s', control) - logger.warning(u'No working mixer controls found. Tried: %s', candidates) + logger.warning(u'No working mixer controls found. Tried: %s', + candidates) def _get_mixer_control_candidates(self): """ diff --git a/mopidy/models.py b/mopidy/models.py index 5a75e620..e4281360 100644 --- a/mopidy/models.py +++ b/mopidy/models.py @@ -1,6 +1,6 @@ from copy import copy -from mopidy.mpd import serializer +from mopidy.frontends.mpd import serializer class ImmutableObject(object): """ diff --git a/mopidy/mpd/frontend.py b/mopidy/mpd/frontend.py deleted file mode 100644 index 041603ba..00000000 --- a/mopidy/mpd/frontend.py +++ /dev/null @@ -1,1696 +0,0 @@ -""" -This is our MPD protocol implementation. - -This is partly based upon the `MPD protocol documentation -`_, which is a useful resource, but it is -rather incomplete with regards to data formats, both for requests and -responses. Thus, we have had to talk a great deal with the the original `MPD -server `_ using telnet to get the details we need to -implement our own MPD server which is compatible with the numerous existing -`MPD clients `_. -""" - -import datetime as dt -import logging -import re - -from mopidy.mpd import (MpdAckError, MpdArgError, MpdUnknownCommand, - MpdNoExistError, MpdNotImplemented) -from mopidy.utils import flatten - -logger = logging.getLogger('mopidy.mpd.frontend') - -_commands = set() -_request_handlers = {} - -def handle_pattern(pattern): - """ - Decorator for connecting command handlers to command patterns. - - If you use named groups in the pattern, the decorated method will get the - groups as keyword arguments. If the group is optional, remember to give the - argument a default value. - - For example, if the command is ``do that thing`` the ``what`` argument will - be ``this thing``:: - - @handle_pattern('^do (?P.+)$') - def do(what): - ... - - :param pattern: regexp pattern for matching commands - :type pattern: string - """ - def decorator(func): - match = re.search('([a-z_]+)', pattern) - if match is not None: - _commands.add(match.group()) - if pattern in _request_handlers: - raise ValueError(u'Tried to redefine handler for %s with %s' % ( - pattern, func)) - _request_handlers[pattern] = func - func.__doc__ = ' - **Pattern:** ``%s``\n\n%s' % ( - pattern, func.__doc__ or '') - return func - return decorator - -class MpdFrontend(object): - def __init__(self, backend=None): - self.backend = backend - self.command_list = False - self.command_list_ok = False - - def handle_request(self, request, command_list_index=None): - if self.command_list is not False and request != u'command_list_end': - self.command_list.append(request) - return None - try: - (handler, kwargs) = self.find_handler(request) - result = handler(self, **kwargs) - except MpdAckError as e: - if command_list_index is not None: - e.index = command_list_index - return self.handle_response(e.get_mpd_ack(), add_ok=False) - if request in (u'command_list_begin', u'command_list_ok_begin'): - return None - if command_list_index is not None: - return self.handle_response(result, add_ok=False) - return self.handle_response(result) - - def find_handler(self, request): - for pattern in _request_handlers: - matches = re.match(pattern, request) - if matches is not None: - return (_request_handlers[pattern], matches.groupdict()) - command = request.split(' ')[0] - if command in _commands: - raise MpdArgError(u'incorrect arguments', command=command) - raise MpdUnknownCommand(command=command) - - def handle_response(self, result, add_ok=True): - response = [] - if result is None: - result = [] - elif isinstance(result, set): - result = list(result) - elif not isinstance(result, list): - result = [result] - for line in flatten(result): - if isinstance(line, dict): - for (key, value) in line.items(): - response.append(u'%s: %s' % (key, value)) - elif isinstance(line, tuple): - (key, value) = line - response.append(u'%s: %s' % (key, value)) - else: - response.append(line) - if add_ok and (not response or not response[-1].startswith(u'ACK')): - response.append(u'OK') - return response - - def _build_query(self, mpd_query): - """ - Parses a mpd query string and converts the MPD query to a list of - (field, what) tuples. - """ - query_pattern = ( - r'"?(?:[Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? "[^"]+"') - query_parts = re.findall(query_pattern, mpd_query) - query_part_pattern = ( - r'"?(?P([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny))"? ' - r'"(?P[^"]+)"') - query = {} - for query_part in query_parts: - m = re.match(query_part_pattern, query_part) - field = m.groupdict()['field'].lower() - if field == u'title': - field = u'track' - field = str(field) # Needed for kwargs keys on OS X and Windows - what = m.groupdict()['what'].lower() - if field in query: - query[field].append(what) - else: - query[field] = [what] - logger.debug(u'Search query: %s', query) - return query - - @handle_pattern(r'^disableoutput "(?P\d+)"$') - def _audio_output_disableoutput(self, outputid): - """ - *musicpd.org, audio output section:* - - ``disableoutput`` - - Turns an output off. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^enableoutput "(?P\d+)"$') - def _audio_output_enableoutput(self, outputid): - """ - *musicpd.org, audio output section:* - - ``enableoutput`` - - Turns an output on. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^outputs$') - def _audio_output_outputs(self): - """ - *musicpd.org, audio output section:* - - ``outputs`` - - Shows information about all outputs. - """ - return [ - ('outputid', 0), - ('outputname', self.backend.__class__.__name__), - ('outputenabled', 1), - ] - - @handle_pattern(r'^command_list_begin$') - def _command_list_begin(self): - """ - *musicpd.org, command list section:* - - To facilitate faster adding of files etc. you can pass a list of - commands all at once using a command list. The command list begins - with ``command_list_begin`` or ``command_list_ok_begin`` and ends - with ``command_list_end``. - - It does not execute any commands until the list has ended. The - return value is whatever the return for a list of commands is. On - success for all commands, ``OK`` is returned. If a command fails, - no more commands are executed and the appropriate ``ACK`` error is - returned. If ``command_list_ok_begin`` is used, ``list_OK`` is - returned for each successful command executed in the command list. - """ - self.command_list = [] - self.command_list_ok = False - - @handle_pattern(r'^command_list_end$') - def _command_list_end(self): - """See :meth:`_command_list_begin`.""" - if self.command_list is False: - # Test for False exactly, and not e.g. empty list - raise MpdUnknownCommand(command='command_list_end') - (command_list, self.command_list) = (self.command_list, False) - (command_list_ok, self.command_list_ok) = (self.command_list_ok, False) - result = [] - for i, command in enumerate(command_list): - response = self.handle_request(command, command_list_index=i) - if response is not None: - result.append(response) - if response and response[-1].startswith(u'ACK'): - return result - if command_list_ok: - response.append(u'list_OK') - return result - - @handle_pattern(r'^command_list_ok_begin$') - def _command_list_ok_begin(self): - """See :meth:`_command_list_begin`.""" - self.command_list = [] - self.command_list_ok = True - - @handle_pattern(r'^close$') - def _connection_close(self): - """ - *musicpd.org, connection section:* - - ``close`` - - Closes the connection to MPD. - """ - # TODO Does not work after multiprocessing branch merge - #self.session.do_close() - - @handle_pattern(r'^kill$') - def _connection_kill(self): - """ - *musicpd.org, connection section:* - - ``kill`` - - Kills MPD. - """ - # TODO Does not work after multiprocessing branch merge - #self.session.do_kill() - - @handle_pattern(r'^password "(?P[^"]+)"$') - def _connection_password(self, password): - """ - *musicpd.org, connection section:* - - ``password {PASSWORD}`` - - This is used for authentication with the server. ``PASSWORD`` is - simply the plaintext password. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^ping$') - def _connection_ping(self): - """ - *musicpd.org, connection section:* - - ``ping`` - - Does nothing but return ``OK``. - """ - pass - - @handle_pattern(r'^add "(?P[^"]*)"$') - def _current_playlist_add(self, uri): - """ - *musicpd.org, current playlist section:* - - ``add {URI}`` - - Adds the file ``URI`` to the playlist (directories add recursively). - ``URI`` can also be a single file. - """ - track = self.backend.library.lookup(uri) - if track is None: - raise MpdNoExistError( - u'directory or file not found', command=u'add') - self.backend.current_playlist.add(track) - - @handle_pattern(r'^addid "(?P[^"]*)"( "(?P\d+)")*$') - def _current_playlist_addid(self, uri, songpos=None): - """ - *musicpd.org, current playlist section:* - - ``addid {URI} [POSITION]`` - - Adds a song to the playlist (non-recursive) and returns the song id. - - ``URI`` is always a single file or URL. For example:: - - addid "foo.mp3" - Id: 999 - OK - """ - if songpos is not None: - songpos = int(songpos) - track = self.backend.library.lookup(uri) - if track is None: - raise MpdNoExistError(u'No such song', command=u'addid') - if songpos and songpos > len(self.backend.current_playlist.tracks): - raise MpdArgError(u'Bad song index', command=u'addid') - cp_track = self.backend.current_playlist.add(track, at_position=songpos) - return ('Id', cp_track[0]) - - @handle_pattern(r'^delete "(?P\d+):(?P\d+)*"$') - def _current_playlist_delete_range(self, start, end=None): - """ - *musicpd.org, current playlist section:* - - ``delete [{POS} | {START:END}]`` - - Deletes a song from the playlist. - """ - start = int(start) - if end is not None: - end = int(end) - else: - end = len(self.backend.current_playlist.tracks) - cp_tracks = self.backend.current_playlist.cp_tracks[start:end] - if not cp_tracks: - raise MpdArgError(u'Bad song index', command=u'delete') - for (cpid, track) in cp_tracks: - self.backend.current_playlist.remove(cpid=cpid) - - @handle_pattern(r'^delete "(?P\d+)"$') - def _current_playlist_delete_songpos(self, songpos): - """See :meth:`_current_playlist_delete_range`""" - try: - songpos = int(songpos) - (cpid, track) = self.backend.current_playlist.cp_tracks[songpos] - self.backend.current_playlist.remove(cpid=cpid) - except IndexError: - raise MpdArgError(u'Bad song index', command=u'delete') - - @handle_pattern(r'^deleteid "(?P\d+)"$') - def _current_playlist_deleteid(self, cpid): - """ - *musicpd.org, current playlist section:* - - ``deleteid {SONGID}`` - - Deletes the song ``SONGID`` from the playlist - """ - try: - cpid = int(cpid) - return self.backend.current_playlist.remove(cpid=cpid) - except LookupError: - raise MpdNoExistError(u'No such song', command=u'deleteid') - - @handle_pattern(r'^clear$') - def _current_playlist_clear(self): - """ - *musicpd.org, current playlist section:* - - ``clear`` - - Clears the current playlist. - """ - self.backend.current_playlist.clear() - - @handle_pattern(r'^move "(?P\d+):(?P\d+)*" "(?P\d+)"$') - def _current_playlist_move_range(self, start, to, end=None): - """ - *musicpd.org, current playlist section:* - - ``move [{FROM} | {START:END}] {TO}`` - - Moves the song at ``FROM`` or range of songs at ``START:END`` to - ``TO`` in the playlist. - """ - if end is None: - end = len(self.backend.current_playlist.tracks) - start = int(start) - end = int(end) - to = int(to) - self.backend.current_playlist.move(start, end, to) - - @handle_pattern(r'^move "(?P\d+)" "(?P\d+)"$') - def _current_playlist_move_songpos(self, songpos, to): - """See :meth:`_current_playlist_move_range`.""" - songpos = int(songpos) - to = int(to) - self.backend.current_playlist.move(songpos, songpos + 1, to) - - @handle_pattern(r'^moveid "(?P\d+)" "(?P\d+)"$') - def _current_playlist_moveid(self, cpid, to): - """ - *musicpd.org, current playlist section:* - - ``moveid {FROM} {TO}`` - - Moves the song with ``FROM`` (songid) to ``TO`` (playlist index) in - the playlist. If ``TO`` is negative, it is relative to the current - song in the playlist (if there is one). - """ - cpid = int(cpid) - to = int(to) - cp_track = self.backend.current_playlist.get(cpid=cpid) - position = self.backend.current_playlist.cp_tracks.index(cp_track) - self.backend.current_playlist.move(position, position + 1, to) - - @handle_pattern(r'^playlist$') - def _current_playlist_playlist(self): - """ - *musicpd.org, current playlist section:* - - ``playlist`` - - Displays the current playlist. - - .. note:: - - Do not use this, instead use ``playlistinfo``. - """ - return self._current_playlist_playlistinfo() - - @handle_pattern(r'^playlistfind (?P[^"]+) "(?P[^"]+)"$') - @handle_pattern(r'^playlistfind "(?P[^"]+)" "(?P[^"]+)"$') - def _current_playlist_playlistfind(self, tag, needle): - """ - *musicpd.org, current playlist section:* - - ``playlistfind {TAG} {NEEDLE}`` - - Finds songs in the current playlist with strict matching. - - *GMPC:* - - - does not add quotes around the tag. - """ - if tag == 'filename': - try: - cp_track = self.backend.current_playlist.get(uri=needle) - return cp_track[1].mpd_format() - except LookupError: - return None - raise MpdNotImplemented # TODO - - @handle_pattern(r'^playlistid( "(?P\d+)")*$') - def _current_playlist_playlistid(self, cpid=None): - """ - *musicpd.org, current playlist section:* - - ``playlistid {SONGID}`` - - Displays a list of songs in the playlist. ``SONGID`` is optional - and specifies a single song to display info for. - """ - if cpid is not None: - try: - cpid = int(cpid) - cp_track = self.backend.current_playlist.get(cpid=cpid) - position = self.backend.current_playlist.cp_tracks.index( - cp_track) - return cp_track[1].mpd_format(position=position, cpid=cpid) - except LookupError: - raise MpdNoExistError(u'No such song', command=u'playlistid') - else: - return self.backend.current_playlist.mpd_format() - - @handle_pattern(r'^playlistinfo$') - @handle_pattern(r'^playlistinfo "(?P-?\d+)"$') - @handle_pattern(r'^playlistinfo "(?P\d+):(?P\d+)*"$') - def _current_playlist_playlistinfo(self, songpos=None, - start=None, end=None): - """ - *musicpd.org, current playlist section:* - - ``playlistinfo [[SONGPOS] | [START:END]]`` - - Displays a list of all songs in the playlist, or if the optional - argument is given, displays information only for the song - ``SONGPOS`` or the range of songs ``START:END``. - - *ncmpc and mpc:* - - - uses negative indexes, like ``playlistinfo "-1"``, to request - the entire playlist - """ - if songpos == "-1": - songpos = None - - if songpos is not None: - songpos = int(songpos) - start = songpos - end = songpos + 1 - if start == -1: - end = None - return self.backend.current_playlist.mpd_format(start, end) - else: - if start is None: - start = 0 - start = int(start) - if not (0 <= start <= len(self.backend.current_playlist.tracks)): - raise MpdArgError(u'Bad song index', command=u'playlistinfo') - if end is not None: - end = int(end) - if end > len(self.backend.current_playlist.tracks): - end = None - return self.backend.current_playlist.mpd_format(start, end) - - @handle_pattern(r'^playlistsearch "(?P[^"]+)" "(?P[^"]+)"$') - @handle_pattern(r'^playlistsearch (?P\S+) "(?P[^"]+)"$') - def _current_playlist_playlistsearch(self, tag, needle): - """ - *musicpd.org, current playlist section:* - - ``playlistsearch {TAG} {NEEDLE}`` - - Searches case-sensitively for partial matches in the current - playlist. - - *GMPC:* - - - does not add quotes around the tag - - uses ``filename`` and ``any`` as tags - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^plchanges "(?P\d+)"$') - def _current_playlist_plchanges(self, version): - """ - *musicpd.org, current playlist section:* - - ``plchanges {VERSION}`` - - Displays changed songs currently in the playlist since ``VERSION``. - - To detect songs that were deleted at the end of the playlist, use - ``playlistlength`` returned by status command. - """ - # XXX Naive implementation that returns all tracks as changed - if int(version) < self.backend.current_playlist.version: - return self.backend.current_playlist.mpd_format() - - @handle_pattern(r'^plchangesposid "(?P\d+)"$') - def _current_playlist_plchangesposid(self, version): - """ - *musicpd.org, current playlist section:* - - ``plchangesposid {VERSION}`` - - Displays changed songs currently in the playlist since ``VERSION``. - This function only returns the position and the id of the changed - song, not the complete metadata. This is more bandwidth efficient. - - To detect songs that were deleted at the end of the playlist, use - ``playlistlength`` returned by status command. - """ - # XXX Naive implementation that returns all tracks as changed - if int(version) != self.backend.current_playlist.version: - result = [] - for (position, (cpid, track)) in enumerate( - self.backend.current_playlist.cp_tracks): - result.append((u'cpos', position)) - result.append((u'Id', cpid)) - return result - - @handle_pattern(r'^shuffle$') - @handle_pattern(r'^shuffle "(?P\d+):(?P\d+)*"$') - def _current_playlist_shuffle(self, start=None, end=None): - """ - *musicpd.org, current playlist section:* - - ``shuffle [START:END]`` - - Shuffles the current playlist. ``START:END`` is optional and - specifies a range of songs. - """ - if start is not None: - start = int(start) - if end is not None: - end = int(end) - self.backend.current_playlist.shuffle(start, end) - - @handle_pattern(r'^swap "(?P\d+)" "(?P\d+)"$') - def _current_playlist_swap(self, songpos1, songpos2): - """ - *musicpd.org, current playlist section:* - - ``swap {SONG1} {SONG2}`` - - Swaps the positions of ``SONG1`` and ``SONG2``. - """ - songpos1 = int(songpos1) - songpos2 = int(songpos2) - tracks = self.backend.current_playlist.tracks - song1 = tracks[songpos1] - song2 = tracks[songpos2] - del tracks[songpos1] - tracks.insert(songpos1, song2) - del tracks[songpos2] - tracks.insert(songpos2, song1) - self.backend.current_playlist.load(tracks) - - @handle_pattern(r'^swapid "(?P\d+)" "(?P\d+)"$') - def _current_playlist_swapid(self, cpid1, cpid2): - """ - *musicpd.org, current playlist section:* - - ``swapid {SONG1} {SONG2}`` - - Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids). - """ - cpid1 = int(cpid1) - cpid2 = int(cpid2) - cp_track1 = self.backend.current_playlist.get(cpid=cpid1) - cp_track2 = self.backend.current_playlist.get(cpid=cpid2) - position1 = self.backend.current_playlist.cp_tracks.index(cp_track1) - position2 = self.backend.current_playlist.cp_tracks.index(cp_track2) - self._current_playlist_swap(position1, position2) - - @handle_pattern(r'^$') - def _empty(self): - """The original MPD server returns ``OK`` on an empty request.``""" - pass - - @handle_pattern(r'^count "(?P[^"]+)" "(?P[^"]*)"$') - def _music_db_count(self, tag, needle): - """ - *musicpd.org, music database section:* - - ``count {TAG} {NEEDLE}`` - - Counts the number of songs and their total playtime in the db - matching ``TAG`` exactly. - """ - return [('songs', 0), ('playtime', 0)] # TODO - - @handle_pattern(r'^find ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') - def _music_db_find(self, mpd_query): - """ - *musicpd.org, music database section:* - - ``find {TYPE} {WHAT}`` - - Finds songs in the db that are exactly ``WHAT``. ``TYPE`` should be - ``album``, ``artist``, or ``title``. ``WHAT`` is what to find. - - *GMPC:* - - - does not add quotes around the field argument. - - also uses ``find album "[ALBUM]" artist "[ARTIST]"`` to list album - tracks. - - *ncmpc:* - - - does not add quotes around the field argument. - - capitalizes the type argument. - """ - query = self._build_query(mpd_query) - return self.backend.library.find_exact(**query).mpd_format() - - @handle_pattern(r'^findadd ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') - def _music_db_findadd(self, query): - """ - *musicpd.org, music database section:* - - ``findadd {TYPE} {WHAT}`` - - Finds songs in the db that are exactly ``WHAT`` and adds them to - current playlist. ``TYPE`` can be any tag supported by MPD. - ``WHAT`` is what to find. - """ - result = self._music_db_find(query) - # TODO Add result to current playlist - #return result - - @handle_pattern(r'^list (?P[Aa]rtist)$') - @handle_pattern(r'^list "(?P[Aa]rtist)"$') - @handle_pattern(r'^list (?Palbum( artist)?)( "(?P[^"]+)")*$') - @handle_pattern(r'^list "(?Palbum(" "artist)?)"( "(?P[^"]+)")*$') - def _music_db_list(self, field, artist=None): - """ - *musicpd.org, music database section:* - - ``list {TYPE} [ARTIST]`` - - Lists all tags of the specified type. ``TYPE`` should be ``album``, - ``artist``, ``date``, or ``genre``. - - ``ARTIST`` is an optional parameter when type is ``album``, - ``date``, or ``genre``. - - This filters the result list by an artist. - - *GMPC:* - - - does not add quotes around the field argument. - - asks for "list artist" to get available artists and will not query - for artist/album information if this is not retrived - - asks for multiple fields, i.e.:: - - list album artist "an artist name" - - returns the albums available for the asked artist:: - - list album artist "Tiesto" - Album: Radio Trance Vol 4-Promo-CD - Album: Ur A Tear in the Open CDR - Album: Simple Trance 2004 Step One - Album: In Concert 05-10-2003 - - *ncmpc:* - - - does not add quotes around the field argument. - - capitalizes the field argument. - """ - field = field.lower() - if field == u'artist': - return self.__music_db_list_artist() - elif field == u'album artist': - return self.__music_db_list_album_artist(artist) - # TODO More to implement - - def __music_db_list_artist(self): - """ - Since we don't know exactly all available artists, we respond with - the artists we know for sure, which is all artists in our stored playlists. - """ - artists = set() - for playlist in self.backend.stored_playlists.playlists: - for track in playlist.tracks: - for artist in track.artists: - artists.add((u'Artist', artist.name)) - return artists - - def __music_db_list_album_artist(self, artist): - playlist = self.backend.library.find_exact(artist=[artist]) - albums = set() - for track in playlist.tracks: - albums.add((u'Album', track.album.name)) - return albums - - @handle_pattern(r'^listall "(?P[^"]+)"') - def _music_db_listall(self, uri): - """ - *musicpd.org, music database section:* - - ``listall [URI]`` - - Lists all songs and directories in ``URI``. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^listallinfo "(?P[^"]+)"') - def _music_db_listallinfo(self, uri): - """ - *musicpd.org, music database section:* - - ``listallinfo [URI]`` - - Same as ``listall``, except it also returns metadata info in the - same format as ``lsinfo``. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^lsinfo$') - @handle_pattern(r'^lsinfo "(?P[^"]*)"$') - def _music_db_lsinfo(self, uri=None): - """ - *musicpd.org, music database section:* - - ``lsinfo [URI]`` - - Lists the contents of the directory ``URI``. - - When listing the root directory, this currently returns the list of - stored playlists. This behavior is deprecated; use - ``listplaylists`` instead. - - MPD returns the same result, including both playlists and the files and - directories located at the root level, for both ``lsinfo``, ``lsinfo - ""``, and ``lsinfo "/"``. - """ - if uri is None or uri == u'/' or uri == u'': - return self._stored_playlists_listplaylists() - raise MpdNotImplemented # TODO - - @handle_pattern(r'^rescan( "(?P[^"]+)")*$') - def _music_db_rescan(self, uri=None): - """ - *musicpd.org, music database section:* - - ``rescan [URI]`` - - Same as ``update``, but also rescans unmodified files. - """ - return self._music_db_update(uri, rescan_unmodified_files=True) - - @handle_pattern(r'^search ' - r'(?P("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? "[^"]+"\s?)+)$') - def _music_db_search(self, mpd_query): - """ - *musicpd.org, music database section:* - - ``search {TYPE} {WHAT}`` - - Searches for any song that contains ``WHAT``. ``TYPE`` can be - ``title``, ``artist``, ``album`` or ``filename``. Search is not - case sensitive. - - *GMPC:* - - - does not add quotes around the field argument. - - uses the undocumented field ``any``. - - searches for multiple words like this:: - - search any "foo" any "bar" any "baz" - - *ncmpc:* - - - does not add quotes around the field argument. - - capitalizes the field argument. - """ - query = self._build_query(mpd_query) - return self.backend.library.search(**query).mpd_format() - - @handle_pattern(r'^update( "(?P[^"]+)")*$') - def _music_db_update(self, uri=None, rescan_unmodified_files=False): - """ - *musicpd.org, music database section:* - - ``update [URI]`` - - Updates the music database: find new files, remove deleted files, - update modified files. - - ``URI`` is a particular directory or song/file to update. If you do - not specify it, everything is updated. - - Prints ``updating_db: JOBID`` where ``JOBID`` is a positive number - identifying the update job. You can read the current job id in the - ``status`` response. - """ - return {'updating_db': 0} # TODO - - @handle_pattern(r'^consume "(?P[01])"$') - def _playback_consume(self, state): - """ - *musicpd.org, playback section:* - - ``consume {STATE}`` - - Sets consume state to ``STATE``, ``STATE`` should be 0 or - 1. When consume is activated, each song played is removed from - playlist. - """ - if int(state): - self.backend.playback.consume = True - else: - self.backend.playback.consume = False - - @handle_pattern(r'^crossfade "(?P\d+)"$') - def _playback_crossfade(self, seconds): - """ - *musicpd.org, playback section:* - - ``crossfade {SECONDS}`` - - Sets crossfading between songs. - """ - seconds = int(seconds) - raise MpdNotImplemented # TODO - - @handle_pattern(r'^next$') - def _playback_next(self): - """ - *musicpd.org, playback section:* - - ``next`` - - Plays next song in the playlist. - - *MPD's behaviour when affected by repeat/random/single/consume:* - - Given a playlist of three tracks numbered 1, 2, 3, and a currently - playing track ``c``. ``next_track`` is defined at the track that - will be played upon calls to ``next``. - - Tests performed on MPD 0.15.4-1ubuntu3. - - ====== ====== ====== ======= ===== ===== ===== ===== - Inputs next_track - ------------------------------- ------------------- ----- - repeat random single consume c = 1 c = 2 c = 3 Notes - ====== ====== ====== ======= ===== ===== ===== ===== - T T T T 2 3 EOPL - T T T . Rand Rand Rand [1] - T T . T Rand Rand Rand [4] - T T . . Rand Rand Rand [4] - T . T T 2 3 EOPL - T . T . 2 3 1 - T . . T 3 3 EOPL - T . . . 2 3 1 - . T T T Rand Rand Rand [3] - . T T . Rand Rand Rand [3] - . T . T Rand Rand Rand [2] - . T . . Rand Rand Rand [2] - . . T T 2 3 EOPL - . . T . 2 3 EOPL - . . . T 2 3 EOPL - . . . . 2 3 EOPL - ====== ====== ====== ======= ===== ===== ===== ===== - - - When end of playlist (EOPL) is reached, the current track is - unset. - - [1] When *random* and *single* is combined, ``next`` selects - a track randomly at each invocation, and not just the next track - in an internal prerandomized playlist. - - [2] When *random* is active, ``next`` will skip through - all tracks in the playlist in random order, and finally EOPL is - reached. - - [3] *single* has no effect in combination with *random* - alone, or *random* and *consume*. - - [4] When *random* and *repeat* is active, EOPL is never - reached, but the playlist is played again, in the same random - order as the first time. - - """ - return self.backend.playback.next() - - @handle_pattern(r'^pause "(?P[01])"$') - def _playback_pause(self, state): - """ - *musicpd.org, playback section:* - - ``pause {PAUSE}`` - - Toggles pause/resumes playing, ``PAUSE`` is 0 or 1. - """ - if int(state): - self.backend.playback.pause() - else: - self.backend.playback.resume() - - @handle_pattern(r'^play$') - def _playback_play(self): - """ - The original MPD server resumes from the paused state on ``play`` - without arguments. - """ - return self.backend.playback.play() - - @handle_pattern(r'^playid "(?P\d+)"$') - @handle_pattern(r'^playid "(?P-1)"$') - def _playback_playid(self, cpid): - """ - *musicpd.org, playback section:* - - ``playid [SONGID]`` - - Begins playing the playlist at song ``SONGID``. - - *GMPC:* - - - issues ``playid "-1"`` after playlist replacement to start playback - at the first track. - """ - cpid = int(cpid) - try: - if cpid == -1: - cp_track = self.backend.current_playlist.cp_tracks[0] - else: - cp_track = self.backend.current_playlist.get(cpid=cpid) - return self.backend.playback.play(cp_track) - except LookupError: - raise MpdNoExistError(u'No such song', command=u'playid') - - @handle_pattern(r'^play "(?P\d+)"$') - @handle_pattern(r'^play "(?P-1)"$') - def _playback_playpos(self, songpos): - """ - *musicpd.org, playback section:* - - ``play [SONGPOS]`` - - Begins playing the playlist at song number ``SONGPOS``. - - *MPoD:* - - - issues ``play "-1"`` after playlist replacement to start playback at - the first track. - """ - songpos = int(songpos) - try: - if songpos == -1: - cp_track = self.backend.current_playlist.cp_tracks[0] - else: - cp_track = self.backend.current_playlist.cp_tracks[songpos] - return self.backend.playback.play(cp_track) - except IndexError: - raise MpdArgError(u'Bad song index', command=u'play') - - @handle_pattern(r'^previous$') - def _playback_previous(self): - """ - *musicpd.org, playback section:* - - ``previous`` - - Plays previous song in the playlist. - - *MPD's behaviour when affected by repeat/random/single/consume:* - - Given a playlist of three tracks numbered 1, 2, 3, and a currently - playing track ``c``. ``previous_track`` is defined at the track - that will be played upon ``previous`` calls. - - Tests performed on MPD 0.15.4-1ubuntu3. - - ====== ====== ====== ======= ===== ===== ===== - Inputs previous_track - ------------------------------- ------------------- - repeat random single consume c = 1 c = 2 c = 3 - ====== ====== ====== ======= ===== ===== ===== - T T T T Rand? Rand? Rand? - T T T . 3 1 2 - T T . T Rand? Rand? Rand? - T T . . 3 1 2 - T . T T 3 1 2 - T . T . 3 1 2 - T . . T 3 1 2 - T . . . 3 1 2 - . T T T c c c - . T T . c c c - . T . T c c c - . T . . c c c - . . T T 1 1 2 - . . T . 1 1 2 - . . . T 1 1 2 - . . . . 1 1 2 - ====== ====== ====== ======= ===== ===== ===== - - - If :attr:`time_position` of the current track is 15s or more, - ``previous`` should do a seek to time position 0. - - """ - return self.backend.playback.previous() - - @handle_pattern(r'^random "(?P[01])"$') - def _playback_random(self, state): - """ - *musicpd.org, playback section:* - - ``random {STATE}`` - - Sets random state to ``STATE``, ``STATE`` should be 0 or 1. - """ - if int(state): - self.backend.playback.random = True - else: - self.backend.playback.random = False - - @handle_pattern(r'^repeat "(?P[01])"$') - def _playback_repeat(self, state): - """ - *musicpd.org, playback section:* - - ``repeat {STATE}`` - - Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1. - """ - if int(state): - self.backend.playback.repeat = True - else: - self.backend.playback.repeat = False - - @handle_pattern(r'^replay_gain_mode "(?P(off|track|album))"$') - def _playback_replay_gain_mode(self, mode): - """ - *musicpd.org, playback section:* - - ``replay_gain_mode {MODE}`` - - Sets the replay gain mode. One of ``off``, ``track``, ``album``. - - Changing the mode during playback may take several seconds, because - the new settings does not affect the buffered data. - - This command triggers the options idle event. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^replay_gain_status$') - def _playback_replay_gain_status(self): - """ - *musicpd.org, playback section:* - - ``replay_gain_status`` - - Prints replay gain options. Currently, only the variable - ``replay_gain_mode`` is returned. - """ - return u'off' # TODO - - @handle_pattern(r'^seek "(?P\d+)" "(?P\d+)"$') - def _playback_seek(self, songpos, seconds): - """ - *musicpd.org, playback section:* - - ``seek {SONGPOS} {TIME}`` - - Seeks to the position ``TIME`` (in seconds) of entry ``SONGPOS`` in - the playlist. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^seekid "(?P\d+)" "(?P\d+)"$') - def _playback_seekid(self, cpid, seconds): - """ - *musicpd.org, playback section:* - - ``seekid {SONGID} {TIME}`` - - Seeks to the position ``TIME`` (in seconds) of song ``SONGID``. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^setvol "(?P[-+]*\d+)"$') - def _playback_setvol(self, volume): - """ - *musicpd.org, playback section:* - - ``setvol {VOL}`` - - Sets volume to ``VOL``, the range of volume is 0-100. - """ - volume = int(volume) - if volume < 0: - volume = 0 - if volume > 100: - volume = 100 - self.backend.mixer.volume = volume - - @handle_pattern(r'^single "(?P[01])"$') - def _playback_single(self, state): - """ - *musicpd.org, playback section:* - - ``single {STATE}`` - - Sets single state to ``STATE``, ``STATE`` should be 0 or 1. When - single is activated, playback is stopped after current song, or - song is repeated if the ``repeat`` mode is enabled. - """ - if int(state): - self.backend.playback.single = True - else: - self.backend.playback.single = False - - @handle_pattern(r'^stop$') - def _playback_stop(self): - """ - *musicpd.org, playback section:* - - ``stop`` - - Stops playing. - """ - self.backend.playback.stop() - - @handle_pattern(r'^commands$') - def _reflection_commands(self): - """ - *musicpd.org, reflection section:* - - ``commands`` - - Shows which commands the current user has access to. - - As permissions is not implemented, any user has access to all commands. - """ - commands = sorted(list(_commands)) - - # Not shown by MPD in its command list - commands.remove('command_list_begin') - commands.remove('command_list_ok_begin') - commands.remove('command_list_end') - commands.remove('idle') - commands.remove('noidle') - commands.remove('sticker') - - return [('command', c) for c in commands] - - @handle_pattern(r'^decoders$') - def _reflection_decoders(self): - """ - *musicpd.org, reflection section:* - - ``decoders`` - - Print a list of decoder plugins, followed by their supported - suffixes and MIME types. Example response:: - - plugin: mad - suffix: mp3 - suffix: mp2 - mime_type: audio/mpeg - plugin: mpcdec - suffix: mpc - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^notcommands$') - def _reflection_notcommands(self): - """ - *musicpd.org, reflection section:* - - ``notcommands`` - - Shows which commands the current user does not have access to. - - As permissions is not implemented, any user has access to all commands. - """ - pass - - @handle_pattern(r'^tagtypes$') - def _reflection_tagtypes(self): - """ - *musicpd.org, reflection section:* - - ``tagtypes`` - - Shows a list of available song metadata. - """ - pass # TODO - - @handle_pattern(r'^urlhandlers$') - def _reflection_urlhandlers(self): - """ - *musicpd.org, reflection section:* - - ``urlhandlers`` - - Gets a list of available URL handlers. - """ - return [(u'handler', uri) for uri in self.backend.uri_handlers] - - @handle_pattern(r'^clearerror$') - def _status_clearerror(self): - """ - *musicpd.org, status section:* - - ``clearerror`` - - Clears the current error message in status (this is also - accomplished by any command that starts playback). - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^currentsong$') - def _status_currentsong(self): - """ - *musicpd.org, status section:* - - ``currentsong`` - - Displays the song info of the current song (same song that is - identified in status). - """ - if self.backend.playback.current_track is not None: - return self.backend.playback.current_track.mpd_format( - position=self.backend.playback.current_playlist_position, - cpid=self.backend.playback.current_cpid) - - @handle_pattern(r'^idle$') - @handle_pattern(r'^idle (?P.+)$') - def _status_idle(self, subsystems=None): - """ - *musicpd.org, status section:* - - ``idle [SUBSYSTEMS...]`` - - Waits until there is a noteworthy change in one or more of MPD's - subsystems. As soon as there is one, it lists all changed systems - in a line in the format ``changed: SUBSYSTEM``, where ``SUBSYSTEM`` - is one of the following: - - - ``database``: the song database has been modified after update. - - ``update``: a database update has started or finished. If the - database was modified during the update, the database event is - also emitted. - - ``stored_playlist``: a stored playlist has been modified, - renamed, created or deleted - - ``playlist``: the current playlist has been modified - - ``player``: the player has been started, stopped or seeked - - ``mixer``: the volume has been changed - - ``output``: an audio output has been enabled or disabled - - ``options``: options like repeat, random, crossfade, replay gain - - While a client is waiting for idle results, the server disables - timeouts, allowing a client to wait for events as long as MPD runs. - The idle command can be canceled by sending the command ``noidle`` - (no other commands are allowed). MPD will then leave idle mode and - print results immediately; might be empty at this time. - - If the optional ``SUBSYSTEMS`` argument is used, MPD will only send - notifications when something changed in one of the specified - subsystems. - """ - pass # TODO - - @handle_pattern(r'^noidle$') - def _status_noidle(self): - """See :meth:`_status_idle`.""" - pass # TODO - - @handle_pattern(r'^stats$') - def _status_stats(self): - """ - *musicpd.org, status section:* - - ``stats`` - - Displays statistics. - - - ``artists``: number of artists - - ``songs``: number of albums - - ``uptime``: daemon uptime in seconds - - ``db_playtime``: sum of all song times in the db - - ``db_update``: last db update in UNIX time - - ``playtime``: time length of music played - """ - return { - 'artists': 0, # TODO - 'albums': 0, # TODO - 'songs': 0, # TODO - # TODO Does not work after multiprocessing branch merge - 'uptime': 0, # self.session.stats_uptime(), - 'db_playtime': 0, # TODO - 'db_update': 0, # TODO - 'playtime': 0, # TODO - } - - @handle_pattern(r'^status$') - def _status_status(self): - """ - *musicpd.org, status section:* - - ``status`` - - Reports the current status of the player and the volume level. - - - ``volume``: 0-100 - - ``repeat``: 0 or 1 - - ``single``: 0 or 1 - - ``consume``: 0 or 1 - - ``playlist``: 31-bit unsigned integer, the playlist version - number - - ``playlistlength``: integer, the length of the playlist - - ``state``: play, stop, or pause - - ``song``: playlist song number of the current song stopped on or - playing - - ``songid``: playlist songid of the current song stopped on or - playing - - ``nextsong``: playlist song number of the next song to be played - - ``nextsongid``: playlist songid of the next song to be played - - ``time``: total time elapsed (of current playing/paused song) - - ``elapsed``: Total time elapsed within the current song, but with - higher resolution. - - ``bitrate``: instantaneous bitrate in kbps - - ``xfade``: crossfade in seconds - - ``audio``: sampleRate``:bits``:channels - - ``updatings_db``: job id - - ``error``: if there is an error, returns message here - """ - result = [ - ('volume', self.__status_status_volume()), - ('repeat', self.__status_status_repeat()), - ('random', self.__status_status_random()), - ('single', self.__status_status_single()), - ('consume', self.__status_status_consume()), - ('playlist', self.__status_status_playlist_version()), - ('playlistlength', self.__status_status_playlist_length()), - ('xfade', self.__status_status_xfade()), - ('state', self.__status_status_state()), - ] - if self.backend.playback.current_track is not None: - result.append(('song', self.__status_status_songpos())) - result.append(('songid', self.__status_status_songid())) - if self.backend.playback.state in ( - self.backend.playback.PLAYING, self.backend.playback.PAUSED): - result.append(('time', self.__status_status_time())) - result.append(('elapsed', self.__status_status_time_elapsed())) - result.append(('bitrate', self.__status_status_bitrate())) - return result - - def __status_status_bitrate(self): - if self.backend.playback.current_track is not None: - return self.backend.playback.current_track.bitrate - - def __status_status_consume(self): - if self.backend.playback.consume: - return 1 - else: - return 0 - - def __status_status_playlist_length(self): - return len(self.backend.current_playlist.tracks) - - def __status_status_playlist_version(self): - return self.backend.current_playlist.version - - def __status_status_random(self): - return int(self.backend.playback.random) - - def __status_status_repeat(self): - return int(self.backend.playback.repeat) - - def __status_status_single(self): - return int(self.backend.playback.single) - - def __status_status_songid(self): - if self.backend.playback.current_cpid is not None: - return self.backend.playback.current_cpid - else: - return self.__status_status_songpos() - - def __status_status_songpos(self): - return self.backend.playback.current_playlist_position - - def __status_status_state(self): - if self.backend.playback.state == self.backend.playback.PLAYING: - return u'play' - elif self.backend.playback.state == self.backend.playback.STOPPED: - return u'stop' - elif self.backend.playback.state == self.backend.playback.PAUSED: - return u'pause' - - def __status_status_time(self): - return u'%s:%s' % (self.__status_status_time_elapsed() // 1000, - self.__status_status_time_total() // 1000) - - def __status_status_time_elapsed(self): - return self.backend.playback.time_position - - def __status_status_time_total(self): - if self.backend.playback.current_track is None: - return 0 - elif self.backend.playback.current_track.length is None: - return 0 - else: - return self.backend.playback.current_track.length - - def __status_status_volume(self): - if self.backend.mixer.volume is not None: - return self.backend.mixer.volume - else: - return 0 - - def __status_status_xfade(self): - return 0 # TODO - - @handle_pattern(r'^sticker delete "(?P[^"]+)" ' - r'"(?P[^"]+)"( "(?P[^"]+)")*$') - def _sticker_delete(self, field, uri, name=None): - """ - *musicpd.org, sticker section:* - - ``sticker delete {TYPE} {URI} [NAME]`` - - Deletes a sticker value from the specified object. If you do not - specify a sticker name, all sticker values are deleted. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^sticker find "(?P[^"]+)" "(?P[^"]+)" ' - r'"(?P[^"]+)"$') - def _sticker_find(self, field, uri, name): - """ - *musicpd.org, sticker section:* - - ``sticker find {TYPE} {URI} {NAME}`` - - Searches the sticker database for stickers with the specified name, - below the specified directory (``URI``). For each matching song, it - prints the ``URI`` and that one sticker's value. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^sticker get "(?P[^"]+)" "(?P[^"]+)" ' - r'"(?P[^"]+)"$') - def _sticker_get(self, field, uri, name): - """ - *musicpd.org, sticker section:* - - ``sticker get {TYPE} {URI} {NAME}`` - - Reads a sticker value for the specified object. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^sticker list "(?P[^"]+)" "(?P[^"]+)"$') - def _sticker_list(self, field, uri): - """ - *musicpd.org, sticker section:* - - ``sticker list {TYPE} {URI}`` - - Lists the stickers for the specified object. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^sticker set "(?P[^"]+)" "(?P[^"]+)" ' - r'"(?P[^"]+)" "(?P[^"]+)"$') - def _sticker_set(self, field, uri, name, value): - """ - *musicpd.org, sticker section:* - - ``sticker set {TYPE} {URI} {NAME} {VALUE}`` - - Adds a sticker value to the specified object. If a sticker item - with that name already exists, it is replaced. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^listplaylist "(?P[^"]+)"$') - def _stored_playlists_listplaylist(self, name): - """ - *musicpd.org, stored playlists section:* - - ``listplaylist {NAME}`` - - Lists the files in the playlist ``NAME.m3u``. - - Output format:: - - file: relative/path/to/file1.flac - file: relative/path/to/file2.ogg - file: relative/path/to/file3.mp3 - """ - try: - return ['file: %s' % t.uri - for t in self.backend.stored_playlists.get(name=name).tracks] - except LookupError: - raise MpdNoExistError(u'No such playlist', command=u'listplaylist') - - @handle_pattern(r'^listplaylistinfo "(?P[^"]+)"$') - def _stored_playlists_listplaylistinfo(self, name): - """ - *musicpd.org, stored playlists section:* - - ``listplaylistinfo {NAME}`` - - Lists songs in the playlist ``NAME.m3u``. - - Output format: - - Standard track listing, with fields: file, Time, Title, Date, - Album, Artist, Track - """ - try: - return self.backend.stored_playlists.get(name=name).mpd_format() - except LookupError: - raise MpdNoExistError( - u'No such playlist', command=u'listplaylistinfo') - - @handle_pattern(r'^listplaylists$') - def _stored_playlists_listplaylists(self): - """ - *musicpd.org, stored playlists section:* - - ``listplaylists`` - - Prints a list of the playlist directory. - - After each playlist name the server sends its last modification - time as attribute ``Last-Modified`` in ISO 8601 format. To avoid - problems due to clock differences between clients and the server, - clients should not compare this value with their local clock. - - Output format:: - - playlist: a - Last-Modified: 2010-02-06T02:10:25Z - playlist: b - Last-Modified: 2010-02-06T02:11:08Z - """ - result = [] - for playlist in self.backend.stored_playlists.playlists: - result.append((u'playlist', playlist.name)) - last_modified = (playlist.last_modified or - dt.datetime.now()).isoformat() - # Remove microseconds - last_modified = last_modified.split('.')[0] - # Add time zone information - # TODO Convert to UTC before adding Z - last_modified = last_modified + 'Z' - result.append((u'Last-Modified', last_modified)) - return result - - @handle_pattern(r'^load "(?P[^"]+)"$') - def _stored_playlists_load(self, name): - """ - *musicpd.org, stored playlists section:* - - ``load {NAME}`` - - Loads the playlist ``NAME.m3u`` from the playlist directory. - """ - matches = self.backend.stored_playlists.search(name) - if matches: - self.backend.current_playlist.load(matches[0].tracks) - - @handle_pattern(r'^playlistadd "(?P[^"]+)" "(?P[^"]+)"$') - def _stored_playlist_playlistadd(self, name, uri): - """ - *musicpd.org, stored playlists section:* - - ``playlistadd {NAME} {URI}`` - - Adds ``URI`` to the playlist ``NAME.m3u``. - - ``NAME.m3u`` will be created if it does not exist. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^playlistclear "(?P[^"]+)"$') - def _stored_playlist_playlistclear(self, name): - """ - *musicpd.org, stored playlists section:* - - ``playlistclear {NAME}`` - - Clears the playlist ``NAME.m3u``. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^playlistdelete "(?P[^"]+)" "(?P\d+)"$') - def _stored_playlist_playlistdelete(self, name, songpos): - """ - *musicpd.org, stored playlists section:* - - ``playlistdelete {NAME} {SONGPOS}`` - - Deletes ``SONGPOS`` from the playlist ``NAME.m3u``. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^playlistmove "(?P[^"]+)" ' - r'"(?P\d+)" "(?P\d+)"$') - def _stored_playlist_playlistmove(self, name, from_pos, to_pos): - """ - *musicpd.org, stored playlists section:* - - ``playlistmove {NAME} {SONGID} {SONGPOS}`` - - Moves ``SONGID`` in the playlist ``NAME.m3u`` to the position - ``SONGPOS``. - - *Clarifications:* - - - The second argument is not a ``SONGID`` as used elsewhere in the - protocol documentation, but just the ``SONGPOS`` to move *from*, - i.e. ``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^rename "(?P[^"]+)" "(?P[^"]+)"$') - def _stored_playlists_rename(self, old_name, new_name): - """ - *musicpd.org, stored playlists section:* - - ``rename {NAME} {NEW_NAME}`` - - Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^rm "(?P[^"]+)"$') - def _stored_playlists_rm(self, name): - """ - *musicpd.org, stored playlists section:* - - ``rm {NAME}`` - - Removes the playlist ``NAME.m3u`` from the playlist directory. - """ - raise MpdNotImplemented # TODO - - @handle_pattern(r'^save "(?P[^"]+)"$') - def _stored_playlists_save(self, name): - """ - *musicpd.org, stored playlists section:* - - ``save {NAME}`` - - Saves the current playlist to ``NAME.m3u`` in the playlist - directory. - """ - raise MpdNotImplemented # TODO diff --git a/mopidy/settings.py b/mopidy/settings.py index dc2eda57..1192c28d 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -41,8 +41,8 @@ DUMP_LOG_FILENAME = u'dump.log' #: Protocol frontend to use. Default:: #: -#: FRONTEND = u'mopidy.mpd.frontend.MpdFrontend' -FRONTEND = u'mopidy.mpd.frontend.MpdFrontend' +#: FRONTEND = u'mopidy.frontends.mpd.frontend.MpdFrontend' +FRONTEND = u'mopidy.frontends.mpd.frontend.MpdFrontend' #: Path to folder with local music. Default:: #: @@ -107,8 +107,8 @@ MIXER_EXT_SPEAKERS_B = None #: Server to use. Default:: #: -#: SERVER = u'mopidy.mpd.server.MpdServer' -SERVER = u'mopidy.mpd.server.MpdServer' +#: SERVER = u'mopidy.frontends.mpd.server.MpdServer' +SERVER = u'mopidy.frontends.mpd.server.MpdServer' #: Which address Mopidy should bind to. Examples: #: diff --git a/mopidy/utils.py b/mopidy/utils.py index 077f004e..7eac9239 100644 --- a/mopidy/utils.py +++ b/mopidy/utils.py @@ -18,11 +18,15 @@ def flatten(the_list): result.append(element) return result +def import_module(name): + __import__(name) + return sys.modules[name] + def get_class(name): module_name = name[:name.rindex('.')] class_name = name[name.rindex('.') + 1:] logger.debug('Loading: %s', name) - module = __import__(module_name, globals(), locals(), [class_name], -1) + module = import_module(module_name) class_object = getattr(module, class_name) return class_object diff --git a/tests/frontends/__init__.py b/tests/frontends/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/frontends/mpd/__init__.py b/tests/frontends/mpd/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/mpd/audio_output_test.py b/tests/frontends/mpd/audio_output_test.py similarity index 95% rename from tests/mpd/audio_output_test.py rename to tests/frontends/mpd/audio_output_test.py index 6e6ea5b5..c752f40e 100644 --- a/tests/mpd/audio_output_test.py +++ b/tests/frontends/mpd/audio_output_test.py @@ -1,8 +1,8 @@ import unittest from mopidy.backends.dummy import DummyBackend +from mopidy.frontends.mpd import frontend from mopidy.mixers.dummy import DummyMixer -from mopidy.mpd import frontend class AudioOutputHandlerTest(unittest.TestCase): def setUp(self): diff --git a/tests/mpd/command_list_test.py b/tests/frontends/mpd/command_list_test.py similarity index 98% rename from tests/mpd/command_list_test.py rename to tests/frontends/mpd/command_list_test.py index 69eb19ac..eed92a24 100644 --- a/tests/mpd/command_list_test.py +++ b/tests/frontends/mpd/command_list_test.py @@ -1,8 +1,8 @@ import unittest from mopidy.backends.dummy import DummyBackend +from mopidy.frontends.mpd import frontend from mopidy.mixers.dummy import DummyMixer -from mopidy.mpd import frontend class CommandListsTest(unittest.TestCase): def setUp(self): diff --git a/tests/mpd/connection_test.py b/tests/frontends/mpd/connection_test.py similarity index 95% rename from tests/mpd/connection_test.py rename to tests/frontends/mpd/connection_test.py index 82c7d5c8..83133050 100644 --- a/tests/mpd/connection_test.py +++ b/tests/frontends/mpd/connection_test.py @@ -1,8 +1,8 @@ import unittest from mopidy.backends.dummy import DummyBackend +from mopidy.frontends.mpd import frontend from mopidy.mixers.dummy import DummyMixer -from mopidy.mpd import frontend class ConnectionHandlerTest(unittest.TestCase): def setUp(self): diff --git a/tests/mpd/current_playlist_test.py b/tests/frontends/mpd/current_playlist_test.py similarity index 99% rename from tests/mpd/current_playlist_test.py rename to tests/frontends/mpd/current_playlist_test.py index 245cabbb..ce1e4069 100644 --- a/tests/mpd/current_playlist_test.py +++ b/tests/frontends/mpd/current_playlist_test.py @@ -1,9 +1,9 @@ import unittest from mopidy.backends.dummy import DummyBackend +from mopidy.frontends.mpd import frontend from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track -from mopidy.mpd import frontend class CurrentPlaylistHandlerTest(unittest.TestCase): def setUp(self): diff --git a/tests/mpd/exception_test.py b/tests/frontends/mpd/exception_test.py similarity index 93% rename from tests/mpd/exception_test.py rename to tests/frontends/mpd/exception_test.py index 139d56ee..e337550f 100644 --- a/tests/mpd/exception_test.py +++ b/tests/frontends/mpd/exception_test.py @@ -1,6 +1,7 @@ import unittest -from mopidy.mpd import MpdAckError, MpdUnknownCommand, MpdNotImplemented +from mopidy.frontends.mpd import (MpdAckError, MpdUnknownCommand, + MpdNotImplemented) class MpdExceptionsTest(unittest.TestCase): def test_key_error_wrapped_in_mpd_ack_error(self): diff --git a/tests/mpd/music_db_test.py b/tests/frontends/mpd/music_db_test.py similarity index 99% rename from tests/mpd/music_db_test.py rename to tests/frontends/mpd/music_db_test.py index b8feb65f..62915a58 100644 --- a/tests/mpd/music_db_test.py +++ b/tests/frontends/mpd/music_db_test.py @@ -1,8 +1,8 @@ import unittest from mopidy.backends.dummy import DummyBackend +from mopidy.frontends.mpd import frontend from mopidy.mixers.dummy import DummyMixer -from mopidy.mpd import frontend class MusicDatabaseHandlerTest(unittest.TestCase): def setUp(self): diff --git a/tests/mpd/playback_test.py b/tests/frontends/mpd/playback_test.py similarity index 91% rename from tests/mpd/playback_test.py rename to tests/frontends/mpd/playback_test.py index b3806090..aee05d6c 100644 --- a/tests/mpd/playback_test.py +++ b/tests/frontends/mpd/playback_test.py @@ -1,9 +1,9 @@ import unittest from mopidy.backends.dummy import DummyBackend +from mopidy.frontends.mpd import frontend from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track -from mopidy.mpd import frontend class PlaybackOptionsHandlerTest(unittest.TestCase): def setUp(self): @@ -180,6 +180,13 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) self.assertEqual(self.b.playback.current_track, track) + def test_play_minus_one_on_empty_playlist_does_not_ack(self): + self.b.current_playlist.clear() + result = self.h.handle_request(u'play "-1"') + self.assert_(u'OK' in result) + self.assertEqual(self.b.playback.STOPPED, self.b.playback.state) + self.assertEqual(self.b.playback.current_track, None) + def test_playid(self): self.b.current_playlist.load([Track()]) result = self.h.handle_request(u'playid "1"') @@ -194,6 +201,13 @@ class PlaybackControlHandlerTest(unittest.TestCase): self.assertEqual(self.b.playback.PLAYING, self.b.playback.state) self.assertEqual(self.b.playback.current_track, track) + def test_playid_minus_one_on_empty_playlist_does_not_ack(self): + self.b.current_playlist.clear() + result = self.h.handle_request(u'playid "-1"') + self.assert_(u'OK' in result) + self.assertEqual(self.b.playback.STOPPED, self.b.playback.state) + self.assertEqual(self.b.playback.current_track, None) + def test_playid_which_does_not_exist(self): self.b.current_playlist.load([Track()]) result = self.h.handle_request(u'playid "12345"') diff --git a/tests/mpd/reflection_test.py b/tests/frontends/mpd/reflection_test.py similarity index 97% rename from tests/mpd/reflection_test.py rename to tests/frontends/mpd/reflection_test.py index 70af604f..11bd5ba9 100644 --- a/tests/mpd/reflection_test.py +++ b/tests/frontends/mpd/reflection_test.py @@ -1,8 +1,8 @@ import unittest from mopidy.backends.dummy import DummyBackend +from mopidy.frontends.mpd import frontend from mopidy.mixers.dummy import DummyMixer -from mopidy.mpd import frontend class ReflectionHandlerTest(unittest.TestCase): def setUp(self): diff --git a/tests/mpd/request_handler_test.py b/tests/frontends/mpd/request_handler_test.py similarity index 89% rename from tests/mpd/request_handler_test.py rename to tests/frontends/mpd/request_handler_test.py index 1dcba9ad..beea4bc3 100644 --- a/tests/mpd/request_handler_test.py +++ b/tests/frontends/mpd/request_handler_test.py @@ -1,8 +1,8 @@ import unittest from mopidy.backends.dummy import DummyBackend +from mopidy.frontends.mpd import frontend, MpdAckError from mopidy.mixers.dummy import DummyMixer -from mopidy.mpd import frontend, MpdAckError class RequestHandlerTest(unittest.TestCase): def setUp(self): @@ -29,7 +29,7 @@ class RequestHandlerTest(unittest.TestCase): def test_finding_handler_for_known_command_returns_handler_and_kwargs(self): expected_handler = lambda x: None - frontend._request_handlers['known_command (?P.+)'] = \ + frontend.request_handlers['known_command (?P.+)'] = \ expected_handler (handler, kwargs) = self.h.find_handler('known_command an_arg') self.assertEqual(handler, expected_handler) @@ -42,7 +42,7 @@ class RequestHandlerTest(unittest.TestCase): def test_handling_known_request(self): expected = 'magic' - frontend._request_handlers['known request'] = lambda x: expected + frontend.request_handlers['known request'] = lambda x: expected result = self.h.handle_request('known request') self.assert_(u'OK' in result) self.assert_(expected in result) diff --git a/tests/mpd/serializer_test.py b/tests/frontends/mpd/serializer_test.py similarity index 98% rename from tests/mpd/serializer_test.py rename to tests/frontends/mpd/serializer_test.py index 38c240c2..e2fda8fa 100644 --- a/tests/mpd/serializer_test.py +++ b/tests/frontends/mpd/serializer_test.py @@ -1,8 +1,8 @@ import datetime as dt import unittest +from mopidy.frontends.mpd import serializer from mopidy.models import Album, Artist, Playlist, Track -from mopidy.mpd import serializer class TrackMpdFormatTest(unittest.TestCase): def test_mpd_format_for_empty_track(self): diff --git a/tests/mpd/server_test.py b/tests/frontends/mpd/server_test.py similarity index 96% rename from tests/mpd/server_test.py rename to tests/frontends/mpd/server_test.py index e1612c1d..9d006eb3 100644 --- a/tests/mpd/server_test.py +++ b/tests/frontends/mpd/server_test.py @@ -1,6 +1,6 @@ import unittest -from mopidy.mpd import server +from mopidy.frontends.mpd import server class MpdServerTest(unittest.TestCase): def setUp(self): diff --git a/tests/mpd/status_test.py b/tests/frontends/mpd/status_test.py similarity index 84% rename from tests/mpd/status_test.py rename to tests/frontends/mpd/status_test.py index ae1ac816..8be549d6 100644 --- a/tests/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -1,9 +1,9 @@ import unittest from mopidy.backends.dummy import DummyBackend +from mopidy.frontends.mpd import frontend from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track -from mopidy.mpd import frontend class StatusHandlerTest(unittest.TestCase): def setUp(self): @@ -52,7 +52,7 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_stats_method(self): - result = self.h._status_stats() + result = frontend.status.stats(self.h) self.assert_('artists' in result) self.assert_(int(result['artists']) >= 0) self.assert_('albums' in result) @@ -73,106 +73,106 @@ class StatusHandlerTest(unittest.TestCase): self.assert_(u'OK' in result) def test_status_method_contains_volume_which_defaults_to_0(self): - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('volume' in result) self.assertEqual(int(result['volume']), 0) def test_status_method_contains_volume(self): self.b.mixer.volume = 17 - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('volume' in result) self.assertEqual(int(result['volume']), 17) def test_status_method_contains_repeat_is_0(self): - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('repeat' in result) self.assertEqual(int(result['repeat']), 0) def test_status_method_contains_repeat_is_1(self): self.b.playback.repeat = 1 - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('repeat' in result) self.assertEqual(int(result['repeat']), 1) def test_status_method_contains_random_is_0(self): - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('random' in result) self.assertEqual(int(result['random']), 0) def test_status_method_contains_random_is_1(self): self.b.playback.random = 1 - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('random' in result) self.assertEqual(int(result['random']), 1) def test_status_method_contains_single(self): - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('single' in result) self.assert_(int(result['single']) in (0, 1)) def test_status_method_contains_consume_is_0(self): - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('consume' in result) self.assertEqual(int(result['consume']), 0) def test_status_method_contains_consume_is_1(self): self.b.playback.consume = 1 - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('consume' in result) self.assertEqual(int(result['consume']), 1) def test_status_method_contains_playlist(self): - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('playlist' in result) self.assert_(int(result['playlist']) in xrange(0, 2**31 - 1)) def test_status_method_contains_playlistlength(self): - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('playlistlength' in result) self.assert_(int(result['playlistlength']) >= 0) def test_status_method_contains_xfade(self): - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('xfade' in result) self.assert_(int(result['xfade']) >= 0) def test_status_method_contains_state_is_play(self): self.b.playback.state = self.b.playback.PLAYING - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('state' in result) self.assertEqual(result['state'], 'play') def test_status_method_contains_state_is_stop(self): self.b.playback.state = self.b.playback.STOPPED - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('state' in result) self.assertEqual(result['state'], 'stop') def test_status_method_contains_state_is_pause(self): self.b.playback.state = self.b.playback.PLAYING self.b.playback.state = self.b.playback.PAUSED - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('state' in result) self.assertEqual(result['state'], 'pause') def test_status_method_when_playlist_loaded_contains_song(self): self.b.current_playlist.load([Track()]) self.b.playback.play() - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('song' in result) self.assert_(int(result['song']) >= 0) def test_status_method_when_playlist_loaded_contains_cpid_as_songid(self): self.b.current_playlist.load([Track()]) self.b.playback.play() - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('songid' in result) self.assertEqual(int(result['songid']), 1) def test_status_method_when_playing_contains_time_with_no_length(self): self.b.current_playlist.load([Track(length=None)]) self.b.playback.play() - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('time' in result) (position, total) = result['time'].split(':') position = int(position) @@ -182,7 +182,7 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_when_playing_contains_time_with_length(self): self.b.current_playlist.load([Track(length=10000)]) self.b.playback.play() - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('time' in result) (position, total) = result['time'].split(':') position = int(position) @@ -192,13 +192,13 @@ class StatusHandlerTest(unittest.TestCase): def test_status_method_when_playing_contains_elapsed(self): self.b.playback.state = self.b.playback.PAUSED self.b.playback._play_time_accumulated = 59123 - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('elapsed' in result) self.assertEqual(int(result['elapsed']), 59123) def test_status_method_when_playing_contains_bitrate(self): self.b.current_playlist.load([Track(bitrate=320)]) self.b.playback.play() - result = dict(self.h._status_status()) + result = dict(frontend.status.status(self.h)) self.assert_('bitrate' in result) self.assertEqual(int(result['bitrate']), 320) diff --git a/tests/mpd/stickers_test.py b/tests/frontends/mpd/stickers_test.py similarity index 97% rename from tests/mpd/stickers_test.py rename to tests/frontends/mpd/stickers_test.py index 437251a4..83bbdd04 100644 --- a/tests/mpd/stickers_test.py +++ b/tests/frontends/mpd/stickers_test.py @@ -1,8 +1,8 @@ import unittest from mopidy.backends.dummy import DummyBackend +from mopidy.frontends.mpd import frontend from mopidy.mixers.dummy import DummyMixer -from mopidy.mpd import frontend class StickersHandlerTest(unittest.TestCase): def setUp(self): diff --git a/tests/mpd/stored_playlists_test.py b/tests/frontends/mpd/stored_playlists_test.py similarity index 98% rename from tests/mpd/stored_playlists_test.py rename to tests/frontends/mpd/stored_playlists_test.py index b616695e..179e0802 100644 --- a/tests/mpd/stored_playlists_test.py +++ b/tests/frontends/mpd/stored_playlists_test.py @@ -2,9 +2,9 @@ import datetime as dt import unittest from mopidy.backends.dummy import DummyBackend +from mopidy.frontends.mpd import frontend from mopidy.mixers.dummy import DummyMixer from mopidy.models import Track, Playlist -from mopidy.mpd import frontend from tests import SkipTest