diff --git a/beets/plugins.py b/beets/plugins.py index 7019b70a02..5ca9ae3bb3 100644 --- a/beets/plugins.py +++ b/beets/plugins.py @@ -19,6 +19,7 @@ import traceback import re +import inspect from collections import defaultdict from functools import wraps @@ -26,7 +27,6 @@ import beets from beets import logging from beets import mediafile -from beets.util import inspect import six PLUGIN_NAMESPACE = 'beetsplug' @@ -127,7 +127,10 @@ def _set_log_level_and_params(self, base_log_level, func): value after the function returns). Also determines which params may not be sent for backwards-compatibility. """ - argspec = inspect.getargspec(func) + if six.PY2: + func_args = inspect.getargspec(func).args + else: + func_args = inspect.getfullargspec(func).args @wraps(func) def wrapper(*args, **kwargs): @@ -142,7 +145,7 @@ def wrapper(*args, **kwargs): if exc.args[0].startswith(func.__name__): # caused by 'func' and not stuff internal to 'func' kwargs = dict((arg, val) for arg, val in kwargs.items() - if arg in argspec.args) + if arg in func_args) return func(*args, **kwargs) else: raise diff --git a/beets/util/inspect.py b/beets/util/inspect.py deleted file mode 100644 index 9815a561a1..0000000000 --- a/beets/util/inspect.py +++ /dev/null @@ -1,50 +0,0 @@ -# -*- coding: utf-8 -*- -# This file is part of beets. -# Copyright 2019, Vladimir Zhelezov. -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -from __future__ import division, absolute_import, print_function - -import inspect -from collections import namedtuple - -from six import PY2 - - -ArgSpec = namedtuple('ArgSpec', 'args varargs keywords defaults') - - -def getargspec(func): - if PY2: - return inspect.getargspec(func) - - sig = inspect.signature(func) - args = [ - p.name for p in sig.parameters.values() - if p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD - ] - varargs = [ - p.name for p in sig.parameters.values() - if p.kind == inspect.Parameter.VAR_POSITIONAL - ] - varargs = varargs[0] if varargs else None - varkw = [ - p.name for p in sig.parameters.values() - if p.kind == inspect.Parameter.VAR_KEYWORD - ] - varkw = varkw[0] if varkw else None - defaults = tuple(p.default for p in sig.parameters.values() - if p.kind == inspect.Parameter.POSITIONAL_OR_KEYWORD - and p.default is not p.empty) or None - - return ArgSpec(args, varargs, varkw, defaults) diff --git a/beetsplug/bpd/__init__.py b/beetsplug/bpd/__init__.py index a29690b03c..598e2971f3 100644 --- a/beetsplug/bpd/__init__.py +++ b/beetsplug/bpd/__init__.py @@ -25,6 +25,8 @@ import traceback import random import time +import math +import inspect import beets from beets.plugins import BeetsPlugin @@ -172,8 +174,13 @@ def __init__(self, host, port, password, log): # Default server values. self.random = False self.repeat = False + self.consume = False + self.single = False self.volume = VOLUME_MAX self.crossfade = 0 + self.mixrampdb = 0.0 + self.mixrampdelay = float('nan') + self.replay_gain_mode = 'off' self.playlist = [] self.playlist_version = 0 self.current_index = -1 @@ -227,10 +234,10 @@ def _random_idx(self): def _succ_idx(self): """Returns the index for the next song to play. - It also considers random and repeat flags. + It also considers random, single and repeat flags. No boundaries are checked. """ - if self.repeat: + if self.repeat and self.single: return self.current_index if self.random: return self._random_idx() @@ -241,7 +248,7 @@ def _prev_idx(self): It also considers random and repeat flags. No boundaries are checked. """ - if self.repeat: + if self.repeat and self.single: return self.current_index if self.random: return self._random_idx() @@ -305,11 +312,18 @@ def cmd_status(self, conn): u'volume: ' + six.text_type(self.volume), u'repeat: ' + six.text_type(int(self.repeat)), u'random: ' + six.text_type(int(self.random)), + u'consume: ' + six.text_type(int(self.consume)), + u'single: ' + six.text_type(int(self.single)), u'playlist: ' + six.text_type(self.playlist_version), u'playlistlength: ' + six.text_type(len(self.playlist)), - u'xfade: ' + six.text_type(self.crossfade), + u'mixrampdb: ' + six.text_type(self.mixrampdb), ) + if not math.isnan(self.mixrampdelay): + yield u'mixrampdelay: ' + six.text_type(self.mixrampdelay) + if self.crossfade > 0: + yield u'xfade: ' + six.text_type(self.crossfade) + if self.current_index == -1: state = u'stop' elif self.paused: @@ -341,6 +355,15 @@ def cmd_repeat(self, conn, state): """Set or unset repeat mode.""" self.repeat = cast_arg('intbool', state) + def cmd_consume(self, conn, state): + """Set or unset consume mode.""" + self.consume = cast_arg('intbool', state) + + def cmd_single(self, conn, state): + """Set or unset single mode.""" + # TODO support oneshot in addition to 0 and 1 [MPD 0.20] + self.single = cast_arg('intbool', state) + def cmd_setvol(self, conn, vol): """Set the player's volume level (0-100).""" vol = cast_arg(int, vol) @@ -348,11 +371,44 @@ def cmd_setvol(self, conn, vol): raise BPDError(ERROR_ARG, u'volume out of range') self.volume = vol + def cmd_volume(self, conn, vol_delta): + """Deprecated command to change the volume by a relative amount.""" + raise BPDError(ERROR_SYSTEM, u'No mixer') + def cmd_crossfade(self, conn, crossfade): """Set the number of seconds of crossfading.""" crossfade = cast_arg(int, crossfade) if crossfade < 0: raise BPDError(ERROR_ARG, u'crossfade time must be nonnegative') + self._log.warning(u'crossfade is not implemented in bpd') + self.crossfade = crossfade + + def cmd_mixrampdb(self, conn, db): + """Set the mixramp normalised max volume in dB.""" + db = cast_arg(float, db) + if db > 0: + raise BPDError(ERROR_ARG, u'mixrampdb time must be negative') + self._log.warning('mixramp is not implemented in bpd') + self.mixrampdb = db + + def cmd_mixrampdelay(self, conn, delay): + """Set the mixramp delay in seconds.""" + delay = cast_arg(float, delay) + if delay < 0: + raise BPDError(ERROR_ARG, u'mixrampdelay time must be nonnegative') + self._log.warning('mixramp is not implemented in bpd') + self.mixrampdelay = delay + + def cmd_replay_gain_mode(self, conn, mode): + """Set the replay gain mode.""" + if mode not in ['off', 'track', 'album', 'auto']: + raise BPDError(ERROR_ARG, u'Unrecognised replay gain mode') + self._log.warning('replay gain is not implemented in bpd') + self.replay_gain_mode = mode + + def cmd_replay_gain_status(self, conn): + """Get the replaygain mode.""" + yield u'replay_gain_mode: ' + six.text_type(self.replay_gain_mode) def cmd_clear(self, conn): """Clear the playlist.""" @@ -477,20 +533,36 @@ def cmd_currentsong(self, conn): def cmd_next(self, conn): """Advance to the next song in the playlist.""" + old_index = self.current_index self.current_index = self._succ_idx() + if self.consume: + # TODO how does consume interact with single+repeat? + self.playlist.pop(old_index) + if self.current_index > old_index: + self.current_index -= 1 if self.current_index >= len(self.playlist): - # Fallen off the end. Just move to stopped state. + # Fallen off the end. Move to stopped state or loop. + if self.repeat: + self.current_index = -1 + return self.cmd_play(conn) + return self.cmd_stop(conn) + elif self.single and not self.repeat: return self.cmd_stop(conn) else: return self.cmd_play(conn) def cmd_previous(self, conn): """Step back to the last song.""" + old_index = self.current_index self.current_index = self._prev_idx() + if self.consume: + self.playlist.pop(old_index) if self.current_index < 0: - return self.cmd_stop(conn) - else: - return self.cmd_play(conn) + if self.repeat: + self.current_index = len(self.playlist) - 1 + else: + self.current_index = 0 + return self.cmd_play(conn) def cmd_pause(self, conn, state=None): """Set the pause state playback.""" @@ -503,7 +575,7 @@ def cmd_play(self, conn, index=-1): """Begin playback, possibly at a specified playlist index.""" index = cast_arg(int, index) - if index < -1 or index > len(self.playlist): + if index < -1 or index >= len(self.playlist): raise ArgumentIndexError() if index == -1: # No index specified: start where we are. @@ -542,12 +614,22 @@ def cmd_seekid(self, conn, track_id, pos): index = self._id_to_index(track_id) return self.cmd_seek(conn, index, pos) + # Debugging/testing commands that are not part of the MPD protocol. + def cmd_profile(self, conn): """Memory profiling for debugging.""" from guppy import hpy heap = hpy().heap() print(heap) + def cmd_crash_TypeError(self, conn): # noqa: N802 + """Deliberately trigger a TypeError for testing purposes. + We want to test that the server properly responds with ERROR_SYSTEM + without crashing, and that this is not treated as ERROR_ARG (since it + is caused by a programming error, not a protocol error). + """ + 'a' + 2 + class Connection(object): """A connection between a client and the server. Handles input and @@ -670,9 +752,33 @@ def run(self, conn): # Attempt to get correct command function. func_name = 'cmd_' + self.name if not hasattr(conn.server, func_name): - raise BPDError(ERROR_UNKNOWN, u'unknown command', self.name) + raise BPDError(ERROR_UNKNOWN, + u'unknown command "{}"'.format(self.name)) func = getattr(conn.server, func_name) + if six.PY2: + # caution: the fields of the namedtuple are slightly different + # between the results of getargspec and getfullargspec. + argspec = inspect.getargspec(func) + else: + argspec = inspect.getfullargspec(func) + + # Check that `func` is able to handle the number of arguments sent + # by the client (so we can raise ERROR_ARG instead of ERROR_SYSTEM). + # Maximum accepted arguments: argspec includes "self" and "conn". + max_args = len(argspec.args) - 2 + # Minimum accepted arguments: some arguments might be optional/ + min_args = max_args + if argspec.defaults: + min_args -= len(argspec.defaults) + wrong_num = (len(self.args) > max_args) or (len(self.args) < min_args) + # If the command accepts a variable number of arguments skip the check. + if wrong_num and not argspec.varargs: + raise BPDError(ERROR_ARG, + u'wrong number of arguments for "{}"' + .format(self.name), + self.name) + # Ensure we have permission for this command. if conn.server.password and \ not conn.authenticated and \ @@ -697,9 +803,9 @@ def run(self, conn): # it on the Connection. raise - except Exception as e: + except Exception: # An "unintentional" error. Hide it from the client. - conn.server._log.error('{}', traceback.format_exc(e)) + conn.server._log.error('{}', traceback.format_exc()) raise BPDError(ERROR_SYSTEM, u'server error', self.name) @@ -752,10 +858,13 @@ def __init__(self, library, host, port, password, log): raise NoGstreamerError() else: raise + log.info(u'Starting server...') super(Server, self).__init__(host, port, password, log) self.lib = library self.player = gstplayer.GstPlayer(self.play_finished) self.cmd_update(None) + log.info(u'Server ready and listening on {}:{}'.format( + host, port)) def run(self): self.player.run() @@ -939,11 +1048,24 @@ def cmd_status(self, conn): if self.current_index > -1: item = self.playlist[self.current_index] - yield u'bitrate: ' + six.text_type(item.bitrate / 1000) - # Missing 'audio'. + yield ( + u'bitrate: ' + six.text_type(item.bitrate / 1000), + u'audio: {}:{}:{}'.format( + six.text_type(item.samplerate), + six.text_type(item.bitdepth), + six.text_type(item.channels), + ), + ) (pos, total) = self.player.time() - yield u'time: ' + six.text_type(pos) + u':' + six.text_type(total) + yield ( + u'time: {}:{}'.format( + six.text_type(int(pos)), + six.text_type(int(total)), + ), + u'elapsed: ' + u'{:.3f}'.format(pos), + u'duration: ' + u'{:.3f}'.format(total), + ) # Also missing 'updating_db'. @@ -1075,6 +1197,42 @@ def cmd_count(self, conn, tag, value): yield u'songs: ' + six.text_type(songs) yield u'playtime: ' + six.text_type(int(playtime)) + # Persistent playlist manipulation. In MPD this is an optional feature so + # these dummy implementations match MPD's behaviour with the feature off. + + def cmd_listplaylist(self, conn, playlist): + raise BPDError(ERROR_NO_EXIST, u'No such playlist') + + def cmd_listplaylistinfo(self, conn, playlist): + raise BPDError(ERROR_NO_EXIST, u'No such playlist') + + def cmd_listplaylists(self, conn): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + + def cmd_load(self, conn, playlist): + raise BPDError(ERROR_NO_EXIST, u'Stored playlists are disabled') + + def cmd_playlistadd(self, conn, playlist, uri): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + + def cmd_playlistclear(self, conn, playlist): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + + def cmd_playlistdelete(self, conn, playlist, index): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + + def cmd_playlistmove(self, conn, playlist, from_index, to_index): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + + def cmd_rename(self, conn, playlist, new_name): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + + def cmd_rm(self, conn, playlist): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + + def cmd_save(self, conn, playlist): + raise BPDError(ERROR_UNKNOWN, u'Stored playlists are disabled') + # "Outputs." Just a dummy implementation because we don't control # any outputs. diff --git a/beetsplug/bpd/gstplayer.py b/beetsplug/bpd/gstplayer.py index 705692aa51..fffa8a6eda 100644 --- a/beetsplug/bpd/gstplayer.py +++ b/beetsplug/bpd/gstplayer.py @@ -177,12 +177,12 @@ def time(self): posq = self.player.query_position(fmt) if not posq[0]: raise QueryError("query_position failed") - pos = posq[1] // (10 ** 9) + pos = posq[1] / (10 ** 9) lengthq = self.player.query_duration(fmt) if not lengthq[0]: raise QueryError("query_duration failed") - length = lengthq[1] // (10 ** 9) + length = lengthq[1] / (10 ** 9) self.cached_time = (pos, length) return (pos, length) diff --git a/docs/changelog.rst b/docs/changelog.rst index c822af6089..1acfdb9c3d 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -81,6 +81,11 @@ New features: to be displayed. Thanks to :user:`pprkut`. :bug:`3089` +* :doc:`/plugins/bpd`: MPD protocol commands ``consume`` and ``single`` are now + supported along with updated semantics for ``repeat`` and ``previous`` and + new fields for ``status``. The bpd server now understands and ignores some + additional commands. + :bug:`3200` :bug:`800` Changes: @@ -181,6 +186,8 @@ Fixes: :bug:`3192` * Fix compatibility with pre-release versions of Python 3.8. :bug:`3201` :bug:`3202` +* :doc:`/plugins/bpd`: Fix crashes in the bpd server during exception handling. + :bug:`3200` .. _python-itunes: https://github.com/ocelma/python-itunes diff --git a/test/test_player.py b/test/test_player.py index 1c2b0165d8..98fd13f636 100644 --- a/test/test_player.py +++ b/test/test_player.py @@ -46,7 +46,7 @@ def _gstplayer_play(_): # noqa: 42 "seek" ], **{ 'playing': False, - 'volume': 0.0, + 'volume': 0, 'time.return_value': (0, 0), 'play_file.side_effect': _gstplayer_play, }) @@ -241,7 +241,7 @@ def _test(self): return unittest.expectedFailure(_test) if expectedFailure else _test -class BPDTest(unittest.TestCase, TestHelper): +class BPDTestHelper(unittest.TestCase, TestHelper): def setUp(self): self.setup_beets(disk=True) self.load_plugins('bpd') @@ -327,54 +327,308 @@ def _assert_failed(self, response, code, pos=None): if code is not None: self.assertEqual(code, response.err_data[0]) + def _bpd_add(self, client, *items, **kwargs): + """ Add the given item to the BPD playlist or queue. + """ + paths = ['/'.join([ + item.artist, item.album, + py3_path(os.path.basename(item.path))]) for item in items] + playlist = kwargs.get('playlist') + if playlist: + commands = [('playlistadd', playlist, path) for path in paths] + else: + commands = [('add', path) for path in paths] + responses = client.send_commands(*commands) + self._assert_ok(*responses) + + +class BPDTest(BPDTestHelper): def test_server_hello(self): with self.run_bpd(do_hello=False) as client: self.assertEqual(client.readline(), b'OK MPD 0.13.0\n') + def test_unknown_cmd(self): + with self.run_bpd() as client: + response = client.send_command('notacommand') + self._assert_failed(response, bpd.ERROR_UNKNOWN) + + def test_unexpected_argument(self): + with self.run_bpd() as client: + response = client.send_command('ping', 'extra argument') + self._assert_failed(response, bpd.ERROR_ARG) + + def test_missing_argument(self): + with self.run_bpd() as client: + response = client.send_command('add') + self._assert_failed(response, bpd.ERROR_ARG) + + def test_system_error(self): + with self.run_bpd() as client: + response = client.send_command('crash_TypeError') + self._assert_failed(response, bpd.ERROR_SYSTEM) + + +class BPDQueryTest(BPDTestHelper): test_implements_query = implements({ - 'clearerror', 'currentsong', 'idle', 'status', 'stats', + 'clearerror', 'currentsong', 'idle', 'stats', }, expectedFailure=True) + def test_cmd_status(self): + with self.run_bpd() as client: + self._bpd_add(client, self.item1, self.item2) + responses = client.send_commands( + ('status',), + ('play',), + ('status',)) + self._assert_ok(*responses) + fields_not_playing = { + 'repeat', 'random', 'single', 'consume', 'playlist', + 'playlistlength', 'mixrampdb', 'state', + 'volume' # not (always?) returned by MPD + } + self.assertEqual(fields_not_playing, set(responses[0].data.keys())) + fields_playing = fields_not_playing | { + 'song', 'songid', 'time', 'elapsed', 'bitrate', 'duration', 'audio' + } + self.assertEqual(fields_playing, set(responses[2].data.keys())) + + +class BPDPlaybackTest(BPDTestHelper): test_implements_playback = implements({ - 'consume', 'crossfade', 'mixrampd', 'mixrampdelay', 'random', - 'repeat', 'setvol', 'single', 'replay_gain_mode', - 'replay_gain_status', 'volume', - }, expectedFailure=True) + 'random', + }) - test_implements_control = implements({ - 'next', 'pause', 'play', 'playid', 'previous', 'seek', - 'seekid', 'seekcur', 'stop', - }, expectedFailure=True) + def test_cmd_consume(self): + with self.run_bpd() as client: + self._bpd_add(client, self.item1, self.item2) + responses = client.send_commands( + ('consume', '0'), + ('playlistinfo',), + ('next',), + ('playlistinfo',), + ('consume', '1'), + ('playlistinfo',), + ('play', '0'), + ('next',), + ('playlistinfo',), + ('status',)) + self._assert_ok(*responses) + self.assertEqual(responses[1].data['Id'], responses[3].data['Id']) + self.assertEqual(['1', '2'], responses[5].data['Id']) + self.assertEqual('2', responses[8].data['Id']) + self.assertEqual('1', responses[9].data['consume']) + self.assertEqual('play', responses[9].data['state']) - def _bpd_add(self, client, *items): - """ Add the given item to the BPD playlist - """ - paths = ['/'.join([ - item.artist, item.album, - py3_path(os.path.basename(item.path))]) for item in items] - responses = client.send_commands(*[('add', path) for path in paths]) + def test_cmd_consume_in_reverse(self): + with self.run_bpd() as client: + self._bpd_add(client, self.item1, self.item2) + responses = client.send_commands( + ('consume', '1'), + ('play', '1'), + ('playlistinfo',), + ('previous',), + ('playlistinfo',), + ('status',)) self._assert_ok(*responses) + self.assertEqual(['1', '2'], responses[2].data['Id']) + self.assertEqual('1', responses[4].data['Id']) + self.assertEqual('play', responses[5].data['state']) - def test_unknown_cmd(self): + def test_cmd_single(self): with self.run_bpd() as client: - response = client.send_command('notacommand') - self._assert_failed(response, bpd.ERROR_UNKNOWN) + self._bpd_add(client, self.item1, self.item2) + responses = client.send_commands( + ('status',), + ('single', '1'), + ('play',), + ('status',), + ('next',), + ('status',)) + self._assert_ok(*responses) + self.assertEqual('0', responses[0].data['single']) + self.assertEqual('1', responses[3].data['single']) + self.assertEqual('play', responses[3].data['state']) + self.assertEqual('stop', responses[5].data['state']) - def test_cmd_play(self): + def test_cmd_repeat(self): with self.run_bpd() as client: - self._bpd_add(client, self.item1) + self._bpd_add(client, self.item1, self.item2) + responses = client.send_commands( + ('repeat', '1'), + ('play',), + ('currentsong',), + ('next',), + ('currentsong',), + ('next',), + ('currentsong',)) + self._assert_ok(*responses) + self.assertEqual('1', responses[2].data['Id']) + self.assertEqual('2', responses[4].data['Id']) + self.assertEqual('1', responses[6].data['Id']) + + def test_cmd_repeat_with_single(self): + with self.run_bpd() as client: + self._bpd_add(client, self.item1, self.item2) responses = client.send_commands( + ('repeat', '1'), + ('single', '1'), + ('play',), + ('currentsong',), + ('next',), ('status',), + ('currentsong',)) + self._assert_ok(*responses) + self.assertEqual('1', responses[3].data['Id']) + self.assertEqual('play', responses[5].data['state']) + self.assertEqual('1', responses[6].data['Id']) + + def test_cmd_repeat_in_reverse(self): + with self.run_bpd() as client: + self._bpd_add(client, self.item1, self.item2) + responses = client.send_commands( + ('repeat', '1'), ('play',), + ('currentsong',), + ('previous',), + ('currentsong',)) + self._assert_ok(*responses) + self.assertEqual('1', responses[2].data['Id']) + self.assertEqual('2', responses[4].data['Id']) + + def test_cmd_repeat_with_single_in_reverse(self): + with self.run_bpd() as client: + self._bpd_add(client, self.item1, self.item2) + responses = client.send_commands( + ('repeat', '1'), + ('single', '1'), + ('play',), + ('currentsong',), + ('previous',), + ('status',), + ('currentsong',)) + self._assert_ok(*responses) + self.assertEqual('1', responses[3].data['Id']) + self.assertEqual('play', responses[5].data['state']) + self.assertEqual('1', responses[6].data['Id']) + + def test_cmd_crossfade(self): + with self.run_bpd() as client: + responses = client.send_commands( + ('status',), + ('crossfade', '123'), + ('status',), + ('crossfade', '-2')) + response = client.send_command('crossfade', '0.5') + self._assert_failed(responses, bpd.ERROR_ARG, pos=3) + self._assert_failed(response, bpd.ERROR_ARG) + self.assertNotIn('xfade', responses[0].data) + self.assertAlmostEqual(123, int(responses[2].data['xfade'])) + + def test_cmd_mixrampdb(self): + with self.run_bpd() as client: + responses = client.send_commands( + ('mixrampdb', '-17'), ('status',)) self._assert_ok(*responses) + self.assertAlmostEqual(-17, float(responses[1].data['mixrampdb'])) + + def test_cmd_mixrampdelay(self): + with self.run_bpd() as client: + responses = client.send_commands( + ('mixrampdelay', '2'), + ('status',), + ('mixrampdelay', 'nan'), + ('status',), + ('mixrampdelay', '-2')) + self._assert_failed(responses, bpd.ERROR_ARG, pos=4) + self.assertAlmostEqual(2, float(responses[1].data['mixrampdelay'])) + self.assertNotIn('mixrampdelay', responses[3].data) + + def test_cmd_setvol(self): + with self.run_bpd() as client: + responses = client.send_commands( + ('setvol', '67'), + ('status',), + ('setvol', '32'), + ('status',), + ('setvol', '101')) + self._assert_failed(responses, bpd.ERROR_ARG, pos=4) + self.assertEqual('67', responses[1].data['volume']) + self.assertEqual('32', responses[3].data['volume']) + + def test_cmd_volume(self): + with self.run_bpd() as client: + response = client.send_command('volume', '10') + self._assert_failed(response, bpd.ERROR_SYSTEM) + + def test_cmd_replay_gain(self): + with self.run_bpd() as client: + responses = client.send_commands( + ('replay_gain_mode', 'track'), + ('replay_gain_status',), + ('replay_gain_mode', 'notanoption')) + self._assert_failed(responses, bpd.ERROR_ARG, pos=2) + self.assertAlmostEqual('track', responses[1].data['replay_gain_mode']) + + +class BPDControlTest(BPDTestHelper): + test_implements_control = implements({ + 'pause', 'playid', 'seek', + 'seekid', 'seekcur', 'stop', + }, expectedFailure=True) + + def test_cmd_play(self): + with self.run_bpd() as client: + self._bpd_add(client, self.item1, self.item2) + responses = client.send_commands( + ('status',), + ('play',), + ('status',), + ('play', '1'), + ('currentsong',)) + self._assert_ok(*responses) self.assertEqual('stop', responses[0].data['state']) self.assertEqual('play', responses[2].data['state']) + self.assertEqual('2', responses[4].data['Id']) + + def test_cmd_next(self): + with self.run_bpd() as client: + self._bpd_add(client, self.item1, self.item2) + responses = client.send_commands( + ('play',), + ('currentsong',), + ('next',), + ('currentsong',), + ('next',), + ('status',)) + self._assert_ok(*responses) + self.assertEqual('1', responses[1].data['Id']) + self.assertEqual('2', responses[3].data['Id']) + self.assertEqual('stop', responses[5].data['state']) + + def test_cmd_previous(self): + with self.run_bpd() as client: + self._bpd_add(client, self.item1, self.item2) + responses = client.send_commands( + ('play', '1'), + ('currentsong',), + ('previous',), + ('currentsong',), + ('previous',), + ('status',), + ('currentsong',)) + self._assert_ok(*responses) + self.assertEqual('2', responses[1].data['Id']) + self.assertEqual('1', responses[3].data['Id']) + self.assertEqual('play', responses[5].data['state']) + self.assertEqual('1', responses[6].data['Id']) + +class BPDQueueTest(BPDTestHelper): test_implements_queue = implements({ - 'add', 'addid', 'clear', 'delete', 'deleteid', 'move', + 'addid', 'clear', 'delete', 'deleteid', 'move', 'moveid', 'playlist', 'playlistfind', 'playlistid', - 'playlistinfo', 'playlistsearch', 'plchanges', + 'playlistsearch', 'plchanges', 'plchangesposid', 'prio', 'prioid', 'rangeid', 'shuffle', 'swap', 'swapid', 'addtagid', 'cleartagid', }, expectedFailure=True) @@ -390,19 +644,75 @@ def test_cmd_playlistinfo(self): ('playlistinfo',), ('playlistinfo', '0'), ('playlistinfo', '200')) - self._assert_failed(responses, bpd.ERROR_ARG, pos=2) - test_implements_playlists = implements({ - 'listplaylist', 'listplaylistinfo', 'listplaylists', 'load', - 'playlistadd', 'playlistclear', 'playlistdelete', - 'playlistmove', 'rename', 'rm', 'save', - }, expectedFailure=True) +class BPDPlaylistsTest(BPDTestHelper): + test_implements_playlists = implements({'playlistadd'}) + + def test_cmd_listplaylist(self): + with self.run_bpd() as client: + response = client.send_command('listplaylist', 'anything') + self._assert_failed(response, bpd.ERROR_NO_EXIST) + + def test_cmd_listplaylistinfo(self): + with self.run_bpd() as client: + response = client.send_command('listplaylistinfo', 'anything') + self._assert_failed(response, bpd.ERROR_NO_EXIST) + + def test_cmd_listplaylists(self): + with self.run_bpd() as client: + response = client.send_command('listplaylists') + self._assert_failed(response, bpd.ERROR_UNKNOWN) + + def test_cmd_load(self): + with self.run_bpd() as client: + response = client.send_command('load', 'anything') + self._assert_failed(response, bpd.ERROR_NO_EXIST) + + @unittest.skip + def test_cmd_playlistadd(self): + with self.run_bpd() as client: + self._bpd_add(client, self.item1, playlist='anything') + + def test_cmd_playlistclear(self): + with self.run_bpd() as client: + response = client.send_command('playlistclear', 'anything') + self._assert_failed(response, bpd.ERROR_UNKNOWN) + + def test_cmd_playlistdelete(self): + with self.run_bpd() as client: + response = client.send_command('playlistdelete', 'anything', '0') + self._assert_failed(response, bpd.ERROR_UNKNOWN) + + def test_cmd_playlistmove(self): + with self.run_bpd() as client: + response = client.send_command( + 'playlistmove', 'anything', '0', '1') + self._assert_failed(response, bpd.ERROR_UNKNOWN) + + def test_cmd_rename(self): + with self.run_bpd() as client: + response = client.send_command('rename', 'anything', 'newname') + self._assert_failed(response, bpd.ERROR_UNKNOWN) + + def test_cmd_rm(self): + with self.run_bpd() as client: + response = client.send_command('rm', 'anything') + self._assert_failed(response, bpd.ERROR_UNKNOWN) + + def test_cmd_save(self): + with self.run_bpd() as client: + self._bpd_add(client, self.item1) + response = client.send_command('save', 'newplaylist') + self._assert_failed(response, bpd.ERROR_UNKNOWN) + + +class BPDDatabaseTest(BPDTestHelper): test_implements_database = implements({ - 'albumart', 'count', 'find', 'findadd', 'list', 'listall', - 'listallinfo', 'listfiles', 'lsinfo', 'readcomments', - 'search', 'searchadd', 'searchaddpl', 'update', 'rescan', + 'albumart', 'find', 'findadd', 'listall', + 'listallinfo', 'listfiles', 'readcomments', + 'searchadd', 'searchaddpl', 'update', 'rescan', }, expectedFailure=True) def test_cmd_search(self): @@ -411,7 +721,7 @@ def test_cmd_search(self): self._assert_ok(response) self.assertEqual(self.item1.title, response.data['Title']) - def test_cmd_list_simple(self): + def test_cmd_list(self): with self.run_bpd() as client: responses = client.send_commands( ('list', 'album'), @@ -439,16 +749,22 @@ def test_cmd_count(self): self.assertEqual('1', response.data['songs']) self.assertEqual('0', response.data['playtime']) + +class BPDMountsTest(BPDTestHelper): test_implements_mounts = implements({ 'mount', 'unmount', 'listmounts', 'listneighbors', }, expectedFailure=True) + +class BPDStickerTest(BPDTestHelper): test_implements_stickers = implements({ 'sticker', }, expectedFailure=True) + +class BPDConnectionTest(BPDTestHelper): test_implements_connection = implements({ - 'close', 'kill', 'password', 'ping', 'tagtypes', + 'close', 'kill', 'tagtypes', }) def test_cmd_password(self): @@ -489,19 +805,27 @@ def test_tagtypes_mask(self): response = client.send_command('tagtypes', 'clear') self._assert_ok(response) + +class BPDPartitionTest(BPDTestHelper): test_implements_partitions = implements({ 'partition', 'listpartitions', 'newpartition', }, expectedFailure=True) + +class BPDDeviceTest(BPDTestHelper): test_implements_devices = implements({ 'disableoutput', 'enableoutput', 'toggleoutput', 'outputs', }, expectedFailure=True) + +class BPDReflectionTest(BPDTestHelper): test_implements_reflection = implements({ 'config', 'commands', 'notcommands', 'urlhandlers', 'decoders', }, expectedFailure=True) + +class BPDPeersTest(BPDTestHelper): test_implements_peers = implements({ 'subscribe', 'unsubscribe', 'channels', 'readmessages', 'sendmessage', diff --git a/test/test_plugins.py b/test/test_plugins.py index 7c32e9aca6..b141586995 100644 --- a/test/test_plugins.py +++ b/test/test_plugins.py @@ -322,7 +322,7 @@ def dummy(self): @patch('beets.plugins.find_plugins') @patch('beets.plugins.inspect') def test_events_called(self, mock_inspect, mock_find_plugins): - mock_inspect.getargspec.return_value = None + mock_inspect.getargspec.args.return_value = None class DummyPlugin(plugins.BeetsPlugin): def __init__(self):