Merge pull request #277 from jodal/feature/mpd-0.17

Implement MPD protocol changes from MPD 0.17
This commit is contained in:
Thomas Adamcik 2012-12-15 14:56:05 -08:00
commit e00dd958b9
13 changed files with 395 additions and 42 deletions

View File

@ -8,7 +8,24 @@ This change log is used to track all major changes to Mopidy.
v0.11.0 (in development)
========================
- No changes yet.
**MPD frontend**
- Add support for the ``findadd`` command.
- Updated to match the MPD 0.17 protocol (Fixes: :issue:`228`):
- Add support for ``seekcur`` command.
- Add support for ``config`` command.
- Add support for loading a range of tracks from a playlist to the ``load``
command.
- Add support for ``searchadd`` command.
- Add support for ``searchaddpl`` command.
- Add empty stubs for channel commands for client to client communication.
v0.10.0 (2012-12-12)

View File

@ -30,6 +30,14 @@ Audio output
:members:
Channels
--------
.. automodule:: mopidy.frontends.mpd.protocol.channels
:synopsis: MPD protocol: channels -- client to client communication
:members:
Command list
------------

View File

@ -21,8 +21,8 @@ ENCODING = 'UTF-8'
#: The MPD protocol uses ``\n`` as line terminator.
LINE_TERMINATOR = '\n'
#: The MPD protocol version is 0.16.0.
VERSION = '0.16.0'
#: The MPD protocol version is 0.17.0.
VERSION = '0.17.0'
MpdCommand = namedtuple('MpdCommand', ['name', 'auth_required'])
@ -74,6 +74,7 @@ def load_protocol_modules():
"""
# pylint: disable = W0612
from . import ( # noqa
audio_output, command_list, connection, current_playlist, empty,
music_db, playback, reflection, status, stickers, stored_playlists)
audio_output, channels, command_list, connection, current_playlist,
empty, music_db, playback, reflection, status, stickers,
stored_playlists)
# pylint: enable = W0612

View File

@ -0,0 +1,69 @@
from __future__ import unicode_literals
from mopidy.frontends.mpd.protocol import handle_request
from mopidy.frontends.mpd.exceptions import MpdNotImplemented
@handle_request(r'^subscribe "(?P<channel>[A-Za-z0-9:._-]+)"$')
def subscribe(context, channel):
"""
*musicpd.org, client to client section:*
``subscribe {NAME}``
Subscribe to a channel. The channel is created if it does not exist
already. The name may consist of alphanumeric ASCII characters plus
underscore, dash, dot and colon.
"""
raise MpdNotImplemented # TODO
@handle_request(r'^unsubscribe "(?P<channel>[A-Za-z0-9:._-]+)"$')
def unsubscribe(context, channel):
"""
*musicpd.org, client to client section:*
``unsubscribe {NAME}``
Unsubscribe from a channel.
"""
raise MpdNotImplemented # TODO
@handle_request(r'^channels$')
def channels(context):
"""
*musicpd.org, client to client section:*
``channels``
Obtain a list of all channels. The response is a list of "channel:"
lines.
"""
raise MpdNotImplemented # TODO
@handle_request(r'^readmessages$')
def readmessages(context):
"""
*musicpd.org, client to client section:*
``readmessages``
Reads messages for this client. The response is a list of "channel:"
and "message:" lines.
"""
raise MpdNotImplemented # TODO
@handle_request(
r'^sendmessage "(?P<channel>[A-Za-z0-9:._-]+)" "(?P<text>[^"]*)"$')
def sendmessage(context, channel, text):
"""
*musicpd.org, client to client section:*
``sendmessage {CHANNEL} {TEXT}``
Send a message to the specified channel.
"""
raise MpdNotImplemented # TODO

View File

@ -8,6 +8,11 @@ from mopidy.frontends.mpd.protocol import handle_request, stored_playlists
from mopidy.frontends.mpd.translator import tracks_to_mpd_format
QUERY_RE = (
r'(?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile|[Ff]ilename|'
r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$')
def _build_query(mpd_query):
"""
Parses a MPD query string and converts it to the Mopidy query format.
@ -50,17 +55,17 @@ def count(context, tag, needle):
return [('songs', 0), ('playtime', 0)] # TODO
@handle_request(
r'^find (?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|'
r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$')
@handle_request(r'^find ' + QUERY_RE)
def find(context, mpd_query):
"""
*musicpd.org, music database section:*
``find {TYPE} {WHAT}``
Finds songs in the db that are exactly ``WHAT``. ``TYPE`` should be
``album``, ``artist``, or ``title``. ``WHAT`` is what to find.
Finds songs in the db that are exactly ``WHAT``. ``TYPE`` can be any
tag supported by MPD, or one of the two special parameters - ``file``
to search by full path (relative to database root), and ``any`` to
match against all available tags. ``WHAT`` is what to find.
*GMPC:*
@ -82,26 +87,26 @@ def find(context, mpd_query):
query = _build_query(mpd_query)
except ValueError:
return
return tracks_to_mpd_format(
context.core.library.find_exact(**query).get())
result = context.core.library.find_exact(**query).get()
return tracks_to_mpd_format(result)
@handle_request(
r'^findadd '
r'(?P<query>("?([Aa]lbum|[Aa]rtist|[Ff]ilename|[Tt]itle|[Aa]ny)"? '
r'"[^"]+"\s?)+)$')
def findadd(context, query):
@handle_request(r'^findadd ' + QUERY_RE)
def findadd(context, mpd_query):
"""
*musicpd.org, music database section:*
``findadd {TYPE} {WHAT}``
Finds songs in the db that are exactly ``WHAT`` and adds them to
current playlist. ``TYPE`` can be any tag supported by MPD.
``WHAT`` is what to find.
current playlist. Parameters have the same meaning as for ``find``.
"""
# TODO Add result to current playlist
#result = context.find(query)
try:
query = _build_query(mpd_query)
except ValueError:
return
result = context.core.library.find_exact(**query).get()
context.core.tracklist.add(result)
@handle_request(
@ -333,18 +338,15 @@ def rescan(context, uri=None):
return update(context, uri, rescan_unmodified_files=True)
@handle_request(
r'^search (?P<mpd_query>("?([Aa]lbum|[Aa]rtist|[Dd]ate|[Ff]ile[name]*|'
r'[Tt]itle|[Aa]ny)"? "[^"]*"\s?)+)$')
@handle_request(r'^search ' + QUERY_RE)
def search(context, mpd_query):
"""
*musicpd.org, music database section:*
``search {TYPE} {WHAT}``
``search {TYPE} {WHAT} [...]``
Searches for any song that contains ``WHAT``. ``TYPE`` can be
``title``, ``artist``, ``album`` or ``filename``. Search is not
case sensitive.
Searches for any song that contains ``WHAT``. Parameters have the same
meaning as for ``find``, except that search is not case sensitive.
*GMPC:*
@ -368,8 +370,60 @@ def search(context, mpd_query):
query = _build_query(mpd_query)
except ValueError:
return
return tracks_to_mpd_format(
context.core.library.search(**query).get())
result = context.core.library.search(**query).get()
return tracks_to_mpd_format(result)
@handle_request(r'^searchadd ' + QUERY_RE)
def searchadd(context, mpd_query):
"""
*musicpd.org, music database section:*
``searchadd {TYPE} {WHAT} [...]``
Searches for any song that contains ``WHAT`` in tag ``TYPE`` and adds
them to current playlist.
Parameters have the same meaning as for ``find``, except that search is
not case sensitive.
"""
try:
query = _build_query(mpd_query)
except ValueError:
return
result = context.core.library.search(**query).get()
context.core.tracklist.add(result)
@handle_request(r'^searchaddpl "(?P<playlist_name>[^"]+)" ' + QUERY_RE)
def searchaddpl(context, playlist_name, mpd_query):
"""
*musicpd.org, music database section:*
``searchaddpl {NAME} {TYPE} {WHAT} [...]``
Searches for any song that contains ``WHAT`` in tag ``TYPE`` and adds
them to the playlist named ``NAME``.
If a playlist by that name doesn't exist it is created.
Parameters have the same meaning as for ``find``, except that search is
not case sensitive.
"""
try:
query = _build_query(mpd_query)
except ValueError:
return
result = context.core.library.search(**query).get()
playlists = context.core.playlists.filter(name=playlist_name).get()
if playlists:
playlist = playlists[0]
else:
playlist = context.core.playlists.create(playlist_name).get()
tracks = list(playlist.tracks) + result
playlist = playlist.copy(tracks=tracks)
context.core.playlists.save(playlist)
@handle_request(r'^update( "(?P<uri>[^"]+)")*$')

View File

@ -349,6 +349,26 @@ def seekid(context, tlid, seconds):
context.core.playback.seek(int(seconds) * 1000).get()
@handle_request(r'^seekcur "(?P<position>\d+)"$')
@handle_request(r'^seekcur "(?P<diff>[-+]\d+)"$')
def seekcur(context, position=None, diff=None):
"""
*musicpd.org, playback section:*
``seekcur {TIME}``
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:
position = context.core.playback.time_position.get()
position += int(diff) * 1000
context.core.playback.seek(position).get()
@handle_request(r'^setvol (?P<volume>[-+]*\d+)$')
@handle_request(r'^setvol "(?P<volume>[-+]*\d+)"$')
def setvol(context, volume):

View File

@ -1,8 +1,23 @@
from __future__ import unicode_literals
from mopidy.frontends.mpd.exceptions import MpdPermissionError
from mopidy.frontends.mpd.protocol import handle_request, mpd_commands
@handle_request(r'^config$', auth_required=False)
def config(context):
"""
*musicpd.org, reflection section:*
``config``
Dumps configuration values that may be interesting for the client. This
command is only permitted to "local" clients (connected via UNIX domain
socket).
"""
raise MpdPermissionError(command='config')
@handle_request(r'^commands$', auth_required=False)
def commands(context):
"""
@ -19,10 +34,10 @@ def commands(context):
command.name for command in mpd_commands
if not command.auth_required])
# No one is permited to use kill, rest of commands are not listed by MPD,
# so we shouldn't either.
# 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([
'kill', 'command_list_begin', 'command_list_ok_begin',
'config', 'kill', 'command_list_begin', 'command_list_ok_begin',
'command_list_ok_begin', 'command_list_end', 'idle', 'noidle',
'sticker'])
@ -73,6 +88,7 @@ def notcommands(context):
command.name for command in mpd_commands if command.auth_required]
# No permission to use
command_names.append('config')
command_names.append('kill')
return [

View File

@ -92,23 +92,36 @@ def listplaylists(context):
return result
@handle_request(r'^load "(?P<name>[^"]+)"$')
def load(context, name):
@handle_request(r'^load "(?P<name>[^"]+)"( "(?P<start>\d+):(?P<end>\d+)*")*$')
def load(context, name, start=None, end=None):
"""
*musicpd.org, stored playlists section:*
``load {NAME}``
``load {NAME} [START:END]``
Loads the playlist ``NAME.m3u`` from the playlist directory.
Loads the playlist into the current queue. Playlist plugins are
supported. A range may be specified to load only a part of the
playlist.
*Clarifications:*
- ``load`` appends the given playlist to the current playlist.
- MPD 0.17.1 does not support open-ended ranges, i.e. without end
specified, for the ``load`` command, even though MPD's general range docs
allows open-ended ranges.
- MPD 0.17.1 does not fail if the specified range is outside the playlist,
in either or both ends.
"""
playlists = context.core.playlists.filter(name=name).get()
if not playlists:
raise MpdNoExistError('No such playlist', command='load')
context.core.tracklist.add(playlists[0].tracks)
if start is not None:
start = int(start)
if end is not None:
end = int(end)
context.core.tracklist.add(playlists[0].tracks[start:end])
@handle_request(r'^playlistadd "(?P<name>[^"]+)" "(?P<uri>[^"]+)"$')

View File

@ -0,0 +1,25 @@
from __future__ import unicode_literals
from tests.frontends.mpd import protocol
class ChannelsHandlerTest(protocol.BaseTestCase):
def test_subscribe(self):
self.sendRequest('subscribe "topic"')
self.assertEqualResponse('ACK [0@0] {} Not implemented')
def test_unsubscribe(self):
self.sendRequest('unsubscribe "topic"')
self.assertEqualResponse('ACK [0@0] {} Not implemented')
def test_channels(self):
self.sendRequest('channels')
self.assertEqualResponse('ACK [0@0] {} Not implemented')
def test_readmessages(self):
self.sendRequest('readmessages')
self.assertEqualResponse('ACK [0@0] {} Not implemented')
def test_sendmessage(self):
self.sendRequest('sendmessage "topic" "a message"')
self.assertEqualResponse('ACK [0@0] {} Not implemented')

View File

@ -13,7 +13,65 @@ class MusicDatabaseHandlerTest(protocol.BaseTestCase):
self.assertInResponse('OK')
def test_findadd(self):
self.sendRequest('findadd "album" "what"')
self.backend.library.dummy_find_exact_result = [
Track(uri='dummy:a', name='A'),
]
self.assertEqual(self.core.tracklist.length.get(), 0)
self.sendRequest('findadd "title" "A"')
self.assertEqual(self.core.tracklist.length.get(), 1)
self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a')
self.assertInResponse('OK')
def test_searchadd(self):
self.backend.library.dummy_search_result = [
Track(uri='dummy:a', name='A'),
]
self.assertEqual(self.core.tracklist.length.get(), 0)
self.sendRequest('searchadd "title" "a"')
self.assertEqual(self.core.tracklist.length.get(), 1)
self.assertEqual(self.core.tracklist.tracks.get()[0].uri, 'dummy:a')
self.assertInResponse('OK')
def test_searchaddpl_appends_to_existing_playlist(self):
playlist = self.core.playlists.create('my favs').get()
playlist = playlist.copy(tracks=[
Track(uri='dummy:x', name='X'),
Track(uri='dummy:y', name='y'),
])
self.core.playlists.save(playlist)
self.backend.library.dummy_search_result = [
Track(uri='dummy:a', name='A'),
]
playlists = self.core.playlists.filter(name='my favs').get()
self.assertEqual(len(playlists), 1)
self.assertEqual(len(playlists[0].tracks), 2)
self.sendRequest('searchaddpl "my favs" "title" "a"')
playlists = self.core.playlists.filter(name='my favs').get()
self.assertEqual(len(playlists), 1)
self.assertEqual(len(playlists[0].tracks), 3)
self.assertEqual(playlists[0].tracks[0].uri, 'dummy:x')
self.assertEqual(playlists[0].tracks[1].uri, 'dummy:y')
self.assertEqual(playlists[0].tracks[2].uri, 'dummy:a')
self.assertInResponse('OK')
def test_searchaddpl_creates_missing_playlist(self):
self.backend.library.dummy_search_result = [
Track(uri='dummy:a', name='A'),
]
self.assertEqual(
len(self.core.playlists.filter(name='my favs').get()), 0)
self.sendRequest('searchaddpl "my favs" "title" "a"')
playlists = self.core.playlists.filter(name='my favs').get()
self.assertEqual(len(playlists), 1)
self.assertEqual(playlists[0].tracks[0].uri, 'dummy:a')
self.assertInResponse('OK')
def test_listall(self):

View File

@ -414,6 +414,37 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase):
self.assertEqual(seek_track, self.core.playback.current_track.get())
self.assertInResponse('OK')
def test_seekcur_absolute_value(self):
self.core.tracklist.add([Track(uri='dummy:a', length=40000)])
self.core.playback.play()
self.sendRequest('seekcur "30"')
self.assertGreaterEqual(self.core.playback.time_position.get(), 30000)
self.assertInResponse('OK')
def test_seekcur_positive_diff(self):
self.core.tracklist.add([Track(uri='dummy:a', length=40000)])
self.core.playback.play()
self.core.playback.seek(10000)
self.assertGreaterEqual(self.core.playback.time_position.get(), 10000)
self.sendRequest('seekcur "+20"')
self.assertGreaterEqual(self.core.playback.time_position.get(), 30000)
self.assertInResponse('OK')
def test_seekcur_negative_diff(self):
self.core.tracklist.add([Track(uri='dummy:a', length=40000)])
self.core.playback.play()
self.core.playback.seek(30000)
self.assertGreaterEqual(self.core.playback.time_position.get(), 30000)
self.sendRequest('seekcur "-20"')
self.assertLessEqual(self.core.playback.time_position.get(), 15000)
self.assertInResponse('OK')
def test_stop(self):
self.sendRequest('stop')
self.assertEqual(STOPPED, self.core.playback.state.get())

