Release v2.0.1

This commit is contained in:
Stein Magnus Jodal 2016-08-12 20:56:57 +02:00
commit 7018c03e30
21 changed files with 420 additions and 120 deletions

View File

@ -61,10 +61,6 @@ To get started with Mopidy, check out
:target: https://pypi.python.org/pypi/Mopidy/ :target: https://pypi.python.org/pypi/Mopidy/
:alt: Latest PyPI version :alt: Latest PyPI version
.. image:: https://img.shields.io/pypi/dm/Mopidy.svg?style=flat
:target: https://pypi.python.org/pypi/Mopidy/
:alt: Number of PyPI downloads
.. image:: https://img.shields.io/travis/mopidy/mopidy/develop.svg?style=flat .. image:: https://img.shields.io/travis/mopidy/mopidy/develop.svg?style=flat
:target: https://travis-ci.org/mopidy/mopidy :target: https://travis-ci.org/mopidy/mopidy
:alt: Travis CI build status :alt: Travis CI build status

View File

@ -5,6 +5,64 @@ Changelog
This changelog is used to track all major changes to Mopidy. This changelog is used to track all major changes to Mopidy.
v2.0.1 (2016-08-16)
===================
Bug fix release.
- Audio: Set ``soft-volume`` flag on GStreamer's playbin element. This is the
playbin's default, but we managed to override it when configuring the playbin
to only process audio. This should fix the "Volume/mute is not available"
warning.
- Audio: Fix buffer conversion. This fixes image extraction.
(Fixes: :issue:`1469`, PR: :issue:`1472`)
- Audio: Update scan logic to workaround GStreamer issue where tags and
duration might only be available after we start playing.
(Fixes: :issue:`935`, :issue:`1453`, :issue:`1474`, :issue:`1480`, PR:
:issue:`1487`)
- Audio: Better handling of seek when position does not match the expected
pending position. (Fixes: :issue:`1462`, :issue:`1505`, PR: :issue:`1496`)
- Audio: Handle bad date tags from audio, thanks to Mario Lang and Tom Parker
who fixed this in parallel. (Fixes: :issue:`1506`, PR: :issue:`1525`,
:issue:`1517`)
- Audio: Make sure scanner handles streams without a duration.
(Fixes: :issue:`1526`)
- Audio: Ensure audio tags are never ``None``. (Fixes: :issue:`1449`)
- Audio: Update :meth:`mopidy.audio.Audio.set_metadata` to postpone sending
tags if there is a pending track change. (Fixes: :issue:`1357`, PR:
:issue:`1538`)
- Core: Avoid endless loop if all tracks in the tracklist are unplayable and
consume mode is off. (Fixes: :issue:`1221`, :issue:`1454`, PR: :issue:`1455`)
- Core: Correctly record the last position of a track when switching to another
one. Particularly relevant for Mopidy-Scrobbler users, as before it was
essentially unusable. (Fixes: :issue:`1456`, PR: :issue:`1534`)
- Models: Fix encoding error if :class:`~mopidy.models.fields.Identifier`
fields, like the ``musicbrainz_id`` model fields, contained non-ASCII Unicode
data. (Fixes: :issue:`1508`, PR: :issue:`1546`)
- File: Ensure path comparison is done between bytestrings only. Fixes crash
where a :confval:`file/media_dirs` path contained non-ASCII characters.
(Fixes: :issue:`1345`, PR: :issue:`1493`)
- Stream: Fix milliseconds vs seconds mistake in timeout handling.
(Fixes: :issue:`1521`, PR: :issue:`1522`)
- Docs: Fix the rendering of :class:`mopidy.core.Core` and
:class:`mopidy.audio.Audio` docs. This should also contribute towards making
the Mopidy Debian package build bit-by-bit reproducible. (Fixes:
:issue:`1500`)
v2.0.0 (2016-02-15) v2.0.0 (2016-02-15)
=================== ===================
@ -343,7 +401,7 @@ Bug fix release.
proceed startup. (Fixes: :issue:`1248`, PR: :issue:`1254`) proceed startup. (Fixes: :issue:`1248`, PR: :issue:`1254`)
- Stream: Fix bug in new playlist parser. A non-ASCII char in an urilist - Stream: Fix bug in new playlist parser. A non-ASCII char in an urilist
comment would cause a crash while parsing due to comparision of a non-ASCII comment would cause a crash while parsing due to comparison of a non-ASCII
bytestring with a Unicode string. (Fixes: :issue:`1265`) bytestring with a Unicode string. (Fixes: :issue:`1265`)
- File: Adjust log levels when failing to expand ``$XDG_MUSIC_DIR`` into a real - File: Adjust log levels when failing to expand ``$XDG_MUSIC_DIR`` into a real
@ -2046,10 +2104,10 @@ A release with a number of small and medium fixes, with no specific focus.
- Converted from the optparse to the argparse library for handling command line - Converted from the optparse to the argparse library for handling command line
options. options.
- :option:`mopidy --show-config` will now take into consideration any - ``mopidy --show-config`` will now take into consideration any
:option:`mopidy --option` arguments appearing later on the command line. This :option:`mopidy --option` arguments appearing later on the command line. This
helps you see the effective configuration for runs with the same helps you see the effective configuration for runs with the same
:option:`mopidy --options` arguments. ``mopidy --options`` arguments.
**Audio** **Audio**
@ -2123,7 +2181,7 @@ v0.14.1 (2013-04-28)
==================== ====================
This release addresses an issue in v0.14.0 where the new This release addresses an issue in v0.14.0 where the new
:option:`mopidy-convert-config` tool and the new :option:`mopidy --option` ``mopidy-convert-config`` tool and the new :option:`mopidy --option`
command line option was broken because some string operations inadvertently command line option was broken because some string operations inadvertently
converted some byte strings to unicode. converted some byte strings to unicode.
@ -2153,7 +2211,7 @@ one new.
As part of this change we have cleaned up the naming of our config values. As part of this change we have cleaned up the naming of our config values.
To ease migration we've made a tool named :option:`mopidy-convert-config` for To ease migration we've made a tool named ``mopidy-convert-config`` for
automatically converting the old ``settings.py`` to a new ``mopidy.conf`` automatically converting the old ``settings.py`` to a new ``mopidy.conf``
file. This tool takes care of all the renamed config values as well. See file. This tool takes care of all the renamed config values as well. See
``mopidy-convert-config`` for details on how to use it. ``mopidy-convert-config`` for details on how to use it.
@ -2186,11 +2244,11 @@ one new.
**Command line options** **Command line options**
- The command option :option:`mopidy --list-settings` is now named - The command option ``mopidy --list-settings`` is now named
:option:`mopidy --show-config`. ``mopidy --show-config``.
- The command option :option:`mopidy --list-deps` is now named - The command option ``mopidy --list-deps`` is now named
:option:`mopidy --show-deps`. ``mopidy --show-deps``.
- What configuration files to use can now be specified through the command - What configuration files to use can now be specified through the command
option :option:`mopidy --config`, multiple files can be specified using colon option :option:`mopidy --config`, multiple files can be specified using colon
@ -2200,8 +2258,8 @@ one new.
:option:`mopidy --option`. For example: ``mopidy --option :option:`mopidy --option`. For example: ``mopidy --option
spotify/enabled=false``. spotify/enabled=false``.
- The GStreamer command line options, :option:`mopidy --gst-*` and - The GStreamer command line options, ``mopidy --gst-*`` and
:option:`mopidy --help-gst` are no longer supported. To set GStreamer debug ``mopidy --help-gst`` are no longer supported. To set GStreamer debug
flags, you can use environment variables such as :envvar:`GST_DEBUG`. Refer flags, you can use environment variables such as :envvar:`GST_DEBUG`. Refer
to GStreamer's documentation for details. to GStreamer's documentation for details.
@ -2250,7 +2308,7 @@ already have.
**Core** **Core**
- Removed the :attr:`mopidy.settings.DEBUG_THREAD` setting and the - Removed the :attr:`mopidy.settings.DEBUG_THREAD` setting and the
:option:`--debug-thread` command line option. Sending SIGUSR1 to ``mopidy --debug-thread`` command line option. Sending SIGUSR1 to
the Mopidy process will now always make it log tracebacks for all alive the Mopidy process will now always make it log tracebacks for all alive
threads. threads.
@ -2531,9 +2589,8 @@ We've added an HTTP frontend for those wanting to build web clients for Mopidy!
- Make ``mopidy-scan`` ignore invalid dates, e.g. dates in years outside the - Make ``mopidy-scan`` ignore invalid dates, e.g. dates in years outside the
range 1-9999. range 1-9999.
- Make ``mopidy-scan`` accept :option:`-q`/:option:`--quiet` and - Make ``mopidy-scan`` accept ``-q``/``--quiet`` and ``-v``/``--verbose``
:option:`-v`/:option:`--verbose` options to control the amount of logging options to control the amount of logging output when scanning.
output when scanning.
- The scanner can now handle files with other encodings than UTF-8. Rebuild - The scanner can now handle files with other encodings than UTF-8. Rebuild
your tag cache with ``mopidy-scan`` to include tracks that may have been your tag cache with ``mopidy-scan`` to include tracks that may have been
@ -2658,7 +2715,7 @@ long time been our most requested feature. Finally, it's here!
**Developer support** **Developer support**
- Added optional background thread for debugging deadlocks. When the feature is - Added optional background thread for debugging deadlocks. When the feature is
enabled via the ``--debug-thread`` option or enabled via the ``mopidy --debug-thread`` option or
:attr:`mopidy.settings.DEBUG_THREAD` setting a ``SIGUSR1`` signal will dump :attr:`mopidy.settings.DEBUG_THREAD` setting a ``SIGUSR1`` signal will dump
the traceback for all running threads. the traceback for all running threads.
@ -2870,9 +2927,9 @@ resolved a bunch of related issues.
known setting, and suggests to the user what we think the setting should have known setting, and suggests to the user what we think the setting should have
been. been.
- Added :option:`--list-deps` option to the ``mopidy`` command that lists - Added ``mopidy --list-deps`` option that lists required and optional
required and optional dependencies, their current versions, and some other dependencies, their current versions, and some other information useful for
information useful for debugging. (Fixes: :issue:`74`) debugging. (Fixes: :issue:`74`)
- Added ``tools/debug-proxy.py`` to tee client requests to two backends and - Added ``tools/debug-proxy.py`` to tee client requests to two backends and
diff responses. Intended as a developer tool for checking for MPD protocol diff responses. Intended as a developer tool for checking for MPD protocol
@ -3152,12 +3209,12 @@ Please note that 0.5.0 requires some updated dependencies, as listed under
- Command line usage: - Command line usage:
- Support passing options to GStreamer. See :option:`--help-gst` for a list - Support passing options to GStreamer. See ``mopidy --help-gst`` for a list
of available options. (Fixes: :issue:`95`) of available options. (Fixes: :issue:`95`)
- Improve :option:`--list-settings` output. (Fixes: :issue:`91`) - Improve ``mopidy --list-settings`` output. (Fixes: :issue:`91`)
- Added :option:`--interactive` for reading missing local settings from - Added ``mopidy --interactive`` for reading missing local settings from
``stdin``. (Fixes: :issue:`96`) ``stdin``. (Fixes: :issue:`96`)
- Improve shutdown procedure at CTRL+C. Add signal handler for ``SIGTERM``, - Improve shutdown procedure at CTRL+C. Add signal handler for ``SIGTERM``,
@ -3300,8 +3357,8 @@ loading from Mopidy 0.3.0 is still present.
- Settings: - Settings:
- Fix crash on ``--list-settings`` on clean installation. Thanks to Martins - Fix crash on ``mopidy --list-settings`` on clean installation. Thanks to
Grunskis for the bug report and patch. (Fixes: :issue:`63`) Martins Grunskis for the bug report and patch. (Fixes: :issue:`63`)
- Packaging: - Packaging:
@ -3529,11 +3586,11 @@ to Valentin David.
- Simplify the default log format, - Simplify the default log format,
:attr:`mopidy.settings.CONSOLE_LOG_FORMAT`. From a user's point of view: :attr:`mopidy.settings.CONSOLE_LOG_FORMAT`. From a user's point of view:
Less noise, more information. Less noise, more information.
- Rename the :option:`--dump` command line option to - Rename the ``mopidy --dump`` command line option to
:option:`--save-debug-log`. :option:`mopidy --save-debug-log`.
- Rename setting :attr:`mopidy.settings.DUMP_LOG_FORMAT` to - Rename setting :attr:`mopidy.settings.DUMP_LOG_FORMAT` to
:attr:`mopidy.settings.DEBUG_LOG_FORMAT` and use it for :option:`--verbose` :attr:`mopidy.settings.DEBUG_LOG_FORMAT` and use it for
too. :option:`mopidy --verbose` too.
- Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to - Rename setting :attr:`mopidy.settings.DUMP_LOG_FILENAME` to
:attr:`mopidy.settings.DEBUG_LOG_FILENAME`. :attr:`mopidy.settings.DEBUG_LOG_FILENAME`.
@ -3599,7 +3656,7 @@ fixing the OS X issues for a future release. You can track the progress at
- Exit early if not Python >= 2.6, < 3. - Exit early if not Python >= 2.6, < 3.
- Validate settings at startup and print useful error messages if the settings - Validate settings at startup and print useful error messages if the settings
has not been updated or anything is misspelled. has not been updated or anything is misspelled.
- Add command line option :option:`--list-settings` to print the currently - Add command line option ``mopidy --list-settings`` to print the currently
active settings. active settings.
- Include Sphinx scripts for building docs, pylintrc, tests and test data in - Include Sphinx scripts for building docs, pylintrc, tests and test data in
the packages created by ``setup.py`` for i.e. PyPI. the packages created by ``setup.py`` for i.e. PyPI.
@ -3781,7 +3838,7 @@ the established pace of at least a release per month.
- Improvements to MPD protocol handling, making Mopidy work much better with a - Improvements to MPD protocol handling, making Mopidy work much better with a
group of clients, including ncmpc, MPoD, and Theremin. group of clients, including ncmpc, MPoD, and Theremin.
- New command line flag :option:`--dump` for dumping debug log to ``dump.log`` - New command line flag ``mopidy --dump`` for dumping debug log to ``dump.log``
in the current directory. in the current directory.
- New setting :attr:`mopidy.settings.MIXER_ALSA_CONTROL` for forcing what ALSA - New setting :attr:`mopidy.settings.MIXER_ALSA_CONTROL` for forcing what ALSA
control :class:`mopidy.mixers.alsa.AlsaMixer` should use. control :class:`mopidy.mixers.alsa.AlsaMixer` should use.

