Merge branch 'develop' into feature/stream-reference
Conflicts: docs/changelog.rst
This commit is contained in:
commit
67f9bd73bf
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
BIN
docs/ext/mobile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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} ')
|
||||
|
||||
Loading…
Reference in New Issue
Block a user