View File

@ -6,6 +6,11 @@ from tests.frontends.mpd import protocol
class ReflectionHandlerTest(protocol.BaseTestCase):
def test_config_is_not_allowed_across_the_network(self):
self.sendRequest('config')
self.assertEqualResponse(
'ACK [4@0] {config} you don\'t have permission for "config"')
def test_commands_returns_list_of_all_commands(self):
self.sendRequest('commands')
# Check if some random commands are included
@ -13,6 +18,7 @@ class ReflectionHandlerTest(protocol.BaseTestCase):
self.assertInResponse('command: play')
self.assertInResponse('command: status')
# Check if commands you do not have access to are not present
self.assertNotInResponse('command: config')
self.assertNotInResponse('command: kill')
# Check if the blacklisted commands are not present
self.assertNotInResponse('command: command_list_begin')
@ -40,9 +46,10 @@ class ReflectionHandlerTest(protocol.BaseTestCase):
self.sendRequest('decoders')
self.assertInResponse('OK')
def test_notcommands_returns_only_kill_and_ok(self):
def test_notcommands_returns_only_config_and_kill_and_ok(self):
response = self.sendRequest('notcommands')
self.assertEqual(2, len(response))
self.assertEqual(3, len(response))
self.assertInResponse('command: config')
self.assertInResponse('command: kill')
self.assertInResponse('OK')