View File

@ -40,7 +40,6 @@ MOCK_MODULES = [
'dbus.mainloop.glib', 'dbus.mainloop.glib',
'dbus.service', 'dbus.service',
'mopidy.internal.gi', 'mopidy.internal.gi',
'pykka',
] ]
for mod_name in MOCK_MODULES: for mod_name in MOCK_MODULES:
sys.modules[mod_name] = Mock() sys.modules[mod_name] = Mock()

View File

@ -13,8 +13,7 @@ Creating releases
#. Update changelog and commit it. #. Update changelog and commit it.
#. Bump the version number in ``mopidy/__init__.py``. Remember to update the #. Bump the version number in ``mopidy/__init__.py``.
test case in ``tests/test_version.py``.
#. Merge the release branch (``develop`` in the example) into master:: #. Merge the release branch (``develop`` in the example) into master::

View File

@ -1,2 +1,4 @@
Sphinx >= 1.0 Sphinx >= 1.0
pygraphviz pygraphviz
Pykka >= 1.1
sphinx_rtd_theme

View File

@ -31,10 +31,3 @@ accelerate requests to all Mopidy services, including:
- https://dl.mopidy.com/pimusicbox/, which is used to distribute Pi Musicbox - https://dl.mopidy.com/pimusicbox/, which is used to distribute Pi Musicbox
images. images.
GlobalSign
==========
`GlobalSign <https://www.globalsign.com/>`_ provides Mopidy with a free SSL
certificate for mopidy.com, which we use to secure access to all our web sites.

