From 3a4a9e60e00e851d460c4aeed3cbac22fe545cb9 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Mon, 24 Dec 2012 13:55:46 +0100 Subject: [PATCH 01/30] Fix use of threading.Event for Python 2.6 and clear connected state. threading.Event's wait method returns None on python pre 2.7, which means all searches would fail. This also corrects that fact that we weren't clearing the connected threading event on disconnects. I did not add any tests for this at this time as I just want to get the fix out. --- mopidy/backends/spotify/library.py | 4 +++- mopidy/backends/spotify/session_manager.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/mopidy/backends/spotify/library.py b/mopidy/backends/spotify/library.py index a8a9bcd6..96e5f616 100644 --- a/mopidy/backends/spotify/library.py +++ b/mopidy/backends/spotify/library.py @@ -163,7 +163,9 @@ class SpotifyLibraryProvider(base.BaseLibraryProvider): translator.to_mopidy_track(t) for t in results.tracks()]) future.set(search_result) - if not self.backend.spotify.connected.wait(settings.SPOTIFY_TIMEOUT): + # Wait always returns None on python 2.6 :/ + self.backend.spotify.connected.wait(settings.SPOTIFY_TIMEOUT) + if not self.backend.spotify.connected.is_set(): logger.debug('Not connected: Spotify search cancelled') return SearchResult(uri='spotify:search') diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 0eed9939..ad0a806e 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -84,6 +84,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): def logged_out(self, session): """Callback used by pyspotify""" logger.info('Disconnected from Spotify') + self.connected.clear() def metadata_updated(self, session): """Callback used by pyspotify""" From f1aa1f9d665946b5f80085947872d211aea537d2 Mon Sep 17 00:00:00 2001 From: "Jeremy B. Merrill" Date: Mon, 24 Dec 2012 13:14:01 -0500 Subject: [PATCH 02/30] Add caveat about static in sound test file. Add a note saying that the purpose of the "aplay ... Front_Center.wav" line is merely to test whether the sound works or not, rather than to test its quality. Anecdotally, I had very static-y sound from the aplay command, which prompted me to incorrectly believe that my sound would not work with Mopidy. As it turns out, the sound works fine using Mopidy or gstreamer. This note will hopefully keep other Mopidy users from thinking their sound is broken when it is not. (I ended up installing armhf version and trying to use despotify, which didn't work, before coming back to Mopidy.) --- docs/installation/raspberrypi.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/installation/raspberrypi.rst b/docs/installation/raspberrypi.rst index fbb07364..8a4d9409 100644 --- a/docs/installation/raspberrypi.rst +++ b/docs/installation/raspberrypi.rst @@ -210,6 +210,10 @@ software packages, as Wheezy is going to be the next release of Debian. aplay /usr/share/sounds/alsa/Front_Center.wav + If you hear a voice saying "Front Center," then your sound is working. Don't + be concerned if this test sound includes static, output from Mopidy will not. + Test your sound with gstreamer to determine sound quality. + To make the change to analog output stick, you can add the ``amixer`` command to e.g. ``/etc/rc.local``, which will be executed when the system is booting. From 8a0c48e61e51ed700ec2e286bd20ad4f1551a843 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 27 Dec 2012 00:30:03 +0100 Subject: [PATCH 03/30] Add timestamp and duration to all spotify buffers. This fixes the issue where pausing playback would show the time of the last timestamped buffer instead of the current time. We also make sure to reset the time when we start a new track. This was done by overriding the play method on the session manager as it is also used for pausing, resuming and stopping. Ideally this should probably be reworked to avoid the gst import in mopidy.backends.spotify.playback, but for now this should do. --- mopidy/backends/spotify/playback.py | 7 ++++++- mopidy/backends/spotify/session_manager.py | 10 ++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 9069ce7e..d7e622fb 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,5 +1,9 @@ from __future__ import unicode_literals +import pygst +pygst.require('0.10') +import gst + import logging import functools @@ -35,6 +39,7 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) + self.backend.spotify.buffer_timestamp = 0 self.audio.prepare_change() self.audio.set_appsrc( @@ -54,5 +59,5 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): def on_seek_data(self, time_position): logger.debug('playback.on_seek_data(%d) called', time_position) - self.backend.spotify.next_buffer_timestamp = time_position + self.backend.spotify.buffer_timestamp = time_position * gst.MSECOND self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index ad0a806e..d372bfa4 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -46,7 +46,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.backend_ref = backend_ref self.connected = threading.Event() - self.next_buffer_timestamp = None + self.buffer_timestamp = 0 self.container_manager = None self.playlist_manager = None @@ -121,11 +121,13 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): 'sample_rate': sample_rate, 'channels': channels, } + buffer_ = gst.Buffer(bytes(frames)) buffer_.set_caps(gst.caps_from_string(capabilites)) - if self.next_buffer_timestamp is not None: - buffer_.timestamp = self.next_buffer_timestamp * gst.MSECOND - self.next_buffer_timestamp = None + buffer_.timestamp = self.buffer_timestamp + buffer_.duration = num_frames * gst.SECOND / sample_rate + + self.buffer_timestamp += buffer_.duration if self.audio.emit_data(buffer_).get(): return num_frames From ce750ddbf9ef3becefb2d613954eb422b4e3026b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 27 Dec 2012 03:28:44 +0100 Subject: [PATCH 04/30] Fix racecondition triggered KeyError in our DebugThread. --- mopidy/utils/process.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/utils/process.py b/mopidy/utils/process.py index 5edf287e..6be8937c 100644 --- a/mopidy/utils/process.py +++ b/mopidy/utils/process.py @@ -101,7 +101,7 @@ class DebugThread(threading.Thread): stack = ''.join(traceback.format_stack(frame)) logger.debug( 'Current state of %s (%s):\n%s', - threads[ident], ident, stack) + threads.get(ident, '?'), ident, stack) del frame self.event.clear() From ab7bb2e2fa7dea4f368dc48b6d8524511dd61929 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Dec 2012 20:36:25 +0100 Subject: [PATCH 05/30] Update authors list --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index d536c059..45e1a37e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -13,3 +13,4 @@ - Matt Bray - Trygve Aaberge - Wouter van Wijk +- Jeremy B. Merrill From 0459f037a4e51886ccfbbdb59e7b70cea60e5f03 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Dec 2012 20:44:19 +0100 Subject: [PATCH 06/30] spotify: Remove gst import from spotify.playback module --- mopidy/backends/spotify/playback.py | 6 +----- mopidy/backends/spotify/session_manager.py | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index d7e622fb..63ee45cc 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,9 +1,5 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst - import logging import functools @@ -59,5 +55,5 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): def on_seek_data(self, time_position): logger.debug('playback.on_seek_data(%d) called', time_position) - self.backend.spotify.buffer_timestamp = time_position * gst.MSECOND + self.backend.spotify.buffer_timestamp = time_position self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index d372bfa4..82287497 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -124,7 +124,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): buffer_ = gst.Buffer(bytes(frames)) buffer_.set_caps(gst.caps_from_string(capabilites)) - buffer_.timestamp = self.buffer_timestamp + buffer_.timestamp = self.buffer_timestamp * gst.MSECOND buffer_.duration = num_frames * gst.SECOND / sample_rate self.buffer_timestamp += buffer_.duration From f9c50051c2dfae3da74dc777c01dcbe365e7706f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Dec 2012 20:53:20 +0100 Subject: [PATCH 07/30] Revert "spotify: Remove gst import from spotify.playback module" This reverts commit 0459f037a4e51886ccfbbdb59e7b70cea60e5f03. --- mopidy/backends/spotify/playback.py | 6 +++++- mopidy/backends/spotify/session_manager.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 63ee45cc..d7e622fb 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,5 +1,9 @@ from __future__ import unicode_literals +import pygst +pygst.require('0.10') +import gst + import logging import functools @@ -55,5 +59,5 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): def on_seek_data(self, time_position): logger.debug('playback.on_seek_data(%d) called', time_position) - self.backend.spotify.buffer_timestamp = time_position + self.backend.spotify.buffer_timestamp = time_position * gst.MSECOND self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 82287497..d372bfa4 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -124,7 +124,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): buffer_ = gst.Buffer(bytes(frames)) buffer_.set_caps(gst.caps_from_string(capabilites)) - buffer_.timestamp = self.buffer_timestamp * gst.MSECOND + buffer_.timestamp = self.buffer_timestamp buffer_.duration = num_frames * gst.SECOND / sample_rate self.buffer_timestamp += buffer_.duration From b4028e9c577c03277cfc39b24b070fd4868a3847 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Dec 2012 20:58:05 +0100 Subject: [PATCH 08/30] spotify: Remove gst import from spotify.playback module --- mopidy/backends/spotify/playback.py | 8 ++------ mopidy/backends/spotify/session_manager.py | 8 ++++---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index d7e622fb..2937f881 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,9 +1,5 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst - import logging import functools @@ -39,7 +35,7 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self.backend.spotify.buffer_timestamp = 0 + self.backend.spotify.buffer_timestamp_in_ms = 0 self.audio.prepare_change() self.audio.set_appsrc( @@ -59,5 +55,5 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): def on_seek_data(self, time_position): logger.debug('playback.on_seek_data(%d) called', time_position) - self.backend.spotify.buffer_timestamp = time_position * gst.MSECOND + self.backend.spotify.buffer_timestamp_in_ms = time_position self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index d372bfa4..6eba6f05 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -46,7 +46,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.backend_ref = backend_ref self.connected = threading.Event() - self.buffer_timestamp = 0 + self.buffer_timestamp_in_ms = 0 self.container_manager = None self.playlist_manager = None @@ -124,10 +124,10 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): buffer_ = gst.Buffer(bytes(frames)) buffer_.set_caps(gst.caps_from_string(capabilites)) - buffer_.timestamp = self.buffer_timestamp - buffer_.duration = num_frames * gst.SECOND / sample_rate + buffer_.timestamp = self.buffer_timestamp_in_ms * gst.MSECOND + buffer_.duration = num_frames * gst.SECOND // sample_rate - self.buffer_timestamp += buffer_.duration + self.buffer_timestamp_in_ms += buffer_.duration // gst.MSECOND if self.audio.emit_data(buffer_).get(): return num_frames From ea431c2f184fa432ff03b1d77778eb3a21690df0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 27 Dec 2012 21:00:37 +0100 Subject: [PATCH 09/30] Revert "spotify: Remove gst import from spotify.playback module" This reverts commit b4028e9c577c03277cfc39b24b070fd4868a3847. --- mopidy/backends/spotify/playback.py | 8 ++++++-- mopidy/backends/spotify/session_manager.py | 8 ++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index 2937f881..d7e622fb 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,5 +1,9 @@ from __future__ import unicode_literals +import pygst +pygst.require('0.10') +import gst + import logging import functools @@ -35,7 +39,7 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) self.backend.spotify.session.play(1) - self.backend.spotify.buffer_timestamp_in_ms = 0 + self.backend.spotify.buffer_timestamp = 0 self.audio.prepare_change() self.audio.set_appsrc( @@ -55,5 +59,5 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): def on_seek_data(self, time_position): logger.debug('playback.on_seek_data(%d) called', time_position) - self.backend.spotify.buffer_timestamp_in_ms = time_position + self.backend.spotify.buffer_timestamp = time_position * gst.MSECOND self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 6eba6f05..d372bfa4 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -46,7 +46,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.backend_ref = backend_ref self.connected = threading.Event() - self.buffer_timestamp_in_ms = 0 + self.buffer_timestamp = 0 self.container_manager = None self.playlist_manager = None @@ -124,10 +124,10 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): buffer_ = gst.Buffer(bytes(frames)) buffer_.set_caps(gst.caps_from_string(capabilites)) - buffer_.timestamp = self.buffer_timestamp_in_ms * gst.MSECOND - buffer_.duration = num_frames * gst.SECOND // sample_rate + buffer_.timestamp = self.buffer_timestamp + buffer_.duration = num_frames * gst.SECOND / sample_rate - self.buffer_timestamp_in_ms += buffer_.duration // gst.MSECOND + self.buffer_timestamp += buffer_.duration if self.audio.emit_data(buffer_).get(): return num_frames From fb8e96bbf043211858bae76cbc3a7b1754854959 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Sat, 29 Dec 2012 13:23:21 +0100 Subject: [PATCH 10/30] docs: Add woutervanwijk's web client --- .../woutervanwijk-mopidy-webclient.png | Bin 0 -> 46487 bytes docs/clients/http.rst | 21 +++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 docs/_static/woutervanwijk-mopidy-webclient.png diff --git a/docs/_static/woutervanwijk-mopidy-webclient.png b/docs/_static/woutervanwijk-mopidy-webclient.png new file mode 100644 index 0000000000000000000000000000000000000000..0dd99acc9b9a52cc21dc994d12dd5b68f6615a8f GIT binary patch literal 46487 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 z

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 Y7O;bsDhxZ^fKPpr5RnzG7Ss>;KMm1+Q2+n{ literal 0 HcmV?d00001 diff --git a/docs/clients/http.rst b/docs/clients/http.rst index e41adb5b..c6e8b7d8 100644 --- a/docs/clients/http.rst +++ b/docs/clients/http.rst @@ -4,11 +4,26 @@ HTTP clients ************ -Mopidy added an :ref:`http-frontend` in 0.10 which provides the building blocks -needed for creating web clients for Mopidy with the help of a WebSocket and a -JavaScript library provided by Mopidy. +Mopidy added an :ref:`HTTP frontend ` in 0.10 which provides the +building blocks needed for creating web clients for Mopidy with the help of a +WebSocket and a JavaScript library provided by Mopidy. This page will list any HTTP/web Mopidy clients. If you've created one, please notify us so we can include your client on this page. See :ref:`http-frontend` for details on how to build your own web client. + + +woutervanwijk/Mopidy-Webclient +============================== + +.. image:: /_static/woutervanwijk-mopidy-webclient.png + :width: 410 + :height: 511 + +The first web client for Mopidy is still under development, but is already very +usable. It targets both desktop and mobile browsers. + +To try it out, get a copy of https://github.com/woutervanwijk/Mopidy-WebClient +and point the :attr:`mopidy.settings.HTTP_SERVER_STATIC_DIR` setting towards +your copy of the web client. From 4f11ac77aede97af8edb00098561c838af2557ad Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 27 Dec 2012 03:20:05 +0100 Subject: [PATCH 11/30] settings: Update settings validator to support empty iterables. --- mopidy/utils/settings.py | 22 ++++++++++++++-------- tests/utils/settings_test.py | 4 ++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/mopidy/utils/settings.py b/mopidy/utils/settings.py index 6eb462ce..8ae61e5b 100644 --- a/mopidy/utils/settings.py +++ b/mopidy/utils/settings.py @@ -142,7 +142,13 @@ def validate_settings(defaults, settings): 'SPOTIFY_LIB_CACHE': 'SPOTIFY_CACHE_PATH', } - list_of_one_or_more = [ + must_be_iterable = [ + 'BACKENDS', + 'FRONTENDS', + 'STREAM_PROTOCOLS', + ] + + must_have_value_set = [ 'BACKENDS', 'FRONTENDS', ] @@ -171,13 +177,13 @@ def validate_settings(defaults, settings): 'Deprecated setting, please set the value via the GStreamer ' 'bin in OUTPUT.') - elif setting in list_of_one_or_more: - if not hasattr(value, '__iter__'): - errors[setting] = ( - 'Must be a tuple. ' - "Remember the comma after single values: (u'value',)") - if not value: - errors[setting] = 'Must contain at least one value.' + elif setting in must_be_iterable and not hasattr(value, '__iter__'): + errors[setting] = ( + 'Must be a tuple. ' + "Remember the comma after single values: (u'value',)") + + elif setting in must_have_value_set and not value: + errors[setting] = 'Must be set.' elif setting not in defaults and not setting.startswith('CUSTOM_'): errors[setting] = 'Unknown setting.' diff --git a/tests/utils/settings_test.py b/tests/utils/settings_test.py index 1dcac1bb..51f0d89c 100644 --- a/tests/utils/settings_test.py +++ b/tests/utils/settings_test.py @@ -79,13 +79,13 @@ class ValidateSettingsTest(unittest.TestCase): result = setting_utils.validate_settings( self.defaults, {'FRONTENDS': []}) self.assertEqual( - result['FRONTENDS'], 'Must contain at least one value.') + result['FRONTENDS'], 'Must be set.') def test_empty_backends_list_returns_error(self): result = setting_utils.validate_settings( self.defaults, {'BACKENDS': []}) self.assertEqual( - result['BACKENDS'], 'Must contain at least one value.') + result['BACKENDS'], 'Must be set.') def test_noniterable_multivalue_setting_returns_error(self): result = setting_utils.validate_settings( From 341dea7262071ba3384eede98ca7b22f97c596f5 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 27 Dec 2012 03:21:20 +0100 Subject: [PATCH 12/30] stream backend: Add StreamBackend, fixes #151 Adds a basic streaming backend simply handles streaming audio and nothing else. I.e. no metadata beyond the URI we where given. #270 still needs to be solved for actual metadata to make sense in this backend. --- mopidy/backends/stream/__init__.py | 23 ++++++++++++ mopidy/backends/stream/actor.py | 57 ++++++++++++++++++++++++++++++ mopidy/settings.py | 25 +++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 mopidy/backends/stream/__init__.py create mode 100644 mopidy/backends/stream/actor.py diff --git a/mopidy/backends/stream/__init__.py b/mopidy/backends/stream/__init__.py new file mode 100644 index 00000000..82755540 --- /dev/null +++ b/mopidy/backends/stream/__init__.py @@ -0,0 +1,23 @@ +"""A backend for playing music for streaming music. + +This backend will handle streaming of URIs in +:attr:`mopidy.settings.STREAM_PROTOCOLS` assuming the right plugins are +installed. + +**Issues:** + +https://github.com/mopidy/mopidy/issues?labels=Stream+backend + +**Dependencies:** + +- None + +**Settings:** + +- :attr:`mopidy.settings.STREAM_PROTOCOLS` +""" + +from __future__ import unicode_literals + +# flake8: noqa +from .actor import StreamBackend diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py new file mode 100644 index 00000000..7fc28711 --- /dev/null +++ b/mopidy/backends/stream/actor.py @@ -0,0 +1,57 @@ +from __future__ import unicode_literals + +import pygst +pygst.require('0.10') +import gst + +import logging +import urlparse + +import pykka + +from mopidy import settings +from mopidy.backends import base +from mopidy.models import SearchResult, Track + +logger = logging.getLogger('mopidy.backends.stream') + + +class StreamBackend(pykka.ThreadingActor, base.Backend): + def __init__(self, audio): + super(StreamBackend, self).__init__() + + self.library = StreamLibraryProvider(backend=self) + self.playback = base.BasePlaybackProvider(audio=audio, backend=self) + self.playlists = None + + available_protocols = set() + + registry = gst.registry_get_default() + for factory in registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY): + for uri in factory.get_uri_protocols(): + if uri in settings.STREAM_PROTOCOLS: + available_protocols.add(uri) + + self.uri_schemes = list(available_protocols) + + +# TODO: Should we consider letting lookup know how to expand common playlist +# formats (m3u, pls, etc) for http(s) URIs? +class StreamLibraryProvider(base.BaseLibraryProvider): + def lookup(self, uri): + if urlparse.urlsplit(uri).scheme not in self.backend.uri_schemes: + return [] + # TODO: actually lookup the stream metadata by getting tags in same + # way as we do for updating the local library with mopidy.scanner + # Note that we would only want the stream metadata at this stage, + # not the currently playing track's. + return [Track(uri=uri, name=uri)] + + def find_exact(self, **query): + return SearchResult() + + def search(self, **query): + return SearchResult() + + def refresh(self, uri=None): + pass diff --git a/mopidy/settings.py b/mopidy/settings.py index c2081e27..9d99a7cb 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -20,10 +20,12 @@ from __future__ import unicode_literals #: BACKENDS = ( #: u'mopidy.backends.local.LocalBackend', #: u'mopidy.backends.spotify.SpotifyBackend', +#: u'mopidy.backends.spotify.StreamBackend', #: ) BACKENDS = ( 'mopidy.backends.local.LocalBackend', 'mopidy.backends.spotify.SpotifyBackend', + 'mopidy.backends.stream.StreamBackend', ) #: The log format used for informational logging. @@ -301,3 +303,26 @@ SPOTIFY_PROXY_PASSWORD = None #: #: SPOTIFY_TIMEOUT = 10 SPOTIFY_TIMEOUT = 10 + +#: Whitelist of URIs to support streaming from. +#: +#: Used by :mod:`mopidy.backends.stream` +#: +#: Default:: +#: +#: STREAM_PROTOCOLS = ( +#: u'http', +#: u'https', +#: u'mms', +#: u'rtmp', +#: u'rtmps', +#: u'rtsp', +#: ) +STREAM_PROTOCOLS = ( + 'http', + 'https', + 'mms', + 'rtmp', + 'rtmps', + 'rtsp', +) From 5dd7f4b07a4a996032864ffa0e5622695d5b6534 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 1 Jan 2013 16:56:34 +0100 Subject: [PATCH 13/30] core: Update BaseLibraryProvider to not require refresh, search or find_exact. These methods may now return None, and the core code has been updated to filter out missing SearchResults. --- mopidy/backends/base.py | 12 ++++++------ mopidy/backends/stream/actor.py | 9 --------- mopidy/core/library.py | 4 ++-- 3 files changed, 8 insertions(+), 17 deletions(-) diff --git a/mopidy/backends/base.py b/mopidy/backends/base.py index 8250a24c..f49aa89b 100644 --- a/mopidy/backends/base.py +++ b/mopidy/backends/base.py @@ -57,9 +57,9 @@ class BaseLibraryProvider(object): """ See :meth:`mopidy.core.LibraryController.find_exact`. - *MUST be implemented by subclass.* + *MAY be implemented by subclass.* """ - raise NotImplementedError + pass def lookup(self, uri): """ @@ -73,17 +73,17 @@ class BaseLibraryProvider(object): """ See :meth:`mopidy.core.LibraryController.refresh`. - *MUST be implemented by subclass.* + *MAY be implemented by subclass.* """ - raise NotImplementedError + pass def search(self, **query): """ See :meth:`mopidy.core.LibraryController.search`. - *MUST be implemented by subclass.* + *MAY be implemented by subclass.* """ - raise NotImplementedError + pass class BasePlaybackProvider(object): diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index 7fc28711..cdf777af 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -46,12 +46,3 @@ class StreamLibraryProvider(base.BaseLibraryProvider): # Note that we would only want the stream metadata at this stage, # not the currently playing track's. return [Track(uri=uri, name=uri)] - - def find_exact(self, **query): - return SearchResult() - - def search(self, **query): - return SearchResult() - - def refresh(self, uri=None): - pass diff --git a/mopidy/core/library.py b/mopidy/core/library.py index 39a1e99c..e4be7ce8 100644 --- a/mopidy/core/library.py +++ b/mopidy/core/library.py @@ -41,7 +41,7 @@ class LibraryController(object): query = query or kwargs futures = [ b.library.find_exact(**query) for b in self.backends.with_library] - return pykka.get_all(futures) + return [result for result in pykka.get_all(futures) if result] def lookup(self, uri): """ @@ -101,4 +101,4 @@ class LibraryController(object): query = query or kwargs futures = [ b.library.search(**query) for b in self.backends.with_library] - return pykka.get_all(futures) + return [result for result in pykka.get_all(futures) if result] From f1bd092e63f501bc5f3bb2dee3e65a267b61ccdc Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 1 Jan 2013 17:03:48 +0100 Subject: [PATCH 14/30] core: Update tests with cases for filtering out None results. --- tests/core/library_test.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/core/library_test.py b/tests/core/library_test.py index 32e618d2..e01696c7 100644 --- a/tests/core/library_test.py +++ b/tests/core/library_test.py @@ -90,6 +90,22 @@ class CoreLibraryTest(unittest.TestCase): self.library1.find_exact.assert_called_once_with(any=['a']) self.library2.find_exact.assert_called_once_with(any=['a']) + def test_find_exact_filters_out_none(self): + track1 = Track(uri='dummy1:a') + result1 = SearchResult(tracks=[track1]) + + self.library1.find_exact().get.return_value = result1 + self.library1.find_exact.reset_mock() + self.library2.find_exact().get.return_value = None + self.library2.find_exact.reset_mock() + + result = self.core.library.find_exact(any=['a']) + + self.assertIn(result1, result) + self.assertNotIn(None, result) + self.library1.find_exact.assert_called_once_with(any=['a']) + self.library2.find_exact.assert_called_once_with(any=['a']) + def test_find_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') @@ -126,6 +142,22 @@ class CoreLibraryTest(unittest.TestCase): self.library1.search.assert_called_once_with(any=['a']) self.library2.search.assert_called_once_with(any=['a']) + def test_search_filters_out_none(self): + track1 = Track(uri='dummy1:a') + result1 = SearchResult(tracks=[track1]) + + self.library1.search().get.return_value = result1 + self.library1.search.reset_mock() + self.library2.search().get.return_value = None + self.library2.search.reset_mock() + + result = self.core.library.search(any=['a']) + + self.assertIn(result1, result) + self.assertNotIn(None, result) + self.library1.search.assert_called_once_with(any=['a']) + self.library2.search.assert_called_once_with(any=['a']) + def test_search_accepts_query_dict_instead_of_kwargs(self): track1 = Track(uri='dummy1:a') track2 = Track(uri='dummy2:a') From ca82565b0889f81f8ae9890d1a729a8f014a4458 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Tue, 1 Jan 2013 17:25:07 +0100 Subject: [PATCH 15/30] audio: Move supported URI checking to mopidy.audio.utils In order to avoid gstreamer imports leaking into more of our code I'm moving this to a new utils class in audio. --- mopidy/audio/utils.py | 23 +++++++++++++++++++++++ mopidy/backends/stream/actor.py | 16 +++------------- 2 files changed, 26 insertions(+), 13 deletions(-) create mode 100644 mopidy/audio/utils.py diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py new file mode 100644 index 00000000..3f5f685e --- /dev/null +++ b/mopidy/audio/utils.py @@ -0,0 +1,23 @@ +from __future__ import unicode_literals + +import pygst +pygst.require('0.10') +import gst + + +def supported_uri_schemes(uri_schemes): + """Determine which URIs we can actually support from provided whitelist. + + :param uri_schemes: list/set of URIs to check support for. + :type uri_schemes: list or set or URI schemes as strings. + :rtype: set of URI schemes we can support via this GStreamer install. + """ + supported_schemes= set() + registry = gst.registry_get_default() + + for factory in registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY): + for uri in factory.get_uri_protocols(): + if uri in uri_schemes: + supported_schemes.add(uri) + + return supported_schemes diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index cdf777af..0c91f291 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -1,15 +1,12 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst - import logging import urlparse import pykka from mopidy import settings +from mopidy.audio import utils from mopidy.backends import base from mopidy.models import SearchResult, Track @@ -24,15 +21,8 @@ class StreamBackend(pykka.ThreadingActor, base.Backend): self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.playlists = None - available_protocols = set() - - registry = gst.registry_get_default() - for factory in registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY): - for uri in factory.get_uri_protocols(): - if uri in settings.STREAM_PROTOCOLS: - available_protocols.add(uri) - - self.uri_schemes = list(available_protocols) + self.uri_schemes = utils.supported_uri_schemes( + settings.STREAM_PROTOCOLS) # TODO: Should we consider letting lookup know how to expand common playlist From dfa0d648f985a58ad536b3dc5bdbaf5ea20438b0 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 2 Jan 2013 21:39:14 +0100 Subject: [PATCH 16/30] scanner: Support symlinks --- docs/changes.rst | 4 ++++ mopidy/utils/path.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index 89707b6a..fba9b80e 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -14,6 +14,10 @@ v0.12.0 (in development) - Let GStreamer handle time position tracking and seeks. (Fixes: :issue:`191`) +**Local backend** + +- Make ``mopidy-scan`` support symlinks. + v0.11.0 (2012-12-24) ==================== diff --git a/mopidy/utils/path.py b/mopidy/utils/path.py index c4fa0ce2..7d988a90 100644 --- a/mopidy/utils/path.py +++ b/mopidy/utils/path.py @@ -120,7 +120,7 @@ def find_files(path): if not os.path.basename(path).startswith(b'.'): yield path else: - for dirpath, dirnames, filenames in os.walk(path): + for dirpath, dirnames, filenames in os.walk(path, followlinks=True): for dirname in dirnames: if dirname.startswith(b'.'): # Skip hidden folders by modifying dirnames inplace From af6ee16b3a0bec886c312c2904a2e9cc8dcbee20 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 2 Jan 2013 22:25:32 +0100 Subject: [PATCH 17/30] Fix flake8 warnings --- mopidy/audio/utils.py | 2 +- mopidy/backends/stream/actor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 3f5f685e..e9eac9f8 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -12,7 +12,7 @@ def supported_uri_schemes(uri_schemes): :type uri_schemes: list or set or URI schemes as strings. :rtype: set of URI schemes we can support via this GStreamer install. """ - supported_schemes= set() + supported_schemes = set() registry = gst.registry_get_default() for factory in registry.get_feature_list(gst.TYPE_ELEMENT_FACTORY): diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index 0c91f291..b7070454 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -8,7 +8,7 @@ import pykka from mopidy import settings from mopidy.audio import utils from mopidy.backends import base -from mopidy.models import SearchResult, Track +from mopidy.models import Track logger = logging.getLogger('mopidy.backends.stream') From b009606df5eb5a74cf2d372a0ad6bca575afb87b Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 2 Jan 2013 22:29:07 +0100 Subject: [PATCH 18/30] docs: Document new backend --- docs/api/backends.rst | 3 ++- docs/modules/backends/stream.rst | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 docs/modules/backends/stream.rst diff --git a/docs/api/backends.rst b/docs/api/backends.rst index f0aadd53..32c04d37 100644 --- a/docs/api/backends.rst +++ b/docs/api/backends.rst @@ -46,5 +46,6 @@ Backend implementations ======================= * :mod:`mopidy.backends.dummy` -* :mod:`mopidy.backends.spotify` * :mod:`mopidy.backends.local` +* :mod:`mopidy.backends.spotify` +* :mod:`mopidy.backends.stream` diff --git a/docs/modules/backends/stream.rst b/docs/modules/backends/stream.rst new file mode 100644 index 00000000..73e53048 --- /dev/null +++ b/docs/modules/backends/stream.rst @@ -0,0 +1,7 @@ +*********************************************** +:mod:`mopidy.backends.stream` -- Stream backend +*********************************************** + +.. automodule:: mopidy.backends.stream + :synopsis: Backend for playing audio streams + :members: From 052efc23eda7e73c3db4b23a0c93986c1e85be50 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 2 Jan 2013 22:32:58 +0100 Subject: [PATCH 19/30] docs: Add stream backend to changelog --- docs/changes.rst | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/changes.rst b/docs/changes.rst index fba9b80e..22f221cd 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,7 +10,7 @@ v0.12.0 (in development) (in development) -**Spotify** +**Spotify backend** - Let GStreamer handle time position tracking and seeks. (Fixes: :issue:`191`) @@ -18,6 +18,23 @@ v0.12.0 (in development) - Make ``mopidy-scan`` support symlinks. +**Stream backend** + +We've added a new backend for playing audio streams, the :mod:`stream backend +`. It is activated by default. + +The stream backend supports the intersection of what your GStreamer +installation supports and what protocols are included in the +:attr:`mopidy.settings.STREAM_PROTOCOLS` settings. + +Current limitations: + +- No metadata about the current track in the stream is available. + +- Playlists are not parsed, so you can't play e.g. a M3U or PLS file which + contains stream URIs. You need to extract the stream URL from the playlist + yourself. See :issue:`303` for progress on this. + v0.11.0 (2012-12-24) ==================== From bd8ab175ed581527d16838aa1bfcb62c6d775409 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 2 Jan 2013 22:35:43 +0100 Subject: [PATCH 20/30] docs: Add period for consistency --- mopidy/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 9d99a7cb..6ee9357e 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -306,7 +306,7 @@ SPOTIFY_TIMEOUT = 10 #: Whitelist of URIs to support streaming from. #: -#: Used by :mod:`mopidy.backends.stream` +#: Used by :mod:`mopidy.backends.stream`. #: #: Default:: #: From fa929fd01da8d5aad193102a16ff7a8b8265069f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 2 Jan 2013 22:36:29 +0100 Subject: [PATCH 21/30] docs: Add more periods for consistency --- mopidy/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy/settings.py b/mopidy/settings.py index 6ee9357e..fd3dfd6f 100644 --- a/mopidy/settings.py +++ b/mopidy/settings.py @@ -288,7 +288,7 @@ SPOTIFY_PROXY_USERNAME = None #: Spotify proxy password. #: -#: Used by :mod:`mopidy.backends.spotify` +#: Used by :mod:`mopidy.backends.spotify`. #: #: Default:: #: @@ -297,7 +297,7 @@ SPOTIFY_PROXY_PASSWORD = None #: Max number of seconds to wait for Spotify operations to complete. #: -#: Used by :mod:`mopidy.backends.spotify` +#: Used by :mod:`mopidy.backends.spotify`. #: #: Default:: #: From 62cbcee5d76d17035ce4d8fc04191214db29409f Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Wed, 2 Jan 2013 23:40:59 +0100 Subject: [PATCH 22/30] Fix Python 2.6.0/2.6.1 support (fixes #302) --- docs/changes.rst | 3 +++ mopidy/__main__.py | 19 +++++++++++-------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index 22f221cd..dd82dd5f 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,6 +10,9 @@ v0.12.0 (in development) (in development) +- Make Mopidy work on Python 2.6 versions less than 2.6.2rc1. (Fixes: + :issue:`302`) + **Spotify backend** - Let GStreamer handle time position tracking and seeks. (Fixes: :issue:`191`) diff --git a/mopidy/__main__.py b/mopidy/__main__.py index 952f158c..e111fcef 100644 --- a/mopidy/__main__.py +++ b/mopidy/__main__.py @@ -79,37 +79,40 @@ def main(): def parse_options(): parser = optparse.OptionParser( version='Mopidy %s' % versioning.get_version()) + # NOTE Python 2.6: To support Python versions < 2.6.2rc1 we must use + # bytestrings for the first argument to ``add_option`` + # See https://github.com/mopidy/mopidy/issues/302 for details parser.add_option( - '--help-gst', + b'--help-gst', action='store_true', dest='help_gst', help='show GStreamer help options') parser.add_option( - '-i', '--interactive', + b'-i', '--interactive', action='store_true', dest='interactive', help='ask interactively for required settings which are missing') parser.add_option( - '-q', '--quiet', + b'-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') parser.add_option( - '-v', '--verbose', + b'-v', '--verbose', action='count', default=1, dest='verbosity_level', help='more output (debug level)') parser.add_option( - '--save-debug-log', + b'--save-debug-log', action='store_true', dest='save_debug_log', help='save debug log to "./mopidy.log"') parser.add_option( - '--list-settings', + b'--list-settings', action='callback', callback=settings_utils.list_settings_optparse_callback, help='list current settings') parser.add_option( - '--list-deps', + b'--list-deps', action='callback', callback=deps.list_deps_optparse_callback, help='list dependencies and their versions') parser.add_option( - '--debug-thread', + b'--debug-thread', action='store_true', dest='debug_thread', help='run background thread that dumps tracebacks on SIGUSR1') return parser.parse_args(args=mopidy_args)[0] From d34ba24cfee27b12b9de0524453926ce80c42d59 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 3 Jan 2013 10:04:05 +0100 Subject: [PATCH 23/30] Use bytestrings for the keys of **kwargs dicts Python < 2.6.5rc1 does not work with unicode keys. Fixes #302. --- docs/changes.rst | 9 +++++-- mopidy/backends/local/translator.py | 39 ++++++++++++++++------------- mopidy/scanner.py | 10 +++++--- tests/scanner_test.py | 36 ++++++++++++++------------ 4 files changed, 55 insertions(+), 39 deletions(-) diff --git a/docs/changes.rst b/docs/changes.rst index dd82dd5f..c2b076fc 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -10,8 +10,13 @@ v0.12.0 (in development) (in development) -- Make Mopidy work on Python 2.6 versions less than 2.6.2rc1. (Fixes: - :issue:`302`) +- Make Mopidy work on early Python 2.6 versions. (Fixes: :issue:`302`) + + - ``optparse`` fails if the first argument to ``add_option`` is a unicode + string on Python < 2.6.2rc1. + + - ``foo(**data)`` fails if the keys in ``data`` is unicode strings on Python + < 2.6.5rc1. **Spotify backend** diff --git a/mopidy/backends/local/translator.py b/mopidy/backends/local/translator.py index 390fd92a..157804b4 100644 --- a/mopidy/backends/local/translator.py +++ b/mopidy/backends/local/translator.py @@ -98,6 +98,9 @@ def _convert_mpd_data(data, tracks, music_dir): if not data: return + # NOTE: kwargs are explicitly made bytestrings to work on Python + # 2.6.0/2.6.1. See https://github.com/mopidy/mopidy/issues/302 for details. + track_kwargs = {} album_kwargs = {} artist_kwargs = {} @@ -105,38 +108,38 @@ def _convert_mpd_data(data, tracks, music_dir): if 'track' in data: if '/' in data['track']: - album_kwargs['num_tracks'] = int(data['track'].split('/')[1]) - track_kwargs['track_no'] = int(data['track'].split('/')[0]) + album_kwargs[b'num_tracks'] = int(data['track'].split('/')[1]) + track_kwargs[b'track_no'] = int(data['track'].split('/')[0]) else: - track_kwargs['track_no'] = int(data['track']) + track_kwargs[b'track_no'] = int(data['track']) if 'artist' in data: - artist_kwargs['name'] = data['artist'] - albumartist_kwargs['name'] = data['artist'] + artist_kwargs[b'name'] = data['artist'] + albumartist_kwargs[b'name'] = data['artist'] if 'albumartist' in data: - albumartist_kwargs['name'] = data['albumartist'] + albumartist_kwargs[b'name'] = data['albumartist'] if 'album' in data: - album_kwargs['name'] = data['album'] + album_kwargs[b'name'] = data['album'] if 'title' in data: - track_kwargs['name'] = data['title'] + track_kwargs[b'name'] = data['title'] if 'date' in data: - track_kwargs['date'] = data['date'] + track_kwargs[b'date'] = data['date'] if 'musicbrainz_trackid' in data: - track_kwargs['musicbrainz_id'] = data['musicbrainz_trackid'] + track_kwargs[b'musicbrainz_id'] = data['musicbrainz_trackid'] if 'musicbrainz_albumid' in data: - album_kwargs['musicbrainz_id'] = data['musicbrainz_albumid'] + album_kwargs[b'musicbrainz_id'] = data['musicbrainz_albumid'] if 'musicbrainz_artistid' in data: - artist_kwargs['musicbrainz_id'] = data['musicbrainz_artistid'] + artist_kwargs[b'musicbrainz_id'] = data['musicbrainz_artistid'] if 'musicbrainz_albumartistid' in data: - albumartist_kwargs['musicbrainz_id'] = ( + albumartist_kwargs[b'musicbrainz_id'] = ( data['musicbrainz_albumartistid']) if data['file'][0] == '/': @@ -147,18 +150,18 @@ def _convert_mpd_data(data, tracks, music_dir): if artist_kwargs: artist = Artist(**artist_kwargs) - track_kwargs['artists'] = [artist] + track_kwargs[b'artists'] = [artist] if albumartist_kwargs: albumartist = Artist(**albumartist_kwargs) - album_kwargs['artists'] = [albumartist] + album_kwargs[b'artists'] = [albumartist] if album_kwargs: album = Album(**album_kwargs) - track_kwargs['album'] = album + track_kwargs[b'album'] = album - track_kwargs['uri'] = path_to_uri(music_dir, path) - track_kwargs['length'] = int(data.get('time', 0)) * 1000 + track_kwargs[b'uri'] = path_to_uri(music_dir, path) + track_kwargs[b'length'] = int(data.get('time', 0)) * 1000 track = Track(**track_kwargs) tracks.add(track) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 0b10d061..68d7440a 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -96,9 +96,13 @@ def translator(data): artist_kwargs = {} track_kwargs = {} + # NOTE: kwargs are explicitly made bytestrings to work on Python + # 2.6.0/2.6.1. See https://github.com/mopidy/mopidy/issues/302 for + # details. + def _retrieve(source_key, target_key, target): if source_key in data: - target[target_key] = data[source_key] + target[str(target_key)] = data[source_key] _retrieve(gst.TAG_ALBUM, 'name', album_kwargs) _retrieve(gst.TAG_TRACK_COUNT, 'num_tracks', album_kwargs) @@ -111,7 +115,7 @@ def translator(data): except ValueError: pass # Ignore invalid dates else: - track_kwargs['date'] = date.isoformat() + track_kwargs[b'date'] = date.isoformat() _retrieve(gst.TAG_TITLE, 'name', track_kwargs) _retrieve(gst.TAG_TRACK_NUMBER, 'track_no', track_kwargs) @@ -125,7 +129,7 @@ def translator(data): 'musicbrainz-albumartistid', 'musicbrainz_id', albumartist_kwargs) if albumartist_kwargs: - album_kwargs['artists'] = [Artist(**albumartist_kwargs)] + album_kwargs[b'artists'] = [Artist(**albumartist_kwargs)] track_kwargs['uri'] = data['uri'] track_kwargs['length'] = data[gst.TAG_DURATION] diff --git a/tests/scanner_test.py b/tests/scanner_test.py index 92e9a269..d8466e26 100644 --- a/tests/scanner_test.py +++ b/tests/scanner_test.py @@ -32,36 +32,40 @@ class TranslatorTest(unittest.TestCase): 'musicbrainz-albumartistid': 'mbalbumartistid', } + # NOTE: kwargs are explicitly made bytestrings to work on Python + # 2.6.0/2.6.1. See https://github.com/mopidy/mopidy/issues/302 for + # details. + self.album = { - 'name': 'albumname', - 'num_tracks': 2, - 'musicbrainz_id': 'mbalbumid', + b'name': 'albumname', + b'num_tracks': 2, + b'musicbrainz_id': 'mbalbumid', } self.artist = { - 'name': 'name', - 'musicbrainz_id': 'mbartistid', + b'name': 'name', + b'musicbrainz_id': 'mbartistid', } self.albumartist = { - 'name': 'albumartistname', - 'musicbrainz_id': 'mbalbumartistid', + b'name': 'albumartistname', + b'musicbrainz_id': 'mbalbumartistid', } self.track = { - 'uri': 'uri', - 'name': 'trackname', - 'date': '2006-01-01', - 'track_no': 1, - 'length': 4531, - 'musicbrainz_id': 'mbtrackid', + b'uri': 'uri', + b'name': 'trackname', + b'date': '2006-01-01', + b'track_no': 1, + b'length': 4531, + b'musicbrainz_id': 'mbtrackid', } def build_track(self): if self.albumartist: - self.album['artists'] = [Artist(**self.albumartist)] - self.track['album'] = Album(**self.album) - self.track['artists'] = [Artist(**self.artist)] + self.album[b'artists'] = [Artist(**self.albumartist)] + self.track[b'album'] = Album(**self.album) + self.track[b'artists'] = [Artist(**self.artist)] return Track(**self.track) def check(self): From 5ff8ea451f0b8e08707822ed998327e21dd133be Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 3 Jan 2013 12:58:08 +0100 Subject: [PATCH 24/30] More **kwargs key fixing (#302) --- mopidy/scanner.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index 68d7440a..aba2491c 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -131,10 +131,10 @@ def translator(data): if albumartist_kwargs: album_kwargs[b'artists'] = [Artist(**albumartist_kwargs)] - track_kwargs['uri'] = data['uri'] - track_kwargs['length'] = data[gst.TAG_DURATION] - track_kwargs['album'] = Album(**album_kwargs) - track_kwargs['artists'] = [Artist(**artist_kwargs)] + track_kwargs[b'uri'] = data['uri'] + track_kwargs[b'length'] = data[gst.TAG_DURATION] + track_kwargs[b'album'] = Album(**album_kwargs) + track_kwargs[b'artists'] = [Artist(**artist_kwargs)] return Track(**track_kwargs) From ef3d5e92ceeb1ca0619885f4c6707ce6b4062b31 Mon Sep 17 00:00:00 2001 From: Stein Magnus Jodal Date: Thu, 3 Jan 2013 13:14:21 +0100 Subject: [PATCH 25/30] scanner: Fix optparse on early 2.6 (#302) --- mopidy/scanner.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/mopidy/scanner.py b/mopidy/scanner.py index aba2491c..9f8c12f7 100644 --- a/mopidy/scanner.py +++ b/mopidy/scanner.py @@ -79,12 +79,15 @@ def main(): def parse_options(): parser = optparse.OptionParser( version='Mopidy %s' % versioning.get_version()) + # NOTE Python 2.6: To support Python versions < 2.6.2rc1 we must use + # bytestrings for the first argument to ``add_option`` + # See https://github.com/mopidy/mopidy/issues/302 for details parser.add_option( - '-q', '--quiet', + b'-q', '--quiet', action='store_const', const=0, dest='verbosity_level', help='less output (warning level)') parser.add_option( - '-v', '--verbose', + b'-v', '--verbose', action='count', default=1, dest='verbosity_level', help='more output (debug level)') return parser.parse_args(args=mopidy_args)[0] From 8d2656f75c15f7e64e53d180566f425110f5ff9b Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 3 Jan 2013 14:27:13 +0100 Subject: [PATCH 26/30] Temporary workaround for #300 Likely cause of this issue is libspotify getting the intial seek to early. We have not yet had time to dig beyond this point and develop has been broken for to long due to this. As such this work aroundly simply ignores the first seek to position zero outright, this avoiding what is likely a race condition in libspotify. Next step will be to create a minimal libspotify/pyspotify test case for this to verify that assumption and hopefully figure out a correct fix. We also need to look into if the intial seek can be avoided in gstreamer. --- mopidy/backends/spotify/playback.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index d7e622fb..cead01bf 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -27,6 +27,10 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): 'width=(int)16, depth=(int)16, signed=(boolean)true, ' 'rate=(int)44100') + def __init__(self, *args, **kwargs): + super(SpotifyPlaybackProvider, self).__init__(*args, **kwargs) + self._first_seek = False + def play(self, track): if track.uri is None: return False @@ -35,6 +39,8 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): seek_data_callback_bound = functools.partial( seek_data_callback, spotify_backend) + self._first_seek = True + try: self.backend.spotify.session.load( Link.from_string(track.uri).as_track()) @@ -59,5 +65,11 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): def on_seek_data(self, time_position): logger.debug('playback.on_seek_data(%d) called', time_position) + + if time_position == 0 and self._first_seek: + self._first_seek = False + logger.debug('Skipping seek due to issue #300') + return + self.backend.spotify.buffer_timestamp = time_position * gst.MSECOND self.backend.spotify.session.seek(time_position) From 88398ea355dff692c93e0a3220deeaa60ebd00a1 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 3 Jan 2013 15:35:52 +0100 Subject: [PATCH 27/30] Add new mopidy.audio.utils functions (fixes #301). Adds functions to create buffers, calcalute buffer durations based on number of samples and the sample rate and converting milliseconds to gst internal clock time. This also alows for the removal of all gst imports outside of mopidy.audio. --- mopidy/audio/__init__.py | 2 ++ mopidy/audio/utils.py | 27 ++++++++++++++++++++++ mopidy/backends/spotify/playback.py | 8 +++---- mopidy/backends/spotify/session_manager.py | 15 +++++------- mopidy/backends/stream/actor.py | 8 +++---- 5 files changed, 42 insertions(+), 18 deletions(-) diff --git a/mopidy/audio/__init__.py b/mopidy/audio/__init__.py index 7cf1dcee..5adb333c 100644 --- a/mopidy/audio/__init__.py +++ b/mopidy/audio/__init__.py @@ -4,3 +4,5 @@ from __future__ import unicode_literals from .actor import Audio from .listener import AudioListener from .constants import PlaybackState +from .utils import (calculate_duration, create_buffer, millisecond_to_clocktime, + supported_uri_schemes) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index e9eac9f8..af80b3ab 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -5,6 +5,33 @@ pygst.require('0.10') import gst +def calculate_duration(num_samples, sample_rate): + """Determine duration of samples using a gst helper for preciese math.""" + return gst.util_uint64_scale(num_samples, gst.SECOND, sample_rate) + + +def create_buffer(data, capabilites=None, timestamp=None, duration=None): + """Create a new gstreamer buffer based on provided data. + + Mainly intended to keep gst imports out of non audio modules. + """ + buffer_ = gst.Buffer(data) + if capabilites: + if isinstance(capabilites, basestring): + capabilites = gst.caps_from_string(capabilites) + buffer_.set_caps(capabilites) + if timestamp: + buffer_.timestamp = timestamp + if duration: + buffer_.duration = duration + return buffer_ + + +def millisecond_to_clocktime(value): + """Convert a millisecond time to internal gstreamer time.""" + return value * gst.MSECOND + + def supported_uri_schemes(uri_schemes): """Determine which URIs we can actually support from provided whitelist. diff --git a/mopidy/backends/spotify/playback.py b/mopidy/backends/spotify/playback.py index cead01bf..36d90cac 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -1,14 +1,11 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst - import logging import functools from spotify import Link, SpotifyError +from mopidy import audio from mopidy.backends import base @@ -71,5 +68,6 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): logger.debug('Skipping seek due to issue #300') return - self.backend.spotify.buffer_timestamp = time_position * gst.MSECOND + self.backend.spotify.buffer_timestamp = audio.millisecond_to_clocktime( + time_position) self.backend.spotify.session.seek(time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index d372bfa4..7f71dc76 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -1,9 +1,5 @@ from __future__ import unicode_literals -import pygst -pygst.require('0.10') -import gst - import logging import os import threading @@ -122,12 +118,13 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): 'channels': channels, } - buffer_ = gst.Buffer(bytes(frames)) - buffer_.set_caps(gst.caps_from_string(capabilites)) - buffer_.timestamp = self.buffer_timestamp - buffer_.duration = num_frames * gst.SECOND / sample_rate + duration = audio.calculate_duration(num_frames, sample_rate) + buffer_ = audio.create_buffer(bytes(frames), + capabilites=capabilites, + timestamp=self.buffer_timestamp, + duration=duration) - self.buffer_timestamp += buffer_.duration + self.buffer_timestamp += duration if self.audio.emit_data(buffer_).get(): return num_frames diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index b7070454..99b32195 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -5,8 +5,7 @@ import urlparse import pykka -from mopidy import settings -from mopidy.audio import utils +from mopidy import audio as audio_lib, settings from mopidy.backends import base from mopidy.models import Track @@ -18,10 +17,11 @@ class StreamBackend(pykka.ThreadingActor, base.Backend): super(StreamBackend, self).__init__() self.library = StreamLibraryProvider(backend=self) - self.playback = base.BasePlaybackProvider(audio=audio, backend=self) + self.playback = base.BasePlaybackProvider(audio=audio, + backend=self) self.playlists = None - self.uri_schemes = utils.supported_uri_schemes( + self.uri_schemes = audio_lib.supported_uri_schemes( settings.STREAM_PROTOCOLS) From d5b19ab213604d193d1059428df05a0f3287a165 Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 3 Jan 2013 15:45:48 +0100 Subject: [PATCH 28/30] audio: Docstring typo fixing. --- mopidy/audio/utils.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index af80b3ab..66084a16 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -6,14 +6,14 @@ import gst def calculate_duration(num_samples, sample_rate): - """Determine duration of samples using a gst helper for preciese math.""" + """Determine duration of samples using GStremer helper for precise math.""" return gst.util_uint64_scale(num_samples, gst.SECOND, sample_rate) def create_buffer(data, capabilites=None, timestamp=None, duration=None): - """Create a new gstreamer buffer based on provided data. + """Create a new GStreamer buffer based on provided data. - Mainly intended to keep gst imports out of non audio modules. + Mainly intended to keep gst imports out of non-audio modules. """ buffer_ = gst.Buffer(data) if capabilites: From 364f0c68e8325c24e7fb7f9e6c676df3fa62f80a Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 3 Jan 2013 15:47:03 +0100 Subject: [PATCH 29/30] audio: Unwrap line that was less than 80 chars. --- mopidy/backends/stream/actor.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/mopidy/backends/stream/actor.py b/mopidy/backends/stream/actor.py index 99b32195..f80ac7a9 100644 --- a/mopidy/backends/stream/actor.py +++ b/mopidy/backends/stream/actor.py @@ -17,8 +17,7 @@ class StreamBackend(pykka.ThreadingActor, base.Backend): super(StreamBackend, self).__init__() self.library = StreamLibraryProvider(backend=self) - self.playback = base.BasePlaybackProvider(audio=audio, - backend=self) + self.playback = base.BasePlaybackProvider(audio=audio, backend=self) self.playlists = None self.uri_schemes = audio_lib.supported_uri_schemes( From 855987447b87f6a91f02a8dfd8751fc6d28cb08c Mon Sep 17 00:00:00 2001 From: Thomas Adamcik Date: Thu, 3 Jan 2013 15:51:09 +0100 Subject: [PATCH 30/30] audio: Docstring typo typo fixing. --- mopidy/audio/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 66084a16..9d0f46dd 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -6,7 +6,7 @@ import gst def calculate_duration(num_samples, sample_rate): - """Determine duration of samples using GStremer helper for precise math.""" + """Determine duration of samples using GStreamer helper for precise math.""" return gst.util_uint64_scale(num_samples, gst.SECOND, sample_rate)