Release v1.0.1
This commit is contained in:
commit
b0000404f5
@ -5,6 +5,35 @@ Changelog
|
||||
This changelog is used to track all major changes to Mopidy.
|
||||
|
||||
|
||||
v1.0.1 (2015-04-23)
|
||||
===================
|
||||
|
||||
Bug fix release.
|
||||
|
||||
- Core: Make the new history controller available for use. (Fixes: :js:`6`)
|
||||
|
||||
- Audio: Software volume control has been reworked to greatly reduce the delay
|
||||
between changing the volume and the change taking effect. (Fixes:
|
||||
:issue:`1097`, PR: :issue:`1101`)
|
||||
|
||||
- Audio: As a side effect of the previous bug fix, software volume is no longer
|
||||
tied to the PulseAudio application volume when using ``pulsesink``. This
|
||||
behavior was confusing for many users and doesn't work well with the plans
|
||||
for multiple outputs.
|
||||
|
||||
- Audio: Update scanner to decode all media it finds. This should fix cases
|
||||
where the scanner hangs on non-audio files like video. The scanner will now
|
||||
also let us know if we found any decodeable audio. (Fixes: :issue:`726`, PR:
|
||||
issue:`1124`)
|
||||
|
||||
- HTTP: Fix threading bug that would cause duplicate delivery of WS messages.
|
||||
(PR: :issue:`1127`)
|
||||
|
||||
- MPD: Fix case where a playlist that is present in both browse and as a listed
|
||||
playlist breaks the MPD frontend protocol output. (Fixes :issue:`1120`, PR:
|
||||
:issue:`1142`)
|
||||
|
||||
|
||||
v1.0.0 (2015-03-25)
|
||||
===================
|
||||
|
||||
|
||||
@ -30,4 +30,4 @@ except ImportError:
|
||||
warnings.filterwarnings('ignore', 'could not open display')
|
||||
|
||||
|
||||
__version__ = '1.0.0'
|
||||
__version__ = '1.0.1'
|
||||
|
||||
@ -8,7 +8,7 @@ import gobject
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
import gst.pbutils
|
||||
import gst.pbutils # noqa
|
||||
|
||||
import pykka
|
||||
|
||||
@ -34,25 +34,6 @@ _GST_STATE_MAPPING = {
|
||||
gst.STATE_PAUSED: PlaybackState.PAUSED,
|
||||
gst.STATE_NULL: PlaybackState.STOPPED}
|
||||
|
||||
MB = 1 << 20
|
||||
|
||||
# GST_PLAY_FLAG_VIDEO (1<<0)
|
||||
# GST_PLAY_FLAG_AUDIO (1<<1)
|
||||
# GST_PLAY_FLAG_TEXT (1<<2)
|
||||
# GST_PLAY_FLAG_VIS (1<<3)
|
||||
# GST_PLAY_FLAG_SOFT_VOLUME (1<<4)
|
||||
# GST_PLAY_FLAG_NATIVE_AUDIO (1<<5)
|
||||
# GST_PLAY_FLAG_NATIVE_VIDEO (1<<6)
|
||||
# GST_PLAY_FLAG_DOWNLOAD (1<<7)
|
||||
# GST_PLAY_FLAG_BUFFERING (1<<8)
|
||||
# GST_PLAY_FLAG_DEINTERLACE (1<<9)
|
||||
# GST_PLAY_FLAG_SOFT_COLORBALANCE (1<<10)
|
||||
|
||||
# Default flags to use for playbin: AUDIO, SOFT_VOLUME
|
||||
# TODO: consider removing soft volume when we do multi outputs and handling it
|
||||
# ourselves.
|
||||
PLAYBIN_FLAGS = (1 << 1) | (1 << 4)
|
||||
|
||||
|
||||
class _Signals(object):
|
||||
"""Helper for tracking gobject signal registrations"""
|
||||
@ -114,7 +95,7 @@ class _Appsrc(object):
|
||||
source.set_property('caps', self._caps)
|
||||
source.set_property('format', b'time')
|
||||
source.set_property('stream-type', b'seekable')
|
||||
source.set_property('max-bytes', 1 * MB)
|
||||
source.set_property('max-bytes', 1 << 20) # 1MB
|
||||
source.set_property('min-percent', 50)
|
||||
|
||||
if self._need_data_callback:
|
||||
@ -152,26 +133,12 @@ class _Appsrc(object):
|
||||
# TODO: expose this as a property on audio when #790 gets further along.
|
||||
class _Outputs(gst.Bin):
|
||||
def __init__(self):
|
||||
gst.Bin.__init__(self)
|
||||
gst.Bin.__init__(self, 'outputs')
|
||||
|
||||
self._tee = gst.element_factory_make('tee')
|
||||
self.add(self._tee)
|
||||
|
||||
# Queue element to buy us time between the about to finish event and
|
||||
# the actual switch, i.e. about to switch can block for longer thanks
|
||||
# to this queue.
|
||||
# TODO: make the min-max values a setting?
|
||||
# TODO: this does not belong in this class.
|
||||
queue = gst.element_factory_make('queue')
|
||||
queue.set_property('max-size-buffers', 0)
|
||||
queue.set_property('max-size-bytes', 0)
|
||||
queue.set_property('max-size-time', 5 * gst.SECOND)
|
||||
queue.set_property('min-threshold-time', 3 * gst.SECOND)
|
||||
self.add(queue)
|
||||
|
||||
queue.link(self._tee)
|
||||
|
||||
ghost_pad = gst.GhostPad('sink', queue.get_pad('sink'))
|
||||
ghost_pad = gst.GhostPad('sink', self._tee.get_pad('sink'))
|
||||
self.add_pad(ghost_pad)
|
||||
|
||||
# Add an always connected fakesink which respects the clock so the tee
|
||||
@ -195,7 +162,9 @@ class _Outputs(gst.Bin):
|
||||
|
||||
def _add(self, element):
|
||||
# All tee branches need a queue in front of them.
|
||||
# But keep the queue short so the volume change isn't to slow:
|
||||
queue = gst.element_factory_make('queue')
|
||||
queue.set_property('max-size-buffers', 5)
|
||||
self.add(element)
|
||||
self.add(queue)
|
||||
queue.link(element)
|
||||
@ -214,10 +183,6 @@ class SoftwareMixer(object):
|
||||
|
||||
def setup(self, element, mixer_ref):
|
||||
self._element = element
|
||||
|
||||
self._signals.connect(element, 'notify::volume', self._volume_changed)
|
||||
self._signals.connect(element, 'notify::mute', self._mute_changed)
|
||||
|
||||
self._mixer.setup(mixer_ref)
|
||||
|
||||
def teardown(self):
|
||||
@ -229,24 +194,16 @@ class SoftwareMixer(object):
|
||||
|
||||
def set_volume(self, volume):
|
||||
self._element.set_property('volume', volume / 100.0)
|
||||
self._mixer.trigger_volume_changed(volume)
|
||||
|
||||
def get_mute(self):
|
||||
return self._element.get_property('mute')
|
||||
|
||||
def set_mute(self, mute):
|
||||
return self._element.set_property('mute', bool(mute))
|
||||
|
||||
def _volume_changed(self, element, property_):
|
||||
old_volume, self._last_volume = self._last_volume, self.get_volume()
|
||||
if old_volume != self._last_volume:
|
||||
gst_logger.debug('Notify volume: %s', self._last_volume / 100.0)
|
||||
self._mixer.trigger_volume_changed(self._last_volume)
|
||||
|
||||
def _mute_changed(self, element, property_):
|
||||
old_mute, self._last_mute = self._last_mute, self.get_mute()
|
||||
if old_mute != self._last_mute:
|
||||
gst_logger.debug('Notify mute: %s', self._last_mute)
|
||||
self._mixer.trigger_mute_changed(self._last_mute)
|
||||
result = self._element.set_property('mute', bool(mute))
|
||||
if result:
|
||||
self._mixer.trigger_mute_changed(bool(mute))
|
||||
return result
|
||||
|
||||
|
||||
class _Handler(object):
|
||||
@ -451,8 +408,8 @@ class Audio(pykka.ThreadingActor):
|
||||
try:
|
||||
self._setup_preferences()
|
||||
self._setup_playbin()
|
||||
self._setup_output()
|
||||
self._setup_mixer()
|
||||
self._setup_outputs()
|
||||
self._setup_audio_sink()
|
||||
except gobject.GError as ex:
|
||||
logger.exception(ex)
|
||||
process.exit_process()
|
||||
@ -472,11 +429,11 @@ class Audio(pykka.ThreadingActor):
|
||||
|
||||
def _setup_playbin(self):
|
||||
playbin = gst.element_factory_make('playbin2')
|
||||
playbin.set_property('flags', PLAYBIN_FLAGS)
|
||||
playbin.set_property('flags', 2) # GST_PLAY_FLAG_AUDIO
|
||||
|
||||
# TODO: turn into config values...
|
||||
playbin.set_property('buffer-size', 2 * 1024 * 1024)
|
||||
playbin.set_property('buffer-duration', 2 * gst.SECOND)
|
||||
playbin.set_property('buffer-size', 5 << 20) # 5MB
|
||||
playbin.set_property('buffer-duration', 5 * gst.SECOND)
|
||||
|
||||
self._signals.connect(playbin, 'source-setup', self._on_source_setup)
|
||||
self._signals.connect(playbin, 'about-to-finish',
|
||||
@ -492,7 +449,7 @@ class Audio(pykka.ThreadingActor):
|
||||
self._signals.disconnect(self._playbin, 'source-setup')
|
||||
self._playbin.set_state(gst.STATE_NULL)
|
||||
|
||||
def _setup_output(self):
|
||||
def _setup_outputs(self):
|
||||
# We don't want to use outputs for regular testing, so just install
|
||||
# an unsynced fakesink when someone asks for a 'testoutput'.
|
||||
if self._config['audio']['output'] == 'testoutput':
|
||||
@ -505,11 +462,36 @@ class Audio(pykka.ThreadingActor):
|
||||
process.exit_process() # TODO: move this up the chain
|
||||
|
||||
self._handler.setup_event_handling(self._outputs.get_pad('sink'))
|
||||
self._playbin.set_property('audio-sink', self._outputs)
|
||||
|
||||
def _setup_mixer(self):
|
||||
def _setup_audio_sink(self):
|
||||
audio_sink = gst.Bin('audio-sink')
|
||||
|
||||
# Queue element to buy us time between the about to finish event and
|
||||
# the actual switch, i.e. about to switch can block for longer thanks
|
||||
# to this queue.
|
||||
# TODO: make the min-max values a setting?
|
||||
queue = gst.element_factory_make('queue')
|
||||
queue.set_property('max-size-buffers', 0)
|
||||
queue.set_property('max-size-bytes', 0)
|
||||
queue.set_property('max-size-time', 3 * gst.SECOND)
|
||||
queue.set_property('min-threshold-time', 1 * gst.SECOND)
|
||||
|
||||
audio_sink.add(queue)
|
||||
audio_sink.add(self._outputs)
|
||||
|
||||
if self.mixer:
|
||||
self.mixer.setup(self._playbin, self.actor_ref.proxy().mixer)
|
||||
volume = gst.element_factory_make('volume')
|
||||
audio_sink.add(volume)
|
||||
queue.link(volume)
|
||||
volume.link(self._outputs)
|
||||
self.mixer.setup(volume, self.actor_ref.proxy().mixer)
|
||||
else:
|
||||
queue.link(self._outputs)
|
||||
|
||||
ghost_pad = gst.GhostPad('sink', queue.get_pad('sink'))
|
||||
audio_sink.add_pad(ghost_pad)
|
||||
|
||||
self._playbin.set_property('audio-sink', audio_sink)
|
||||
|
||||
def _teardown_mixer(self):
|
||||
if self.mixer:
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
from __future__ import absolute_import, division, unicode_literals
|
||||
from __future__ import (
|
||||
absolute_import, division, print_function, unicode_literals)
|
||||
|
||||
import collections
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
import gst.pbutils
|
||||
import gst.pbutils # noqa
|
||||
|
||||
from mopidy import exceptions
|
||||
from mopidy.audio import utils
|
||||
@ -14,7 +15,7 @@ from mopidy.utils import encoding
|
||||
_missing_plugin_desc = gst.pbutils.missing_plugin_message_get_description
|
||||
|
||||
_Result = collections.namedtuple(
|
||||
'Result', ('uri', 'tags', 'duration', 'seekable', 'mime'))
|
||||
'Result', ('uri', 'tags', 'duration', 'seekable', 'mime', 'playable'))
|
||||
|
||||
_RAW_AUDIO = gst.Caps(b'audio/x-raw-int; audio/x-raw-float')
|
||||
|
||||
@ -51,14 +52,14 @@ class Scanner(object):
|
||||
|
||||
try:
|
||||
_start_pipeline(pipeline)
|
||||
tags, mime = _process(pipeline, self._timeout_ms)
|
||||
tags, mime, have_audio = _process(pipeline, self._timeout_ms)
|
||||
duration = _query_duration(pipeline)
|
||||
seekable = _query_seekable(pipeline)
|
||||
finally:
|
||||
pipeline.set_state(gst.STATE_NULL)
|
||||
del pipeline
|
||||
|
||||
return _Result(uri, tags, duration, seekable, mime)
|
||||
return _Result(uri, tags, duration, seekable, mime, have_audio)
|
||||
|
||||
|
||||
# Turns out it's _much_ faster to just create a new pipeline for every as
|
||||
@ -70,30 +71,38 @@ def _setup_pipeline(uri, proxy_config=None):
|
||||
|
||||
typefind = gst.element_factory_make('typefind')
|
||||
decodebin = gst.element_factory_make('decodebin2')
|
||||
sink = gst.element_factory_make('fakesink')
|
||||
|
||||
pipeline = gst.element_factory_make('pipeline')
|
||||
pipeline.add_many(src, typefind, decodebin, sink)
|
||||
pipeline.add_many(src, typefind, decodebin)
|
||||
gst.element_link_many(src, typefind, decodebin)
|
||||
|
||||
if proxy_config:
|
||||
utils.setup_proxy(src, proxy_config)
|
||||
|
||||
decodebin.set_property('caps', _RAW_AUDIO)
|
||||
decodebin.connect('pad-added', _pad_added, sink)
|
||||
typefind.connect('have-type', _have_type, decodebin)
|
||||
decodebin.connect('pad-added', _pad_added, pipeline)
|
||||
|
||||
return pipeline
|
||||
|
||||
|
||||
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)
|
||||
struct = gst.Structure('have-type')
|
||||
struct['caps'] = caps.get_structure(0)
|
||||
element.get_bus().post(gst.message_new_application(element, struct))
|
||||
|
||||
|
||||
def _pad_added(element, pad, sink):
|
||||
return pad.link(sink.get_pad('sink'))
|
||||
def _pad_added(element, pad, pipeline):
|
||||
sink = gst.element_factory_make('fakesink')
|
||||
sink.set_property('sync', False)
|
||||
|
||||
pipeline.add(sink)
|
||||
sink.sync_state_with_parent()
|
||||
pad.link(sink.get_pad('sink'))
|
||||
|
||||
if pad.get_caps().is_subset(_RAW_AUDIO):
|
||||
struct = gst.Structure('have-audio')
|
||||
element.get_bus().post(gst.message_new_application(element, struct))
|
||||
|
||||
|
||||
def _start_pipeline(pipeline):
|
||||
@ -123,7 +132,7 @@ def _process(pipeline, timeout_ms):
|
||||
clock = pipeline.get_clock()
|
||||
bus = pipeline.get_bus()
|
||||
timeout = timeout_ms * gst.MSECOND
|
||||
tags, mime, missing_description = {}, None, None
|
||||
tags, mime, have_audio, missing_description = {}, None, False, None
|
||||
|
||||
types = (gst.MESSAGE_ELEMENT | gst.MESSAGE_APPLICATION | gst.MESSAGE_ERROR
|
||||
| gst.MESSAGE_EOS | gst.MESSAGE_ASYNC_DONE | gst.MESSAGE_TAG)
|
||||
@ -139,19 +148,22 @@ def _process(pipeline, timeout_ms):
|
||||
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
|
||||
if message.structure.get_name() == 'have-type':
|
||||
mime = message.structure['caps'].get_name()
|
||||
if mime.startswith('text/') or mime == 'application/xml':
|
||||
return tags, mime, have_audio
|
||||
elif message.structure.get_name() == 'have-audio':
|
||||
have_audio = True
|
||||
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
|
||||
return tags, mime, have_audio
|
||||
elif message.type == gst.MESSAGE_ASYNC_DONE:
|
||||
if message.src == pipeline:
|
||||
return tags, mime
|
||||
return tags, mime, have_audio
|
||||
elif message.type == gst.MESSAGE_TAG:
|
||||
taglist = message.parse_tag()
|
||||
# Note that this will only keep the last tag.
|
||||
@ -160,3 +172,28 @@ def _process(pipeline, timeout_ms):
|
||||
timeout -= clock.get_time() - start
|
||||
|
||||
raise exceptions.ScannerError('Timeout after %dms' % timeout_ms)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
import sys
|
||||
|
||||
import gobject
|
||||
|
||||
from mopidy.utils import path
|
||||
|
||||
gobject.threads_init()
|
||||
|
||||
scanner = Scanner(5000)
|
||||
for uri in sys.argv[1:]:
|
||||
if not gst.uri_is_valid(uri):
|
||||
uri = path.path_to_uri(os.path.abspath(uri))
|
||||
try:
|
||||
result = scanner.scan(uri)
|
||||
for key in ('uri', 'mime', 'duration', 'playable', 'seekable'):
|
||||
print('%-20s %s' % (key, getattr(result, key)))
|
||||
print('tags')
|
||||
for tag, value in result.tags.items():
|
||||
print('%-20s %s' % (tag, value))
|
||||
except exceptions.ScannerError as error:
|
||||
print('%s: %s' % (uri, error))
|
||||
|
||||
@ -11,6 +11,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HistoryController(object):
|
||||
pykka_traversable = True
|
||||
|
||||
def __init__(self):
|
||||
self._history = []
|
||||
|
||||
@ -5,6 +5,7 @@ import os
|
||||
import socket
|
||||
|
||||
import tornado.escape
|
||||
import tornado.ioloop
|
||||
import tornado.web
|
||||
import tornado.websocket
|
||||
|
||||
@ -65,6 +66,19 @@ def make_jsonrpc_wrapper(core_actor):
|
||||
)
|
||||
|
||||
|
||||
def _send_broadcast(client, msg):
|
||||
# We could check for client.ws_connection, but we don't really
|
||||
# care why the broadcast failed, we just want the rest of them
|
||||
# to succeed, so catch everything.
|
||||
try:
|
||||
client.write_message(msg)
|
||||
except Exception as e:
|
||||
error_msg = encoding.locale_decode(e)
|
||||
logger.debug('Broadcast of WebSocket message to %s failed: %s',
|
||||
client.request.remote_ip, error_msg)
|
||||
# TODO: should this do the same cleanup as the on_message code?
|
||||
|
||||
|
||||
class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
||||
|
||||
# XXX This set is shared by all WebSocketHandler objects. This isn't
|
||||
@ -74,17 +88,12 @@ class WebSocketHandler(tornado.websocket.WebSocketHandler):
|
||||
|
||||
@classmethod
|
||||
def broadcast(cls, msg):
|
||||
# This can be called from outside the Tornado ioloop, so we need to
|
||||
# safely cross the thread boundary by adding a callback to the loop.
|
||||
loop = tornado.ioloop.IOLoop.current()
|
||||
for client in cls.clients:
|
||||
# We could check for client.ws_connection, but we don't really
|
||||
# care why the broadcast failed, we just want the rest of them
|
||||
# to succeed, so catch everything.
|
||||
try:
|
||||
client.write_message(msg)
|
||||
except Exception as e:
|
||||
error_msg = encoding.locale_decode(e)
|
||||
logger.debug('Broadcast of WebSocket message to %s failed: %s',
|
||||
client.request.remote_ip, error_msg)
|
||||
# TODO: should this do the same cleanup as the on_message code?
|
||||
# One callback per client to keep time we hold up the loop short
|
||||
loop.add_callback(_send_broadcast, client, msg)
|
||||
|
||||
def initialize(self, core):
|
||||
self.jsonrpc = make_jsonrpc_wrapper(core)
|
||||
|
||||
@ -135,7 +135,9 @@ class ScanCommand(commands.Command):
|
||||
file_uri = path.path_to_uri(os.path.join(media_dir, relpath))
|
||||
result = scanner.scan(file_uri)
|
||||
tags, duration = result.tags, result.duration
|
||||
if duration < MIN_DURATION_MS:
|
||||
if not result.playable:
|
||||
logger.warning('Failed %s: No audio found in file.', uri)
|
||||
elif duration < MIN_DURATION_MS:
|
||||
logger.warning('Failed %s: Track shorter than %dms',
|
||||
uri, MIN_DURATION_MS)
|
||||
else:
|
||||
|
||||
@ -2,8 +2,12 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import re
|
||||
|
||||
# TOOD: refactor this into a generic mapper that does not know about browse
|
||||
# or playlists and then use one instance for each case?
|
||||
|
||||
|
||||
class MpdUriMapper(object):
|
||||
|
||||
"""
|
||||
Maintains the mappings between uniquified MPD names and URIs.
|
||||
"""
|
||||
@ -17,7 +21,8 @@ class MpdUriMapper(object):
|
||||
def __init__(self, core=None):
|
||||
self.core = core
|
||||
self._uri_from_name = {}
|
||||
self._name_from_uri = {}
|
||||
self._browse_name_from_uri = {}
|
||||
self._playlist_name_from_uri = {}
|
||||
|
||||
def _create_unique_name(self, name, uri):
|
||||
stripped_name = self._invalid_browse_chars.sub(' ', name)
|
||||
@ -30,33 +35,37 @@ class MpdUriMapper(object):
|
||||
i += 1
|
||||
return name
|
||||
|
||||
def insert(self, name, uri):
|
||||
def insert(self, name, uri, playlist=False):
|
||||
"""
|
||||
Create a unique and MPD compatible name that maps to the given URI.
|
||||
"""
|
||||
name = self._create_unique_name(name, uri)
|
||||
self._uri_from_name[name] = uri
|
||||
self._name_from_uri[uri] = name
|
||||
if playlist:
|
||||
self._playlist_name_from_uri[uri] = name
|
||||
else:
|
||||
self._browse_name_from_uri[uri] = name
|
||||
return name
|
||||
|
||||
def uri_from_name(self, name):
|
||||
"""
|
||||
Return the uri for the given MPD name.
|
||||
"""
|
||||
if name in self._uri_from_name:
|
||||
return self._uri_from_name[name]
|
||||
return self._uri_from_name.get(name)
|
||||
|
||||
def refresh_playlists_mapping(self):
|
||||
"""
|
||||
Maintain map between playlists and unique playlist names to be used by
|
||||
MPD.
|
||||
"""
|
||||
if self.core is not None:
|
||||
for playlist_ref in self.core.playlists.as_list().get():
|
||||
if not playlist_ref.name:
|
||||
continue
|
||||
name = self._invalid_playlist_chars.sub('|', playlist_ref.name)
|
||||
self.insert(name, playlist_ref.uri)
|
||||
if self.core is None:
|
||||
return
|
||||
|
||||
for playlist_ref in self.core.playlists.as_list().get():
|
||||
if not playlist_ref.name:
|
||||
continue
|
||||
name = self._invalid_playlist_chars.sub('|', playlist_ref.name)
|
||||
self.insert(name, playlist_ref.uri, playlist=True)
|
||||
|
||||
def playlist_uri_from_name(self, name):
|
||||
"""
|
||||
@ -70,6 +79,6 @@ class MpdUriMapper(object):
|
||||
"""
|
||||
Helper function to retrieve the unique MPD playlist name from its URI.
|
||||
"""
|
||||
if uri not in self._name_from_uri:
|
||||
if uri not in self._playlist_name_from_uri:
|
||||
self.refresh_playlists_mapping()
|
||||
return self._name_from_uri[uri]
|
||||
return self._playlist_name_from_uri[uri]
|
||||
|
||||
@ -5,12 +5,12 @@ import os
|
||||
import platform
|
||||
import sys
|
||||
|
||||
import pkg_resources
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
|
||||
import pkg_resources
|
||||
|
||||
from mopidy.utils import formatting
|
||||
|
||||
|
||||
|
||||
@ -6,12 +6,12 @@ import unittest
|
||||
import gobject
|
||||
gobject.threads_init()
|
||||
|
||||
import mock
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
|
||||
import mock
|
||||
|
||||
import pykka
|
||||
|
||||
from mopidy import audio
|
||||
|
||||
@ -16,8 +16,7 @@ from tests import path_to_data_dir
|
||||
class ScannerTest(unittest.TestCase):
|
||||
def setUp(self): # noqa: N802
|
||||
self.errors = {}
|
||||
self.tags = {}
|
||||
self.durations = {}
|
||||
self.result = {}
|
||||
|
||||
def find(self, path):
|
||||
media_dir = path_to_data_dir(path)
|
||||
@ -31,19 +30,17 @@ class ScannerTest(unittest.TestCase):
|
||||
uri = path_lib.path_to_uri(path)
|
||||
key = uri[len('file://'):]
|
||||
try:
|
||||
result = scanner.scan(uri)
|
||||
self.tags[key] = result.tags
|
||||
self.durations[key] = result.duration
|
||||
self.result[key] = scanner.scan(uri)
|
||||
except exceptions.ScannerError as error:
|
||||
self.errors[key] = error
|
||||
|
||||
def check(self, name, key, value):
|
||||
name = path_to_data_dir(name)
|
||||
self.assertEqual(self.tags[name][key], value)
|
||||
self.assertEqual(self.result[name].tags[key], value)
|
||||
|
||||
def test_tags_is_set(self):
|
||||
self.scan(self.find('scanner/simple'))
|
||||
self.assert_(self.tags)
|
||||
self.assert_(self.result.values()[0].tags)
|
||||
|
||||
def test_errors_is_not_set(self):
|
||||
self.scan(self.find('scanner/simple'))
|
||||
@ -52,10 +49,10 @@ class ScannerTest(unittest.TestCase):
|
||||
def test_duration_is_set(self):
|
||||
self.scan(self.find('scanner/simple'))
|
||||
|
||||
self.assertEqual(
|
||||
self.durations[path_to_data_dir('scanner/simple/song1.mp3')], 4680)
|
||||
self.assertEqual(
|
||||
self.durations[path_to_data_dir('scanner/simple/song1.ogg')], 4680)
|
||||
ogg = path_to_data_dir('scanner/simple/song1.ogg')
|
||||
mp3 = path_to_data_dir('scanner/simple/song1.mp3')
|
||||
self.assertEqual(self.result[mp3].duration, 4680)
|
||||
self.assertEqual(self.result[ogg].duration, 4680)
|
||||
|
||||
def test_artist_is_set(self):
|
||||
self.scan(self.find('scanner/simple'))
|
||||
@ -78,17 +75,17 @@ class ScannerTest(unittest.TestCase):
|
||||
|
||||
def test_other_media_is_ignored(self):
|
||||
self.scan(self.find('scanner/image'))
|
||||
self.assert_(self.errors)
|
||||
self.assertFalse(self.result.values()[0].playable)
|
||||
|
||||
def test_log_file_that_gst_thinks_is_mpeg_1_is_ignored(self):
|
||||
self.scan([path_to_data_dir('scanner/example.log')])
|
||||
self.assertLess(
|
||||
self.durations[path_to_data_dir('scanner/example.log')], 100)
|
||||
log = path_to_data_dir('scanner/example.log')
|
||||
self.assertLess(self.result[log].duration, 100)
|
||||
|
||||
def test_empty_wav_file(self):
|
||||
self.scan([path_to_data_dir('scanner/empty.wav')])
|
||||
self.assertEqual(
|
||||
self.durations[path_to_data_dir('scanner/empty.wav')], 0)
|
||||
wav = path_to_data_dir('scanner/empty.wav')
|
||||
self.assertEqual(self.result[wav].duration, 0)
|
||||
|
||||
@unittest.SkipTest
|
||||
def test_song_without_time_is_handeled(self):
|
||||
|
||||
@ -2,7 +2,7 @@ from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import random
|
||||
|
||||
from mopidy.models import Track
|
||||
from mopidy.models import Playlist, Ref, Track
|
||||
|
||||
from tests.mpd import protocol
|
||||
|
||||
@ -175,3 +175,33 @@ class IssueGH137RegressionTest(protocol.BaseTestCase):
|
||||
u'Album "This Is Remixed Hits - Mashups & Rare 12" Mixes"')
|
||||
|
||||
self.assertInResponse('ACK [2@0] {list} Invalid unquoted character')
|
||||
|
||||
|
||||
class IssueGH1120RegressionTest(protocol.BaseTestCase):
|
||||
"""
|
||||
The issue: https://github.com/mopidy/mopidy/issues/1120
|
||||
|
||||
How to reproduce:
|
||||
|
||||
- A playlist must be in both browse results and playlists
|
||||
- Call for instance ``lsinfo "/"`` to populate the cache with the
|
||||
playlist name from the playlist backend.
|
||||
- Call ``lsinfo "/dummy"`` to override the playlist name with the browse
|
||||
name.
|
||||
- Call ``lsinfo "/"`` and we now have an invalid name with ``/`` in it.
|
||||
|
||||
"""
|
||||
|
||||
def test(self):
|
||||
self.backend.library.dummy_browse_result = {
|
||||
'dummy:/': [Ref.playlist(name='Top 100 tracks', uri='dummy:/1')],
|
||||
}
|
||||
self.backend.playlists.set_dummy_playlists([
|
||||
Playlist(name='Top 100 tracks', uri='dummy:/1'),
|
||||
])
|
||||
|
||||
response1 = self.send_request('lsinfo "/"')
|
||||
self.send_request('lsinfo "/dummy"')
|
||||
|
||||
response2 = self.send_request('lsinfo "/"')
|
||||
self.assertEqual(response1, response2)
|
||||
|
||||
@ -5,12 +5,12 @@ import unittest
|
||||
import gobject
|
||||
gobject.threads_init()
|
||||
|
||||
import mock
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa: pygst magic is needed to import correct gst
|
||||
|
||||
import mock
|
||||
|
||||
from mopidy.models import Track
|
||||
from mopidy.stream import actor
|
||||
from mopidy.utils.path import path_to_uri
|
||||
|
||||
@ -55,5 +55,6 @@ class VersionTest(unittest.TestCase):
|
||||
self.assertVersionLess('0.19.2', '0.19.3')
|
||||
self.assertVersionLess('0.19.3', '0.19.4')
|
||||
self.assertVersionLess('0.19.4', '0.19.5')
|
||||
self.assertVersionLess('0.19.5', __version__)
|
||||
self.assertVersionLess(__version__, '1.0.1')
|
||||
self.assertVersionLess('0.19.5', '1.0.0')
|
||||
self.assertVersionLess('1.0.0', __version__)
|
||||
self.assertVersionLess(__version__, '1.0.2')
|
||||
|
||||
@ -6,12 +6,12 @@ import unittest
|
||||
|
||||
import mock
|
||||
|
||||
import pkg_resources
|
||||
|
||||
import pygst
|
||||
pygst.require('0.10')
|
||||
import gst # noqa
|
||||
|
||||
import pkg_resources
|
||||
|
||||
from mopidy.utils import deps
|
||||
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user