diff --git a/docs/changes.rst b/docs/changes.rst index c2b076fc..a0562628 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -18,6 +18,8 @@ v0.12.0 (in development) - ``foo(**data)`` fails if the keys in ``data`` is unicode strings on Python < 2.6.5rc1. +- Improve selection of mixer tracks for volume control. (Fixes: :issuse:`307`) + **Spotify backend** - Let GStreamer handle time position tracking and seeks. (Fixes: :issue:`191`) @@ -44,6 +46,13 @@ Current limitations: yourself. See :issue:`303` for progress on this. +v0.11.1 (2012-12-24) +==================== + +Spotify search was broken in 0.11.0 for users of Python 2.6. This release fixes +it. If you're using Python 2.7, v0.11.0 and v0.11.1 should be equivalent. + + v0.11.0 (2012-12-24) ==================== diff --git a/js/README.rst b/js/README.rst index a68dd9a0..e8782213 100644 --- a/js/README.rst +++ b/js/README.rst @@ -36,40 +36,27 @@ Building from source sudo apt-get update sudo apt-get install nodejs npm -2. Assuming you install from PPA, setup your ``NODE_PATH`` environment variable - to include ``/usr/lib/node_modules``. Add the following to your - ``~/.bashrc`` or equivalent:: - - export NODE_PATH=/usr/lib/node_modules:$NODE_PATH - -3. Install `Buster.js `_ and `Grunt - `_ globally (or locally, and make sure you get their - binaries on your ``PATH``):: - - sudo npm -g install buster grunt - -4. Install the grunt-buster Grunt plugin locally, when in the ``js/`` dir:: +2. Enter the ``js/`` dir and install development dependencies:: cd js/ - npm install grunt-buster + npm install -5. Install `PhantomJS `_ so that we can run the tests - without a browser:: +That's it. - sudo apt-get install phantomjs +You can now run the tests:: - It is packaged in Ubuntu since 12.04, but I haven't tested with versions - older than 1.6 which is the one packaged in Ubuntu 12.10. + npm test -6. Run Grunt to lint, test, concatenate, and minify the source:: +To run tests automatically when you save a file:: - grunt + npm run-script watch - The files in ``../mopidy/frontends/http/data/`` should now be up to date. +To run tests, concatenate, minify the source, and update the JavaScript files +in ``mopidy/frontends/http/data/``:: + npm run-script build -Development tips -================ +To run other `grunt `_ targets which isn't predefined in +``package.json`` and thus isn't available through ``npm run-script``:: -If you're coding on the JavaScript library, you should know about ``grunt -watch``. It lints and tests the code every time you save a file. + PATH=./node_modules/.bin:$PATH grunt foo diff --git a/js/grunt.js b/js/grunt.js index d835fd77..46afc8af 100644 --- a/js/grunt.js +++ b/js/grunt.js @@ -64,7 +64,9 @@ module.exports = function (grunt) { uglify: {} }); - grunt.registerTask("default", "lint buster concat min"); + grunt.registerTask("test", "lint buster"); + grunt.registerTask("build", "lint buster concat min"); + grunt.registerTask("default", "build"); grunt.loadNpmTasks("grunt-buster"); }; diff --git a/js/package.json b/js/package.json new file mode 100644 index 00000000..a8737cfb --- /dev/null +++ b/js/package.json @@ -0,0 +1,15 @@ +{ + "name": "mopidy", + "version": "0.0.0", + "devDependencies": { + "buster": "*", + "grunt": "*", + "grunt-buster": "*", + "phantomjs": "*" + }, + "scripts": { + "test": "grunt test", + "build": "grunt build", + "watch": "grunt watch" + } +} diff --git a/mopidy/__init__.py b/mopidy/__init__.py index 2e5aeeba..9d66b722 100644 --- a/mopidy/__init__.py +++ b/mopidy/__init__.py @@ -23,7 +23,7 @@ if (isinstance(pykka.__version__, basestring) warnings.filterwarnings('ignore', 'could not open display') -__version__ = '0.11.0' +__version__ = '0.11.1' from mopidy import settings as default_settings_module diff --git a/mopidy/audio/actor.py b/mopidy/audio/actor.py index f812d49f..206eae09 100644 --- a/mopidy/audio/actor.py +++ b/mopidy/audio/actor.py @@ -12,7 +12,7 @@ import pykka from mopidy import settings from mopidy.utils import process -from . import mixers +from . import mixers, utils from .constants import PlaybackState from .listener import AudioListener @@ -20,6 +20,8 @@ logger = logging.getLogger('mopidy.audio') mixers.register_mixers() +MB = 1 << 20 + class Audio(pykka.ThreadingActor): """ @@ -39,6 +41,7 @@ class Audio(pykka.ThreadingActor): super(Audio, self).__init__() self._playbin = None + self._signal_ids = {} # {(element, event): signal_id} self._mixer = None self._mixer_track = None @@ -48,12 +51,9 @@ class Audio(pykka.ThreadingActor): self._appsrc = None self._appsrc_caps = None + self._appsrc_need_data_callback = None + self._appsrc_enough_data_callback = None self._appsrc_seek_data_callback = None - self._appsrc_seek_data_id = None - - self._notify_source_signal_id = None - self._about_to_finish_id = None - self._message_signal_id = None def on_start(self): try: @@ -70,26 +70,36 @@ class Audio(pykka.ThreadingActor): self._teardown_mixer() self._teardown_playbin() + def _connect(self, element, event, *args): + """Helper to keep track of signal ids based on element+event""" + self._signal_ids[(element, event)] = element.connect(event, *args) + + def _disconnect(self, element, event): + """Helper to disconnect signals created with _connect helper.""" + signal_id = self._signal_ids.pop((element, event), None) + if signal_id is not None: + element.disconnect(signal_id) + def _setup_playbin(self): - self._playbin = gst.element_factory_make('playbin2') + playbin = gst.element_factory_make('playbin2') fakesink = gst.element_factory_make('fakesink') - self._playbin.set_property('video-sink', fakesink) + playbin.set_property('video-sink', fakesink) - self._about_to_finish_id = self._playbin.connect( - 'about-to-finish', self._on_about_to_finish) - self._notify_source_signal_id = self._playbin.connect( - 'notify::source', self._on_new_source) + self._connect(playbin, 'about-to-finish', self._on_about_to_finish) + self._connect(playbin, 'notify::source', self._on_new_source) + + self._playbin = playbin def _on_about_to_finish(self, element): # Cleanup appsrc related stuff. old_appsrc, self._appsrc = self._appsrc, None - if self._appsrc_seek_data_id is not None and old_appsrc: - old_appsrc.disconnect(self._appsrc_seek_data_id) + self._disconnect(old_appsrc, 'need-data') + self._disconnect(old_appsrc, 'enough-data') + self._disconnect(old_appsrc, 'seek-data') self._appsrc_caps = None - self._appsrc_seek_data_id = None # TODO: this is just a horrible hack to get us started. the # comunication is correct, but this way of hooking it up is not. @@ -112,23 +122,35 @@ class Audio(pykka.ThreadingActor): source.set_property('caps', self._appsrc_caps) source.set_property('format', b'time') source.set_property('stream-type', b'seekable') + source.set_property('max-bytes', 1 * MB) + source.set_property('min-percent', 50) - self._appsrc_seek_data_id = source.connect( - 'seek-data', self._appsrc_on_seek_data) + self._connect(source, 'need-data', self._appsrc_on_need_data) + self._connect(source, 'enough-data', self._appsrc_on_enough_data) + self._connect(source, 'seek-data', self._appsrc_on_seek_data) self._appsrc = source - def _appsrc_on_seek_data(self, appsrc, time_in_ns): - time_in_ms = time_in_ns // gst.MSECOND + def _appsrc_on_need_data(self, appsrc, gst_length_hint): + length_hint = utils.clocktime_to_millisecond(gst_length_hint) + if self._appsrc_need_data_callback is not None: + self._appsrc_need_data_callback(length_hint) + return True + + def _appsrc_on_enough_data(self, appsrc): + if self._appsrc_enough_data_callback is not None: + self._appsrc_enough_data_callback() + return True + + def _appsrc_on_seek_data(self, appsrc, gst_position): + position = utils.clocktime_to_millisecond(gst_position) if self._appsrc_seek_data_callback is not None: - self._appsrc_seek_data_callback(time_in_ms) + self._appsrc_seek_data_callback(position) return True def _teardown_playbin(self): - if self._about_to_finish_id: - self._playbin.disconnect(self._about_to_finish_id) - if self._notify_source_signal_id: - self._playbin.disconnect(self._notify_source_signal_id) + self._disconnect(self._playbin, 'about-to-finish') + self._disconnect(self._playbin, 'notify::source') self._playbin.set_state(gst.STATE_NULL) def _setup_output(self): @@ -186,15 +208,23 @@ class Audio(pykka.ThreadingActor): mixer.get_factory().get_name(), track.label) def _select_mixer_track(self, mixer, track_label): - # Look for track with label == MIXER_TRACK, otherwise fallback to - # master track which is also an output. + # Ignore tracks without volumes, then look for track with + # label == settings.MIXER_TRACK, otherwise fallback to first usable + # track hoping the mixer gave them to us in a sensible order. + + usable_tracks = [] for track in mixer.list_tracks(): - if track_label: - if track.label == track_label: - return track + if not mixer.get_volume(track): + continue + + if track_label and track.label == track_label: + return track elif track.flags & (gst.interfaces.MIXER_TRACK_MASTER | gst.interfaces.MIXER_TRACK_OUTPUT): - return track + usable_tracks.append(track) + + if usable_tracks: + return usable_tracks[0] def _teardown_mixer(self): if self._mixer is not None: @@ -203,19 +233,21 @@ class Audio(pykka.ThreadingActor): def _setup_message_processor(self): bus = self._playbin.get_bus() bus.add_signal_watch() - self._message_signal_id = bus.connect('message', self._on_message) + self._connect(bus, 'message', self._on_message) def _teardown_message_processor(self): - if self._message_signal_id: - bus = self._playbin.get_bus() - bus.disconnect(self._message_signal_id) - bus.remove_signal_watch() + bus = self._playbin.get_bus() + self._disconnect(bus, 'message') + bus.remove_signal_watch() def _on_message(self, bus, message): if (message.type == gst.MESSAGE_STATE_CHANGED and message.src == self._playbin): old_state, new_state, pending_state = message.parse_state_changed() self._on_playbin_state_changed(old_state, new_state, pending_state) + elif message.type == gst.MESSAGE_BUFFERING: + percent = message.parse_buffering() + logger.debug('Buffer %d%% full', percent) elif message.type == gst.MESSAGE_EOS: self._on_end_of_stream() elif message.type == gst.MESSAGE_ERROR: @@ -270,7 +302,8 @@ class Audio(pykka.ThreadingActor): """ self._playbin.set_property('uri', uri) - def set_appsrc(self, caps, seek_data=None): + def set_appsrc( + self, caps, need_data=None, enough_data=None, seek_data=None): """ Switch to using appsrc for getting audio to be played. @@ -279,6 +312,10 @@ class Audio(pykka.ThreadingActor): :param caps: GStreamer caps string describing the audio format to expect :type caps: string + :param need_data: callback for when appsrc needs data + :type need_data: callable which takes data length hint in ms + :param enough_data: callback for when appsrc has enough data + :type enough_data: callable :param seek_data: callback for when data from a new position is needed to continue playback :type seek_data: callable which takes time position in ms @@ -286,6 +323,8 @@ class Audio(pykka.ThreadingActor): if isinstance(caps, unicode): caps = caps.encode('utf-8') self._appsrc_caps = gst.Caps(caps) + self._appsrc_need_data_callback = need_data + self._appsrc_enough_data_callback = enough_data self._appsrc_seek_data_callback = seek_data self._playbin.set_property('uri', 'appsrc://') @@ -322,8 +361,8 @@ class Audio(pykka.ThreadingActor): :rtype: int """ try: - position = self._playbin.query_position(gst.FORMAT_TIME)[0] - return position // gst.MSECOND + gst_position = self._playbin.query_position(gst.FORMAT_TIME)[0] + return utils.clocktime_to_millisecond(gst_position) except gst.QueryError: logger.debug('Position query failed') return 0 @@ -336,9 +375,9 @@ class Audio(pykka.ThreadingActor): :type position: int :rtype: :class:`True` if successful, else :class:`False` """ + gst_position = utils.millisecond_to_clocktime(position) return self._playbin.seek_simple( - gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, - position * gst.MSECOND) + gst.Format(gst.FORMAT_TIME), gst.SEEK_FLAG_FLUSH, gst_position) def start_playback(self): """ diff --git a/mopidy/audio/utils.py b/mopidy/audio/utils.py index 9d0f46dd..15196b20 100644 --- a/mopidy/audio/utils.py +++ b/mopidy/audio/utils.py @@ -6,7 +6,8 @@ import gst def calculate_duration(num_samples, sample_rate): - """Determine duration of samples using GStreamer 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) @@ -28,10 +29,15 @@ def create_buffer(data, capabilites=None, timestamp=None, duration=None): def millisecond_to_clocktime(value): - """Convert a millisecond time to internal gstreamer time.""" + """Convert a millisecond time to internal GStreamer time.""" return value * gst.MSECOND +def clocktime_to_millisecond(value): + """Convert an internal GStreamer time to millisecond 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 c8d4a659..f8de2e57 100644 --- a/mopidy/backends/spotify/playback.py +++ b/mopidy/backends/spotify/playback.py @@ -12,8 +12,15 @@ from mopidy.backends import base logger = logging.getLogger('mopidy.backends.spotify') +def need_data_callback(spotify_backend, length_hint): + spotify_backend.playback.on_need_data(length_hint) + + +def enough_data_callback(spotify_backend): + spotify_backend.playback.on_enough_data() + + def seek_data_callback(spotify_backend, time_position): - logger.debug('seek_data_callback(%d) called', time_position) spotify_backend.playback.on_seek_data(time_position) @@ -29,12 +36,22 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): self._first_seek = False def change_track(self, track): + spotify_backend = self.backend.actor_ref.proxy() + + need_data_callback_bound = functools.partial( + need_data_callback, spotify_backend) + enough_data_callback_bound = functools.partial( + enough_data_callback, spotify_backend) seek_data_callback_bound = functools.partial( - seek_data_callback, self.backend.actor_ref.proxy()) + seek_data_callback, spotify_backend) self._first_seek = True - self.audio.set_appsrc(self._caps, seek_data=seek_data_callback_bound) + self.audio.set_appsrc( + self._caps, + need_data=need_data_callback_bound, + enough_data=enough_data_callback_bound, + seek_data=seek_data_callback_bound) self.audio.set_metadata(track) try: @@ -42,7 +59,6 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): Link.from_string(track.uri).as_track()) self.backend.spotify.buffer_timestamp = 0 self.backend.spotify.session.play(1) - return True except SpotifyError as e: logger.info('Playback of %s failed: %s', track.uri, e) @@ -52,6 +68,14 @@ class SpotifyPlaybackProvider(base.BasePlaybackProvider): self.backend.spotify.session.play(0) return super(SpotifyPlaybackProvider, self).stop() + def on_need_data(self, length_hint): + logger.debug('playback.on_need_data(%d) called', length_hint) + self.backend.spotify.push_audio_data = True + + def on_enough_data(self): + logger.debug('playback.on_enough_data() called') + self.backend.spotify.push_audio_data = False + def on_seek_data(self, time_position): logger.debug('playback.on_seek_data(%d) called', time_position) diff --git a/mopidy/backends/spotify/session_manager.py b/mopidy/backends/spotify/session_manager.py index 7f71dc76..6f386aae 100644 --- a/mopidy/backends/spotify/session_manager.py +++ b/mopidy/backends/spotify/session_manager.py @@ -42,6 +42,7 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): self.backend_ref = backend_ref self.connected = threading.Event() + self.push_audio_data = True self.buffer_timestamp = 0 self.container_manager = None @@ -104,6 +105,10 @@ class SpotifySessionManager(process.BaseThread, PyspotifySessionManager): """Callback used by pyspotify""" # pylint: disable = R0913 # Too many arguments (8/5) + + if not self.push_audio_data: + return 0 + assert sample_type == 0, 'Expects 16-bit signed integer samples' capabilites = """ audio/x-raw-int, diff --git a/mopidy/frontends/http/__init__.py b/mopidy/frontends/http/__init__.py index 94b8e58e..ab8dff42 100644 --- a/mopidy/frontends/http/__init__.py +++ b/mopidy/frontends/http/__init__.py @@ -198,7 +198,7 @@ them: .. code-block:: js - mopidy.on(console.log); + mopidy.on(console.log.bind(console)); Several types of events are emitted: @@ -289,7 +289,8 @@ Instead, typical usage will look like this: } }; - mopidy.playback.getCurrentTrack().then(printCurrentTrack, console.error); + mopidy.playback.getCurrentTrack().then( + printCurrentTrack, console.error.bind(console)); The first function passed to ``then()``, ``printCurrentTrack``, is the callback that will be called if the method call succeeds. The second function, @@ -303,7 +304,7 @@ callback: .. code-block:: js - mopidy.playback.next().then(null, console.error); + mopidy.playback.next().then(null, console.error.bind(console)); The promise objects returned by Mopidy.js adheres to the `CommonJS Promises/A `_ standard. We use the @@ -368,6 +369,8 @@ Example to get started with .. code-block:: js + var consoleError = console.error.bind(error); + var trackDesc = function (track) { return track.name + " by " + track.artists[0].name + " from " + track.album.name; @@ -381,14 +384,14 @@ Example to get started with mopidy.playback.play(tlTracks[0]).then(function () { mopidy.playback.getCurrentTrack().then(function (track) { console.log("Now playing:", trackDesc(track)); - }, console.error); - }, console.error); - }, console.error); - }, console.error); + }, consoleError); + }, consoleError); + }, consoleError); + }, consoleError); }; - var mopidy = new Mopidy(); // Connect to server - mopidy.on(console.log); // Log all events + var mopidy = new Mopidy(); // Connect to server + mopidy.on(console.log.bind(console)); // Log all events mopidy.on("state:online", queueAndPlayFirstPlaylist); Approximately the same behavior in a more functional style, using chaining @@ -396,6 +399,8 @@ Example to get started with .. code-block:: js + var consoleError = console.error.bind(error); + var getFirst = function (list) { return list[0]; }; @@ -429,23 +434,23 @@ Example to get started with var queueAndPlayFirstPlaylist = function () { mopidy.playlists.getPlaylists() // => list of Playlists - .then(getFirst, console.error) + .then(getFirst, consoleError) // => Playlist - .then(printTypeAndName, console.error) + .then(printTypeAndName, consoleError) // => Playlist - .then(extractTracks, console.error) + .then(extractTracks, consoleError) // => list of Tracks - .then(mopidy.tracklist.add, console.error) + .then(mopidy.tracklist.add, consoleError) // => list of TlTracks - .then(getFirst, console.error) + .then(getFirst, consoleError) // => TlTrack - .then(mopidy.playback.play, console.error) + .then(mopidy.playback.play, consoleError) // => null - .then(printNowPlaying, console.error); + .then(printNowPlaying, consoleError); }; - var mopidy = new Mopidy(); // Connect to server - mopidy.on(console.log); // Log all events + var mopidy = new Mopidy(); // Connect to server + mopidy.on(console.log.bind(console)); // Log all events mopidy.on("state:online", queueAndPlayFirstPlaylist); 9. The web page should now queue and play your first playlist every time your diff --git a/mopidy/frontends/mpd/actor.py b/mopidy/frontends/mpd/actor.py index 11e07aa7..33ccd077 100644 --- a/mopidy/frontends/mpd/actor.py +++ b/mopidy/frontends/mpd/actor.py @@ -19,10 +19,12 @@ class MpdFrontend(pykka.ThreadingActor, CoreListener): hostname = network.format_hostname(settings.MPD_SERVER_HOSTNAME) port = settings.MPD_SERVER_PORT + # NOTE: dict key must be bytestring to work on Python < 2.6.5 + # See https://github.com/mopidy/mopidy/issues/302 for details try: network.Server( hostname, port, - protocol=session.MpdSession, protocol_kwargs={'core': core}, + protocol=session.MpdSession, protocol_kwargs={b'core': core}, max_connections=settings.MPD_SERVER_MAX_CONNECTIONS, timeout=settings.MPD_SERVER_CONNECTION_TIMEOUT) except IOError as error: diff --git a/mopidy/frontends/mpd/protocol/__init__.py b/mopidy/frontends/mpd/protocol/__init__.py index 1827624b..55a1563b 100644 --- a/mopidy/frontends/mpd/protocol/__init__.py +++ b/mopidy/frontends/mpd/protocol/__init__.py @@ -56,7 +56,12 @@ def handle_request(pattern, auth_required=True): if match is not None: mpd_commands.add( MpdCommand(name=match.group(), auth_required=auth_required)) - compiled_pattern = re.compile(pattern, flags=re.UNICODE) + # NOTE: Make pattern a bytestring to get bytestring keys in the dict + # returned from matches.groupdict(), which is again used as a **kwargs + # dict. This is needed to work on Python < 2.6.5. See + # https://github.com/mopidy/mopidy/issues/302 for details. + bytestring_pattern = pattern.encode('utf-8') + compiled_pattern = re.compile(bytestring_pattern, flags=re.UNICODE) if compiled_pattern in request_handlers: raise ValueError('Tried to redefine handler for %s with %s' % ( pattern, func)) diff --git a/tests/version_test.py b/tests/version_test.py index f353f201..1cb3967c 100644 --- a/tests/version_test.py +++ b/tests/version_test.py @@ -33,5 +33,6 @@ class VersionTest(unittest.TestCase): self.assertLess(SV('0.8.0'), SV('0.8.1')) self.assertLess(SV('0.8.1'), SV('0.9.0')) self.assertLess(SV('0.9.0'), SV('0.10.0')) - self.assertLess(SV('0.10.0'), SV(__version__)) - self.assertLess(SV(__version__), SV('0.11.1')) + self.assertLess(SV('0.10.0'), SV('0.11.0')) + self.assertLess(SV('0.11.0'), SV(__version__)) + self.assertLess(SV(__version__), SV('0.11.2'))