diff --git a/docs/authors.rst b/docs/authors.rst index 1a0f21ed..90ec6f23 100644 --- a/docs/authors.rst +++ b/docs/authors.rst @@ -14,7 +14,7 @@ our Git repository. .. include:: ../AUTHORS -If you already enjoy Mopidy, or don't enjoy it and want to help us making -Mopidy better, the best way to do so is to contribute back to the community. -You can contribute code, documentation, tests, bug reports, or help other -users, spreading the word, etc. See :ref:`contributing` for a head start. +If want to help us making Mopidy better, the best way to do so is to contribute +back to the community, either through code, documentation, tests, bug reports, +or by helping other users, spreading the word, etc. See :ref:`contributing` for +a head start. diff --git a/docs/changelog.rst b/docs/changelog.rst index c354f2b9..6ba402bc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -61,6 +61,9 @@ v0.20.0 (UNRELEASED) - Add debug logging of unknown sections. (Fixes: :issue:`694`, PR: :issue:`1002`) +- Add support for configuring :confval:`audio/mixer` to ``none``. (Fixes: + :issue:`936`) + **Logging** - Add custom log level ``TRACE`` (numerical level 5), which can be used by @@ -78,7 +81,7 @@ v0.20.0 (UNRELEASED) - Add support for giving local libraries direct access to tags and duration. (Fixes: :issue:`967`) -- Add "--force" option for local scan (Fixes: :issue:'910', PR: :issue:'1010') +- Add "--force" option for local scan (Fixes: :issue:`910`, PR: :issue:`1010`) - Stop ignoring ``offset`` and ``limit`` in searches. (Fixes: :issue:`917`, PR: :issue:`949`) @@ -92,6 +95,9 @@ v0.20.0 (UNRELEASED) - Sort local playlists by name. (Fixes: :issue:`1026`, PR: :issue:`1028`) +- Add :meth:`mopidy.local.Library.get_images` for looking up images + for local URIs. (Fixes: :issue:`1031`, PR: :issue:`1032`) + **File scanner** - Improve error logging for scan code (Fixes: :issue:`856`, PR: :issue:`874`) @@ -118,6 +124,10 @@ v0.20.0 (UNRELEASED) - Switch the ``list`` command over to using :meth:`mopidy.core.LibraryController.get_distinct`. (Fixes: :issue:`913`) +- Add support for ``toggleoutput`` command. The ``mixrampdb`` and + ``mixrampdelay`` commands are now supported but throw a NotImplemented + exception. + - Start setting the ``Name`` field which is used for radio streams. (Fixes: :issue:`944`) @@ -151,6 +161,13 @@ v0.20.0 (UNRELEASED) - Update scanner to operate with milliseconds for duration. + - Update scanner to use a custom src, typefind and decodebin. This allows us + to catch playlists before we try to decode them. + + - Refactored scanner to create a new pipeline per song, this is needed as + reseting decodebin is much slower than tearing it down and making a fresh + one. + - Add :meth:`mopidy.audio.AudioListener.tags_changed`. Notifies core when new tags are found. @@ -170,6 +187,12 @@ v0.20.0 (UNRELEASED) - Add workaround for volume not persisting across tracks on OS X. (Issue: :issue:`886`, PR: :issue:`958`) +- Improved missing plugin error reporting in scanner. + +- Introduced a new return type for the scanner, a named tuple with ``uri``, + ``tags``, ``duration``, ``seekable`` and ``mime``. Also added support for + checking seekable, and the initial MIME type guess. + **Stream backend** - Add basic tests for the stream library provider. diff --git a/docs/config.rst b/docs/config.rst index 69945ab8..46b15635 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -70,6 +70,8 @@ Audio configuration will affect the audio volume if you're streaming the audio from Mopidy through Shoutcast. + If you want to disable audio mixing set the value to ``none``. + If you want to use a hardware mixer, you need to install a Mopidy extension which integrates with your sound subsystem. E.g. for ALSA, install `Mopidy-ALSAMixer `_. diff --git a/docs/ext/mobile.png b/docs/ext/mobile.png new file mode 100644 index 00000000..983aa27c Binary files /dev/null and b/docs/ext/mobile.png differ diff --git a/docs/ext/web.rst b/docs/ext/web.rst index 5abf5b15..b4a9660f 100644 --- a/docs/ext/web.rst +++ b/docs/ext/web.rst @@ -30,17 +30,21 @@ To install, run:: pip install Mopidy-API-Explorer -Mopidy-HTTP-Kuechenradio -========================= +Mopidy-Mobile +============= -https://github.com/tkem/mopidy-http-kuechenradio +https://github.com/tkem/mopidy-mobile -A deliberately simple Mopidy Web client for mobile devices. Made with jQuery -Mobile by Thomas Kemmer. +A Mopidy Web client extension and hybrid mobile app, made with Ionic, +AngularJS and Apache Cordova by Thomas Kemmer. + +.. image:: /ext/mobile.png + :width: 1024 + :height: 606 To install, run:: - pip install Mopidy-HTTP-Kuechenradio + pip install Mopidy-Mobile Mopidy-Moped diff --git a/mopidy/audio/scan.py b/mopidy/audio/scan.py index 38b86437..3880d91a 100644 --- a/mopidy/audio/scan.py +++ b/mopidy/audio/scan.py @@ -1,16 +1,25 @@ from __future__ import absolute_import, division, unicode_literals -import time +import collections import pygst pygst.require('0.10') import gst # noqa +import gst.pbutils from mopidy import exceptions from mopidy.audio import utils from mopidy.utils import encoding +_missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description +_Result = collections.namedtuple( + 'Result', ('uri', 'tags', 'duration', 'seekable', 'mime')) + +_RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') + + +# TODO: replace with a scan(uri, timeout=1000, proxy_config=None)? class Scanner(object): """ Helper to get tags and other relevant info from URIs. @@ -21,29 +30,8 @@ class Scanner(object): """ def __init__(self, timeout=1000, proxy_config=None): - self._timeout_ms = timeout - - sink = gst.element_factory_make('fakesink') - - audio_caps = gst.Caps(b'audio/x-raw-int; audio/x-raw-float') - - def pad_added(src, pad): - return pad.link(sink.get_pad('sink')) - - def source_setup(element, source): - utils.setup_proxy(source, proxy_config or {}) - - self._uribin = gst.element_factory_make('uridecodebin') - self._uribin.set_property('caps', audio_caps) - self._uribin.connect('pad-added', pad_added) - self._uribin.connect('source-setup', source_setup) - - self._pipe = gst.element_factory_make('pipeline') - self._pipe.add(self._uribin) - self._pipe.add(sink) - - self._bus = self._pipe.get_bus() - self._bus.set_flushing(True) + self._timeout_ms = int(timeout) + self._proxy_config = proxy_config or {} def scan(self, uri): """ @@ -51,68 +39,124 @@ class Scanner(object): :param uri: URI of the resource to scan. :type event: string - :return: (tags, duration) pair. tags is a dictionary of lists for all - the tags we found and duration is the length of the URI in - milliseconds, or :class:`None` if the URI has no duration. + :return: A named tuple containing + ``(uri, tags, duration, seekable, mime)``. + ``tags`` is a dictionary of lists for all the tags we found. + ``duration`` is the length of the URI in milliseconds, or + :class:`None` if the URI has no duration. ``seekable`` is boolean. + indicating if a seek would succeed. """ - tags, duration = None, None + tags, duration, seekable, mime = None, None, None, None + pipeline = _setup_pipeline(uri, self._proxy_config) + try: - self._setup(uri) - tags = self._collect() - duration = self._query_duration() + _start_pipeline(pipeline) + tags, mime = _process(pipeline, self._timeout_ms) + duration = _query_duration(pipeline) + seekable = _query_seekable(pipeline) finally: - self._reset() + pipeline.set_state(gst.STATE_NULL) + del pipeline - return tags, duration + return _Result(uri, tags, duration, seekable, mime) - def _setup(self, uri): - """Primes the pipeline for collection.""" - self._pipe.set_state(gst.STATE_READY) - self._uribin.set_property(b'uri', uri) - self._bus.set_flushing(False) - result = self._pipe.set_state(gst.STATE_PAUSED) - if result == gst.STATE_CHANGE_NO_PREROLL: - # Live sources don't pre-roll, so set to playing to get data. - self._pipe.set_state(gst.STATE_PLAYING) - def _collect(self): - """Polls for messages to collect data.""" - start = time.time() - timeout_s = self._timeout_ms / 1000.0 - tags = {} +# Turns out it's _much_ faster to just create a new pipeline for every as +# decodebins and other elements don't seem to take well to being reused. +def _setup_pipeline(uri, proxy_config=None): + src = gst.element_make_from_uri(gst.URI_SRC, uri) + if not src: + raise exceptions.ScannerError('GStreamer can not open: %s' % uri) - while time.time() - start < timeout_s: - if not self._bus.have_pending(): - continue - message = self._bus.pop() + typefind = gst.element_factory_make('typefind') + decodebin = gst.element_factory_make('decodebin2') + sink = gst.element_factory_make('fakesink') - if message.type == gst.MESSAGE_ERROR: - raise exceptions.ScannerError( - encoding.locale_decode(message.parse_error()[0])) - elif message.type == gst.MESSAGE_EOS: - return tags - elif message.type == gst.MESSAGE_ASYNC_DONE: - if message.src == self._pipe: - return tags - elif message.type == gst.MESSAGE_TAG: - taglist = message.parse_tag() - # Note that this will only keep the last tag. - tags.update(utils.convert_taglist(taglist)) + pipeline = gst.element_factory_make('pipeline') + pipeline.add_many(src, typefind, decodebin, sink) + gst.element_link_many(src, typefind, decodebin) - raise exceptions.ScannerError('Timeout after %dms' % self._timeout_ms) + if proxy_config: + utils.setup_proxy(src, proxy_config) - def _reset(self): - """Ensures we cleanup child elements and flush the bus.""" - self._bus.set_flushing(True) - self._pipe.set_state(gst.STATE_NULL) + decodebin.set_property('caps', _RAW_AUDIO) + decodebin.connect('pad-added', _pad_added, sink) + typefind.connect('have-type', _have_type, decodebin) - def _query_duration(self): - try: - duration = self._pipe.query_duration(gst.FORMAT_TIME, None)[0] - except gst.QueryError: - return None + return pipeline - if duration < 0: - return None - else: - return duration // gst.MSECOND + +def _have_type(element, probability, caps, decodebin): + decodebin.set_property('sink-caps', caps) + msg = gst.message_new_application(element, caps.get_structure(0)) + element.get_bus().post(msg) + + +def _pad_added(element, pad, sink): + return pad.link(sink.get_pad('sink')) + + +def _start_pipeline(pipeline): + if pipeline.set_state(gst.STATE_PAUSED) == gst.STATE_CHANGE_NO_PREROLL: + pipeline.set_state(gst.STATE_PLAYING) + + +def _query_duration(pipeline): + try: + duration = pipeline.query_duration(gst.FORMAT_TIME, None)[0] + except gst.QueryError: + return None + + if duration < 0: + return None + else: + return duration // gst.MSECOND + + +def _query_seekable(pipeline): + query = gst.query_new_seeking(gst.FORMAT_TIME) + pipeline.query(query) + return query.parse_seeking()[1] + + +def _process(pipeline, timeout_ms): + clock = pipeline.get_clock() + bus = pipeline.get_bus() + timeout = timeout_ms * gst.MSECOND + tags, mime, missing_description = {}, None, None + + types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR + | gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG) + + start = clock.get_time() + while timeout > 0: + message = bus.timed_pop_filtered(timeout, types) + + if message is None: + break + elif message.type == gst.MESSAGE_ELEMENT: + if gst.pbutils.is_missing_plugin_message(message): + missing_description = encoding.locale_decode( + _missing_plugin_desc(message)) + elif message.type == gst.MESSAGE_APPLICATION: + mime = message.structure.get_name() + if mime.startswith('text/') or mime == 'application/xml': + return tags, mime + elif message.type == gst.MESSAGE_ERROR: + error = encoding.locale_decode(message.parse_error()[0]) + if missing_description: + error = '%s (%s)' % (missing_description, error) + raise exceptions.ScannerError(error) + elif message.type == gst.MESSAGE_EOS: + return tags, mime + elif message.type == gst.MESSAGE_ASYNC_DONE: + if message.src == pipeline: + return tags, mime + elif message.type == gst.MESSAGE_TAG: + taglist = message.parse_tag() + # Note that this will only keep the last tag. + tags.update(utils.convert_taglist(taglist)) + + timeout -= clock.get_time() - start + + raise exceptions.ScannerError('Timeout after %dms' % timeout_ms) diff --git a/mopidy/commands.py b/mopidy/commands.py index d9b4ce0e..5df8dd5a 100644 --- a/mopidy/commands.py +++ b/mopidy/commands.py @@ -276,7 +276,9 @@ class RootCommand(Command): exit_status_code = 0 try: - mixer = self.start_mixer(config, mixer_class) + mixer = None + if mixer_class is not None: + mixer = self.start_mixer(config, mixer_class) audio = self.start_audio(config, mixer) backends = self.start_backends(config, backend_classes, audio) core = self.start_core(mixer, backends, audio) @@ -297,7 +299,8 @@ class RootCommand(Command): self.stop_core() self.stop_backends(backend_classes) self.stop_audio() - self.stop_mixer(mixer_class) + if mixer_class is not None: + self.stop_mixer(mixer_class) process.stop_remaining_actors() return exit_status_code @@ -306,13 +309,18 @@ class RootCommand(Command): 'Available Mopidy mixers: %s', ', '.join(m.__name__ for m in mixer_classes) or 'none') + if config['audio']['mixer'] == 'none': + logger.debug('Mixer disabled') + return None + selected_mixers = [ m for m in mixer_classes if m.name == config['audio']['mixer']] if len(selected_mixers) != 1: logger.error( 'Did not find unique mixer "%s". Alternatives are: %s', config['audio']['mixer'], - ', '.join([m.name for m in mixer_classes])) + ', '.join([m.name for m in mixer_classes]) + ', none' or + 'none') process.exit_process() return selected_mixers[0] diff --git a/mopidy/core/mixer.py b/mopidy/core/mixer.py index 4d77f8bc..3388d706 100644 --- a/mopidy/core/mixer.py +++ b/mopidy/core/mixer.py @@ -11,8 +11,6 @@ class MixerController(object): def __init__(self, mixer): self._mixer = mixer - self._volume = None - self._mute = False def get_volume(self): """Get the volume. @@ -30,9 +28,13 @@ class MixerController(object): The volume is defined as an integer in range [0..100]. The volume scale is linear. + + Returns :class:`True` if call is successful, otherwise :class:`False`. """ - if self._mixer is not None: - self._mixer.set_volume(volume) + if self._mixer is None: + return False + else: + return self._mixer.set_volume(volume).get() def get_mute(self): """Get mute state. @@ -47,6 +49,10 @@ class MixerController(object): """Set mute state. :class:`True` to mute, :class:`False` to unmute. + + Returns :class:`True` if call is successful, otherwise :class:`False`. """ - if self._mixer is not None: - self._mixer.set_mute(bool(mute)) + if self._mixer is None: + return False + else: + return self._mixer.set_mute(bool(mute)).get() diff --git a/mopidy/local/__init__.py b/mopidy/local/__init__.py index 97ed4a09..eecaa4a2 100644 --- a/mopidy/local/__init__.py +++ b/mopidy/local/__init__.py @@ -4,7 +4,7 @@ import logging import os import mopidy -from mopidy import config, ext +from mopidy import config, ext, models logger = logging.getLogger(__name__) @@ -101,6 +101,27 @@ class Library(object): """ return set() + def get_images(self, uris): + """ + Lookup the images for the given URIs. + + The default implementation will simply call :meth:`lookup` and + try and use the album art for any tracks returned. Most local + libraries should replace this with something smarter or simply + return an empty dictionary. + + :param list uris: list of URIs to find images for + :rtype: {uri: tuple of :class:`mopidy.models.Image`} + """ + result = {} + for uri in uris: + image_uris = set() + for track in self.lookup(uri): + if track.album and track.album.images: + image_uris.update(track.album.images) + result[uri] = [models.Image(uri=u) for u in image_uris] + return result + def load(self): """ (Re)load any tracks stored in memory, if any, otherwise just return diff --git a/mopidy/local/commands.py b/mopidy/local/commands.py index 798c10f8..af8b0025 100644 --- a/mopidy/local/commands.py +++ b/mopidy/local/commands.py @@ -133,7 +133,8 @@ class ScanCommand(commands.Command): try: relpath = translator.local_track_uri_to_path(uri, media_dir) file_uri = path.path_to_uri(os.path.join(media_dir, relpath)) - tags, duration = scanner.scan(file_uri) + result = scanner.scan(file_uri) + tags, duration = result.tags, result.duration if duration < MIN_DURATION_MS: logger.warning('Failed %s: Track shorter than %dms', uri, MIN_DURATION_MS) @@ -177,6 +178,6 @@ class _Progress(object): logger.info('Scanned %d of %d files in %ds.', self.count, self.total, duration) else: - remainder = duration // self.count * (self.total - self.count) + remainder = duration / self.count * (self.total - self.count) logger.info('Scanned %d of %d files in %ds, ~%ds left.', self.count, self.total, duration, remainder) diff --git a/mopidy/local/library.py b/mopidy/local/library.py index 90a54770..77c122bd 100644 --- a/mopidy/local/library.py +++ b/mopidy/local/library.py @@ -28,6 +28,11 @@ class LocalLibraryProvider(backend.LibraryProvider): return set() return self._library.get_distinct(field, query) + def get_images(self, uris): + if not self._library: + return {} + return self._library.get_images(uris) + def refresh(self, uri=None): if not self._library: return 0 diff --git a/mopidy/mpd/protocol/audio_output.py b/mopidy/mpd/protocol/audio_output.py index 0152f852..565ea3d0 100644 --- a/mopidy/mpd/protocol/audio_output.py +++ b/mopidy/mpd/protocol/audio_output.py @@ -13,7 +13,9 @@ def disableoutput(context, outputid): Turns an output off. """ if outputid == 0: - context.core.mixer.set_mute(False) + success = context.core.mixer.set_mute(False).get() + if not success: + raise exceptions.MpdSystemError('problems disabling output') else: raise exceptions.MpdNoExistError('No such audio output') @@ -28,13 +30,14 @@ def enableoutput(context, outputid): Turns an output on. """ if outputid == 0: - context.core.mixer.set_mute(True) + success = context.core.mixer.set_mute(True).get() + if not success: + raise exceptions.MpdSystemError('problems enabling output') else: raise exceptions.MpdNoExistError('No such audio output') -# TODO: implement and test -# @protocol.commands.add('toggleoutput', outputid=protocol.UINT) +@protocol.commands.add('toggleoutput', outputid=protocol.UINT) def toggleoutput(context, outputid): """ *musicpd.org, audio output section:* @@ -43,7 +46,13 @@ def toggleoutput(context, outputid): Turns an output on or off, depending on the current state. """ - pass + if outputid == 0: + mute_status = context.core.mixer.get_mute().get() + success = context.core.mixer.set_mute(not mute_status) + if not success: + raise exceptions.MpdSystemError('problems toggling output') + else: + raise exceptions.MpdNoExistError('No such audio output') @protocol.commands.add('outputs') diff --git a/mopidy/mpd/protocol/playback.py b/mopidy/mpd/protocol/playback.py index f7856a03..86f2e36b 100644 --- a/mopidy/mpd/protocol/playback.py +++ b/mopidy/mpd/protocol/playback.py @@ -32,8 +32,7 @@ def crossfade(context, seconds): raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('mixrampdb') +@protocol.commands.add('mixrampdb') def mixrampdb(context, decibels): """ *musicpd.org, playback section:* @@ -46,11 +45,10 @@ def mixrampdb(context, decibels): 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 + raise exceptions.MpdNotImplemented # TODO -# TODO: add at least reflection tests before adding NotImplemented version -# @protocol.commands.add('mixrampdelay', seconds=protocol.UINT) +@protocol.commands.add('mixrampdelay', seconds=protocol.UINT) def mixrampdelay(context, seconds): """ *musicpd.org, playback section:* @@ -61,7 +59,7 @@ def mixrampdelay(context, seconds): value of "nan" disables MixRamp overlapping and falls back to crossfading. """ - pass + raise exceptions.MpdNotImplemented # TODO @protocol.commands.add('next') @@ -397,7 +395,10 @@ def setvol(context, volume): - issues ``setvol 50`` without quotes around the argument. """ # NOTE: we use INT as clients can pass in +N etc. - context.core.mixer.set_volume(min(max(0, volume), 100)) + value = min(max(0, volume), 100) + success = context.core.mixer.set_volume(value).get() + if not success: + raise exceptions.MpdSystemError('problems setting volume') @protocol.commands.add('single', state=protocol.BOOL) diff --git a/mopidy/stream/actor.py b/mopidy/stream/actor.py index 58fd966a..47bfd58f 100644 --- a/mopidy/stream/actor.py +++ b/mopidy/stream/actor.py @@ -45,9 +45,9 @@ class StreamLibraryProvider(backend.LibraryProvider): return [Track(uri=uri)] try: - tags, duration = self._scanner.scan(uri) - track = utils.convert_tags_to_track(tags).copy( - uri=uri, length=duration) + result = self._scanner.scan(uri) + track = utils.convert_tags_to_track(result.tags).copy( + uri=uri, length=result.duration) except exceptions.ScannerError as e: logger.warning('Problem looking up %s: %s', uri, e) track = Track(uri=uri) diff --git a/tests/audio/test_scan.py b/tests/audio/test_scan.py index 50ec8352..b2937a3f 100644 --- a/tests/audio/test_scan.py +++ b/tests/audio/test_scan.py @@ -31,9 +31,9 @@ class ScannerTest(unittest.TestCase): uri = path_lib.path_to_uri(path) key = uri[len('file://'):] try: - tags, duration = scanner.scan(uri) - self.tags[key] = tags - self.durations[key] = duration + result = scanner.scan(uri) + self.tags[key] = result.tags + self.durations[key] = result.duration except exceptions.ScannerError as error: self.errors[key] = error diff --git a/tests/core/test_listener.py b/tests/core/test_listener.py index 64003769..1338ec5e 100644 --- a/tests/core/test_listener.py +++ b/tests/core/test_listener.py @@ -57,3 +57,6 @@ class CoreListenerTest(unittest.TestCase): def test_listener_has_default_impl_for_seeked(self): self.listener.seeked(0) + + def test_listener_has_default_impl_for_current_metadata_changed(self): + self.listener.current_metadata_changed() diff --git a/tests/core/test_mixer.py b/tests/core/test_mixer.py index 80e6f7ef..c4126eaa 100644 --- a/tests/core/test_mixer.py +++ b/tests/core/test_mixer.py @@ -4,7 +4,10 @@ import unittest import mock +import pykka + from mopidy import core, mixer +from tests import dummy_mixer class CoreMixerTest(unittest.TestCase): @@ -33,3 +36,55 @@ class CoreMixerTest(unittest.TestCase): self.core.mixer.set_mute(True) self.mixer.set_mute.assert_called_once_with(True) + + +class CoreNoneMixerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.core = core.Core(mixer=None, backends=[]) + + def test_get_volume_return_none_because_it_is_unknown(self): + self.assertEqual(self.core.mixer.get_volume(), None) + + def test_set_volume_return_false_because_it_failed(self): + self.assertEqual(self.core.mixer.set_volume(30), False) + + def test_get_mute_return_none_because_it_is_unknown(self): + self.assertEqual(self.core.mixer.get_mute(), None) + + def test_set_mute_return_false_because_it_failed(self): + self.assertEqual(self.core.mixer.set_mute(True), False) + + +@mock.patch.object(mixer.MixerListener, 'send') +class CoreMixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.mixer = dummy_mixer.create_proxy() + self.core = core.Core(mixer=self.mixer, backends=[]) + + def tearDown(self): # noqa: N802 + pykka.ActorRegistry.stop_all() + + def test_forwards_mixer_volume_changed_event_to_frontends(self, send): + self.assertEqual(self.core.mixer.set_volume(volume=60), True) + self.assertEqual(send.call_args[0][0], 'volume_changed') + self.assertEqual(send.call_args[1]['volume'], 60) + + def test_forwards_mixer_mute_changed_event_to_frontends(self, send): + self.core.mixer.set_mute(mute=True) + + self.assertEqual(send.call_args[0][0], 'mute_changed') + self.assertEqual(send.call_args[1]['mute'], True) + + +@mock.patch.object(mixer.MixerListener, 'send') +class CoreNoneMixerListenerTest(unittest.TestCase): + def setUp(self): # noqa: N802 + self.core = core.Core(mixer=None, backends=[]) + + def test_forwards_mixer_volume_changed_event_to_frontends(self, send): + self.assertEqual(self.core.mixer.set_volume(volume=60), False) + self.assertEqual(send.call_count, 0) + + def test_forwards_mixer_mute_changed_event_to_frontends(self, send): + self.core.mixer.set_mute(mute=True) + self.assertEqual(send.call_count, 0) diff --git a/tests/dummy_mixer.py b/tests/dummy_mixer.py index f7d90b17..6defddba 100644 --- a/tests/dummy_mixer.py +++ b/tests/dummy_mixer.py @@ -21,9 +21,13 @@ class DummyMixer(pykka.ThreadingActor, mixer.Mixer): def set_volume(self, volume): self._volume = volume + self.trigger_volume_changed(volume=volume) + return True def get_mute(self): return self._mute def set_mute(self, mute): self._mute = mute + self.trigger_mute_changed(mute=mute) + return True diff --git a/tests/local/test_library.py b/tests/local/test_library.py index 6cc1992e..13ad9405 100644 --- a/tests/local/test_library.py +++ b/tests/local/test_library.py @@ -11,7 +11,7 @@ import pykka from mopidy import core from mopidy.local import actor, json -from mopidy.models import Album, Artist, Track +from mopidy.models import Album, Artist, Image, Track from tests import path_to_data_dir @@ -580,3 +580,30 @@ class LocalLibraryProviderTest(unittest.TestCase): with self.assertRaises(LookupError): self.library.search(any=['']) + + def test_default_get_images_impl_no_images(self): + result = self.library.get_images([track.uri for track in self.tracks]) + self.assertEqual(result, {track.uri: tuple() for track in self.tracks}) + + @mock.patch.object(json.JsonLibrary, 'lookup') + def test_default_get_images_impl_album_images(self, mock_lookup): + library = actor.LocalBackend(config=self.config, audio=None).library + + image = Image(uri='imageuri') + album = Album(images=[image.uri]) + track = Track(uri='trackuri', album=album) + mock_lookup.return_value = [track] + + result = library.get_images([track.uri]) + self.assertEqual(result, {track.uri: [image]}) + + @mock.patch.object(json.JsonLibrary, 'get_images') + def test_local_library_get_images(self, mock_get_images): + library = actor.LocalBackend(config=self.config, audio=None).library + + image = Image(uri='imageuri') + track = Track(uri='trackuri') + mock_get_images.return_value = {track.uri: [image]} + + result = library.get_images([track.uri]) + self.assertEqual(result, {track.uri: [image]}) diff --git a/tests/mpd/protocol/__init__.py b/tests/mpd/protocol/__init__.py index b07a5ba3..88e3567b 100644 --- a/tests/mpd/protocol/__init__.py +++ b/tests/mpd/protocol/__init__.py @@ -25,6 +25,8 @@ class MockConnection(mock.Mock): class BaseTestCase(unittest.TestCase): + enable_mixer = True + def get_config(self): return { 'mpd': { @@ -33,7 +35,10 @@ class BaseTestCase(unittest.TestCase): } def setUp(self): # noqa: N802 - self.mixer = dummy_mixer.create_proxy() + if self.enable_mixer: + self.mixer = dummy_mixer.create_proxy() + else: + self.mixer = None self.backend = dummy_backend.create_proxy() self.core = core.Core.start( mixer=self.mixer, backends=[self.backend]).proxy() diff --git a/tests/mpd/protocol/test_audio_output.py b/tests/mpd/protocol/test_audio_output.py index a86f24f0..b42b4c56 100644 --- a/tests/mpd/protocol/test_audio_output.py +++ b/tests/mpd/protocol/test_audio_output.py @@ -4,6 +4,7 @@ from tests.mpd import protocol class AudioOutputHandlerTest(protocol.BaseTestCase): + def test_enableoutput(self): self.core.mixer.set_mute(False) @@ -50,3 +51,97 @@ class AudioOutputHandlerTest(protocol.BaseTestCase): self.assertInResponse('outputname: Mute') self.assertInResponse('outputenabled: 1') self.assertInResponse('OK') + + def test_outputs_toggleoutput(self): + self.core.mixer.set_mute(False) + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 1') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 1') + self.assertInResponse('OK') + + def test_outputs_toggleoutput_unknown_outputid(self): + self.send_request('toggleoutput "7"') + + self.assertInResponse( + 'ACK [50@0] {toggleoutput} No such audio output') + + +class AudioOutputHandlerNoneMixerTest(protocol.BaseTestCase): + enable_mixer = False + + def test_enableoutput(self): + self.assertEqual(self.core.mixer.get_mute().get(), None) + + self.send_request('enableoutput "0"') + self.assertInResponse( + 'ACK [52@0] {enableoutput} problems enabling output') + + self.assertEqual(self.core.mixer.get_mute().get(), None) + + def test_disableoutput(self): + self.assertEqual(self.core.mixer.get_mute().get(), None) + + self.send_request('disableoutput "0"') + self.assertInResponse( + 'ACK [52@0] {disableoutput} problems disabling output') + + self.assertEqual(self.core.mixer.get_mute().get(), None) + + def test_outputs_when_unmuted(self): + self.core.mixer.set_mute(False) + + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + def test_outputs_when_muted(self): + self.core.mixer.set_mute(True) + + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + def test_outputs_toggleoutput(self): + self.core.mixer.set_mute(False) + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') + + self.send_request('toggleoutput "0"') + self.send_request('outputs') + + self.assertInResponse('outputid: 0') + self.assertInResponse('outputname: Mute') + self.assertInResponse('outputenabled: 0') + self.assertInResponse('OK') diff --git a/tests/mpd/protocol/test_idle.py b/tests/mpd/protocol/test_idle.py index 0bd16992..e3c6ad38 100644 --- a/tests/mpd/protocol/test_idle.py +++ b/tests/mpd/protocol/test_idle.py @@ -50,6 +50,12 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertNoEvents() self.assertNoResponse() + def test_idle_output(self): + self.send_request('idle output') + self.assertEqualSubscriptions(['output']) + self.assertNoEvents() + self.assertNoResponse() + def test_idle_player_playlist(self): self.send_request('idle player playlist') self.assertEqualSubscriptions(['player', 'playlist']) @@ -102,6 +108,22 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertOnceInResponse('changed: player') self.assertOnceInResponse('OK') + def test_idle_then_output(self): + self.send_request('idle') + self.idle_event('output') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') + + def test_idle_output_then_event_output(self): + self.send_request('idle output') + self.idle_event('output') + self.assertNoSubscriptions() + self.assertNoEvents() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') + def test_idle_player_then_noidle(self): self.send_request('idle player') self.send_request('noidle') @@ -206,3 +228,11 @@ class IdleHandlerTest(protocol.BaseTestCase): self.assertNotInResponse('changed: player') self.assertOnceInResponse('changed: playlist') self.assertOnceInResponse('OK') + + def test_output_then_idle_toggleoutput(self): + self.idle_event('output') + self.send_request('idle output') + self.assertNoEvents() + self.assertNoSubscriptions() + self.assertOnceInResponse('changed: output') + self.assertOnceInResponse('OK') diff --git a/tests/mpd/protocol/test_playback.py b/tests/mpd/protocol/test_playback.py index ea9c59ce..4f3d6d7a 100644 --- a/tests/mpd/protocol/test_playback.py +++ b/tests/mpd/protocol/test_playback.py @@ -150,6 +150,14 @@ class PlaybackOptionsHandlerTest(protocol.BaseTestCase): self.assertInResponse('OK') self.assertInResponse('off') + def test_mixrampdb(self): + self.send_request('mixrampdb "10"') + self.assertInResponse('ACK [0@0] {mixrampdb} Not implemented') + + def test_mixrampdelay(self): + self.send_request('mixrampdelay "10"') + self.assertInResponse('ACK [0@0] {mixrampdelay} Not implemented') + @unittest.SkipTest def test_replay_gain_status_off(self): pass @@ -463,3 +471,11 @@ class PlaybackControlHandlerTest(protocol.BaseTestCase): self.send_request('stop') self.assertEqual(STOPPED, self.core.playback.state.get()) self.assertInResponse('OK') + + +class PlaybackOptionsHandlerNoneMixerTest(protocol.BaseTestCase): + enable_mixer = False + + def test_setvol_max_error(self): + self.send_request('setvol "100"') + self.assertInResponse('ACK [52@0] {setvol} problems setting volume') diff --git a/tests/mpd/test_exceptions.py b/tests/mpd/test_exceptions.py index d055ef7e..7bb64096 100644 --- a/tests/mpd/test_exceptions.py +++ b/tests/mpd/test_exceptions.py @@ -3,8 +3,8 @@ from __future__ import absolute_import, unicode_literals import unittest from mopidy.mpd.exceptions import ( - MpdAckError, MpdNoCommand, MpdNotImplemented, MpdPermissionError, - MpdSystemError, MpdUnknownCommand) + MpdAckError, MpdNoCommand, MpdNoExistError, MpdNotImplemented, + MpdPermissionError, MpdSystemError, MpdUnknownCommand) class MpdExceptionsTest(unittest.TestCase): @@ -61,3 +61,11 @@ class MpdExceptionsTest(unittest.TestCase): self.assertEqual( e.get_mpd_ack(), 'ACK [4@0] {foo} you don\'t have permission for "foo"') + + def test_mpd_noexist_error(self): + try: + raise MpdNoExistError(command='foo') + except MpdNoExistError as e: + self.assertEqual( + e.get_mpd_ack(), + 'ACK [50@0] {foo} ')