Merge branch 'develop' into feature/stream-reference

Conflicts:
	docs/changelog.rst
This commit is contained in:
Thomas Adamcik 2015-03-14 00:28:51 +01:00
commit 67f9bd73bf
24 changed files with 489 additions and 122 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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 <https://github.com/mopidy/mopidy-alsamixer>`_.

BIN
docs/ext/mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -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

View File

@ -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)

View File

@ -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]

View File

@ -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()

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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')

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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

View File

@ -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]})

View File

@ -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()

View File

@ -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')

View File

@ -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')

View File

@ -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')

View File

@ -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} ')