From d0aac71cfbf64adad4ca89d589d0b5dd855ab9f7 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Tue, 10 Aug 2010 02:08:59 +0200 Subject: [PATCH] MPD: Split protocol implementation into 11 modules --- docs/api/mpd.rst | 112 +- docs/changes.rst | 1 + mopidy/frontends/mpd/__init__.py | 36 + mopidy/frontends/mpd/frontend.py | 1667 +---------------- mopidy/frontends/mpd/protocol/__init__.py | 17 + mopidy/frontends/mpd/protocol/audio_output.py | 38 + mopidy/frontends/mpd/protocol/command_list.py | 47 + mopidy/frontends/mpd/protocol/connection.py | 48 + .../mpd/protocol/current_playlist.py | 351 ++++ mopidy/frontends/mpd/protocol/music_db.py | 254 +++ mopidy/frontends/mpd/protocol/playback.py | 331 ++++ mopidy/frontends/mpd/protocol/reflection.py | 79 + mopidy/frontends/mpd/protocol/status.py | 216 +++ mopidy/frontends/mpd/protocol/stickers.py | 64 + .../mpd/protocol/stored_playlists.py | 180 ++ mopidy/frontends/mpd/server.py | 23 +- tests/frontends/mpd/request_handler_test.py | 4 +- tests/frontends/mpd/status_test.py | 44 +- 18 files changed, 1819 insertions(+), 1693 deletions(-) create mode 100644 mopidy/frontends/mpd/protocol/__init__.py create mode 100644 mopidy/frontends/mpd/protocol/audio_output.py create mode 100644 mopidy/frontends/mpd/protocol/command_list.py create mode 100644 mopidy/frontends/mpd/protocol/connection.py create mode 100644 mopidy/frontends/mpd/protocol/current_playlist.py create mode 100644 mopidy/frontends/mpd/protocol/music_db.py create mode 100644 mopidy/frontends/mpd/protocol/playback.py create mode 100644 mopidy/frontends/mpd/protocol/reflection.py create mode 100644 mopidy/frontends/mpd/protocol/status.py create mode 100644 mopidy/frontends/mpd/protocol/stickers.py create mode 100644 mopidy/frontends/mpd/protocol/stored_playlists.py diff --git a/docs/api/mpd.rst b/docs/api/mpd.rst index 136aad6d..2f86123d 100644 --- a/docs/api/mpd.rst +++ b/docs/api/mpd.rst @@ -6,21 +6,109 @@ :synopsis: MPD frontend -MPD protocol implementation -=========================== - -.. automodule:: mopidy.frontends.mpd.frontend - :synopsis: MPD protocol implementation - :members: - :undoc-members: - - -MPD server implementation -========================= +MPD server +========== .. automodule:: mopidy.frontends.mpd.server - :synopsis: MPD server implementation + :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 protocol +============ + +.. automodule:: mopidy.frontends.mpd.protocol + :synopsis: MPD protocol + :members: + :undoc-members: + + +Audio output +------------ + +.. automodule:: mopidy.frontends.mpd.protocol.audio_output + :members: + :undoc-members: + + +Command list +------------ + +.. automodule:: mopidy.frontends.mpd.protocol.command_list + :members: + :undoc-members: + + +Connection +---------- + +.. automodule:: mopidy.frontends.mpd.protocol.connection + :members: + :undoc-members: + + +Current playlist +---------------- + +.. automodule:: mopidy.frontends.mpd.protocol.current_playlist + :members: + :undoc-members: + +Music database +-------------- + +.. automodule:: mopidy.frontends.mpd.protocol.music_db + :members: + :undoc-members: + + +Playback +-------- + +.. automodule:: mopidy.frontends.mpd.protocol.playback + :members: + :undoc-members: + + +Reflection +---------- + +.. automodule:: mopidy.frontends.mpd.protocol.reflection + :members: + :undoc-members: + + +Status +------ + +.. automodule:: mopidy.frontends.mpd.protocol.status + :members: + :undoc-members: + + +Stickers +-------- + +.. automodule:: mopidy.frontends.mpd.protocol.stickers + :members: + :undoc-members: + + +Stored playlists +---------------- + +.. automodule:: mopidy.frontends.mpd.protocol.stored_playlists + :members: + :undoc-members: diff --git a/docs/changes.rst b/docs/changes.rst index 036ea03c..2e765176 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -18,6 +18,7 @@ Another great release. - 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. diff --git a/mopidy/frontends/mpd/__init__.py b/mopidy/frontends/mpd/__init__.py index 2a18b2f3..2a4908d9 100644 --- a/mopidy/frontends/mpd/__init__.py +++ b/mopidy/frontends/mpd/__init__.py @@ -1,3 +1,5 @@ +import re + from mopidy import MopidyException class MpdAckError(MopidyException): @@ -55,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 index 46fb5ba5..9a0251eb 100644 --- a/mopidy/frontends/mpd/frontend.py +++ b/mopidy/frontends/mpd/frontend.py @@ -1,66 +1,29 @@ -""" -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.frontends.mpd import (MpdAckError, MpdArgError, MpdUnknownCommand, - MpdNoExistError, MpdNotImplemented) +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') -_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): + """ + 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 @@ -78,16 +41,18 @@ class MpdFrontend(object): return self.handle_response(result) def find_handler(self, request): - for pattern in _request_handlers: + """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()) + return (request_handlers[pattern], matches.groupdict()) command = request.split(' ')[0] - if command in _commands: + 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 = [] @@ -108,1597 +73,7 @@ class MpdFrontend(object): 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, _) 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, _) = 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, _)) 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. - """ - # TODO Add result to current playlist - #result = self._music_db_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 _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: - if not self.backend.current_playlist.cp_tracks: - return # Fail silently - 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: - if not self.backend.current_playlist.cp_tracks: - return # Fail silently - 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 +@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..fa1a5e56 --- /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..72042983 --- /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 _stored_playlist_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 _stored_playlist_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 _stored_playlist_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 _stored_playlist_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/frontends/mpd/server.py b/mopidy/frontends/mpd/server.py index 1458b90e..57b6211f 100644 --- a/mopidy/frontends/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,16 +7,11 @@ 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.frontends.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' - class MpdServer(asyncore.dispatcher): """ The MPD server. Creates a :class:`MpdSession` for each client connection. @@ -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/tests/frontends/mpd/request_handler_test.py b/tests/frontends/mpd/request_handler_test.py index ca4cdd3e..beea4bc3 100644 --- a/tests/frontends/mpd/request_handler_test.py +++ b/tests/frontends/mpd/request_handler_test.py @@ -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/frontends/mpd/status_test.py b/tests/frontends/mpd/status_test.py index 7d839b67..8be549d6 100644 --- a/tests/frontends/mpd/status_test.py +++ b/tests/frontends/mpd/status_test.py @@ -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)