Merge pull request #654 from adamcik/feature/mpd-tokenized-requests
Tokenize MPD requests instead of regexp per command variant
This commit is contained in:
commit
daa6af6f1b
@ -8,7 +8,20 @@ This changelog is used to track all major changes to Mopidy.
|
||||
v0.19.0 (UNRELEASED)
|
||||
====================
|
||||
|
||||
- Nothing yet.
|
||||
- Proper command tokenization for MPD requests. This replaces the old regex
|
||||
based system with an MPD protocol specific tokenizer responsible for breaking
|
||||
requests into pieces before the handlers have at them.
|
||||
(Fixes: :issue:`591` and :issue:`592`)
|
||||
|
||||
- Updated commands handler system. As part of the tokenizer cleanup we've updated
|
||||
how commands are registered and making it simpler to create new handlers.
|
||||
|
||||
- Simplifies a bunch of handlers. All the "browse" type commands now use a
|
||||
common browse helper under the hood for less repetition. Likewise the query
|
||||
handling of "search" commands has been somewhat simplified.
|
||||
|
||||
- Adds placeholders for missing MPD commands, preparing the way for bumping the
|
||||
protocol version once they have been added.
|
||||
|
||||
|
||||
v0.18.3 (2014-02-16)
|
||||
@ -47,6 +60,8 @@ Bug fix release.
|
||||
milliseconds since Unix epoch as an integer. This makes serialization of the
|
||||
time stamp simpler.
|
||||
|
||||
**Windows**
|
||||
=======
|
||||
- Minor refactor of the MPD server context so that Mopidy's MPD protocol
|
||||
implementation can easier be reused. (Fixes: :issue:`646`)
|
||||
|
||||
|
||||
@ -7,6 +7,13 @@ For details on how to use Mopidy's MPD server, see :ref:`ext-mpd`.
|
||||
.. automodule:: mopidy.mpd
|
||||
:synopsis: MPD server frontend
|
||||
|
||||
MPD tokenizer
|
||||
=============
|
||||
|
||||
.. automodule:: mopidy.mpd.tokenize
|
||||
:synopsis: MPD request tokenizer
|
||||
:members:
|
||||
|
||||
|
||||
MPD dispatcher
|
||||
==============
|
||||
|
||||
@ -5,7 +5,7 @@ import re
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy.mpd import exceptions, protocol
|
||||
from mopidy.mpd import exceptions, protocol, tokenize
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -37,6 +37,7 @@ class MpdDispatcher(object):
|
||||
response = []
|
||||
filter_chain = [
|
||||
self._catch_mpd_ack_errors_filter,
|
||||
# TODO: tokenize filter
|
||||
self._authenticate_filter,
|
||||
self._command_list_filter,
|
||||
self._idle_filter,
|
||||
@ -88,10 +89,8 @@ class MpdDispatcher(object):
|
||||
return self._call_next_filter(request, response, filter_chain)
|
||||
else:
|
||||
command_name = request.split(' ')[0]
|
||||
command_names_not_requiring_auth = [
|
||||
command.name for command in protocol.mpd_commands
|
||||
if not command.auth_required]
|
||||
if command_name in command_names_not_requiring_auth:
|
||||
command = protocol.commands.handlers.get(command_name)
|
||||
if command and not command.auth_required:
|
||||
return self._call_next_filter(request, response, filter_chain)
|
||||
else:
|
||||
raise exceptions.MpdPermissionError(command=command_name)
|
||||
@ -164,25 +163,15 @@ class MpdDispatcher(object):
|
||||
raise exceptions.MpdSystemError(e)
|
||||
|
||||
def _call_handler(self, request):
|
||||
(handler, kwargs) = self._find_handler(request)
|
||||
tokens = tokenize.split(request)
|
||||
try:
|
||||
return handler(self.context, **kwargs)
|
||||
return protocol.commands.call(tokens, context=self.context)
|
||||
except exceptions.MpdAckError as exc:
|
||||
if exc.command is None:
|
||||
exc.command = handler.__name__.split('__', 1)[0]
|
||||
exc.command = tokens[0]
|
||||
raise
|
||||
|
||||
def _find_handler(self, request):
|
||||
for pattern in protocol.request_handlers:
|
||||
matches = re.match(pattern, request)
|
||||
if matches is not None:
|
||||
return (
|
||||
protocol.request_handlers[pattern], matches.groupdict())
|
||||
command_name = request.split(' ')[0]
|
||||
if command_name in [command.name for command in protocol.mpd_commands]:
|
||||
raise exceptions.MpdArgError(
|
||||
'incorrect arguments', command=command_name)
|
||||
raise exceptions.MpdUnknownCommand(command=command_name)
|
||||
except LookupError:
|
||||
raise exceptions.MpdUnknownCommand(command=tokens[0])
|
||||
|
||||
def _format_response(self, response):
|
||||
formatted_response = []
|
||||
@ -299,18 +288,35 @@ class MpdContext(object):
|
||||
self.refresh_playlists_mapping()
|
||||
return self._playlist_name_from_uri[uri]
|
||||
|
||||
# TODO: consider making context.browse(path) which uses this internally.
|
||||
# advantage would be that all browse requests then go through the same code
|
||||
# and we could prebuild/cache path->uri relationships instead of having to
|
||||
# look them up all the time.
|
||||
def directory_path_to_uri(self, path):
|
||||
parts = re.findall(r'[^/]+', path)
|
||||
def browse(self, path, recursive=True, lookup=True):
|
||||
# TODO: consider caching lookups for less work mapping path->uri
|
||||
path_parts = re.findall(r'[^/]+', path or '')
|
||||
root_path = '/'.join([''] + path_parts)
|
||||
uri = None
|
||||
for part in parts:
|
||||
|
||||
for part in path_parts:
|
||||
for ref in self.core.library.browse(uri).get():
|
||||
if ref.type == ref.DIRECTORY and ref.name == part:
|
||||
uri = ref.uri
|
||||
break
|
||||
else:
|
||||
raise exceptions.MpdNoExistError()
|
||||
return uri
|
||||
raise exceptions.MpdNoExistError('Not found')
|
||||
|
||||
if recursive:
|
||||
yield (root_path, None)
|
||||
|
||||
path_and_futures = [(root_path, self.core.library.browse(uri))]
|
||||
while path_and_futures:
|
||||
base_path, future = path_and_futures.pop()
|
||||
for ref in future.get():
|
||||
path = '/'.join([base_path, ref.name.replace('/', '')])
|
||||
if ref.type == ref.DIRECTORY:
|
||||
yield (path, None)
|
||||
if recursive:
|
||||
path_and_futures.append(
|
||||
(path, self.core.library.browse(ref.uri)))
|
||||
elif ref.type == ref.TRACK:
|
||||
if lookup:
|
||||
yield (path, self.core.library.lookup(ref.uri))
|
||||
else:
|
||||
yield (path, ref)
|
||||
|
||||
@ -54,9 +54,11 @@ class MpdPermissionError(MpdAckError):
|
||||
self.message = 'you don\'t have permission for "%s"' % self.command
|
||||
|
||||
|
||||
class MpdUnknownCommand(MpdAckError):
|
||||
class MpdUnknownError(MpdAckError):
|
||||
error_code = MpdAckError.ACK_ERROR_UNKNOWN
|
||||
|
||||
|
||||
class MpdUnknownCommand(MpdUnknownError):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(MpdUnknownCommand, self).__init__(*args, **kwargs)
|
||||
assert self.command is not None, 'command must be given explicitly'
|
||||
|
||||
@ -12,10 +12,9 @@ implement our own MPD server which is compatible with the numerous existing
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from collections import namedtuple
|
||||
import re
|
||||
import inspect
|
||||
|
||||
from mopidy.utils import formatting
|
||||
from mopidy.mpd import exceptions
|
||||
|
||||
#: The MPD protocol uses UTF-8 for encoding all data.
|
||||
ENCODING = 'UTF-8'
|
||||
@ -26,70 +25,151 @@ LINE_TERMINATOR = '\n'
|
||||
#: The MPD protocol version is 0.17.0.
|
||||
VERSION = '0.17.0'
|
||||
|
||||
MpdCommand = namedtuple('MpdCommand', ['name', 'auth_required'])
|
||||
|
||||
#: Set of all available commands, represented as :class:`MpdCommand` objects.
|
||||
mpd_commands = set()
|
||||
|
||||
#: Map between request matchers and request handler functions.
|
||||
request_handlers = {}
|
||||
|
||||
|
||||
def handle_request(pattern, auth_required=True):
|
||||
"""
|
||||
Decorator for connecting command handlers to command requests.
|
||||
|
||||
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_request('do\ (?P<what>.+)$')
|
||||
def do(what):
|
||||
...
|
||||
|
||||
Note that the patterns are compiled with the :attr:`re.VERBOSE` flag. Thus,
|
||||
you must escape any space characters you want to match, but you're also
|
||||
free to add non-escaped whitespace to format the pattern for easier
|
||||
reading.
|
||||
|
||||
: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(
|
||||
MpdCommand(name=match.group(), auth_required=auth_required))
|
||||
compiled_pattern = re.compile(pattern, flags=(re.UNICODE | re.VERBOSE))
|
||||
if compiled_pattern in request_handlers:
|
||||
raise ValueError('Tried to redefine handler for %s with %s' % (
|
||||
pattern, func))
|
||||
request_handlers[compiled_pattern] = func
|
||||
func.__doc__ = """
|
||||
*Pattern:*
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
%(pattern)s
|
||||
|
||||
%(docs)s
|
||||
""" % {
|
||||
'pattern': formatting.indent(pattern, places=8, singles=True),
|
||||
'docs': func.__doc__ or '',
|
||||
}
|
||||
return func
|
||||
return decorator
|
||||
|
||||
|
||||
def load_protocol_modules():
|
||||
"""
|
||||
The protocol modules must be imported to get them registered in
|
||||
:attr:`request_handlers` and :attr:`mpd_commands`.
|
||||
:attr:`commands`.
|
||||
"""
|
||||
from . import ( # noqa
|
||||
audio_output, channels, command_list, connection, current_playlist,
|
||||
empty, music_db, playback, reflection, status, stickers,
|
||||
stored_playlists)
|
||||
music_db, playback, reflection, status, stickers, stored_playlists)
|
||||
|
||||
|
||||
def INT(value):
|
||||
"""Converts a value that matches [+-]?\d+ into and integer."""
|
||||
if value is None:
|
||||
raise ValueError('None is not a valid integer')
|
||||
# TODO: check for whitespace via value != value.strip()?
|
||||
return int(value)
|
||||
|
||||
|
||||
def UINT(value):
|
||||
"""Converts a value that matches \d+ into an integer."""
|
||||
if value is None:
|
||||
raise ValueError('None is not a valid integer')
|
||||
if not value.isdigit():
|
||||
raise ValueError('Only positive numbers are allowed')
|
||||
return int(value)
|
||||
|
||||
|
||||
def BOOL(value):
|
||||
"""Convert the values 0 and 1 into booleans."""
|
||||
if value in ('1', '0'):
|
||||
return bool(int(value))
|
||||
raise ValueError('%r is not 0 or 1' % value)
|
||||
|
||||
|
||||
def RANGE(value):
|
||||
"""Convert a single integer or range spec into a slice
|
||||
|
||||
``n`` should become ``slice(n, n+1)``
|
||||
``n:`` should become ``slice(n, None)``
|
||||
``n:m`` should become ``slice(n, m)`` and ``m > n`` must hold
|
||||
"""
|
||||
if ':' in value:
|
||||
start, stop = value.split(':', 1)
|
||||
start = UINT(start)
|
||||
if stop.strip():
|
||||
stop = UINT(stop)
|
||||
if start >= stop:
|
||||
raise ValueError('End must be larger than start')
|
||||
else:
|
||||
stop = None
|
||||
else:
|
||||
start = UINT(value)
|
||||
stop = start + 1
|
||||
return slice(start, stop)
|
||||
|
||||
|
||||
class Commands(object):
|
||||
"""Collection of MPD commands to expose to users.
|
||||
|
||||
Normally used through the global instance which command handlers have been
|
||||
installed into.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.handlers = {}
|
||||
|
||||
# TODO: consider removing auth_required and list_command in favour of
|
||||
# additional command instances to register in?
|
||||
def add(self, name, auth_required=True, list_command=True, **validators):
|
||||
"""Create a decorator that registers a handler and validation rules.
|
||||
|
||||
Additional keyword arguments are treated as converters/validators to
|
||||
apply to tokens converting them to proper Python type`s.
|
||||
|
||||
Requirements for valid handlers:
|
||||
|
||||
- must accept a context argument as the first arg.
|
||||
- may not use variable keyword arguments, ``**kwargs``.
|
||||
- may use variable arguments ``*args`` *or* a mix of required and
|
||||
optional arguments.
|
||||
|
||||
Decorator returns the unwrapped function so that tests etc can use the
|
||||
functions with values with correct python types instead of strings.
|
||||
|
||||
:param string name: Name of the command being registered.
|
||||
:param bool auth_required: If authorization is required.
|
||||
:param bool list_command: If command should be listed in reflection.
|
||||
"""
|
||||
|
||||
def wrapper(func):
|
||||
if name in self.handlers:
|
||||
raise ValueError('%s already registered' % name)
|
||||
|
||||
args, varargs, keywords, defaults = inspect.getargspec(func)
|
||||
defaults = dict(zip(args[-len(defaults or []):], defaults or []))
|
||||
|
||||
if not args and not varargs:
|
||||
raise TypeError('Handler must accept at least one argument.')
|
||||
|
||||
if len(args) > 1 and varargs:
|
||||
raise TypeError(
|
||||
'*args may not be combined with regular arguments')
|
||||
|
||||
if not set(validators.keys()).issubset(args):
|
||||
raise TypeError('Validator for non-existent arg passed')
|
||||
|
||||
if keywords:
|
||||
raise TypeError('**kwargs are not permitted')
|
||||
|
||||
def validate(*args, **kwargs):
|
||||
if varargs:
|
||||
return func(*args, **kwargs)
|
||||
callargs = inspect.getcallargs(func, *args, **kwargs)
|
||||
for key, value in callargs.items():
|
||||
default = defaults.get(key, object())
|
||||
if key in validators and value != default:
|
||||
try:
|
||||
callargs[key] = validators[key](value)
|
||||
except ValueError:
|
||||
raise exceptions.MpdArgError('incorrect arguments')
|
||||
return func(**callargs)
|
||||
|
||||
validate.auth_required = auth_required
|
||||
validate.list_command = list_command
|
||||
self.handlers[name] = validate
|
||||
return func
|
||||
return wrapper
|
||||
|
||||
def call(self, tokens, context=None):
|
||||
"""Find and run the handler registered for the given command.
|
||||
|
||||
If the handler was registered with any converters/validators they will
|
||||
be run before calling the real handler.
|
||||
|
||||
:param list tokens: List of tokens to process
|
||||
:param context: MPD context.
|
||||
:type context: :class:`~mopidy.mpd.dispatcher.MpdContext`
|
||||
"""
|
||||
if not tokens:
|
||||
raise exceptions.MpdNoCommand()
|
||||
if tokens[0] not in self.handlers:
|
||||
raise exceptions.MpdUnknownCommand(command=tokens[0])
|
||||
return self.handlers[tokens[0]](context, *tokens[1:])
|
||||
|
||||
|
||||
#: Global instance to install commands into
|
||||
commands = Commands()
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from mopidy.mpd.exceptions import MpdNoExistError
|
||||
from mopidy.mpd.protocol import handle_request
|
||||
from mopidy.mpd import exceptions, protocol
|
||||
|
||||
|
||||
@handle_request(r'disableoutput\ "(?P<outputid>\d+)"$')
|
||||
@protocol.commands.add('disableoutput', outputid=protocol.UINT)
|
||||
def disableoutput(context, outputid):
|
||||
"""
|
||||
*musicpd.org, audio output section:*
|
||||
@ -13,13 +12,13 @@ def disableoutput(context, outputid):
|
||||
|
||||
Turns an output off.
|
||||
"""
|
||||
if int(outputid) == 0:
|
||||
if outputid == 0:
|
||||
context.core.playback.set_mute(False)
|
||||
else:
|
||||
raise MpdNoExistError('No such audio output')
|
||||
raise exceptions.MpdNoExistError('No such audio output')
|
||||
|
||||
|
||||
@handle_request(r'enableoutput\ "(?P<outputid>\d+)"$')
|
||||
@protocol.commands.add('enableoutput', outputid=protocol.UINT)
|
||||
def enableoutput(context, outputid):
|
||||
"""
|
||||
*musicpd.org, audio output section:*
|
||||
@ -28,13 +27,26 @@ def enableoutput(context, outputid):
|
||||
|
||||
Turns an output on.
|
||||
"""
|
||||
if int(outputid) == 0:
|
||||
if outputid == 0:
|
||||
context.core.playback.set_mute(True)
|
||||
else:
|
||||
raise MpdNoExistError('No such audio output')
|
||||
raise exceptions.MpdNoExistError('No such audio output')
|
||||
|
||||
|
||||
@handle_request(r'outputs$')
|
||||
# TODO: implement and test
|
||||
#@protocol.commands.add('toggleoutput', outputid=protocol.UINT)
|
||||
def toggleoutput(context, outputid):
|
||||
"""
|
||||
*musicpd.org, audio output section:*
|
||||
|
||||
``toggleoutput {ID}``
|
||||
|
||||
Turns an output on or off, depending on the current state.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@protocol.commands.add('outputs')
|
||||
def outputs(context):
|
||||
"""
|
||||
*musicpd.org, audio output section:*
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from mopidy.mpd.protocol import handle_request
|
||||
from mopidy.mpd.exceptions import MpdNotImplemented
|
||||
from mopidy.mpd import exceptions, protocol
|
||||
|
||||
|
||||
@handle_request(r'subscribe\ "(?P<channel>[A-Za-z0-9:._-]+)"$')
|
||||
@protocol.commands.add('subscribe')
|
||||
def subscribe(context, channel):
|
||||
"""
|
||||
*musicpd.org, client to client section:*
|
||||
@ -15,10 +14,11 @@ def subscribe(context, channel):
|
||||
already. The name may consist of alphanumeric ASCII characters plus
|
||||
underscore, dash, dot and colon.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
# TODO: match channel against [A-Za-z0-9:._-]+
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(r'unsubscribe\ "(?P<channel>[A-Za-z0-9:._-]+)"$')
|
||||
@protocol.commands.add('unsubscribe')
|
||||
def unsubscribe(context, channel):
|
||||
"""
|
||||
*musicpd.org, client to client section:*
|
||||
@ -27,10 +27,11 @@ def unsubscribe(context, channel):
|
||||
|
||||
Unsubscribe from a channel.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
# TODO: match channel against [A-Za-z0-9:._-]+
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(r'channels$')
|
||||
@protocol.commands.add('channels')
|
||||
def channels(context):
|
||||
"""
|
||||
*musicpd.org, client to client section:*
|
||||
@ -40,10 +41,10 @@ def channels(context):
|
||||
Obtain a list of all channels. The response is a list of "channel:"
|
||||
lines.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(r'readmessages$')
|
||||
@protocol.commands.add('readmessages')
|
||||
def readmessages(context):
|
||||
"""
|
||||
*musicpd.org, client to client section:*
|
||||
@ -53,11 +54,10 @@ def readmessages(context):
|
||||
Reads messages for this client. The response is a list of "channel:"
|
||||
and "message:" lines.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(
|
||||
r'sendmessage\ "(?P<channel>[A-Za-z0-9:._-]+)"\ "(?P<text>[^"]*)"$')
|
||||
@protocol.commands.add('sendmessage')
|
||||
def sendmessage(context, channel, text):
|
||||
"""
|
||||
*musicpd.org, client to client section:*
|
||||
@ -66,4 +66,5 @@ def sendmessage(context, channel, text):
|
||||
|
||||
Send a message to the specified channel.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
# TODO: match channel against [A-Za-z0-9:._-]+
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from mopidy.mpd.protocol import handle_request
|
||||
from mopidy.mpd.exceptions import MpdUnknownCommand
|
||||
from mopidy.mpd import exceptions, protocol
|
||||
|
||||
|
||||
@handle_request(r'command_list_begin$')
|
||||
@protocol.commands.add('command_list_begin', list_command=False)
|
||||
def command_list_begin(context):
|
||||
"""
|
||||
*musicpd.org, command list section:*
|
||||
@ -26,11 +25,12 @@ def command_list_begin(context):
|
||||
context.dispatcher.command_list = []
|
||||
|
||||
|
||||
@handle_request(r'command_list_end$')
|
||||
@protocol.commands.add('command_list_end', list_command=False)
|
||||
def command_list_end(context):
|
||||
"""See :meth:`command_list_begin()`."""
|
||||
# TODO: batch consecutive add commands
|
||||
if not context.dispatcher.command_list_receiving:
|
||||
raise MpdUnknownCommand(command='command_list_end')
|
||||
raise exceptions.MpdUnknownCommand(command='command_list_end')
|
||||
context.dispatcher.command_list_receiving = False
|
||||
(command_list, context.dispatcher.command_list) = (
|
||||
context.dispatcher.command_list, [])
|
||||
@ -49,7 +49,7 @@ def command_list_end(context):
|
||||
return command_list_response
|
||||
|
||||
|
||||
@handle_request(r'command_list_ok_begin$')
|
||||
@protocol.commands.add('command_list_ok_begin', list_command=False)
|
||||
def command_list_ok_begin(context):
|
||||
"""See :meth:`command_list_begin()`."""
|
||||
context.dispatcher.command_list_receiving = True
|
||||
|
||||
@ -1,11 +1,9 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from mopidy.mpd.protocol import handle_request
|
||||
from mopidy.mpd.exceptions import (
|
||||
MpdPasswordError, MpdPermissionError)
|
||||
from mopidy.mpd import exceptions, protocol
|
||||
|
||||
|
||||
@handle_request(r'close$', auth_required=False)
|
||||
@protocol.commands.add('close', auth_required=False)
|
||||
def close(context):
|
||||
"""
|
||||
*musicpd.org, connection section:*
|
||||
@ -17,7 +15,7 @@ def close(context):
|
||||
context.session.close()
|
||||
|
||||
|
||||
@handle_request(r'kill$')
|
||||
@protocol.commands.add('kill', list_command=False)
|
||||
def kill(context):
|
||||
"""
|
||||
*musicpd.org, connection section:*
|
||||
@ -26,10 +24,10 @@ def kill(context):
|
||||
|
||||
Kills MPD.
|
||||
"""
|
||||
raise MpdPermissionError(command='kill')
|
||||
raise exceptions.MpdPermissionError(command='kill')
|
||||
|
||||
|
||||
@handle_request(r'password\ "(?P<password>[^"]+)"$', auth_required=False)
|
||||
@protocol.commands.add('password', auth_required=False)
|
||||
def password(context, password):
|
||||
"""
|
||||
*musicpd.org, connection section:*
|
||||
@ -42,10 +40,10 @@ def password(context, password):
|
||||
if password == context.password:
|
||||
context.dispatcher.authenticated = True
|
||||
else:
|
||||
raise MpdPasswordError('incorrect password')
|
||||
raise exceptions.MpdPasswordError('incorrect password')
|
||||
|
||||
|
||||
@handle_request(r'ping$', auth_required=False)
|
||||
@protocol.commands.add('ping', auth_required=False)
|
||||
def ping(context):
|
||||
"""
|
||||
*musicpd.org, connection section:*
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from mopidy.mpd import translator
|
||||
from mopidy.mpd.exceptions import (
|
||||
MpdArgError, MpdNoExistError, MpdNotImplemented)
|
||||
from mopidy.mpd.protocol import handle_request
|
||||
import warnings
|
||||
|
||||
from mopidy.mpd import exceptions, protocol, translator
|
||||
|
||||
|
||||
@handle_request(r'add\ "(?P<uri>[^"]*)"$')
|
||||
@protocol.commands.add('add')
|
||||
def add(context, uri):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -23,37 +22,24 @@ def add(context, uri):
|
||||
if not uri.strip('/'):
|
||||
return
|
||||
|
||||
tl_tracks = context.core.tracklist.add(uri=uri).get()
|
||||
if tl_tracks:
|
||||
if context.core.tracklist.add(uri=uri).get():
|
||||
return
|
||||
|
||||
try:
|
||||
uri = context.directory_path_to_uri(translator.normalize_path(uri))
|
||||
except MpdNoExistError as e:
|
||||
e.command = 'add'
|
||||
tracks = []
|
||||
for path, lookup_future in context.browse(uri):
|
||||
if lookup_future:
|
||||
tracks.extend(lookup_future.get())
|
||||
except exceptions.MpdNoExistError as e:
|
||||
e.message = 'directory or file not found'
|
||||
raise
|
||||
|
||||
browse_futures = [context.core.library.browse(uri)]
|
||||
lookup_futures = []
|
||||
while browse_futures:
|
||||
for ref in browse_futures.pop().get():
|
||||
if ref.type == ref.DIRECTORY:
|
||||
browse_futures.append(context.core.library.browse(ref.uri))
|
||||
else:
|
||||
lookup_futures.append(context.core.library.lookup(ref.uri))
|
||||
|
||||
tracks = []
|
||||
for future in lookup_futures:
|
||||
tracks.extend(future.get())
|
||||
|
||||
if not tracks:
|
||||
raise MpdNoExistError('directory or file not found')
|
||||
|
||||
raise exceptions.MpdNoExistError('directory or file not found')
|
||||
context.core.tracklist.add(tracks=tracks)
|
||||
|
||||
|
||||
@handle_request(r'addid\ "(?P<uri>[^"]*)"(\ "(?P<songpos>\d+)")*$')
|
||||
@protocol.commands.add('addid', songpos=protocol.UINT)
|
||||
def addid(context, uri, songpos=None):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -73,19 +59,17 @@ def addid(context, uri, songpos=None):
|
||||
- ``addid ""`` should return an error.
|
||||
"""
|
||||
if not uri:
|
||||
raise MpdNoExistError('No such song')
|
||||
if songpos is not None:
|
||||
songpos = int(songpos)
|
||||
if songpos and songpos > context.core.tracklist.length.get():
|
||||
raise MpdArgError('Bad song index')
|
||||
raise exceptions.MpdNoExistError('No such song')
|
||||
if songpos is not None and songpos > context.core.tracklist.length.get():
|
||||
raise exceptions.MpdArgError('Bad song index')
|
||||
tl_tracks = context.core.tracklist.add(uri=uri, at_position=songpos).get()
|
||||
if not tl_tracks:
|
||||
raise MpdNoExistError('No such song')
|
||||
raise exceptions.MpdNoExistError('No such song')
|
||||
return ('Id', tl_tracks[0].tlid)
|
||||
|
||||
|
||||
@handle_request(r'delete\ "(?P<start>\d+):(?P<end>\d+)*"$')
|
||||
def delete_range(context, start, end=None):
|
||||
@protocol.commands.add('delete', position=protocol.RANGE)
|
||||
def delete(context, position):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
@ -93,31 +77,18 @@ def delete_range(context, start, end=None):
|
||||
|
||||
Deletes a song from the playlist.
|
||||
"""
|
||||
start = int(start)
|
||||
if end is not None:
|
||||
end = int(end)
|
||||
else:
|
||||
start = position.start
|
||||
end = position.stop
|
||||
if end is None:
|
||||
end = context.core.tracklist.length.get()
|
||||
tl_tracks = context.core.tracklist.slice(start, end).get()
|
||||
if not tl_tracks:
|
||||
raise MpdArgError('Bad song index', command='delete')
|
||||
raise exceptions.MpdArgError('Bad song index', command='delete')
|
||||
for (tlid, _) in tl_tracks:
|
||||
context.core.tracklist.remove(tlid=[tlid])
|
||||
|
||||
|
||||
@handle_request(r'delete\ "(?P<songpos>\d+)"$')
|
||||
def delete_songpos(context, songpos):
|
||||
"""See :meth:`delete_range`"""
|
||||
try:
|
||||
songpos = int(songpos)
|
||||
(tlid, _) = context.core.tracklist.slice(
|
||||
songpos, songpos + 1).get()[0]
|
||||
context.core.tracklist.remove(tlid=[tlid])
|
||||
except IndexError:
|
||||
raise MpdArgError('Bad song index', command='delete')
|
||||
|
||||
|
||||
@handle_request(r'deleteid\ "(?P<tlid>\d+)"$')
|
||||
@protocol.commands.add('deleteid', tlid=protocol.UINT)
|
||||
def deleteid(context, tlid):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -126,13 +97,12 @@ def deleteid(context, tlid):
|
||||
|
||||
Deletes the song ``SONGID`` from the playlist
|
||||
"""
|
||||
tlid = int(tlid)
|
||||
tl_tracks = context.core.tracklist.remove(tlid=[tlid]).get()
|
||||
if not tl_tracks:
|
||||
raise MpdNoExistError('No such song')
|
||||
raise exceptions.MpdNoExistError('No such song')
|
||||
|
||||
|
||||
@handle_request(r'clear$')
|
||||
@protocol.commands.add('clear')
|
||||
def clear(context):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -144,8 +114,8 @@ def clear(context):
|
||||
context.core.tracklist.clear()
|
||||
|
||||
|
||||
@handle_request(r'move\ "(?P<start>\d+):(?P<end>\d+)*"\ "(?P<to>\d+)"$')
|
||||
def move_range(context, start, to, end=None):
|
||||
@protocol.commands.add('move', position=protocol.RANGE, to=protocol.UINT)
|
||||
def move_range(context, position, to):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
@ -154,23 +124,14 @@ def move_range(context, start, to, end=None):
|
||||
Moves the song at ``FROM`` or range of songs at ``START:END`` to
|
||||
``TO`` in the playlist.
|
||||
"""
|
||||
start = position.start
|
||||
end = position.stop
|
||||
if end is None:
|
||||
end = context.core.tracklist.length.get()
|
||||
start = int(start)
|
||||
end = int(end)
|
||||
to = int(to)
|
||||
context.core.tracklist.move(start, end, to)
|
||||
|
||||
|
||||
@handle_request(r'move\ "(?P<songpos>\d+)"\ "(?P<to>\d+)"$')
|
||||
def move_songpos(context, songpos, to):
|
||||
"""See :meth:`move_range`."""
|
||||
songpos = int(songpos)
|
||||
to = int(to)
|
||||
context.core.tracklist.move(songpos, songpos + 1, to)
|
||||
|
||||
|
||||
@handle_request(r'moveid\ "(?P<tlid>\d+)"\ "(?P<to>\d+)"$')
|
||||
@protocol.commands.add('moveid', tlid=protocol.UINT, to=protocol.UINT)
|
||||
def moveid(context, tlid, to):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -181,16 +142,14 @@ def moveid(context, tlid, to):
|
||||
the playlist. If ``TO`` is negative, it is relative to the current
|
||||
song in the playlist (if there is one).
|
||||
"""
|
||||
tlid = int(tlid)
|
||||
to = int(to)
|
||||
tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
|
||||
if not tl_tracks:
|
||||
raise MpdNoExistError('No such song')
|
||||
raise exceptions.MpdNoExistError('No such song')
|
||||
position = context.core.tracklist.index(tl_tracks[0]).get()
|
||||
context.core.tracklist.move(position, position + 1, to)
|
||||
|
||||
|
||||
@handle_request(r'playlist$')
|
||||
@protocol.commands.add('playlist')
|
||||
def playlist(context):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -203,10 +162,12 @@ def playlist(context):
|
||||
|
||||
Do not use this, instead use ``playlistinfo``.
|
||||
"""
|
||||
warnings.warn(
|
||||
'Do not use this, instead use playlistinfo', DeprecationWarning)
|
||||
return playlistinfo(context)
|
||||
|
||||
|
||||
@handle_request(r'playlistfind\ ("?)(?P<tag>[^"]+)\1\ "(?P<needle>[^"]+)"$')
|
||||
@protocol.commands.add('playlistfind')
|
||||
def playlistfind(context, tag, needle):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -225,11 +186,10 @@ def playlistfind(context, tag, needle):
|
||||
return None
|
||||
position = context.core.tracklist.index(tl_tracks[0]).get()
|
||||
return translator.track_to_mpd_format(tl_tracks[0], position=position)
|
||||
raise MpdNotImplemented # TODO
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(r'playlistid$')
|
||||
@handle_request(r'playlistid\ "(?P<tlid>\d+)"$')
|
||||
@protocol.commands.add('playlistid', tlid=protocol.UINT)
|
||||
def playlistid(context, tlid=None):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -240,10 +200,9 @@ def playlistid(context, tlid=None):
|
||||
and specifies a single song to display info for.
|
||||
"""
|
||||
if tlid is not None:
|
||||
tlid = int(tlid)
|
||||
tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
|
||||
if not tl_tracks:
|
||||
raise MpdNoExistError('No such song')
|
||||
raise exceptions.MpdNoExistError('No such song')
|
||||
position = context.core.tracklist.index(tl_tracks[0]).get()
|
||||
return translator.track_to_mpd_format(tl_tracks[0], position=position)
|
||||
else:
|
||||
@ -251,10 +210,8 @@ def playlistid(context, tlid=None):
|
||||
context.core.tracklist.tl_tracks.get())
|
||||
|
||||
|
||||
@handle_request(r'playlistinfo$')
|
||||
@handle_request(r'playlistinfo\ "(?P<songpos>-?\d+)"$')
|
||||
@handle_request(r'playlistinfo\ "(?P<start>\d+):(?P<end>\d+)*"$')
|
||||
def playlistinfo(context, songpos=None, start=None, end=None):
|
||||
@protocol.commands.add('playlistinfo')
|
||||
def playlistinfo(context, parameter=None):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
@ -269,27 +226,21 @@ def playlistinfo(context, songpos=None, start=None, end=None):
|
||||
- uses negative indexes, like ``playlistinfo "-1"``, to request
|
||||
the entire playlist
|
||||
"""
|
||||
if songpos == '-1':
|
||||
songpos = None
|
||||
if songpos is not None:
|
||||
songpos = int(songpos)
|
||||
tl_track = context.core.tracklist.tl_tracks.get()[songpos]
|
||||
return translator.track_to_mpd_format(tl_track, position=songpos)
|
||||
if parameter is None or parameter == '-1':
|
||||
start, end = 0, None
|
||||
else:
|
||||
if start is None:
|
||||
start = 0
|
||||
start = int(start)
|
||||
if not (0 <= start <= context.core.tracklist.length.get()):
|
||||
raise MpdArgError('Bad song index')
|
||||
if end is not None:
|
||||
end = int(end)
|
||||
if end > context.core.tracklist.length.get():
|
||||
end = None
|
||||
tl_tracks = context.core.tracklist.tl_tracks.get()
|
||||
return translator.tracks_to_mpd_format(tl_tracks, start, end)
|
||||
tracklist_slice = protocol.RANGE(parameter)
|
||||
start, end = tracklist_slice.start, tracklist_slice.stop
|
||||
|
||||
tl_tracks = context.core.tracklist.tl_tracks.get()
|
||||
if start and start > len(tl_tracks):
|
||||
raise exceptions.MpdArgError('Bad song index')
|
||||
if end and end > len(tl_tracks):
|
||||
end = None
|
||||
return translator.tracks_to_mpd_format(tl_tracks, start, end)
|
||||
|
||||
|
||||
@handle_request(r'playlistsearch\ ("?)(?P<tag>\w+)\1\ "(?P<needle>[^"]+)"$')
|
||||
@protocol.commands.add('playlistsearch')
|
||||
def playlistsearch(context, tag, needle):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -304,10 +255,10 @@ def playlistsearch(context, tag, needle):
|
||||
- does not add quotes around the tag
|
||||
- uses ``filename`` and ``any`` as tags
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(r'plchanges\ ("?)(?P<version>-?\d+)\1$')
|
||||
@protocol.commands.add('plchanges', version=protocol.INT)
|
||||
def plchanges(context, version):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -329,7 +280,7 @@ def plchanges(context, version):
|
||||
context.core.tracklist.tl_tracks.get())
|
||||
|
||||
|
||||
@handle_request(r'plchangesposid\ "(?P<version>\d+)"$')
|
||||
@protocol.commands.add('plchangesposid', version=protocol.INT)
|
||||
def plchangesposid(context, version):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -353,9 +304,8 @@ def plchangesposid(context, version):
|
||||
return result
|
||||
|
||||
|
||||
@handle_request(r'shuffle$')
|
||||
@handle_request(r'shuffle\ "(?P<start>\d+):(?P<end>\d+)*"$')
|
||||
def shuffle(context, start=None, end=None):
|
||||
@protocol.commands.add('shuffle', position=protocol.RANGE)
|
||||
def shuffle(context, position=None):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
@ -364,14 +314,14 @@ def shuffle(context, start=None, end=None):
|
||||
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)
|
||||
if position is None:
|
||||
start, end = None, None
|
||||
else:
|
||||
start, end = position.start, position.stop
|
||||
context.core.tracklist.shuffle(start, end)
|
||||
|
||||
|
||||
@handle_request(r'swap\ "(?P<songpos1>\d+)"\ "(?P<songpos2>\d+)"$')
|
||||
@protocol.commands.add('swap', songpos1=protocol.UINT, songpos2=protocol.UINT)
|
||||
def swap(context, songpos1, songpos2):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -380,8 +330,6 @@ def swap(context, songpos1, songpos2):
|
||||
|
||||
Swaps the positions of ``SONG1`` and ``SONG2``.
|
||||
"""
|
||||
songpos1 = int(songpos1)
|
||||
songpos2 = int(songpos2)
|
||||
tracks = context.core.tracklist.tracks.get()
|
||||
song1 = tracks[songpos1]
|
||||
song2 = tracks[songpos2]
|
||||
@ -393,7 +341,7 @@ def swap(context, songpos1, songpos2):
|
||||
context.core.tracklist.add(tracks)
|
||||
|
||||
|
||||
@handle_request(r'swapid\ "(?P<tlid1>\d+)"\ "(?P<tlid2>\d+)"$')
|
||||
@protocol.commands.add('swapid', tlid1=protocol.UINT, tlid2=protocol.UINT)
|
||||
def swapid(context, tlid1, tlid2):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
@ -402,12 +350,72 @@ def swapid(context, tlid1, tlid2):
|
||||
|
||||
Swaps the positions of ``SONG1`` and ``SONG2`` (both song ids).
|
||||
"""
|
||||
tlid1 = int(tlid1)
|
||||
tlid2 = int(tlid2)
|
||||
tl_tracks1 = context.core.tracklist.filter(tlid=[tlid1]).get()
|
||||
tl_tracks2 = context.core.tracklist.filter(tlid=[tlid2]).get()
|
||||
if not tl_tracks1 or not tl_tracks2:
|
||||
raise MpdNoExistError('No such song')
|
||||
raise exceptions.MpdNoExistError('No such song')
|
||||
position1 = context.core.tracklist.index(tl_tracks1[0]).get()
|
||||
position2 = context.core.tracklist.index(tl_tracks2[0]).get()
|
||||
swap(context, position1, position2)
|
||||
|
||||
|
||||
# TODO: add at least reflection tests before adding NotImplemented version
|
||||
#@protocol.commands.add(
|
||||
# 'prio', priority=protocol.UINT, position=protocol.RANGE)
|
||||
def prio(context, priority, position):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
``prio {PRIORITY} {START:END...}``
|
||||
|
||||
Set the priority of the specified songs. A higher priority means that
|
||||
it will be played first when "random" mode is enabled.
|
||||
|
||||
A priority is an integer between 0 and 255. The default priority of new
|
||||
songs is 0.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# TODO: add at least reflection tests before adding NotImplemented version
|
||||
#@protocol.commands.add('prioid')
|
||||
def prioid(context, *args):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
``prioid {PRIORITY} {ID...}``
|
||||
|
||||
Same as prio, but address the songs with their id.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# TODO: add at least reflection tests before adding NotImplemented version
|
||||
#@protocol.commands.add('addtagid', tlid=protocol.UINT)
|
||||
def addtagid(context, tlid, tag, value):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
``addtagid {SONGID} {TAG} {VALUE}``
|
||||
|
||||
Adds a tag to the specified song. Editing song tags is only possible
|
||||
for remote songs. This change is volatile: it may be overwritten by
|
||||
tags received from the server, and the data is gone when the song gets
|
||||
removed from the queue.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# TODO: add at least reflection tests before adding NotImplemented version
|
||||
#@protocol.commands.add('cleartagid', tlid=protocol.UINT)
|
||||
def cleartagid(context, tlid, tag):
|
||||
"""
|
||||
*musicpd.org, current playlist section:*
|
||||
|
||||
``cleartagid {SONGID} [TAG]``
|
||||
|
||||
Removes tags from the specified song. If TAG is not specified, then all
|
||||
tag values will be removed. Editing song tags is only possible for
|
||||
remote songs.
|
||||
"""
|
||||
pass
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from mopidy.mpd.protocol import handle_request
|
||||
from mopidy.mpd.exceptions import MpdNoCommand
|
||||
|
||||
|
||||
@handle_request(r'[\ ]*$')
|
||||
def empty(context):
|
||||
"""The original MPD server returns an error on an empty request."""
|
||||
raise MpdNoCommand()
|
||||
@ -2,106 +2,46 @@ from __future__ import unicode_literals
|
||||
|
||||
import functools
|
||||
import itertools
|
||||
import re
|
||||
|
||||
from mopidy.models import Ref, Track
|
||||
from mopidy.mpd import translator
|
||||
from mopidy.mpd.exceptions import MpdArgError, MpdNoExistError
|
||||
from mopidy.mpd.protocol import handle_request, stored_playlists
|
||||
from mopidy.models import Track
|
||||
from mopidy.mpd import exceptions, protocol, translator
|
||||
|
||||
_SEARCH_MAPPING = {
|
||||
'album': 'album',
|
||||
'albumartist': 'albumartist',
|
||||
'any': 'any',
|
||||
'artist': 'artist',
|
||||
'comment': 'comment',
|
||||
'composer': 'composer',
|
||||
'date': 'date',
|
||||
'file': 'uri',
|
||||
'filename': 'uri',
|
||||
'genre': 'genre',
|
||||
'performer': 'performer',
|
||||
'title': 'track_name',
|
||||
'track': 'track_no'}
|
||||
|
||||
_LIST_MAPPING = {
|
||||
'album': 'album',
|
||||
'albumartist': 'albumartist',
|
||||
'artist': 'artist',
|
||||
'composer': 'composer',
|
||||
'date': 'date',
|
||||
'genre': 'genre',
|
||||
'performer': 'performer'}
|
||||
|
||||
|
||||
LIST_QUERY = r"""
|
||||
("?) # Optional quote around the field type
|
||||
(?P<field>( # Field to list in the response
|
||||
[Aa]lbum
|
||||
| [Aa]lbumartist
|
||||
| [Aa]rtist
|
||||
| [Cc]omposer
|
||||
| [Dd]ate
|
||||
| [Gg]enre
|
||||
| [Pp]erformer
|
||||
))
|
||||
\1 # End of optional quote around the field type
|
||||
(?: # Non-capturing group for optional search query
|
||||
\ # A single space
|
||||
(?P<mpd_query>.*)
|
||||
)?
|
||||
$
|
||||
"""
|
||||
|
||||
SEARCH_FIELDS = r"""
|
||||
[Aa]lbum
|
||||
| [Aa]lbumartist
|
||||
| [Aa]ny
|
||||
| [Aa]rtist
|
||||
| [Cc]omment
|
||||
| [Cc]omposer
|
||||
| [Dd]ate
|
||||
| [Ff]ile
|
||||
| [Ff]ilename
|
||||
| [Gg]enre
|
||||
| [Pp]erformer
|
||||
| [Tt]itle
|
||||
| [Tt]rack
|
||||
"""
|
||||
|
||||
# TODO Would be nice to get ("?)...\1 working for the quotes here
|
||||
SEARCH_QUERY = r"""
|
||||
(?P<mpd_query>
|
||||
(?: # Non-capturing group for repeating query pairs
|
||||
"? # Optional quote around the field type
|
||||
(?:
|
||||
""" + SEARCH_FIELDS + r"""
|
||||
)
|
||||
"? # End of optional quote around the field type
|
||||
\ # A single space
|
||||
"[^"]*" # Matching a quoted search string
|
||||
\s?
|
||||
)+
|
||||
)
|
||||
$
|
||||
"""
|
||||
|
||||
SEARCH_PAIR_WITH_GROUPS = r"""
|
||||
("?) # Optional quote around the field type
|
||||
\b # Only begin matching at word bundaries
|
||||
( # A capturing group for the field type
|
||||
""" + SEARCH_FIELDS + """
|
||||
)
|
||||
\\1 # End of optional quote around the field type
|
||||
\ # A single space
|
||||
"([^"]+)" # Capturing a quoted search string
|
||||
"""
|
||||
SEARCH_PAIR_WITH_GROUPS_RE = re.compile(
|
||||
SEARCH_PAIR_WITH_GROUPS, flags=(re.UNICODE | re.VERBOSE))
|
||||
|
||||
|
||||
def _query_from_mpd_search_format(mpd_query):
|
||||
"""
|
||||
Parses an MPD ``search`` or ``find`` query and converts it to the Mopidy
|
||||
query format.
|
||||
|
||||
:param mpd_query: the MPD search query
|
||||
:type mpd_query: string
|
||||
"""
|
||||
matches = SEARCH_PAIR_WITH_GROUPS_RE.findall(mpd_query)
|
||||
def _query_from_mpd_search_parameters(parameters, mapping):
|
||||
query = {}
|
||||
# discard first field, which just captures optional quote
|
||||
for _, field, what in matches:
|
||||
field = field.lower()
|
||||
if field == 'title':
|
||||
field = 'track_name'
|
||||
elif field == 'track':
|
||||
field = 'track_no'
|
||||
elif field in ('file', 'filename'):
|
||||
field = 'uri'
|
||||
|
||||
if not what:
|
||||
parameters = list(parameters)
|
||||
while parameters:
|
||||
# TODO: does it matter that this is now case insensitive
|
||||
field = mapping.get(parameters.pop(0).lower())
|
||||
if not field:
|
||||
raise exceptions.MpdArgError('incorrect arguments')
|
||||
if not parameters:
|
||||
raise ValueError
|
||||
if field in query:
|
||||
query[field].append(what)
|
||||
else:
|
||||
query[field] = [what]
|
||||
query.setdefault(field, []).append(parameters.pop(0))
|
||||
return query
|
||||
|
||||
|
||||
@ -130,8 +70,8 @@ def _artist_as_track(artist):
|
||||
artists=[artist])
|
||||
|
||||
|
||||
@handle_request(r'count\ ' + SEARCH_QUERY)
|
||||
def count(context, mpd_query):
|
||||
@protocol.commands.add('count')
|
||||
def count(context, *args):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
|
||||
@ -146,9 +86,9 @@ def count(context, mpd_query):
|
||||
- use multiple tag-needle pairs to make more specific searches.
|
||||
"""
|
||||
try:
|
||||
query = _query_from_mpd_search_format(mpd_query)
|
||||
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
||||
except ValueError:
|
||||
raise MpdArgError('incorrect arguments')
|
||||
raise exceptions.MpdArgError('incorrect arguments')
|
||||
results = context.core.library.find_exact(**query).get()
|
||||
result_tracks = _get_tracks(results)
|
||||
return [
|
||||
@ -157,8 +97,8 @@ def count(context, mpd_query):
|
||||
]
|
||||
|
||||
|
||||
@handle_request(r'find\ ' + SEARCH_QUERY)
|
||||
def find(context, mpd_query):
|
||||
@protocol.commands.add('find')
|
||||
def find(context, *args):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
|
||||
@ -186,9 +126,10 @@ def find(context, mpd_query):
|
||||
- uses "file" instead of "filename".
|
||||
"""
|
||||
try:
|
||||
query = _query_from_mpd_search_format(mpd_query)
|
||||
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
results = context.core.library.find_exact(**query).get()
|
||||
result_tracks = []
|
||||
if ('artist' not in query and
|
||||
@ -202,8 +143,8 @@ def find(context, mpd_query):
|
||||
return translator.tracks_to_mpd_format(result_tracks)
|
||||
|
||||
|
||||
@handle_request(r'findadd\ ' + SEARCH_QUERY)
|
||||
def findadd(context, mpd_query):
|
||||
@protocol.commands.add('findadd')
|
||||
def findadd(context, *args):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
|
||||
@ -213,15 +154,15 @@ def findadd(context, mpd_query):
|
||||
current playlist. Parameters have the same meaning as for ``find``.
|
||||
"""
|
||||
try:
|
||||
query = _query_from_mpd_search_format(mpd_query)
|
||||
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
||||
except ValueError:
|
||||
return
|
||||
results = context.core.library.find_exact(**query).get()
|
||||
context.core.tracklist.add(_get_tracks(results))
|
||||
|
||||
|
||||
@handle_request(r'list\ ' + LIST_QUERY)
|
||||
def list_(context, field, mpd_query=None):
|
||||
@protocol.commands.add('list')
|
||||
def list_(context, *args):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
|
||||
@ -303,11 +244,27 @@ def list_(context, field, mpd_query=None):
|
||||
- does not add quotes around the field argument.
|
||||
- capitalizes the field argument.
|
||||
"""
|
||||
field = field.lower()
|
||||
parameters = list(args)
|
||||
if not parameters:
|
||||
raise exceptions.MpdArgError('incorrect arguments')
|
||||
field = parameters.pop(0).lower()
|
||||
|
||||
if field not in _LIST_MAPPING:
|
||||
raise exceptions.MpdArgError('incorrect arguments')
|
||||
|
||||
if len(parameters) == 1:
|
||||
if field != 'album':
|
||||
raise exceptions.MpdArgError('should be "Album" for 3 arguments')
|
||||
return _list_artist(context, {'artist': parameters})
|
||||
|
||||
try:
|
||||
query = translator.query_from_mpd_list_format(field, mpd_query)
|
||||
query = _query_from_mpd_search_parameters(parameters, _LIST_MAPPING)
|
||||
except exceptions.MpdArgError as e:
|
||||
e.message = 'not able to parse args'
|
||||
raise
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if field == 'artist':
|
||||
return _list_artist(context, query)
|
||||
if field == 'albumartist':
|
||||
@ -392,8 +349,7 @@ def _list_genre(context, query):
|
||||
return genres
|
||||
|
||||
|
||||
@handle_request(r'listall$')
|
||||
@handle_request(r'listall\ "(?P<uri>[^"]+)"$')
|
||||
@protocol.commands.add('listall')
|
||||
def listall(context, uri=None):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
@ -403,36 +359,18 @@ def listall(context, uri=None):
|
||||
Lists all songs and directories in ``URI``.
|
||||
"""
|
||||
result = []
|
||||
root_path = translator.normalize_path(uri)
|
||||
# TODO: doesn't the dispatcher._call_handler have enough info to catch
|
||||
# the error this can produce, set the command and then 'raise'?
|
||||
try:
|
||||
uri = context.directory_path_to_uri(root_path)
|
||||
except MpdNoExistError as e:
|
||||
e.command = 'listall'
|
||||
e.message = 'Not found'
|
||||
raise
|
||||
browse_futures = [(root_path, context.core.library.browse(uri))]
|
||||
|
||||
while browse_futures:
|
||||
base_path, future = browse_futures.pop()
|
||||
for ref in future.get():
|
||||
if ref.type == Ref.DIRECTORY:
|
||||
path = '/'.join([base_path, ref.name.replace('/', '')])
|
||||
result.append(('directory', path))
|
||||
browse_futures.append(
|
||||
(path, context.core.library.browse(ref.uri)))
|
||||
elif ref.type == Ref.TRACK:
|
||||
result.append(('file', ref.uri))
|
||||
for path, track_ref in context.browse(uri, lookup=False):
|
||||
if not track_ref:
|
||||
result.append(('directory', path))
|
||||
else:
|
||||
result.append(('file', track_ref.uri))
|
||||
|
||||
if not result:
|
||||
raise MpdNoExistError('Not found')
|
||||
|
||||
return [('directory', root_path)] + result
|
||||
raise exceptions.MpdNoExistError('Not found')
|
||||
return result
|
||||
|
||||
|
||||
@handle_request(r'listallinfo$')
|
||||
@handle_request(r'listallinfo\ "(?P<uri>[^"]+)"$')
|
||||
@protocol.commands.add('listallinfo')
|
||||
def listallinfo(context, uri=None):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
@ -442,45 +380,17 @@ def listallinfo(context, uri=None):
|
||||
Same as ``listall``, except it also returns metadata info in the
|
||||
same format as ``lsinfo``.
|
||||
"""
|
||||
dirs_and_futures = []
|
||||
result = []
|
||||
root_path = translator.normalize_path(uri)
|
||||
try:
|
||||
uri = context.directory_path_to_uri(root_path)
|
||||
except MpdNoExistError as e:
|
||||
e.command = 'listallinfo'
|
||||
e.message = 'Not found'
|
||||
raise
|
||||
browse_futures = [(root_path, context.core.library.browse(uri))]
|
||||
|
||||
while browse_futures:
|
||||
base_path, future = browse_futures.pop()
|
||||
for ref in future.get():
|
||||
if ref.type == Ref.DIRECTORY:
|
||||
path = '/'.join([base_path, ref.name.replace('/', '')])
|
||||
future = context.core.library.browse(ref.uri)
|
||||
browse_futures.append((path, future))
|
||||
dirs_and_futures.append(('directory', path))
|
||||
elif ref.type == Ref.TRACK:
|
||||
# TODO Lookup tracks in batch for better performance
|
||||
dirs_and_futures.append(context.core.library.lookup(ref.uri))
|
||||
|
||||
result = []
|
||||
for obj in dirs_and_futures:
|
||||
if hasattr(obj, 'get'):
|
||||
for track in obj.get():
|
||||
result.extend(translator.track_to_mpd_format(track))
|
||||
for path, lookup_future in context.browse(uri):
|
||||
if not lookup_future:
|
||||
result.append(('directory', path))
|
||||
else:
|
||||
result.append(obj)
|
||||
|
||||
if not result:
|
||||
raise MpdNoExistError('Not found')
|
||||
|
||||
return [('directory', root_path)] + result
|
||||
for track in lookup_future.get():
|
||||
result.extend(translator.track_to_mpd_format(track))
|
||||
return result
|
||||
|
||||
|
||||
@handle_request(r'lsinfo$')
|
||||
@handle_request(r'lsinfo\ "(?P<uri>[^"]*)"$')
|
||||
@protocol.commands.add('lsinfo')
|
||||
def lsinfo(context, uri=None):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
@ -498,31 +408,23 @@ def lsinfo(context, uri=None):
|
||||
""``, and ``lsinfo "/"``.
|
||||
"""
|
||||
result = []
|
||||
root_path = translator.normalize_path(uri, relative=True)
|
||||
try:
|
||||
uri = context.directory_path_to_uri(root_path)
|
||||
except MpdNoExistError as e:
|
||||
e.command = 'lsinfo'
|
||||
e.message = 'Not found'
|
||||
raise
|
||||
if uri in (None, '', '/'):
|
||||
result.extend(protocol.stored_playlists.listplaylists(context))
|
||||
|
||||
if uri is None:
|
||||
result.extend(stored_playlists.listplaylists(context))
|
||||
|
||||
for ref in context.core.library.browse(uri).get():
|
||||
if ref.type == Ref.DIRECTORY:
|
||||
path = '/'.join([root_path, ref.name.replace('/', '')])
|
||||
for path, lookup_future in context.browse(uri, recursive=False):
|
||||
if not lookup_future:
|
||||
result.append(('directory', path.lstrip('/')))
|
||||
elif ref.type == Ref.TRACK:
|
||||
# TODO Lookup tracks in batch for better performance
|
||||
tracks = context.core.library.lookup(ref.uri).get()
|
||||
else:
|
||||
tracks = lookup_future.get()
|
||||
if tracks:
|
||||
result.extend(translator.track_to_mpd_format(tracks[0]))
|
||||
|
||||
if not result:
|
||||
raise exceptions.MpdNoExistError('Not found')
|
||||
return result
|
||||
|
||||
|
||||
@handle_request(r'rescan$')
|
||||
@handle_request(r'rescan\ "(?P<uri>[^"]+)"$')
|
||||
@protocol.commands.add('rescan')
|
||||
def rescan(context, uri=None):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
@ -531,11 +433,11 @@ def rescan(context, uri=None):
|
||||
|
||||
Same as ``update``, but also rescans unmodified files.
|
||||
"""
|
||||
return update(context, uri, rescan_unmodified_files=True)
|
||||
return {'updating_db': 0} # TODO
|
||||
|
||||
|
||||
@handle_request(r'search\ ' + SEARCH_QUERY)
|
||||
def search(context, mpd_query):
|
||||
@protocol.commands.add('search')
|
||||
def search(context, *args):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
|
||||
@ -563,7 +465,7 @@ def search(context, mpd_query):
|
||||
- uses "file" instead of "filename".
|
||||
"""
|
||||
try:
|
||||
query = _query_from_mpd_search_format(mpd_query)
|
||||
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
||||
except ValueError:
|
||||
return
|
||||
results = context.core.library.search(**query).get()
|
||||
@ -573,8 +475,8 @@ def search(context, mpd_query):
|
||||
return translator.tracks_to_mpd_format(artists + albums + tracks)
|
||||
|
||||
|
||||
@handle_request(r'searchadd\ ' + SEARCH_QUERY)
|
||||
def searchadd(context, mpd_query):
|
||||
@protocol.commands.add('searchadd')
|
||||
def searchadd(context, *args):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
|
||||
@ -587,15 +489,15 @@ def searchadd(context, mpd_query):
|
||||
not case sensitive.
|
||||
"""
|
||||
try:
|
||||
query = _query_from_mpd_search_format(mpd_query)
|
||||
query = _query_from_mpd_search_parameters(args, _SEARCH_MAPPING)
|
||||
except ValueError:
|
||||
return
|
||||
results = context.core.library.search(**query).get()
|
||||
context.core.tracklist.add(_get_tracks(results))
|
||||
|
||||
|
||||
@handle_request(r'searchaddpl\ "(?P<playlist_name>[^"]+)"\ ' + SEARCH_QUERY)
|
||||
def searchaddpl(context, playlist_name, mpd_query):
|
||||
@protocol.commands.add('searchaddpl')
|
||||
def searchaddpl(context, *args):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
|
||||
@ -609,8 +511,12 @@ def searchaddpl(context, playlist_name, mpd_query):
|
||||
Parameters have the same meaning as for ``find``, except that search is
|
||||
not case sensitive.
|
||||
"""
|
||||
parameters = list(args)
|
||||
if not parameters:
|
||||
raise exceptions.MpdArgError('incorrect arguments')
|
||||
playlist_name = parameters.pop(0)
|
||||
try:
|
||||
query = _query_from_mpd_search_format(mpd_query)
|
||||
query = _query_from_mpd_search_parameters(parameters, _SEARCH_MAPPING)
|
||||
except ValueError:
|
||||
return
|
||||
results = context.core.library.search(**query).get()
|
||||
@ -623,9 +529,8 @@ def searchaddpl(context, playlist_name, mpd_query):
|
||||
context.core.playlists.save(playlist)
|
||||
|
||||
|
||||
@handle_request(r'update$')
|
||||
@handle_request(r'update\ "(?P<uri>[^"]+)"$')
|
||||
def update(context, uri=None, rescan_unmodified_files=False):
|
||||
@protocol.commands.add('update')
|
||||
def update(context, uri=None):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
|
||||
@ -642,3 +547,27 @@ def update(context, uri=None, rescan_unmodified_files=False):
|
||||
``status`` response.
|
||||
"""
|
||||
return {'updating_db': 0} # TODO
|
||||
|
||||
|
||||
# TODO: add at least reflection tests before adding NotImplemented version
|
||||
#@protocol.commands.add('readcomments')
|
||||
def readcomments(context, uri):
|
||||
"""
|
||||
*musicpd.org, music database section:*
|
||||
|
||||
``readcomments [URI]``
|
||||
|
||||
Read "comments" (i.e. key-value pairs) from the file specified by
|
||||
"URI". This "URI" can be a path relative to the music directory or a
|
||||
URL in the form "file:///foo/bar.ogg".
|
||||
|
||||
This command may be used to list metadata of remote files (e.g. URI
|
||||
beginning with "http://" or "smb://").
|
||||
|
||||
The response consists of lines in the form "KEY: VALUE". Comments with
|
||||
suspicious characters (e.g. newlines) are ignored silently.
|
||||
|
||||
The meaning of these depends on the codec, and not all decoder plugins
|
||||
support it. For example, on Ogg files, this lists the Vorbis comments.
|
||||
"""
|
||||
pass
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import warnings
|
||||
|
||||
from mopidy.core import PlaybackState
|
||||
from mopidy.mpd.protocol import handle_request
|
||||
from mopidy.mpd.exceptions import (
|
||||
MpdArgError, MpdNoExistError, MpdNotImplemented)
|
||||
from mopidy.mpd import exceptions, protocol
|
||||
|
||||
|
||||
@handle_request(r'consume\ ("?)(?P<state>[01])\1$')
|
||||
@protocol.commands.add('consume', state=protocol.BOOL)
|
||||
def consume(context, state):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
@ -17,13 +17,10 @@ def consume(context, state):
|
||||
1. When consume is activated, each song played is removed from
|
||||
playlist.
|
||||
"""
|
||||
if int(state):
|
||||
context.core.tracklist.consume = True
|
||||
else:
|
||||
context.core.tracklist.consume = False
|
||||
context.core.tracklist.consume = state
|
||||
|
||||
|
||||
@handle_request(r'crossfade\ "(?P<seconds>\d+)"$')
|
||||
@protocol.commands.add('crossfade', seconds=protocol.UINT)
|
||||
def crossfade(context, seconds):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
@ -32,11 +29,42 @@ def crossfade(context, seconds):
|
||||
|
||||
Sets crossfading between songs.
|
||||
"""
|
||||
seconds = int(seconds)
|
||||
raise MpdNotImplemented # TODO
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(r'next$')
|
||||
# TODO: add at least reflection tests before adding NotImplemented version
|
||||
#@protocol.commands.add('mixrampdb')
|
||||
def mixrampdb(context, decibels):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
|
||||
``mixrampdb {deciBels}``
|
||||
|
||||
Sets the threshold at which songs will be overlapped. Like crossfading but
|
||||
doesn't fade the track volume, just overlaps. The songs need to have
|
||||
MixRamp tags added by an external tool. 0dB is the normalized maximum
|
||||
volume so use negative values, I prefer -17dB. In the absence of mixramp
|
||||
tags crossfading will be used. See http://sourceforge.net/projects/mixramp
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
# TODO: add at least reflection tests before adding NotImplemented version
|
||||
#@protocol.commands.add('mixrampdelay', seconds=protocol.UINT)
|
||||
def mixrampdelay(context, seconds):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
|
||||
``mixrampdelay {SECONDS}``
|
||||
|
||||
Additional time subtracted from the overlap calculated by mixrampdb. A
|
||||
value of "nan" disables MixRamp overlapping and falls back to
|
||||
crossfading.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@protocol.commands.add('next')
|
||||
def next_(context):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
@ -94,8 +122,7 @@ def next_(context):
|
||||
return context.core.playback.next().get()
|
||||
|
||||
|
||||
@handle_request(r'pause$')
|
||||
@handle_request(r'pause\ "(?P<state>[01])"$')
|
||||
@protocol.commands.add('pause', state=protocol.BOOL)
|
||||
def pause(context, state=None):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
@ -109,54 +136,22 @@ def pause(context, state=None):
|
||||
- Calls ``pause`` without any arguments to toogle pause.
|
||||
"""
|
||||
if state is None:
|
||||
warnings.warn(
|
||||
'The use of pause command w/o the PAUSE argument is deprecated.',
|
||||
DeprecationWarning)
|
||||
|
||||
if (context.core.playback.state.get() == PlaybackState.PLAYING):
|
||||
context.core.playback.pause()
|
||||
elif (context.core.playback.state.get() == PlaybackState.PAUSED):
|
||||
context.core.playback.resume()
|
||||
elif int(state):
|
||||
elif state:
|
||||
context.core.playback.pause()
|
||||
else:
|
||||
context.core.playback.resume()
|
||||
|
||||
|
||||
@handle_request(r'play$')
|
||||
def play(context):
|
||||
"""
|
||||
The original MPD server resumes from the paused state on ``play``
|
||||
without arguments.
|
||||
"""
|
||||
return context.core.playback.play().get()
|
||||
|
||||
|
||||
@handle_request(r'playid\ ("?)(?P<tlid>-?\d+)\1$')
|
||||
def playid(context, tlid):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
|
||||
``playid [SONGID]``
|
||||
|
||||
Begins playing the playlist at song ``SONGID``.
|
||||
|
||||
*Clarifications:*
|
||||
|
||||
- ``playid "-1"`` when playing is ignored.
|
||||
- ``playid "-1"`` when paused resumes playback.
|
||||
- ``playid "-1"`` when stopped with a current track starts playback at the
|
||||
current track.
|
||||
- ``playid "-1"`` when stopped without a current track, e.g. after playlist
|
||||
replacement, starts playback at the first track.
|
||||
"""
|
||||
tlid = int(tlid)
|
||||
if tlid == -1:
|
||||
return _play_minus_one(context)
|
||||
tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
|
||||
if not tl_tracks:
|
||||
raise MpdNoExistError('No such song')
|
||||
return context.core.playback.play(tl_tracks[0]).get()
|
||||
|
||||
|
||||
@handle_request(r'play\ ("?)(?P<songpos>-?\d+)\1$')
|
||||
def play__pos(context, songpos):
|
||||
@protocol.commands.add('play', tlid=protocol.INT)
|
||||
def play(context, tlid=None):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
|
||||
@ -164,6 +159,9 @@ def play__pos(context, songpos):
|
||||
|
||||
Begins playing the playlist at song number ``SONGPOS``.
|
||||
|
||||
The original MPD server resumes from the paused state on ``play``
|
||||
without arguments.
|
||||
|
||||
*Clarifications:*
|
||||
|
||||
- ``play "-1"`` when playing is ignored.
|
||||
@ -177,14 +175,16 @@ def play__pos(context, songpos):
|
||||
|
||||
- issues ``play 6`` without quotes around the argument.
|
||||
"""
|
||||
songpos = int(songpos)
|
||||
if songpos == -1:
|
||||
if tlid is None:
|
||||
return context.core.playback.play().get()
|
||||
elif tlid == -1:
|
||||
return _play_minus_one(context)
|
||||
|
||||
try:
|
||||
tl_track = context.core.tracklist.slice(songpos, songpos + 1).get()[0]
|
||||
tl_track = context.core.tracklist.slice(tlid, tlid + 1).get()[0]
|
||||
return context.core.playback.play(tl_track).get()
|
||||
except IndexError:
|
||||
raise MpdArgError('Bad song index')
|
||||
raise exceptions.MpdArgError('Bad song index')
|
||||
|
||||
|
||||
def _play_minus_one(context):
|
||||
@ -202,7 +202,33 @@ def _play_minus_one(context):
|
||||
return # Fail silently
|
||||
|
||||
|
||||
@handle_request(r'previous$')
|
||||
@protocol.commands.add('playid', tlid=protocol.INT)
|
||||
def playid(context, tlid):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
|
||||
``playid [SONGID]``
|
||||
|
||||
Begins playing the playlist at song ``SONGID``.
|
||||
|
||||
*Clarifications:*
|
||||
|
||||
- ``playid "-1"`` when playing is ignored.
|
||||
- ``playid "-1"`` when paused resumes playback.
|
||||
- ``playid "-1"`` when stopped with a current track starts playback at the
|
||||
current track.
|
||||
- ``playid "-1"`` when stopped without a current track, e.g. after playlist
|
||||
replacement, starts playback at the first track.
|
||||
"""
|
||||
if tlid == -1:
|
||||
return _play_minus_one(context)
|
||||
tl_tracks = context.core.tracklist.filter(tlid=[tlid]).get()
|
||||
if not tl_tracks:
|
||||
raise exceptions.MpdNoExistError('No such song')
|
||||
return context.core.playback.play(tl_tracks[0]).get()
|
||||
|
||||
|
||||
@protocol.commands.add('previous')
|
||||
def previous(context):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
@ -249,7 +275,7 @@ def previous(context):
|
||||
return context.core.playback.previous().get()
|
||||
|
||||
|
||||
@handle_request(r'random\ ("?)(?P<state>[01])\1$')
|
||||
@protocol.commands.add('random', state=protocol.BOOL)
|
||||
def random(context, state):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
@ -258,13 +284,10 @@ def random(context, state):
|
||||
|
||||
Sets random state to ``STATE``, ``STATE`` should be 0 or 1.
|
||||
"""
|
||||
if int(state):
|
||||
context.core.tracklist.random = True
|
||||
else:
|
||||
context.core.tracklist.random = False
|
||||
context.core.tracklist.random = state
|
||||
|
||||
|
||||
@handle_request(r'repeat\ ("?)(?P<state>[01])\1$')
|
||||
@protocol.commands.add('repeat', state=protocol.BOOL)
|
||||
def repeat(context, state):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
@ -273,13 +296,10 @@ def repeat(context, state):
|
||||
|
||||
Sets repeat state to ``STATE``, ``STATE`` should be 0 or 1.
|
||||
"""
|
||||
if int(state):
|
||||
context.core.tracklist.repeat = True
|
||||
else:
|
||||
context.core.tracklist.repeat = False
|
||||
context.core.tracklist.repeat = state
|
||||
|
||||
|
||||
@handle_request(r'replay_gain_mode\ "(?P<mode>(off|track|album))"$')
|
||||
@protocol.commands.add('replay_gain_mode')
|
||||
def replay_gain_mode(context, mode):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
@ -293,10 +313,10 @@ def replay_gain_mode(context, mode):
|
||||
|
||||
This command triggers the options idle event.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(r'replay_gain_status$')
|
||||
@protocol.commands.add('replay_gain_status')
|
||||
def replay_gain_status(context):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
@ -309,8 +329,8 @@ def replay_gain_status(context):
|
||||
return 'off' # TODO
|
||||
|
||||
|
||||
@handle_request(r'seek\ ("?)(?P<songpos>\d+)\1\ ("?)(?P<seconds>\d+)\3$')
|
||||
def seek(context, songpos, seconds):
|
||||
@protocol.commands.add('seek', tlid=protocol.UINT, seconds=protocol.UINT)
|
||||
def seek(context, tlid, seconds):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
|
||||
@ -324,12 +344,12 @@ def seek(context, songpos, seconds):
|
||||
- issues ``seek 1 120`` without quotes around the arguments.
|
||||
"""
|
||||
tl_track = context.core.playback.current_tl_track.get()
|
||||
if context.core.tracklist.index(tl_track).get() != int(songpos):
|
||||
play__pos(context, songpos)
|
||||
context.core.playback.seek(int(seconds) * 1000).get()
|
||||
if context.core.tracklist.index(tl_track).get() != tlid:
|
||||
play(context, tlid)
|
||||
context.core.playback.seek(seconds * 1000).get()
|
||||
|
||||
|
||||
@handle_request(r'seekid\ "(?P<tlid>\d+)"\ "(?P<seconds>\d+)"$')
|
||||
@protocol.commands.add('seekid', tlid=protocol.UINT, seconds=protocol.UINT)
|
||||
def seekid(context, tlid, seconds):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
@ -339,14 +359,13 @@ def seekid(context, tlid, seconds):
|
||||
Seeks to the position ``TIME`` (in seconds) of song ``SONGID``.
|
||||
"""
|
||||
tl_track = context.core.playback.current_tl_track.get()
|
||||
if not tl_track or tl_track.tlid != int(tlid):
|
||||
if not tl_track or tl_track.tlid != tlid:
|
||||
playid(context, tlid)
|
||||
context.core.playback.seek(int(seconds) * 1000).get()
|
||||
context.core.playback.seek(seconds * 1000).get()
|
||||
|
||||
|
||||
@handle_request(r'seekcur\ "(?P<position>\d+)"$')
|
||||
@handle_request(r'seekcur\ "(?P<diff>[-+]\d+)"$')
|
||||
def seekcur(context, position=None, diff=None):
|
||||
@protocol.commands.add('seekcur')
|
||||
def seekcur(context, time):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
|
||||
@ -355,16 +374,16 @@ def seekcur(context, position=None, diff=None):
|
||||
Seeks to the position ``TIME`` within the current song. If prefixed by
|
||||
'+' or '-', then the time is relative to the current playing position.
|
||||
"""
|
||||
if position is not None:
|
||||
position = int(position) * 1000
|
||||
context.core.playback.seek(position).get()
|
||||
elif diff is not None:
|
||||
if time.startswith(('+', '-')):
|
||||
position = context.core.playback.time_position.get()
|
||||
position += int(diff) * 1000
|
||||
position += protocol.INT(time) * 1000
|
||||
context.core.playback.seek(position).get()
|
||||
else:
|
||||
position = protocol.UINT(time) * 1000
|
||||
context.core.playback.seek(position).get()
|
||||
|
||||
|
||||
@handle_request(r'setvol\ ("?)(?P<volume>[-+]*\d+)\1$')
|
||||
@protocol.commands.add('setvol', volume=protocol.INT)
|
||||
def setvol(context, volume):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
@ -377,15 +396,11 @@ def setvol(context, volume):
|
||||
|
||||
- issues ``setvol 50`` without quotes around the argument.
|
||||
"""
|
||||
volume = int(volume)
|
||||
if volume < 0:
|
||||
volume = 0
|
||||
if volume > 100:
|
||||
volume = 100
|
||||
context.core.playback.volume = volume
|
||||
# NOTE: we use INT as clients can pass in +N etc.
|
||||
context.core.playback.volume = min(max(0, volume), 100)
|
||||
|
||||
|
||||
@handle_request(r'single\ ("?)(?P<state>[01])\1$')
|
||||
@protocol.commands.add('single', state=protocol.BOOL)
|
||||
def single(context, state):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
@ -396,13 +411,10 @@ def single(context, state):
|
||||
single is activated, playback is stopped after current song, or
|
||||
song is repeated if the ``repeat`` mode is enabled.
|
||||
"""
|
||||
if int(state):
|
||||
context.core.tracklist.single = True
|
||||
else:
|
||||
context.core.tracklist.single = False
|
||||
context.core.tracklist.single = state
|
||||
|
||||
|
||||
@handle_request(r'stop$')
|
||||
@protocol.commands.add('stop')
|
||||
def stop(context):
|
||||
"""
|
||||
*musicpd.org, playback section:*
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from mopidy.mpd.exceptions import MpdPermissionError
|
||||
from mopidy.mpd.protocol import handle_request, mpd_commands
|
||||
from mopidy.mpd import exceptions, protocol
|
||||
|
||||
|
||||
@handle_request(r'config$', auth_required=False)
|
||||
@protocol.commands.add('config', list_command=False)
|
||||
def config(context):
|
||||
"""
|
||||
*musicpd.org, reflection section:*
|
||||
@ -15,10 +14,10 @@ def config(context):
|
||||
command is only permitted to "local" clients (connected via UNIX domain
|
||||
socket).
|
||||
"""
|
||||
raise MpdPermissionError(command='config')
|
||||
raise exceptions.MpdPermissionError(command='config')
|
||||
|
||||
|
||||
@handle_request(r'commands$', auth_required=False)
|
||||
@protocol.commands.add('commands', auth_required=False)
|
||||
def commands(context):
|
||||
"""
|
||||
*musicpd.org, reflection section:*
|
||||
@ -27,25 +26,18 @@ def commands(context):
|
||||
|
||||
Shows which commands the current user has access to.
|
||||
"""
|
||||
if context.dispatcher.authenticated:
|
||||
command_names = set([command.name for command in mpd_commands])
|
||||
else:
|
||||
command_names = set([
|
||||
command.name for command in mpd_commands
|
||||
if not command.auth_required])
|
||||
|
||||
# No one is permited to use 'config' or 'kill', rest of commands are not
|
||||
# listed by MPD, so we shouldn't either.
|
||||
command_names = command_names - set([
|
||||
'config', 'kill', 'command_list_begin', 'command_list_ok_begin',
|
||||
'command_list_ok_begin', 'command_list_end', 'idle', 'noidle',
|
||||
'sticker'])
|
||||
command_names = set()
|
||||
for name, handler in protocol.commands.handlers.items():
|
||||
if not handler.list_command:
|
||||
continue
|
||||
if context.dispatcher.authenticated or not handler.auth_required:
|
||||
command_names.add(name)
|
||||
|
||||
return [
|
||||
('command', command_name) for command_name in sorted(command_names)]
|
||||
|
||||
|
||||
@handle_request(r'decoders$')
|
||||
@protocol.commands.add('decoders')
|
||||
def decoders(context):
|
||||
"""
|
||||
*musicpd.org, reflection section:*
|
||||
@ -72,7 +64,7 @@ def decoders(context):
|
||||
return # TODO
|
||||
|
||||
|
||||
@handle_request(r'notcommands$', auth_required=False)
|
||||
@protocol.commands.add('notcommands', auth_required=False)
|
||||
def notcommands(context):
|
||||
"""
|
||||
*musicpd.org, reflection section:*
|
||||
@ -81,21 +73,18 @@ def notcommands(context):
|
||||
|
||||
Shows which commands the current user does not have access to.
|
||||
"""
|
||||
if context.dispatcher.authenticated:
|
||||
command_names = []
|
||||
else:
|
||||
command_names = [
|
||||
command.name for command in mpd_commands if command.auth_required]
|
||||
|
||||
# No permission to use
|
||||
command_names.append('config')
|
||||
command_names.append('kill')
|
||||
command_names = set(['config', 'kill']) # No permission to use
|
||||
for name, handler in protocol.commands.handlers.items():
|
||||
if not handler.list_command:
|
||||
continue
|
||||
if not context.dispatcher.authenticated and handler.auth_required:
|
||||
command_names.add(name)
|
||||
|
||||
return [
|
||||
('command', command_name) for command_name in sorted(command_names)]
|
||||
|
||||
|
||||
@handle_request(r'tagtypes$')
|
||||
@protocol.commands.add('tagtypes')
|
||||
def tagtypes(context):
|
||||
"""
|
||||
*musicpd.org, reflection section:*
|
||||
@ -107,7 +96,7 @@ def tagtypes(context):
|
||||
pass # TODO
|
||||
|
||||
|
||||
@handle_request(r'urlhandlers$')
|
||||
@protocol.commands.add('urlhandlers')
|
||||
def urlhandlers(context):
|
||||
"""
|
||||
*musicpd.org, reflection section:*
|
||||
|
||||
@ -3,9 +3,7 @@ from __future__ import unicode_literals
|
||||
import pykka
|
||||
|
||||
from mopidy.core import PlaybackState
|
||||
from mopidy.mpd.exceptions import MpdNotImplemented
|
||||
from mopidy.mpd.protocol import handle_request
|
||||
from mopidy.mpd.translator import track_to_mpd_format
|
||||
from mopidy.mpd import exceptions, protocol, translator
|
||||
|
||||
#: Subsystems that can be registered with idle command.
|
||||
SUBSYSTEMS = [
|
||||
@ -13,7 +11,7 @@ SUBSYSTEMS = [
|
||||
'stored_playlist', 'update']
|
||||
|
||||
|
||||
@handle_request(r'clearerror$')
|
||||
@protocol.commands.add('clearerror')
|
||||
def clearerror(context):
|
||||
"""
|
||||
*musicpd.org, status section:*
|
||||
@ -23,10 +21,10 @@ def clearerror(context):
|
||||
Clears the current error message in status (this is also
|
||||
accomplished by any command that starts playback).
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(r'currentsong$')
|
||||
@protocol.commands.add('currentsong')
|
||||
def currentsong(context):
|
||||
"""
|
||||
*musicpd.org, status section:*
|
||||
@ -39,12 +37,11 @@ def currentsong(context):
|
||||
tl_track = context.core.playback.current_tl_track.get()
|
||||
if tl_track is not None:
|
||||
position = context.core.tracklist.index(tl_track).get()
|
||||
return track_to_mpd_format(tl_track, position=position)
|
||||
return translator.track_to_mpd_format(tl_track, position=position)
|
||||
|
||||
|
||||
@handle_request(r'idle$')
|
||||
@handle_request(r'idle\ (?P<subsystems>.+)$')
|
||||
def idle(context, subsystems=None):
|
||||
@protocol.commands.add('idle', list_command=False)
|
||||
def idle(context, *subsystems):
|
||||
"""
|
||||
*musicpd.org, status section:*
|
||||
|
||||
@ -77,10 +74,9 @@ def idle(context, subsystems=None):
|
||||
notifications when something changed in one of the specified
|
||||
subsystems.
|
||||
"""
|
||||
# TODO: test against valid subsystems
|
||||
|
||||
if subsystems:
|
||||
subsystems = subsystems.split()
|
||||
else:
|
||||
if not subsystems:
|
||||
subsystems = SUBSYSTEMS
|
||||
|
||||
for subsystem in subsystems:
|
||||
@ -100,7 +96,7 @@ def idle(context, subsystems=None):
|
||||
return response
|
||||
|
||||
|
||||
@handle_request(r'noidle$')
|
||||
@protocol.commands.add('noidle', list_command=False)
|
||||
def noidle(context):
|
||||
"""See :meth:`_status_idle`."""
|
||||
if not context.subscriptions:
|
||||
@ -110,7 +106,7 @@ def noidle(context):
|
||||
context.session.prevent_timeout = False
|
||||
|
||||
|
||||
@handle_request(r'stats$')
|
||||
@protocol.commands.add('stats')
|
||||
def stats(context):
|
||||
"""
|
||||
*musicpd.org, status section:*
|
||||
@ -137,7 +133,7 @@ def stats(context):
|
||||
}
|
||||
|
||||
|
||||
@handle_request(r'status$')
|
||||
@protocol.commands.add('status')
|
||||
def status(context):
|
||||
"""
|
||||
*musicpd.org, status section:*
|
||||
|
||||
@ -1,76 +1,38 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from mopidy.mpd.protocol import handle_request
|
||||
from mopidy.mpd.exceptions import MpdNotImplemented
|
||||
from mopidy.mpd import exceptions, protocol
|
||||
|
||||
|
||||
@handle_request(
|
||||
r'sticker\ delete\ "(?P<field>[^"]+)"\ '
|
||||
r'"(?P<uri>[^"]+)"(\ "(?P<name>[^"]+)")*$')
|
||||
def sticker__delete(context, 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_request(
|
||||
r'sticker\ find\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"\ '
|
||||
r'"(?P<name>[^"]+)"$')
|
||||
def sticker__find(context, 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_request(
|
||||
r'sticker\ get\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"\ '
|
||||
r'"(?P<name>[^"]+)"$')
|
||||
def sticker__get(context, field, uri, name):
|
||||
"""
|
||||
*musicpd.org, sticker section:*
|
||||
|
||||
``sticker get {TYPE} {URI} {NAME}``
|
||||
|
||||
Reads a sticker value for the specified object.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(r'sticker\ list\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"$')
|
||||
def sticker__list(context, field, uri):
|
||||
@protocol.commands.add('sticker', list_command=False)
|
||||
def sticker(context, action, field, uri, name=None, value=None):
|
||||
"""
|
||||
*musicpd.org, sticker section:*
|
||||
|
||||
``sticker list {TYPE} {URI}``
|
||||
|
||||
Lists the stickers for the specified object.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
|
||||
``sticker find {TYPE} {URI} {NAME}``
|
||||
|
||||
@handle_request(
|
||||
r'sticker\ set\ "(?P<field>[^"]+)"\ "(?P<uri>[^"]+)"\ '
|
||||
r'"(?P<name>[^"]+)"\ "(?P<value>[^"]+)"$')
|
||||
def sticker__set(context, field, uri, name, value):
|
||||
"""
|
||||
*musicpd.org, sticker section:*
|
||||
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.
|
||||
|
||||
``sticker get {TYPE} {URI} {NAME}``
|
||||
|
||||
Reads a sticker value for the specified object.
|
||||
|
||||
``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.
|
||||
|
||||
``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
|
||||
# TODO: check that action in ('list', 'find', 'get', 'set', 'delete')
|
||||
# TODO: check name/value matches with action
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
@ -2,12 +2,10 @@ from __future__ import division, unicode_literals
|
||||
|
||||
import datetime
|
||||
|
||||
from mopidy.mpd.exceptions import MpdNoExistError, MpdNotImplemented
|
||||
from mopidy.mpd.protocol import handle_request
|
||||
from mopidy.mpd.translator import playlist_to_mpd_format
|
||||
from mopidy.mpd import exceptions, protocol, translator
|
||||
|
||||
|
||||
@handle_request(r'listplaylist\ ("?)(?P<name>[^"]+)\1$')
|
||||
@protocol.commands.add('listplaylist')
|
||||
def listplaylist(context, name):
|
||||
"""
|
||||
*musicpd.org, stored playlists section:*
|
||||
@ -24,11 +22,11 @@ def listplaylist(context, name):
|
||||
"""
|
||||
playlist = context.lookup_playlist_from_name(name)
|
||||
if not playlist:
|
||||
raise MpdNoExistError('No such playlist')
|
||||
raise exceptions.MpdNoExistError('No such playlist')
|
||||
return ['file: %s' % t.uri for t in playlist.tracks]
|
||||
|
||||
|
||||
@handle_request(r'listplaylistinfo\ ("?)(?P<name>[^"]+)\1$')
|
||||
@protocol.commands.add('listplaylistinfo')
|
||||
def listplaylistinfo(context, name):
|
||||
"""
|
||||
*musicpd.org, stored playlists section:*
|
||||
@ -44,11 +42,11 @@ def listplaylistinfo(context, name):
|
||||
"""
|
||||
playlist = context.lookup_playlist_from_name(name)
|
||||
if not playlist:
|
||||
raise MpdNoExistError('No such playlist')
|
||||
return playlist_to_mpd_format(playlist)
|
||||
raise exceptions.MpdNoExistError('No such playlist')
|
||||
return translator.playlist_to_mpd_format(playlist)
|
||||
|
||||
|
||||
@handle_request(r'listplaylists$')
|
||||
@protocol.commands.add('listplaylists')
|
||||
def listplaylists(context):
|
||||
"""
|
||||
*musicpd.org, stored playlists section:*
|
||||
@ -84,6 +82,7 @@ def listplaylists(context):
|
||||
return result
|
||||
|
||||
|
||||
# TODO: move to translators?
|
||||
def _get_last_modified(playlist):
|
||||
"""Formats last modified timestamp of a playlist for MPD.
|
||||
|
||||
@ -100,9 +99,8 @@ def _get_last_modified(playlist):
|
||||
return '%sZ' % dt.isoformat()
|
||||
|
||||
|
||||
@handle_request(
|
||||
r'load\ "(?P<name>[^"]+)"(\ "(?P<start>\d+):(?P<end>\d+)*")*$')
|
||||
def load(context, name, start=None, end=None):
|
||||
@protocol.commands.add('load', playlist_slice=protocol.RANGE)
|
||||
def load(context, name, playlist_slice=slice(0, None)):
|
||||
"""
|
||||
*musicpd.org, stored playlists section:*
|
||||
|
||||
@ -125,15 +123,11 @@ def load(context, name, start=None, end=None):
|
||||
"""
|
||||
playlist = context.lookup_playlist_from_name(name)
|
||||
if not playlist:
|
||||
raise MpdNoExistError('No such playlist')
|
||||
if start is not None:
|
||||
start = int(start)
|
||||
if end is not None:
|
||||
end = int(end)
|
||||
context.core.tracklist.add(playlist.tracks[start:end])
|
||||
raise exceptions.MpdNoExistError('No such playlist')
|
||||
context.core.tracklist.add(playlist.tracks[playlist_slice])
|
||||
|
||||
|
||||
@handle_request(r'playlistadd\ "(?P<name>[^"]+)"\ "(?P<uri>[^"]+)"$')
|
||||
@protocol.commands.add('playlistadd')
|
||||
def playlistadd(context, name, uri):
|
||||
"""
|
||||
*musicpd.org, stored playlists section:*
|
||||
@ -144,10 +138,10 @@ def playlistadd(context, name, uri):
|
||||
|
||||
``NAME.m3u`` will be created if it does not exist.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(r'playlistclear\ "(?P<name>[^"]+)"$')
|
||||
@protocol.commands.add('playlistclear')
|
||||
def playlistclear(context, name):
|
||||
"""
|
||||
*musicpd.org, stored playlists section:*
|
||||
@ -156,10 +150,10 @@ def playlistclear(context, name):
|
||||
|
||||
Clears the playlist ``NAME.m3u``.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(r'playlistdelete\ "(?P<name>[^"]+)"\ "(?P<songpos>\d+)"$')
|
||||
@protocol.commands.add('playlistdelete', songpos=protocol.UINT)
|
||||
def playlistdelete(context, name, songpos):
|
||||
"""
|
||||
*musicpd.org, stored playlists section:*
|
||||
@ -168,12 +162,11 @@ def playlistdelete(context, name, songpos):
|
||||
|
||||
Deletes ``SONGPOS`` from the playlist ``NAME.m3u``.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(
|
||||
r'playlistmove\ "(?P<name>[^"]+)"\ '
|
||||
r'"(?P<from_pos>\d+)"\ "(?P<to_pos>\d+)"$')
|
||||
@protocol.commands.add(
|
||||
'playlistmove', from_pos=protocol.UINT, to_pos=protocol.UINT)
|
||||
def playlistmove(context, name, from_pos, to_pos):
|
||||
"""
|
||||
*musicpd.org, stored playlists section:*
|
||||
@ -189,10 +182,10 @@ def playlistmove(context, name, from_pos, to_pos):
|
||||
documentation, but just the ``SONGPOS`` to move *from*, i.e.
|
||||
``playlistmove {NAME} {FROM_SONGPOS} {TO_SONGPOS}``.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(r'rename\ "(?P<old_name>[^"]+)"\ "(?P<new_name>[^"]+)"$')
|
||||
@protocol.commands.add('rename')
|
||||
def rename(context, old_name, new_name):
|
||||
"""
|
||||
*musicpd.org, stored playlists section:*
|
||||
@ -201,10 +194,10 @@ def rename(context, old_name, new_name):
|
||||
|
||||
Renames the playlist ``NAME.m3u`` to ``NEW_NAME.m3u``.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(r'rm\ "(?P<name>[^"]+)"$')
|
||||
@protocol.commands.add('rm')
|
||||
def rm(context, name):
|
||||
"""
|
||||
*musicpd.org, stored playlists section:*
|
||||
@ -213,10 +206,10 @@ def rm(context, name):
|
||||
|
||||
Removes the playlist ``NAME.m3u`` from the playlist directory.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
|
||||
@handle_request(r'save\ "(?P<name>[^"]+)"$')
|
||||
@protocol.commands.add('save')
|
||||
def save(context, name):
|
||||
"""
|
||||
*musicpd.org, stored playlists section:*
|
||||
@ -226,4 +219,4 @@ def save(context, name):
|
||||
Saves the current playlist to ``NAME.m3u`` in the playlist
|
||||
directory.
|
||||
"""
|
||||
raise MpdNotImplemented # TODO
|
||||
raise exceptions.MpdNotImplemented # TODO
|
||||
|
||||
@ -45,7 +45,7 @@ class MpdSession(network.LineProtocol):
|
||||
|
||||
def decode(self, line):
|
||||
try:
|
||||
return super(MpdSession, self).decode(line.decode('string_escape'))
|
||||
return super(MpdSession, self).decode(line)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
'Stopping actor due to unescaping error, data '
|
||||
|
||||
88
mopidy/mpd/tokenize.py
Normal file
88
mopidy/mpd/tokenize.py
Normal file
@ -0,0 +1,88 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
|
||||
from mopidy.mpd import exceptions
|
||||
|
||||
|
||||
WORD_RE = re.compile(r"""
|
||||
^
|
||||
(\s*) # Leading whitespace not allowed, capture it to report.
|
||||
([a-z][a-z0-9_]*) # A command name
|
||||
(?:\s+|$) # trailing whitespace or EOS
|
||||
(.*) # Possibly a remainder to be parsed
|
||||
""", re.VERBOSE)
|
||||
|
||||
# Quotes matching is an unrolled version of "(?:[^"\\]|\\.)*"
|
||||
PARAM_RE = re.compile(r"""
|
||||
^ # Leading whitespace is not allowed
|
||||
(?:
|
||||
([^%(unprintable)s"']+) # ord(char) < 0x20, not ", not '
|
||||
| # or
|
||||
"([^"\\]*(?:\\.[^"\\]*)*)" # anything surrounded by quotes
|
||||
)
|
||||
(?:\s+|$) # trailing whitespace or EOS
|
||||
(.*) # Possibly a remainder to be parsed
|
||||
""" % {'unprintable': ''.join(map(chr, range(0x21)))}, re.VERBOSE)
|
||||
|
||||
BAD_QUOTED_PARAM_RE = re.compile(r"""
|
||||
^
|
||||
"[^"\\]*(?:\\.[^"\\]*)* # start of a quoted value
|
||||
(?: # followed by:
|
||||
("[^\s]) # non-escaped quote, followed by non-whitespace
|
||||
| # or
|
||||
([^"]) # anything that is not a quote
|
||||
)
|
||||
""", re.VERBOSE)
|
||||
|
||||
UNESCAPE_RE = re.compile(r'\\(.)') # Backslash escapes any following char.
|
||||
|
||||
|
||||
def split(line):
|
||||
"""Splits a line into tokens using same rules as MPD.
|
||||
|
||||
- Lines may not start with whitespace
|
||||
- Tokens are split by arbitrary amount of spaces or tabs
|
||||
- First token must match `[a-z][a-z0-9_]*`
|
||||
- Remaining tokens can be unquoted or quoted tokens.
|
||||
- Unquoted tokens consist of all printable characters except double quotes,
|
||||
single quotes, spaces and tabs.
|
||||
- Quoted tokens are surrounded by a matching pair of double quotes.
|
||||
- The closing quote must be followed by space, tab or end of line.
|
||||
- Any value is allowed inside a quoted token. Including double quotes,
|
||||
assuming it is correctly escaped.
|
||||
- Backslash inside a quoted token is used to escape the following
|
||||
character.
|
||||
|
||||
For examples see the tests for this function.
|
||||
"""
|
||||
if not line.strip():
|
||||
raise exceptions.MpdNoCommand('No command given')
|
||||
match = WORD_RE.match(line)
|
||||
if not match:
|
||||
raise exceptions.MpdUnknownError('Invalid word character')
|
||||
whitespace, command, remainder = match.groups()
|
||||
if whitespace:
|
||||
raise exceptions.MpdUnknownError('Letter expected')
|
||||
|
||||
result = [command]
|
||||
while remainder:
|
||||
match = PARAM_RE.match(remainder)
|
||||
if not match:
|
||||
msg = _determine_error_message(remainder)
|
||||
raise exceptions.MpdArgError(msg, command=command)
|
||||
unquoted, quoted, remainder = match.groups()
|
||||
result.append(unquoted or UNESCAPE_RE.sub(r'\g<1>', quoted))
|
||||
return result
|
||||
|
||||
|
||||
def _determine_error_message(remainder):
|
||||
"""Helper to emulate MPD errors."""
|
||||
# Following checks are simply to match MPD error messages:
|
||||
match = BAD_QUOTED_PARAM_RE.match(remainder)
|
||||
if match:
|
||||
if match.group(1):
|
||||
return 'Space expected after closing \'"\''
|
||||
else:
|
||||
return 'Missing closing \'"\''
|
||||
return 'Invalid unquoted character'
|
||||
@ -1,9 +1,7 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import re
|
||||
import shlex
|
||||
|
||||
from mopidy.mpd.exceptions import MpdArgError
|
||||
from mopidy.models import TlTrack
|
||||
|
||||
# TODO: special handling of local:// uri scheme
|
||||
@ -137,46 +135,3 @@ def playlist_to_mpd_format(playlist, *args, **kwargs):
|
||||
Arguments as for :func:`tracks_to_mpd_format`, except the first one.
|
||||
"""
|
||||
return tracks_to_mpd_format(playlist.tracks, *args, **kwargs)
|
||||
|
||||
|
||||
def query_from_mpd_list_format(field, mpd_query):
|
||||
"""
|
||||
Converts an MPD ``list`` query to a Mopidy query.
|
||||
"""
|
||||
if mpd_query is None:
|
||||
return {}
|
||||
try:
|
||||
# shlex does not seem to be friends with unicode objects
|
||||
tokens = shlex.split(mpd_query.encode('utf-8'))
|
||||
except ValueError as error:
|
||||
if str(error) == 'No closing quotation':
|
||||
raise MpdArgError('Invalid unquoted character', command='list')
|
||||
else:
|
||||
raise
|
||||
tokens = [t.decode('utf-8') for t in tokens]
|
||||
if len(tokens) == 1:
|
||||
if field == 'album':
|
||||
if not tokens[0]:
|
||||
raise ValueError
|
||||
return {'artist': [tokens[0]]}
|
||||
else:
|
||||
raise MpdArgError(
|
||||
'should be "Album" for 3 arguments', command='list')
|
||||
elif len(tokens) % 2 == 0:
|
||||
query = {}
|
||||
while tokens:
|
||||
key = tokens[0].lower()
|
||||
value = tokens[1]
|
||||
tokens = tokens[2:]
|
||||
if key not in ('artist', 'album', 'albumartist', 'composer',
|
||||
'date', 'genre', 'performer'):
|
||||
raise MpdArgError('not able to parse args', command='list')
|
||||
if not value:
|
||||
raise ValueError
|
||||
if key in query:
|
||||
query[key].append(value)
|
||||
else:
|
||||
query[key] = [value]
|
||||
return query
|
||||
else:
|
||||
raise MpdArgError('not able to parse args', command='list')
|
||||
|
||||
@ -389,6 +389,10 @@ class CurrentPlaylistHandlerTest(protocol.BaseTestCase):
|
||||
self.sendRequest('playlistinfo "0:20"')
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_playlistinfo_with_zero_returns_ok(self):
|
||||
self.sendRequest('playlistinfo "0"')
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_playlistsearch(self):
|
||||
self.sendRequest('playlistsearch "any" "needle"')
|
||||
self.assertEqualResponse('ACK [0@0] {playlistsearch} Not implemented')
|
||||
|
||||
@ -10,8 +10,8 @@ from tests.mpd import protocol
|
||||
|
||||
class QueryFromMpdSearchFormatTest(unittest.TestCase):
|
||||
def test_dates_are_extracted(self):
|
||||
result = music_db._query_from_mpd_search_format(
|
||||
'Date "1974-01-02" "Date" "1975"')
|
||||
result = music_db._query_from_mpd_search_parameters(
|
||||
['Date', '1974-01-02', 'Date', '1975'], music_db._SEARCH_MAPPING)
|
||||
self.assertEqual(result['date'][0], '1974-01-02')
|
||||
self.assertEqual(result['date'][1], '1975')
|
||||
|
||||
@ -305,12 +305,33 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase):
|
||||
|
||||
def test_lsinfo_for_dir_includes_subdirs(self):
|
||||
self.backend.library.dummy_browse_result = {
|
||||
'dummy:/': [Ref.directory(uri='/foo', name='foo')]}
|
||||
'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')]}
|
||||
|
||||
self.sendRequest('lsinfo "/dummy"')
|
||||
self.assertInResponse('directory: dummy/foo')
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_lsinfo_for_dir_does_not_recurse(self):
|
||||
self.backend.library.dummy_library = [
|
||||
Track(uri='dummy:/a', name='a'),
|
||||
]
|
||||
self.backend.library.dummy_browse_result = {
|
||||
'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')],
|
||||
'dummy:/foo': [Ref.track(uri='dummy:/a', name='a')]}
|
||||
|
||||
self.sendRequest('lsinfo "/dummy"')
|
||||
self.assertNotInResponse('file: dummy:/a')
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_lsinfo_for_dir_does_not_include_self(self):
|
||||
self.backend.library.dummy_browse_result = {
|
||||
'dummy:/': [Ref.directory(uri='dummy:/foo', name='foo')],
|
||||
'dummy:/foo': [Ref.track(uri='dummy:/a', name='a')]}
|
||||
|
||||
self.sendRequest('lsinfo "/dummy"')
|
||||
self.assertNotInResponse('directory: dummy')
|
||||
self.assertInResponse('OK')
|
||||
|
||||
def test_update_without_uri(self):
|
||||
self.sendRequest('update')
|
||||
self.assertInResponse('updating_db: 0')
|
||||
|
||||
241
tests/mpd/test_commands.py
Normal file
241
tests/mpd/test_commands.py
Normal file
@ -0,0 +1,241 @@
|
||||
#encoding: utf-8
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import unittest
|
||||
|
||||
from mopidy.mpd import exceptions, protocol
|
||||
|
||||
|
||||
class TestConverts(unittest.TestCase):
|
||||
def test_integer(self):
|
||||
self.assertEqual(123, protocol.INT('123'))
|
||||
self.assertEqual(-123, protocol.INT('-123'))
|
||||
self.assertEqual(123, protocol.INT('+123'))
|
||||
self.assertRaises(ValueError, protocol.INT, '3.14')
|
||||
self.assertRaises(ValueError, protocol.INT, '')
|
||||
self.assertRaises(ValueError, protocol.INT, 'abc')
|
||||
self.assertRaises(ValueError, protocol.INT, '12 34')
|
||||
|
||||
def test_unsigned_integer(self):
|
||||
self.assertEqual(123, protocol.UINT('123'))
|
||||
self.assertRaises(ValueError, protocol.UINT, '-123')
|
||||
self.assertRaises(ValueError, protocol.UINT, '+123')
|
||||
self.assertRaises(ValueError, protocol.UINT, '3.14')
|
||||
self.assertRaises(ValueError, protocol.UINT, '')
|
||||
self.assertRaises(ValueError, protocol.UINT, 'abc')
|
||||
self.assertRaises(ValueError, protocol.UINT, '12 34')
|
||||
|
||||
def test_boolean(self):
|
||||
self.assertEqual(True, protocol.BOOL('1'))
|
||||
self.assertEqual(False, protocol.BOOL('0'))
|
||||
self.assertRaises(ValueError, protocol.BOOL, '3.14')
|
||||
self.assertRaises(ValueError, protocol.BOOL, '')
|
||||
self.assertRaises(ValueError, protocol.BOOL, 'true')
|
||||
self.assertRaises(ValueError, protocol.BOOL, 'false')
|
||||
self.assertRaises(ValueError, protocol.BOOL, 'abc')
|
||||
self.assertRaises(ValueError, protocol.BOOL, '12 34')
|
||||
|
||||
def test_range(self):
|
||||
self.assertEqual(slice(1, 2), protocol.RANGE('1'))
|
||||
self.assertEqual(slice(0, 1), protocol.RANGE('0'))
|
||||
self.assertEqual(slice(0, None), protocol.RANGE('0:'))
|
||||
self.assertEqual(slice(1, 3), protocol.RANGE('1:3'))
|
||||
self.assertRaises(ValueError, protocol.RANGE, '3.14')
|
||||
self.assertRaises(ValueError, protocol.RANGE, '1:abc')
|
||||
self.assertRaises(ValueError, protocol.RANGE, 'abc:1')
|
||||
self.assertRaises(ValueError, protocol.RANGE, '2:1')
|
||||
self.assertRaises(ValueError, protocol.RANGE, '-1:2')
|
||||
self.assertRaises(ValueError, protocol.RANGE, '1 : 2')
|
||||
self.assertRaises(ValueError, protocol.RANGE, '')
|
||||
self.assertRaises(ValueError, protocol.RANGE, 'true')
|
||||
self.assertRaises(ValueError, protocol.RANGE, 'false')
|
||||
self.assertRaises(ValueError, protocol.RANGE, 'abc')
|
||||
self.assertRaises(ValueError, protocol.RANGE, '12 34')
|
||||
|
||||
|
||||
class TestCommands(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.commands = protocol.Commands()
|
||||
|
||||
def test_add_as_a_decorator(self):
|
||||
@self.commands.add('test')
|
||||
def test(context):
|
||||
pass
|
||||
|
||||
def test_register_second_command_to_same_name_fails(self):
|
||||
func = lambda context: True
|
||||
|
||||
self.commands.add('foo')(func)
|
||||
with self.assertRaises(Exception):
|
||||
self.commands.add('foo')(func)
|
||||
|
||||
def test_function_only_takes_context_succeeds(self):
|
||||
sentinel = object()
|
||||
self.commands.add('bar')(lambda context: sentinel)
|
||||
self.assertEqual(sentinel, self.commands.call(['bar']))
|
||||
|
||||
def test_function_has_required_arg_succeeds(self):
|
||||
sentinel = object()
|
||||
self.commands.add('bar')(lambda context, required: sentinel)
|
||||
self.assertEqual(sentinel, self.commands.call(['bar', 'arg']))
|
||||
|
||||
def test_function_has_optional_args_succeeds(self):
|
||||
sentinel = object()
|
||||
self.commands.add('bar')(lambda context, optional=None: sentinel)
|
||||
self.assertEqual(sentinel, self.commands.call(['bar']))
|
||||
self.assertEqual(sentinel, self.commands.call(['bar', 'arg']))
|
||||
|
||||
def test_function_has_required_and_optional_args_succeeds(self):
|
||||
sentinel = object()
|
||||
func = lambda context, required, optional=None: sentinel
|
||||
self.commands.add('bar')(func)
|
||||
self.assertEqual(sentinel, self.commands.call(['bar', 'arg']))
|
||||
self.assertEqual(sentinel, self.commands.call(['bar', 'arg', 'arg']))
|
||||
|
||||
def test_function_has_varargs_succeeds(self):
|
||||
sentinel, args = object(), []
|
||||
self.commands.add('bar')(lambda context, *args: sentinel)
|
||||
for i in range(10):
|
||||
self.assertEqual(sentinel, self.commands.call(['bar'] + args))
|
||||
args.append('test')
|
||||
|
||||
def test_function_has_only_varags_succeeds(self):
|
||||
sentinel = object()
|
||||
self.commands.add('baz')(lambda *args: sentinel)
|
||||
self.assertEqual(sentinel, self.commands.call(['baz']))
|
||||
|
||||
def test_function_has_no_arguments_fails(self):
|
||||
with self.assertRaises(TypeError):
|
||||
self.commands.add('test')(lambda: True)
|
||||
|
||||
def test_function_has_required_and_varargs_fails(self):
|
||||
with self.assertRaises(TypeError):
|
||||
func = lambda context, required, *args: True
|
||||
self.commands.add('test')(func)
|
||||
|
||||
def test_function_has_optional_and_varargs_fails(self):
|
||||
with self.assertRaises(TypeError):
|
||||
func = lambda context, optional=None, *args: True
|
||||
self.commands.add('test')(func)
|
||||
|
||||
def test_function_hash_keywordargs_fails(self):
|
||||
with self.assertRaises(TypeError):
|
||||
self.commands.add('test')(lambda context, **kwargs: True)
|
||||
|
||||
def test_call_chooses_correct_handler(self):
|
||||
sentinel1, sentinel2, sentinel3 = object(), object(), object()
|
||||
self.commands.add('foo')(lambda context: sentinel1)
|
||||
self.commands.add('bar')(lambda context: sentinel2)
|
||||
self.commands.add('baz')(lambda context: sentinel3)
|
||||
|
||||
self.assertEqual(sentinel1, self.commands.call(['foo']))
|
||||
self.assertEqual(sentinel2, self.commands.call(['bar']))
|
||||
self.assertEqual(sentinel3, self.commands.call(['baz']))
|
||||
|
||||
def test_call_with_nonexistent_handler(self):
|
||||
with self.assertRaises(exceptions.MpdUnknownCommand):
|
||||
self.commands.call(['bar'])
|
||||
|
||||
def test_call_passes_context(self):
|
||||
sentinel = object()
|
||||
self.commands.add('foo')(lambda context: context)
|
||||
self.assertEqual(
|
||||
sentinel, self.commands.call(['foo'], context=sentinel))
|
||||
|
||||
def test_call_without_args_fails(self):
|
||||
with self.assertRaises(exceptions.MpdNoCommand):
|
||||
self.commands.call([])
|
||||
|
||||
def test_call_passes_required_argument(self):
|
||||
self.commands.add('foo')(lambda context, required: required)
|
||||
self.assertEqual('test123', self.commands.call(['foo', 'test123']))
|
||||
|
||||
def test_call_passes_optional_argument(self):
|
||||
sentinel = object()
|
||||
self.commands.add('foo')(lambda context, optional=sentinel: optional)
|
||||
self.assertEqual(sentinel, self.commands.call(['foo']))
|
||||
self.assertEqual('test', self.commands.call(['foo', 'test']))
|
||||
|
||||
def test_call_passes_required_and_optional_argument(self):
|
||||
func = lambda context, required, optional=None: (required, optional)
|
||||
self.commands.add('foo')(func)
|
||||
self.assertEqual(('arg', None), self.commands.call(['foo', 'arg']))
|
||||
self.assertEqual(
|
||||
('arg', 'kwarg'), self.commands.call(['foo', 'arg', 'kwarg']))
|
||||
|
||||
def test_call_passes_varargs(self):
|
||||
self.commands.add('foo')(lambda context, *args: args)
|
||||
|
||||
def test_call_incorrect_args(self):
|
||||
self.commands.add('foo')(lambda context: context)
|
||||
with self.assertRaises(TypeError):
|
||||
self.commands.call(['foo', 'bar'])
|
||||
|
||||
self.commands.add('bar')(lambda context, required: context)
|
||||
with self.assertRaises(TypeError):
|
||||
self.commands.call(['bar', 'bar', 'baz'])
|
||||
|
||||
self.commands.add('baz')(lambda context, optional=None: context)
|
||||
with self.assertRaises(TypeError):
|
||||
self.commands.call(['baz', 'bar', 'baz'])
|
||||
|
||||
def test_validator_gets_applied_to_required_arg(self):
|
||||
sentinel = object()
|
||||
func = lambda context, required: required
|
||||
self.commands.add('test', required=lambda v: sentinel)(func)
|
||||
self.assertEqual(sentinel, self.commands.call(['test', 'foo']))
|
||||
|
||||
def test_validator_gets_applied_to_optional_arg(self):
|
||||
sentinel = object()
|
||||
func = lambda context, optional=None: optional
|
||||
self.commands.add('foo', optional=lambda v: sentinel)(func)
|
||||
|
||||
self.assertEqual(sentinel, self.commands.call(['foo', '123']))
|
||||
|
||||
def test_validator_skips_optional_default(self):
|
||||
sentinel = object()
|
||||
func = lambda context, optional=sentinel: optional
|
||||
self.commands.add('foo', optional=lambda v: None)(func)
|
||||
|
||||
self.assertEqual(sentinel, self.commands.call(['foo']))
|
||||
|
||||
def test_validator_applied_to_non_existent_arg_fails(self):
|
||||
self.commands.add('foo')(lambda context, arg: arg)
|
||||
with self.assertRaises(TypeError):
|
||||
func = lambda context, wrong_arg: wrong_arg
|
||||
self.commands.add('bar', arg=lambda v: v)(func)
|
||||
|
||||
def test_validator_called_context_fails(self):
|
||||
return # TODO: how to handle this
|
||||
with self.assertRaises(TypeError):
|
||||
func = lambda context: True
|
||||
self.commands.add('bar', context=lambda v: v)(func)
|
||||
|
||||
def test_validator_value_error_is_converted(self):
|
||||
def validdate(value):
|
||||
raise ValueError
|
||||
|
||||
func = lambda context, arg: True
|
||||
self.commands.add('bar', arg=validdate)(func)
|
||||
|
||||
with self.assertRaises(exceptions.MpdArgError):
|
||||
self.commands.call(['bar', 'test'])
|
||||
|
||||
def test_auth_required_gets_stored(self):
|
||||
func1 = lambda context: context
|
||||
func2 = lambda context: context
|
||||
self.commands.add('foo')(func1)
|
||||
self.commands.add('bar', auth_required=False)(func2)
|
||||
|
||||
self.assertTrue(self.commands.handlers['foo'].auth_required)
|
||||
self.assertFalse(self.commands.handlers['bar'].auth_required)
|
||||
|
||||
def test_list_command_gets_stored(self):
|
||||
func1 = lambda context: context
|
||||
func2 = lambda context: context
|
||||
self.commands.add('foo')(func1)
|
||||
self.commands.add('bar', list_command=False)(func2)
|
||||
|
||||
self.assertTrue(self.commands.handlers['foo'].list_command)
|
||||
self.assertFalse(self.commands.handlers['bar'].list_command)
|
||||
@ -8,7 +8,6 @@ from mopidy import core
|
||||
from mopidy.backend import dummy
|
||||
from mopidy.mpd.dispatcher import MpdDispatcher
|
||||
from mopidy.mpd.exceptions import MpdAckError
|
||||
from mopidy.mpd.protocol import request_handlers, handle_request
|
||||
|
||||
|
||||
class MpdDispatcherTest(unittest.TestCase):
|
||||
@ -25,41 +24,15 @@ class MpdDispatcherTest(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
pykka.ActorRegistry.stop_all()
|
||||
|
||||
def test_register_same_pattern_twice_fails(self):
|
||||
func = lambda: None
|
||||
def test_call_handler_for_unknown_command_raises_exception(self):
|
||||
try:
|
||||
handle_request('a pattern')(func)
|
||||
handle_request('a pattern')(func)
|
||||
self.fail('Registering a pattern twice shoulde raise ValueError')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
def test_finding_handler_for_unknown_command_raises_exception(self):
|
||||
try:
|
||||
self.dispatcher._find_handler('an_unknown_command with args')
|
||||
self.dispatcher._call_handler('an_unknown_command with args')
|
||||
self.fail('Should raise exception')
|
||||
except MpdAckError as e:
|
||||
self.assertEqual(
|
||||
e.get_mpd_ack(),
|
||||
'ACK [5@0] {} unknown command "an_unknown_command"')
|
||||
|
||||
def test_find_handler_for_known_command_returns_handler_and_kwargs(self):
|
||||
expected_handler = lambda x: None
|
||||
request_handlers['known_command (?P<arg1>.+)'] = \
|
||||
expected_handler
|
||||
(handler, kwargs) = self.dispatcher._find_handler(
|
||||
'known_command an_arg')
|
||||
self.assertEqual(handler, expected_handler)
|
||||
self.assertIn('arg1', kwargs)
|
||||
self.assertEqual(kwargs['arg1'], 'an_arg')
|
||||
|
||||
def test_handling_unknown_request_yields_error(self):
|
||||
result = self.dispatcher.handle_request('an unhandled request')
|
||||
self.assertEqual(result[0], 'ACK [5@0] {} unknown command "an"')
|
||||
|
||||
def test_handling_known_request(self):
|
||||
expected = 'magic'
|
||||
request_handlers['known request'] = lambda x: expected
|
||||
result = self.dispatcher.handle_request('known request')
|
||||
self.assertIn('OK', result)
|
||||
self.assertIn(expected, result)
|
||||
|
||||
149
tests/mpd/test_tokenizer.py
Normal file
149
tests/mpd/test_tokenizer.py
Normal file
@ -0,0 +1,149 @@
|
||||
#encoding: utf-8
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import unittest
|
||||
|
||||
from mopidy.mpd import exceptions, tokenize
|
||||
|
||||
|
||||
class TestTokenizer(unittest.TestCase):
|
||||
def assertTokenizeEquals(self, expected, line):
|
||||
self.assertEqual(expected, tokenize.split(line))
|
||||
|
||||
def assertTokenizeRaises(self, exception, message, line):
|
||||
with self.assertRaises(exception) as cm:
|
||||
tokenize.split(line)
|
||||
self.assertEqual(cm.exception.message, message)
|
||||
|
||||
def test_empty_string(self):
|
||||
ex = exceptions.MpdNoCommand
|
||||
msg = 'No command given'
|
||||
self.assertTokenizeRaises(ex, msg, '')
|
||||
self.assertTokenizeRaises(ex, msg, ' ')
|
||||
self.assertTokenizeRaises(ex, msg, '\t\t\t')
|
||||
|
||||
def test_command(self):
|
||||
self.assertTokenizeEquals(['test'], 'test')
|
||||
self.assertTokenizeEquals(['test123'], 'test123')
|
||||
self.assertTokenizeEquals(['foo_bar'], 'foo_bar')
|
||||
|
||||
def test_command_trailing_whitespace(self):
|
||||
self.assertTokenizeEquals(['test'], 'test ')
|
||||
self.assertTokenizeEquals(['test'], 'test\t\t\t')
|
||||
|
||||
def test_command_leading_whitespace(self):
|
||||
ex = exceptions.MpdUnknownError
|
||||
msg = 'Letter expected'
|
||||
self.assertTokenizeRaises(ex, msg, ' test')
|
||||
self.assertTokenizeRaises(ex, msg, '\ttest')
|
||||
|
||||
def test_invalid_command(self):
|
||||
ex = exceptions.MpdUnknownError
|
||||
msg = 'Invalid word character'
|
||||
self.assertTokenizeRaises(ex, msg, 'foo/bar')
|
||||
self.assertTokenizeRaises(ex, msg, 'æøå')
|
||||
self.assertTokenizeRaises(ex, msg, 'test?')
|
||||
self.assertTokenizeRaises(ex, msg, 'te"st')
|
||||
|
||||
def test_unquoted_param(self):
|
||||
self.assertTokenizeEquals(['test', 'param'], 'test param')
|
||||
self.assertTokenizeEquals(['test', 'param'], 'test\tparam')
|
||||
|
||||
def test_unquoted_param_leading_whitespace(self):
|
||||
self.assertTokenizeEquals(['test', 'param'], 'test param')
|
||||
self.assertTokenizeEquals(['test', 'param'], 'test\t\tparam')
|
||||
|
||||
def test_unquoted_param_trailing_whitespace(self):
|
||||
self.assertTokenizeEquals(['test', 'param'], 'test param ')
|
||||
self.assertTokenizeEquals(['test', 'param'], 'test param\t\t')
|
||||
|
||||
def test_unquoted_param_invalid_chars(self):
|
||||
ex = exceptions.MpdArgError
|
||||
msg = 'Invalid unquoted character'
|
||||
self.assertTokenizeRaises(ex, msg, 'test par"m')
|
||||
self.assertTokenizeRaises(ex, msg, 'test foo\bbar')
|
||||
self.assertTokenizeRaises(ex, msg, 'test foo"bar"baz')
|
||||
self.assertTokenizeRaises(ex, msg, 'test foo\'bar')
|
||||
|
||||
def test_unquoted_param_numbers(self):
|
||||
self.assertTokenizeEquals(['test', '123'], 'test 123')
|
||||
self.assertTokenizeEquals(['test', '+123'], 'test +123')
|
||||
self.assertTokenizeEquals(['test', '-123'], 'test -123')
|
||||
self.assertTokenizeEquals(['test', '3.14'], 'test 3.14')
|
||||
|
||||
def test_unquoted_param_extended_chars(self):
|
||||
self.assertTokenizeEquals(['test', 'æøå'], 'test æøå')
|
||||
self.assertTokenizeEquals(['test', '?#$'], 'test ?#$')
|
||||
self.assertTokenizeEquals(['test', '/foo/bar/'], 'test /foo/bar/')
|
||||
self.assertTokenizeEquals(['test', 'foo\\bar'], 'test foo\\bar')
|
||||
|
||||
def test_unquoted_params(self):
|
||||
self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test foo bar')
|
||||
|
||||
def test_quoted_param(self):
|
||||
self.assertTokenizeEquals(['test', 'param'], 'test "param"')
|
||||
self.assertTokenizeEquals(['test', 'param'], 'test\t"param"')
|
||||
|
||||
def test_quoted_param_leading_whitespace(self):
|
||||
self.assertTokenizeEquals(['test', 'param'], 'test "param"')
|
||||
self.assertTokenizeEquals(['test', 'param'], 'test\t\t"param"')
|
||||
|
||||
def test_quoted_param_trailing_whitespace(self):
|
||||
self.assertTokenizeEquals(['test', 'param'], 'test "param" ')
|
||||
self.assertTokenizeEquals(['test', 'param'], 'test "param"\t\t')
|
||||
|
||||
def test_quoted_param_invalid_chars(self):
|
||||
ex = exceptions.MpdArgError
|
||||
msg = 'Space expected after closing \'"\''
|
||||
self.assertTokenizeRaises(ex, msg, 'test "foo"bar"')
|
||||
self.assertTokenizeRaises(ex, msg, 'test "foo"bar" ')
|
||||
self.assertTokenizeRaises(ex, msg, 'test "foo"bar')
|
||||
self.assertTokenizeRaises(ex, msg, 'test "foo"bar ')
|
||||
|
||||
def test_quoted_param_numbers(self):
|
||||
self.assertTokenizeEquals(['test', '123'], 'test "123"')
|
||||
self.assertTokenizeEquals(['test', '+123'], 'test "+123"')
|
||||
self.assertTokenizeEquals(['test', '-123'], 'test "-123"')
|
||||
self.assertTokenizeEquals(['test', '3.14'], 'test "3.14"')
|
||||
|
||||
def test_quoted_param_spaces(self):
|
||||
self.assertTokenizeEquals(['test', 'foo bar'], 'test "foo bar"')
|
||||
self.assertTokenizeEquals(['test', 'foo bar'], 'test "foo bar"')
|
||||
self.assertTokenizeEquals(['test', ' param\t'], 'test " param\t"')
|
||||
|
||||
def test_quoted_param_extended_chars(self):
|
||||
self.assertTokenizeEquals(['test', 'æøå'], 'test "æøå"')
|
||||
self.assertTokenizeEquals(['test', '?#$'], 'test "?#$"')
|
||||
self.assertTokenizeEquals(['test', '/foo/bar/'], 'test "/foo/bar/"')
|
||||
|
||||
def test_quoted_param_escaping(self):
|
||||
self.assertTokenizeEquals(['test', '\\'], r'test "\\"')
|
||||
self.assertTokenizeEquals(['test', '"'], r'test "\""')
|
||||
self.assertTokenizeEquals(['test', ' '], r'test "\ "')
|
||||
self.assertTokenizeEquals(['test', '\\n'], r'test "\\\n"')
|
||||
|
||||
def test_quoted_params(self):
|
||||
self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test "foo" "bar"')
|
||||
|
||||
def test_mixed_params(self):
|
||||
self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test foo "bar"')
|
||||
self.assertTokenizeEquals(['test', 'foo', 'bar'], 'test "foo" bar')
|
||||
self.assertTokenizeEquals(['test', '1', '2'], 'test 1 "2"')
|
||||
self.assertTokenizeEquals(['test', '1', '2'], 'test "1" 2')
|
||||
|
||||
self.assertTokenizeEquals(['test', 'foo bar', 'baz', '123'],
|
||||
'test "foo bar" baz 123')
|
||||
self.assertTokenizeEquals(['test', 'foo"bar', 'baz', '123'],
|
||||
r'test "foo\"bar" baz 123')
|
||||
|
||||
def test_unbalanced_quotes(self):
|
||||
ex = exceptions.MpdArgError
|
||||
msg = 'Invalid unquoted character'
|
||||
self.assertTokenizeRaises(ex, msg, 'test "foo bar" baz"')
|
||||
|
||||
def test_missing_closing_quote(self):
|
||||
ex = exceptions.MpdArgError
|
||||
msg = 'Missing closing \'"\''
|
||||
self.assertTokenizeRaises(ex, msg, 'test "foo')
|
||||
self.assertTokenizeRaises(ex, msg, 'test "foo a ')
|
||||
Loading…
Reference in New Issue
Block a user