View File

@ -73,7 +73,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase):
self.assertNotInResponse('playlist: ')
self.assertInResponse('OK')
def test_load_known_playlist_appends_to_tracklist(self):
def test_load_appends_to_tracklist(self):
self.core.tracklist.add([Track(uri='a'), Track(uri='b')])
self.assertEqual(len(self.core.tracklist.tracks.get()), 2)
self.backend.playlists.playlists = [
@ -81,6 +81,7 @@ class PlaylistsHandlerTest(protocol.BaseTestCase):
Track(uri='c'), Track(uri='d'), Track(uri='e')])]
self.sendRequest('load "A-list"')
tracks = self.core.tracklist.tracks.get()
self.assertEqual(5, len(tracks))
self.assertEqual('a', tracks[0].uri)
@ -90,6 +91,39 @@ class PlaylistsHandlerTest(protocol.BaseTestCase):
self.assertEqual('e', tracks[4].uri)
self.assertInResponse('OK')
def test_load_with_range_loads_part_of_playlist(self):
self.core.tracklist.add([Track(uri='a'), Track(uri='b')])
self.assertEqual(len(self.core.tracklist.tracks.get()), 2)
self.backend.playlists.playlists = [
Playlist(name='A-list', tracks=[
Track(uri='c'), Track(uri='d'), Track(uri='e')])]
self.sendRequest('load "A-list" "1:2"')
tracks = self.core.tracklist.tracks.get()
self.assertEqual(3, len(tracks))
self.assertEqual('a', tracks[0].uri)
self.assertEqual('b', tracks[1].uri)
self.assertEqual('d', tracks[2].uri)
self.assertInResponse('OK')
def test_load_with_range_without_end_loads_rest_of_playlist(self):
self.core.tracklist.add([Track(uri='a'), Track(uri='b')])
self.assertEqual(len(self.core.tracklist.tracks.get()), 2)
self.backend.playlists.playlists = [
Playlist(name='A-list', tracks=[
Track(uri='c'), Track(uri='d'), Track(uri='e')])]
self.sendRequest('load "A-list" "1:"')
tracks = self.core.tracklist.tracks.get()
self.assertEqual(4, len(tracks))
self.assertEqual('a', tracks[0].uri)
self.assertEqual('b', tracks[1].uri)
self.assertEqual('d', tracks[2].uri)
self.assertEqual('e', tracks[3].uri)
self.assertInResponse('OK')
def test_load_unknown_playlist_acks(self):
self.sendRequest('load "unknown playlist"')
self.assertEqual(0, len(self.core.tracklist.tracks.get()))