View File

@ -14,4 +14,4 @@ if not (2, 7) <= sys.version_info < (3,):
warnings.filterwarnings('ignore', 'could not open display') warnings.filterwarnings('ignore', 'could not open display')
__version__ = '2.0.0' __version__ = '2.0.1'

View File

@ -5,7 +5,7 @@ import os
import signal import signal
import sys import sys
from mopidy.internal.gi import Gst # noqa: Import to initialize from mopidy.internal.gi import Gst # noqa: F401
try: try:
# Make GObject's mainloop the event loop for python-dbus # Make GObject's mainloop the event loop for python-dbus

View File

@ -21,6 +21,8 @@ logger = logging.getLogger(__name__)
# set_state() on a pipeline. # set_state() on a pipeline.
gst_logger = logging.getLogger('mopidy.audio.gst') gst_logger = logging.getLogger('mopidy.audio.gst')
_GST_PLAY_FLAGS_AUDIO = 0x02
_GST_STATE_MAPPING = { _GST_STATE_MAPPING = {
Gst.State.PLAYING: PlaybackState.PLAYING, Gst.State.PLAYING: PlaybackState.PLAYING,
Gst.State.PAUSED: PlaybackState.PAUSED, Gst.State.PAUSED: PlaybackState.PAUSED,
@ -366,12 +368,16 @@ class _Handler(object):
# Emit any postponed tags that we got after about-to-finish. # Emit any postponed tags that we got after about-to-finish.
tags, self._audio._pending_tags = self._audio._pending_tags, None tags, self._audio._pending_tags = self._audio._pending_tags, None
self._audio._tags = tags self._audio._tags = tags or {}
if tags: if tags:
logger.debug('Audio event: tags_changed(tags=%r)', tags.keys()) logger.debug('Audio event: tags_changed(tags=%r)', tags.keys())
AudioListener.send('tags_changed', tags=tags.keys()) AudioListener.send('tags_changed', tags=tags.keys())
if self._audio._pending_metadata:
self._audio._playbin.send_event(self._audio._pending_metadata)
self._audio._pending_metadata = None
def on_segment(self, segment): def on_segment(self, segment):
gst_logger.debug( gst_logger.debug(
'Got SEGMENT pad event: ' 'Got SEGMENT pad event: '
@ -410,6 +416,7 @@ class Audio(pykka.ThreadingActor):
self._tags = {} self._tags = {}
self._pending_uri = None self._pending_uri = None
self._pending_tags = None self._pending_tags = None
self._pending_metadata = None
self._playbin = None self._playbin = None
self._outputs = None self._outputs = None
@ -448,7 +455,7 @@ class Audio(pykka.ThreadingActor):
def _setup_playbin(self): def _setup_playbin(self):
playbin = Gst.ElementFactory.make('playbin') playbin = Gst.ElementFactory.make('playbin')
playbin.set_property('flags', 2) # GST_PLAY_FLAG_AUDIO playbin.set_property('flags', _GST_PLAY_FLAGS_AUDIO)
# TODO: turn into config values... # TODO: turn into config values...
playbin.set_property('buffer-size', 5 << 20) # 5MB playbin.set_property('buffer-size', 5 << 20) # 5MB
@ -485,15 +492,16 @@ class Audio(pykka.ThreadingActor):
def _setup_audio_sink(self): def _setup_audio_sink(self):
audio_sink = Gst.ElementFactory.make('bin', 'audio-sink') audio_sink = Gst.ElementFactory.make('bin', 'audio-sink')
queue = Gst.ElementFactory.make('queue')
volume = Gst.ElementFactory.make('volume')
# Queue element to buy us time between the about-to-finish event and # 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 # the actual switch, i.e. about to switch can block for longer thanks
# to this queue. # to this queue.
# TODO: See if settings should be set to minimize latency. Previous # TODO: See if settings should be set to minimize latency. Previous
# setting breaks appsrc, and settings before that broke on a few # setting breaks appsrc, and settings before that broke on a few
# systems. So leave the default to play it safe. # systems. So leave the default to play it safe.
queue = Gst.ElementFactory.make('queue')
if self._config['audio']['buffer_time'] > 0: if self._config['audio']['buffer_time'] > 0:
queue.set_property( queue.set_property(
'max-size-time', 'max-size-time',
@ -501,15 +509,13 @@ class Audio(pykka.ThreadingActor):
audio_sink.add(queue) audio_sink.add(queue)
audio_sink.add(self._outputs) audio_sink.add(self._outputs)
audio_sink.add(volume)
queue.link(volume)
volume.link(self._outputs)
if self.mixer: if self.mixer:
volume = Gst.ElementFactory.make('volume')
audio_sink.add(volume)
queue.link(volume)
volume.link(self._outputs)
self.mixer.setup(volume, self.actor_ref.proxy().mixer) self.mixer.setup(volume, self.actor_ref.proxy().mixer)
else:
queue.link(self._outputs)
ghost_pad = Gst.GhostPad.new('sink', queue.get_static_pad('sink')) ghost_pad = Gst.GhostPad.new('sink', queue.get_static_pad('sink'))
audio_sink.add_pad(ghost_pad) audio_sink.add_pad(ghost_pad)
@ -803,8 +809,10 @@ class Audio(pykka.ThreadingActor):
'Sending TAG event for track %r: %r', 'Sending TAG event for track %r: %r',
track.uri, taglist.to_string()) track.uri, taglist.to_string())
event = Gst.Event.new_tag(taglist) event = Gst.Event.new_tag(taglist)
# TODO: check if we get this back on our own bus? if self._pending_uri:
self._playbin.send_event(event) self._pending_metadata = event
else:
self._playbin.send_event(event)
def get_current_tags(self): def get_current_tags(self):
""" """

View File

@ -61,8 +61,7 @@ class Scanner(object):
try: try:
_start_pipeline(pipeline) _start_pipeline(pipeline)
tags, mime, have_audio = _process(pipeline, timeout) tags, mime, have_audio, duration = _process(pipeline, timeout)
duration = _query_duration(pipeline)
seekable = _query_seekable(pipeline) seekable = _query_seekable(pipeline)
finally: finally:
signals.clear() signals.clear()
@ -136,28 +135,15 @@ def _start_pipeline(pipeline):
pipeline.set_state(Gst.State.PLAYING) pipeline.set_state(Gst.State.PLAYING)
def _query_duration(pipeline, timeout=100): def _query_duration(pipeline):
# 1. Try and get a duration, return if success.
# 2. Some formats need to play some buffers before duration is found.
# 3. Wait for a duration change event.
# 4. Try and get a duration again.
success, duration = pipeline.query_duration(Gst.Format.TIME) success, duration = pipeline.query_duration(Gst.Format.TIME)
if success and duration >= 0: if not success:
return duration // Gst.MSECOND duration = None # Make sure error case preserves None.
elif duration < 0:
result = pipeline.set_state(Gst.State.PLAYING) duration = None # Stream without duration.
if result == Gst.StateChangeReturn.FAILURE: else:
return None duration = duration // Gst.MSECOND
return success, duration
gst_timeout = timeout * Gst.MSECOND
bus = pipeline.get_bus()
bus.timed_pop_filtered(gst_timeout, Gst.MessageType.DURATION_CHANGED)
success, duration = pipeline.query_duration(Gst.Format.TIME)
if success and duration >= 0:
return duration // Gst.MSECOND
return None
def _query_seekable(pipeline): def _query_seekable(pipeline):
@ -172,6 +158,7 @@ def _process(pipeline, timeout_ms):
mime = None mime = None
have_audio = False have_audio = False
missing_message = None missing_message = None
duration = None
types = ( types = (
Gst.MessageType.ELEMENT | Gst.MessageType.ELEMENT |
@ -179,11 +166,12 @@ def _process(pipeline, timeout_ms):
Gst.MessageType.ERROR | Gst.MessageType.ERROR |
Gst.MessageType.EOS | Gst.MessageType.EOS |
Gst.MessageType.ASYNC_DONE | Gst.MessageType.ASYNC_DONE |
Gst.MessageType.DURATION_CHANGED |
Gst.MessageType.TAG Gst.MessageType.TAG
) )
timeout = timeout_ms timeout = timeout_ms
previous = int(time.time() * 1000) start = int(time.time() * 1000)
while timeout > 0: while timeout > 0:
message = bus.timed_pop_filtered(timeout * Gst.MSECOND, types) message = bus.timed_pop_filtered(timeout * Gst.MSECOND, types)
@ -197,7 +185,7 @@ def _process(pipeline, timeout_ms):
mime = message.get_structure().get_value('caps').get_name() mime = message.get_structure().get_value('caps').get_name()
if mime and ( if mime and (
mime.startswith('text/') or mime == 'application/xml'): mime.startswith('text/') or mime == 'application/xml'):
return tags, mime, have_audio return tags, mime, have_audio, duration
elif message.get_structure().get_name() == 'have-audio': elif message.get_structure().get_name() == 'have-audio':
have_audio = True have_audio = True
elif message.type == Gst.MessageType.ERROR: elif message.type == Gst.MessageType.ERROR:
@ -205,21 +193,38 @@ def _process(pipeline, timeout_ms):
if missing_message and not mime: if missing_message and not mime:
caps = missing_message.get_structure().get_value('detail') caps = missing_message.get_structure().get_value('detail')
mime = caps.get_structure(0).get_name() mime = caps.get_structure(0).get_name()
return tags, mime, have_audio return tags, mime, have_audio, duration
raise exceptions.ScannerError(error) raise exceptions.ScannerError(error)
elif message.type == Gst.MessageType.EOS: elif message.type == Gst.MessageType.EOS:
return tags, mime, have_audio return tags, mime, have_audio, duration
elif message.type == Gst.MessageType.ASYNC_DONE: elif message.type == Gst.MessageType.ASYNC_DONE:
if message.src == pipeline: success, duration = _query_duration(pipeline)
return tags, mime, have_audio if tags and success:
return tags, mime, have_audio, duration
# Workaround for upstream bug which causes tags/duration to arrive
# after pre-roll. We get around this by starting to play the track
# and then waiting for a duration change.
# https://bugzilla.gnome.org/show_bug.cgi?id=763553
result = pipeline.set_state(Gst.State.PLAYING)
if result == Gst.StateChangeReturn.FAILURE:
return tags, mime, have_audio, duration
elif message.type == Gst.MessageType.DURATION_CHANGED:
# duration will be read after ASYNC_DONE received; for now
# just give it a non-None value to flag that we have a duration:
duration = 0
elif message.type == Gst.MessageType.TAG: elif message.type == Gst.MessageType.TAG:
taglist = message.parse_tag() taglist = message.parse_tag()
# Note that this will only keep the last tag. # Note that this will only keep the last tag.
tags.update(tags_lib.convert_taglist(taglist)) tags.update(tags_lib.convert_taglist(taglist))
now = int(time.time() * 1000) timeout = timeout_ms - (int(time.time() * 1000) - start)
timeout -= now - previous
previous = now # workaround for https://bugzilla.gnome.org/show_bug.cgi?id=763553:
# if we got what we want then stop playing (and wait for ASYNC_DONE)
if tags and duration is not None:
pipeline.set_state(Gst.State.PAUSED)
raise exceptions.ScannerError('Timeout after %dms' % timeout_ms) raise exceptions.ScannerError('Timeout after %dms' % timeout_ms)

View File

@ -44,15 +44,24 @@ gstreamer-GstTagList.html
value = taglist.get_value_index(tag, i) value = taglist.get_value_index(tag, i)
if isinstance(value, GLib.Date): if isinstance(value, GLib.Date):
date = datetime.date( try:
value.get_year(), value.get_month(), value.get_day()) date = datetime.date(
result[tag].append(date.isoformat().decode('utf-8')) value.get_year(), value.get_month(), value.get_day())
if isinstance(value, Gst.DateTime): result[tag].append(date.isoformat().decode('utf-8'))
except ValueError:
logger.debug(
'Ignoring dodgy date value: %d-%d-%d',
value.get_year(), value.get_month(), value.get_day())
elif isinstance(value, Gst.DateTime):
result[tag].append(value.to_iso8601_string().decode('utf-8')) result[tag].append(value.to_iso8601_string().decode('utf-8'))
elif isinstance(value, bytes): elif isinstance(value, bytes):
result[tag].append(value.decode('utf-8', 'replace')) result[tag].append(value.decode('utf-8', 'replace'))
elif isinstance(value, (compat.text_type, bool, numbers.Number)): elif isinstance(value, (compat.text_type, bool, numbers.Number)):
result[tag].append(value) result[tag].append(value)
elif isinstance(value, Gst.Sample):
data = _extract_sample_data(value)
if data:
result[tag].append(data)
else: else:
logger.log( logger.log(
log.TRACE_LOG_LEVEL, log.TRACE_LOG_LEVEL,
@ -62,6 +71,13 @@ gstreamer-GstTagList.html
return result return result
def _extract_sample_data(sample):
buf = sample.get_buffer()
if not buf:
return None
return buf.extract_dup(0, buf.get_size())
# TODO: split based on "stream" and "track" based conversion? i.e. handle data # TODO: split based on "stream" and "track" based conversion? i.e. handle data
# from radios in it's own helper instead? # from radios in it's own helper instead?
def convert_tags_to_track(tags): def convert_tags_to_track(tags):
@ -120,12 +136,11 @@ def convert_tags_to_track(tags):
return Track(**track_kwargs) return Track(**track_kwargs)
def _artists( def _artists(tags, artist_name, artist_id=None, artist_sortname=None):
tags, artist_name, artist_id=None, artist_sortname=None):
# Name missing, don't set artist # Name missing, don't set artist
if not tags.get(artist_name): if not tags.get(artist_name):
return None return None
# One artist name and either id or sortname, include all available fields # One artist name and either id or sortname, include all available fields
if len(tags[artist_name]) == 1 and \ if len(tags[artist_name]) == 1 and \
(artist_id in tags or artist_sortname in tags): (artist_id in tags or artist_sortname in tags):

View File

@ -9,12 +9,15 @@ import re
from mopidy import compat from mopidy import compat
from mopidy.compat import configparser from mopidy.compat import configparser
from mopidy.config import keyring from mopidy.config import keyring
from mopidy.config.schemas import * # noqa from mopidy.config.schemas import *
from mopidy.config.types import * # noqa from mopidy.config.types import *
from mopidy.internal import path, versioning from mopidy.internal import path, versioning
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# flake8: noqa:
# TODO: Update this to be flake8 compliant
_core_schema = ConfigSchema('core') _core_schema = ConfigSchema('core')
_core_schema['cache_dir'] = Path() _core_schema['cache_dir'] = Path()
_core_schema['config_dir'] = Path() _core_schema['config_dir'] = Path()

View File

@ -230,8 +230,8 @@ class PlaybackController(object):
self._seek(self._pending_position) self._seek(self._pending_position)
def _on_position_changed(self, position): def _on_position_changed(self, position):
if self._pending_position == position: if self._pending_position is not None:
self._trigger_seeked(position) self._trigger_seeked(self._pending_position)
self._pending_position = None self._pending_position = None
def _on_about_to_finish_callback(self): def _on_about_to_finish_callback(self):
@ -251,23 +251,34 @@ class PlaybackController(object):
if self._state == PlaybackState.STOPPED: if self._state == PlaybackState.STOPPED:
return return
pending = self.core.tracklist.eot_track(self._current_tl_track) # Unless overridden by other calls (e.g. next / previous / stop) this
while pending: # will be the last position recorded until the track gets reassigned.
# TODO: Avoid infinite loops if all tracks are unplayable. # TODO: Check if case when track.length isn't populated needs to be
backend = self._get_backend(pending) # handled.
if not backend: self._last_position = self._current_tl_track.track.length
continue
try: pending = self.core.tracklist.eot_track(self._current_tl_track)
if backend.playback.change_track(pending.track).get(): # avoid endless loop if 'repeat' is 'true' and no track is playable
self._pending_tl_track = pending # * 2 -> second run to get all playable track in a shuffled playlist
break count = self.core.tracklist.get_length() * 2
except Exception:
logger.exception('%s backend caused an exception.', while pending:
backend.actor_ref.actor_class.__name__) backend = self._get_backend(pending)
if backend:
try:
if backend.playback.change_track(pending.track).get():
self._pending_tl_track = pending
break
except Exception:
logger.exception('%s backend caused an exception.',
backend.actor_ref.actor_class.__name__)
self.core.tracklist._mark_unplayable(pending) self.core.tracklist._mark_unplayable(pending)
pending = self.core.tracklist.eot_track(pending) pending = self.core.tracklist.eot_track(pending)
count -= 1
if not count:
logger.info('No playable track in the list.')
break
def _on_tracklist_change(self): def _on_tracklist_change(self):
""" """
@ -290,6 +301,9 @@ class PlaybackController(object):
""" """
state = self.get_state() state = self.get_state()
current = self._pending_tl_track or self._current_tl_track current = self._pending_tl_track or self._current_tl_track
# avoid endless loop if 'repeat' is 'true' and no track is playable
# * 2 -> second run to get all playable track in a shuffled playlist
count = self.core.tracklist.get_length() * 2
while current: while current:
pending = self.core.tracklist.next_track(current) pending = self.core.tracklist.next_track(current)
@ -301,6 +315,10 @@ class PlaybackController(object):
# if current == pending: # if current == pending:
# break # break
current = pending current = pending
count -= 1
if not count:
logger.info('No playable track in the list.')
break
# TODO return result? # TODO return result?
@ -352,6 +370,9 @@ class PlaybackController(object):
current = self._pending_tl_track or self._current_tl_track current = self._pending_tl_track or self._current_tl_track
pending = tl_track or current or self.core.tracklist.next_track(None) pending = tl_track or current or self.core.tracklist.next_track(None)
# avoid endless loop if 'repeat' is 'true' and no track is playable
# * 2 -> second run to get all playable track in a shuffled playlist
count = self.core.tracklist.get_length() * 2
while pending: while pending:
if self._change(pending, PlaybackState.PLAYING): if self._change(pending, PlaybackState.PLAYING):
@ -360,6 +381,10 @@ class PlaybackController(object):
self.core.tracklist._mark_unplayable(pending) self.core.tracklist._mark_unplayable(pending)
current = pending current = pending
pending = self.core.tracklist.next_track(current) pending = self.core.tracklist.next_track(current)
count -= 1
if not count:
logger.info('No playable track in the list.')
break
# TODO return result? # TODO return result?
@ -375,6 +400,10 @@ class PlaybackController(object):
if not backend: if not backend:
return False return False
# This must happen before prepare_change gets called, otherwise the
# backend flushes the information of the track.
self._last_position = self.get_time_position()
# TODO: Wrap backend call in error handling. # TODO: Wrap backend call in error handling.
backend.playback.prepare_change() backend.playback.prepare_change()
@ -416,6 +445,9 @@ class PlaybackController(object):
self._previous = True self._previous = True
state = self.get_state() state = self.get_state()
current = self._pending_tl_track or self._current_tl_track current = self._pending_tl_track or self._current_tl_track
# avoid endless loop if 'repeat' is 'true' and no track is playable
# * 2 -> second run to get all playable track in a shuffled playlist
count = self.core.tracklist.get_length() * 2
while current: while current:
pending = self.core.tracklist.previous_track(current) pending = self.core.tracklist.previous_track(current)
@ -427,6 +459,10 @@ class PlaybackController(object):
# if current == pending: # if current == pending:
# break # break
current = pending current = pending
count -= 1
if not count:
logger.info('No playable track in the list.')
break
# TODO: no return value? # TODO: no return value?

View File

@ -132,5 +132,6 @@ class FileLibraryProvider(backend.LibraryProvider):
def _is_in_basedir(self, local_path): def _is_in_basedir(self, local_path):
return any( return any(
path.is_path_inside_base_dir(local_path, media_dir['path']) path.is_path_inside_base_dir(
local_path, media_dir['path'].encode('utf-8'))
for media_dir in self._media_dirs) for media_dir in self._media_dirs)

View File

@ -196,6 +196,11 @@ def find_mtimes(root, follow=False):
def is_path_inside_base_dir(path, base_path): def is_path_inside_base_dir(path, base_path):
if not isinstance(path, bytes):
raise ValueError('path is not a bytestring')
if not isinstance(base_path, bytes):
raise ValueError('base_path is not a bytestring')
if path.endswith(os.sep): if path.endswith(os.sep):
raise ValueError('Path %s cannot end with a path separator' raise ValueError('Path %s cannot end with a path separator'
% path) % path)

View File

@ -88,14 +88,17 @@ class Date(String):
class Identifier(String): class Identifier(String):
""" """
:class:`Field` for storing ASCII values such as GUIDs or other identifiers. :class:`Field` for storing values such as GUIDs or other identifiers.
Values will be interned. Values will be interned.
:param default: default value for field :param default: default value for field
""" """
def validate(self, value): def validate(self, value):
return compat.intern(str(super(Identifier, self).validate(value))) value = super(Identifier, self).validate(value)
if isinstance(value, compat.text_type):
value = value.encode('utf-8')
return compat.intern(value)
class URI(Identifier): class URI(Identifier):

View File

@ -68,7 +68,7 @@ class StreamLibraryProvider(backend.LibraryProvider):
track = tags.convert_tags_to_track(scan_result.tags).replace( track = tags.convert_tags_to_track(scan_result.tags).replace(
uri=uri, length=scan_result.duration) uri=uri, length=scan_result.duration)
else: else:
logger.warning('Problem looking up %s: %s', uri) logger.warning('Problem looking up %s', uri)
track = Track(uri=uri) track = Track(uri=uri)
return [track] return [track]
@ -142,7 +142,7 @@ def _unwrap_stream(uri, timeout, scanner, requests_session):
uri, timeout) uri, timeout)
return None, None return None, None
content = http.download( content = http.download(
requests_session, uri, timeout=download_timeout) requests_session, uri, timeout=download_timeout / 1000)
if content is None: if content is None:
logger.info( logger.info(

View File

@ -44,6 +44,14 @@ class TestConvertTaglist(object):
assert isinstance(result[Gst.TAG_DATE][0], compat.text_type) assert isinstance(result[Gst.TAG_DATE][0], compat.text_type)
assert result[Gst.TAG_DATE][0] == '2014-01-07' assert result[Gst.TAG_DATE][0] == '2014-01-07'
def test_date_tag_bad_value(self):
date = GLib.Date.new_dmy(7, 1, 10000)
taglist = self.make_taglist(Gst.TAG_DATE, [date])
result = tags.convert_taglist(taglist)
assert len(result[Gst.TAG_DATE]) == 0
def test_date_time_tag(self): def test_date_time_tag(self):
taglist = self.make_taglist(Gst.TAG_DATE_TIME, [ taglist = self.make_taglist(Gst.TAG_DATE_TIME, [
Gst.DateTime.new_from_iso8601_string(b'2014-01-07 14:13:12') Gst.DateTime.new_from_iso8601_string(b'2014-01-07 14:13:12')

View File

@ -14,11 +14,42 @@ from tests import dummy_audio
class TestPlaybackProvider(backend.PlaybackProvider): class TestPlaybackProvider(backend.PlaybackProvider):
def __init__(self, audio, backend):
super(TestPlaybackProvider, self).__init__(audio, backend)
self._call_limit = 10
self._call_count = 0
self._call_onetime = False
def reset_call_limit(self):
self._call_count = 0
self._call_onetime = False
def is_call_limit_reached(self):
return self._call_count > self._call_limit
def _translate_uri_call_limit(self, uri):
self._call_count += 1
if self._call_count > self._call_limit:
# return any url (not 'None') to stop the endless loop
return 'assert: call limit reached'
if 'limit_never' in uri:
# unplayable
return None
elif 'limit_one' in uri:
# one time playable
if self._call_onetime:
return None
self._call_onetime = True
return uri
def translate_uri(self, uri): def translate_uri(self, uri):
if 'error' in uri: if 'error' in uri:
raise Exception(uri) raise Exception(uri)
elif 'unplayable' in uri: elif 'unplayable' in uri:
return None return None
elif 'limit' in uri:
return self._translate_uri_call_limit(uri)
else: else:
return uri return uri
@ -703,6 +734,7 @@ class EventEmissionTest(BaseTest):
self.core.playback.play(tl_tracks[0]) self.core.playback.play(tl_tracks[0])
self.trigger_about_to_finish(replay_until='stream_changed') self.trigger_about_to_finish(replay_until='stream_changed')
self.replay_events()
listener_mock.reset_mock() listener_mock.reset_mock()
self.core.playback.seek(1000) self.core.playback.seek(1000)
@ -1125,3 +1157,77 @@ class TestBug1352Regression(BaseTest):
self.core.history._add_track.assert_called_once_with(self.tracks[1]) self.core.history._add_track.assert_called_once_with(self.tracks[1])
self.core.tracklist._mark_playing.assert_called_once_with(tl_tracks[1]) self.core.tracklist._mark_playing.assert_called_once_with(tl_tracks[1])
class TestEndlessLoop(BaseTest):
tracks_play = [
Track(uri='dummy:limit_never:a'),
Track(uri='dummy:limit_never:b')
]
tracks_other = [
Track(uri='dummy:limit_never:a'),
Track(uri='dummy:limit_one'),
Track(uri='dummy:limit_never:b')
]
def test_play(self):
self.core.tracklist.clear()
self.core.tracklist.add(self.tracks_play)
self.backend.playback.reset_call_limit().get()
self.core.tracklist.set_repeat(True)
tl_tracks = self.core.tracklist.get_tl_tracks()
self.core.playback.play(tl_tracks[0])
self.replay_events()
self.assertFalse(self.backend.playback.is_call_limit_reached().get())
def test_next(self):
self.core.tracklist.clear()
self.core.tracklist.add(self.tracks_other)
self.backend.playback.reset_call_limit().get()
self.core.tracklist.set_repeat(True)
tl_tracks = self.core.tracklist.get_tl_tracks()
self.core.playback.play(tl_tracks[1])
self.replay_events()
self.core.playback.next()
self.replay_events()
self.assertFalse(self.backend.playback.is_call_limit_reached().get())
def test_previous(self):
self.core.tracklist.clear()
self.core.tracklist.add(self.tracks_other)
self.backend.playback.reset_call_limit().get()
self.core.tracklist.set_repeat(True)
tl_tracks = self.core.tracklist.get_tl_tracks()
self.core.playback.play(tl_tracks[1])
self.replay_events()
self.core.playback.previous()
self.replay_events()
self.assertFalse(self.backend.playback.is_call_limit_reached().get())
def test_on_about_to_finish(self):
self.core.tracklist.clear()
self.core.tracklist.add(self.tracks_other)
self.backend.playback.reset_call_limit().get()
self.core.tracklist.set_repeat(True)
tl_tracks = self.core.tracklist.get_tl_tracks()
self.core.playback.play(tl_tracks[1])
self.replay_events()
self.trigger_about_to_finish()
self.assertFalse(self.backend.playback.is_call_limit_reached().get())

View File

@ -7,6 +7,8 @@ import shutil
import tempfile import tempfile
import unittest import unittest
import pytest
from mopidy import compat, exceptions from mopidy import compat, exceptions
from mopidy.internal import path from mopidy.internal import path
from mopidy.internal.gi import GLib from mopidy.internal.gi import GLib
@ -392,6 +394,30 @@ class FindMTimesTest(unittest.TestCase):
self.assertEqual(errors, {}) self.assertEqual(errors, {})
class TestIsPathInsideBaseDir(object):
def test_when_inside(self):
assert path.is_path_inside_base_dir(
'/æ/øå'.encode('utf-8'),
''.encode('utf-8'))
def test_when_outside(self):
assert not path.is_path_inside_base_dir(
'/æ/øå'.encode('utf-8'),
''.encode('utf-8'))
def test_byte_inside_str_fails(self):
with pytest.raises(ValueError):
path.is_path_inside_base_dir('/æ/øå'.encode('utf-8'), '')
def test_str_inside_byte_fails(self):
with pytest.raises(ValueError):
path.is_path_inside_base_dir('/æ/øå', ''.encode('utf-8'))
def test_str_inside_str_fails(self):
with pytest.raises(ValueError):
path.is_path_inside_base_dir('/æ/øå', '')
# TODO: kill this in favour of just os.path.getmtime + mocks # TODO: kill this in favour of just os.path.getmtime + mocks
class MtimeTest(unittest.TestCase): class MtimeTest(unittest.TestCase):

View File

@ -1,8 +1,10 @@
# encoding: utf-8
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
import unittest import unittest
from mopidy.models.fields import * # noqa: F403 from mopidy.models.fields import Collection, Field, Identifier, Integer, String
def create_instance(field): def create_instance(field):
@ -126,6 +128,42 @@ class StringTest(unittest.TestCase):
self.assertEqual('', instance.attr) self.assertEqual('', instance.attr)
class IdentifierTest(unittest.TestCase):
def test_default_handling(self):
instance = create_instance(Identifier(default='abc'))
self.assertEqual('abc', instance.attr)
def test_native_str_allowed(self):
instance = create_instance(Identifier())
instance.attr = str('abc')
self.assertEqual('abc', instance.attr)
def test_bytes_allowed(self):
instance = create_instance(Identifier())
instance.attr = b'abc'
self.assertEqual(b'abc', instance.attr)
def test_unicode_allowed(self):
instance = create_instance(Identifier())
instance.attr = u'abc'
self.assertEqual(u'abc', instance.attr)
def test_unicode_with_nonascii_allowed(self):
instance = create_instance(Identifier())
instance.attr = u'æøå'
self.assertEqual(u'æøå'.encode('utf-8'), instance.attr)
def test_other_disallowed(self):
instance = create_instance(Identifier())
with self.assertRaises(TypeError):
instance.attr = 1234
def test_empty_string(self):
instance = create_instance(Identifier())
instance.attr = ''
self.assertEqual('', instance.attr)
class IntegerTest(unittest.TestCase): class IntegerTest(unittest.TestCase):
def test_default_handling(self): def test_default_handling(self):
instance = create_instance(Integer(default=1234)) instance = create_instance(Integer(default=1234))