diff --git a/docs/changes.rst b/docs/changes.rst index b0ca8989..4317e4ef 100644 --- a/docs/changes.rst +++ b/docs/changes.rst @@ -124,6 +124,10 @@ backends: - The event :meth:`mopidy.core.CoreListener.playlist_changed` has been changed to include the playlist that was changed. +- The MPRIS playlists interface is now supported by our MPRIS frontend. This + means that you now can select playlists to queue and play from the Ubuntu + Sound Menu. + **Bug fixes** - :issue:`218`: The MPD commands ``listplaylist`` and ``listplaylistinfo`` now diff --git a/docs/clients/mpris.rst b/docs/clients/mpris.rst index 95866089..c782fa26 100644 --- a/docs/clients/mpris.rst +++ b/docs/clients/mpris.rst @@ -9,8 +9,8 @@ Specification. It's a spec that describes a standard D-Bus interface for making media players available to other applications on the same system. Mopidy's :ref:`MPRIS frontend ` currently implements all -required parts of the MPRIS spec, but not the optional playlist interface. For -tracking the development of the playlist interface, see :issue:`229`. +required parts of the MPRIS spec, plus the optional playlist interface. It does +not implement the optional tracklist interface. .. _ubuntu-sound-menu: diff --git a/mopidy/frontends/mpris/actor.py b/mopidy/frontends/mpris/actor.py index 81a44fbb..795b2694 100644 --- a/mopidy/frontends/mpris/actor.py +++ b/mopidy/frontends/mpris/actor.py @@ -57,35 +57,48 @@ class MprisFrontend(pykka.ThreadingActor, CoreListener): self.indicate_server.show() logger.debug('Startup notification sent') - def _emit_properties_changed(self, *changed_properties): + def _emit_properties_changed(self, interface, changed_properties): if self.mpris_object is None: return props_with_new_values = [ - (p, self.mpris_object.Get(objects.PLAYER_IFACE, p)) + (p, self.mpris_object.Get(interface, p)) for p in changed_properties] self.mpris_object.PropertiesChanged( - objects.PLAYER_IFACE, dict(props_with_new_values), []) + interface, dict(props_with_new_values), []) def track_playback_paused(self, track, time_position): - logger.debug('Received track playback paused event') - self._emit_properties_changed('PlaybackStatus') + logger.debug('Received track_playback_paused event') + self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus']) def track_playback_resumed(self, track, time_position): - logger.debug('Received track playback resumed event') - self._emit_properties_changed('PlaybackStatus') + logger.debug('Received track_playback_resumed event') + self._emit_properties_changed(objects.PLAYER_IFACE, ['PlaybackStatus']) def track_playback_started(self, track): - logger.debug('Received track playback started event') - self._emit_properties_changed('PlaybackStatus', 'Metadata') + logger.debug('Received track_playback_started event') + self._emit_properties_changed( + objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) def track_playback_ended(self, track, time_position): - logger.debug('Received track playback ended event') - self._emit_properties_changed('PlaybackStatus', 'Metadata') + logger.debug('Received track_playback_ended event') + self._emit_properties_changed( + objects.PLAYER_IFACE, ['PlaybackStatus', 'Metadata']) def volume_changed(self): - logger.debug('Received volume changed event') - self._emit_properties_changed('Volume') + logger.debug('Received volume_changed event') + self._emit_properties_changed(objects.PLAYER_IFACE, ['Volume']) def seeked(self, time_position_in_ms): logger.debug('Received seeked event') self.mpris_object.Seeked(time_position_in_ms * 1000) + + def playlists_loaded(self): + logger.debug('Received playlists_loaded event') + self._emit_properties_changed( + objects.PLAYLISTS_IFACE, ['PlaylistCount']) + + def playlist_changed(self, playlist): + logger.debug('Received playlist_changed event') + playlist_id = self.mpris_object.get_playlist_id(playlist.uri) + playlist = (playlist_id, playlist.name, '') + self.mpris_object.PlaylistChanged(playlist) diff --git a/mopidy/frontends/mpris/objects.py b/mopidy/frontends/mpris/objects.py index cf7f71ce..e7a9243e 100644 --- a/mopidy/frontends/mpris/objects.py +++ b/mopidy/frontends/mpris/objects.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +import base64 import logging import os @@ -27,6 +28,7 @@ BUS_NAME = 'org.mpris.MediaPlayer2.mopidy' OBJECT_PATH = '/org/mpris/MediaPlayer2' ROOT_IFACE = 'org.mpris.MediaPlayer2' PLAYER_IFACE = 'org.mpris.MediaPlayer2.Player' +PLAYLISTS_IFACE = 'org.mpris.MediaPlayer2.Playlists' class MprisObject(dbus.service.Object): @@ -39,6 +41,7 @@ class MprisObject(dbus.service.Object): self.properties = { ROOT_IFACE: self._get_root_iface_properties(), PLAYER_IFACE: self._get_player_iface_properties(), + PLAYLISTS_IFACE: self._get_playlists_iface_properties(), } bus_name = self._connect_to_dbus() dbus.service.Object.__init__(self, bus_name, OBJECT_PATH) @@ -78,6 +81,13 @@ class MprisObject(dbus.service.Object): 'CanControl': (self.get_CanControl, None), } + def _get_playlists_iface_properties(self): + return { + 'PlaylistCount': (self.get_PlaylistCount, None), + 'Orderings': (self.get_Orderings, None), + 'ActivePlaylist': (self.get_ActivePlaylist, None), + } + def _connect_to_dbus(self): logger.debug('Connecting to D-Bus...') mainloop = dbus.mainloop.glib.DBusGMainLoop() @@ -86,10 +96,22 @@ class MprisObject(dbus.service.Object): logger.info('Connected to D-Bus') return bus_name - def _get_track_id(self, tl_track): + def get_playlist_id(self, playlist_uri): + # Only A-Za-z0-9_ is allowed, which is 63 chars, so we can't use + # base64. Luckily, D-Bus does not limit the length of object paths. + # Since base32 pads trailing bytes with = chars, we need to replace + # them with the allow _ char. + encoded_uri = base64.b32encode(playlist_uri).replace('=', '_') + return '/com/mopidy/playlist/%s' % encoded_uri + + def get_playlist_uri(self, playlist_id): + encoded_uri = playlist_id.split('/')[-1].replace('_', '=') + return base64.b32decode(encoded_uri) + + def get_track_id(self, tl_track): return '/com/mopidy/track/%d' % tl_track.tlid - def _get_tlid(self, track_id): + def get_track_tlid(self, track_id): assert track_id.startswith('/com/mopidy/track/') return track_id.split('/')[-1] @@ -239,7 +261,7 @@ class MprisObject(dbus.service.Object): current_tl_track = self.core.playback.current_tl_track.get() if current_tl_track is None: return - if track_id != self._get_track_id(current_tl_track): + if track_id != self.get_track_id(current_tl_track): return if position < 0: return @@ -337,7 +359,7 @@ class MprisObject(dbus.service.Object): return {'mpris:trackid': ''} else: (_, track) = current_tl_track - metadata = {'mpris:trackid': self._get_track_id(current_tl_track)} + metadata = {'mpris:trackid': self.get_track_id(current_tl_track)} if track.length: metadata['mpris:length'] = track.length * 1000 if track.uri: @@ -420,3 +442,58 @@ class MprisObject(dbus.service.Object): def get_CanControl(self): # NOTE This could be a setting for the end user to change. return True + + ### Playlists interface methods + + @dbus.service.method(dbus_interface=PLAYLISTS_IFACE) + def ActivatePlaylist(self, playlist_id): + logger.debug( + '%s.ActivatePlaylist(%r) called', PLAYLISTS_IFACE, playlist_id) + playlist_uri = self.get_playlist_uri(playlist_id) + playlist = self.core.playlists.lookup(playlist_uri).get() + if playlist and playlist.tracks: + tl_tracks = self.core.tracklist.append(playlist.tracks).get() + self.core.playback.play(tl_tracks[0]) + + @dbus.service.method(dbus_interface=PLAYLISTS_IFACE) + def GetPlaylists(self, index, max_count, order, reverse): + logger.debug( + '%s.GetPlaylists(%r, %r, %r, %r) called', + PLAYLISTS_IFACE, index, max_count, order, reverse) + playlists = self.core.playlists.playlists.get() + if order == 'Alphabetical': + playlists.sort(key=lambda p: p.name, reverse=reverse) + elif order == 'Modified': + playlists.sort(key=lambda p: p.last_modified, reverse=reverse) + elif order == 'User' and reverse: + playlists.reverse() + slice_end = index + max_count + playlists = playlists[index:slice_end] + results = [ + (self.get_playlist_id(p.uri), p.name, '') + for p in playlists] + return dbus.Array(results, signature='(oss)') + + ### Playlists interface signals + + @dbus.service.signal(dbus_interface=PLAYLISTS_IFACE, signature='(oss)') + def PlaylistChanged(self, playlist): + logger.debug('%s.PlaylistChanged signaled', PLAYLISTS_IFACE) + # Do nothing, as just calling the method is enough to emit the signal. + + ### Playlists interface properties + + def get_PlaylistCount(self): + return len(self.core.playlists.playlists.get()) + + def get_Orderings(self): + return [ + 'Alphabetical', # Order by playlist.name + 'Modified', # Order by playlist.last_modified + 'User', # Don't change order + ] + + def get_ActivePlaylist(self): + playlist_is_valid = False + playlist = ('/', 'None', '') + return (playlist_is_valid, playlist) diff --git a/tests/frontends/mpris/events_test.py b/tests/frontends/mpris/events_test.py index 94f48115..18a9de6f 100644 --- a/tests/frontends/mpris/events_test.py +++ b/tests/frontends/mpris/events_test.py @@ -5,7 +5,7 @@ import sys import mock from mopidy.exceptions import OptionalDependencyError -from mopidy.models import Track +from mopidy.models import Playlist, Track try: from mopidy.frontends.mpris import MprisFrontend, objects @@ -75,3 +75,19 @@ class BackendEventsTest(unittest.TestCase): def test_seeked_event_causes_mpris_seeked_event(self): self.mpris_frontend.seeked(31000) self.mpris_object.Seeked.assert_called_with(31000000) + + def test_playlists_loaded_event_changes_playlist_count(self): + self.mpris_object.Get.return_value = 17 + self.mpris_frontend.playlists_loaded() + self.assertListEqual(self.mpris_object.Get.call_args_list, [ + ((objects.PLAYLISTS_IFACE, 'PlaylistCount'), {}), + ]) + self.mpris_object.PropertiesChanged.assert_called_with( + objects.PLAYLISTS_IFACE, {'PlaylistCount': 17}, []) + + def test_playlist_changed_event_causes_mpris_playlist_changed_event(self): + self.mpris_object.get_playlist_id.return_value = 'id-for-dummy:foo' + playlist = Playlist(uri='dummy:foo', name='foo') + self.mpris_frontend.playlist_changed(playlist) + self.mpris_object.PlaylistChanged.assert_called_with( + ('id-for-dummy:foo', 'foo', '')) diff --git a/tests/frontends/mpris/playlists_interface_test.py b/tests/frontends/mpris/playlists_interface_test.py new file mode 100644 index 00000000..21038d4b --- /dev/null +++ b/tests/frontends/mpris/playlists_interface_test.py @@ -0,0 +1,171 @@ +from __future__ import unicode_literals + +import datetime +import sys + +import mock +import pykka + +from mopidy import core, exceptions +from mopidy.audio import PlaybackState +from mopidy.backends import dummy +from mopidy.models import Track + +try: + from mopidy.frontends.mpris import objects +except exceptions.OptionalDependencyError: + pass + +from tests import unittest + + +@unittest.skipUnless(sys.platform.startswith('linux'), 'requires Linux') +class PlayerInterfaceTest(unittest.TestCase): + def setUp(self): + objects.MprisObject._connect_to_dbus = mock.Mock() + self.backend = dummy.DummyBackend.start(audio=None).proxy() + self.core = core.Core.start(backends=[self.backend]).proxy() + self.mpris = objects.MprisObject(core=self.core) + + foo = self.core.playlists.create('foo').get() + foo = foo.copy(last_modified=datetime.datetime(2012, 3, 1, 6, 0, 0)) + foo = self.core.playlists.save(foo).get() + + bar = self.core.playlists.create('bar').get() + bar = bar.copy(last_modified=datetime.datetime(2012, 2, 1, 6, 0, 0)) + bar = self.core.playlists.save(bar).get() + + baz = self.core.playlists.create('baz').get() + baz = baz.copy(last_modified=datetime.datetime(2012, 1, 1, 6, 0, 0)) + baz = self.core.playlists.save(baz).get() + self.playlist = baz + + def tearDown(self): + pykka.ActorRegistry.stop_all() + + def test_activate_playlist_appends_tracks_to_tracklist(self): + self.core.tracklist.append([ + Track(uri='dummy:old-a'), + Track(uri='dummy:old-b'), + ]) + self.playlist = self.playlist.copy(tracks=[ + Track(uri='dummy:baz-a'), + Track(uri='dummy:baz-b'), + Track(uri='dummy:baz-c'), + ]) + self.playlist = self.core.playlists.save(self.playlist).get() + + self.assertEqual(2, self.core.tracklist.length.get()) + + playlists = self.mpris.GetPlaylists(0, 100, 'User', False) + playlist_id = playlists[2][0] + self.mpris.ActivatePlaylist(playlist_id) + + self.assertEqual(5, self.core.tracklist.length.get()) + self.assertEqual( + PlaybackState.PLAYING, self.core.playback.state.get()) + self.assertEqual( + self.playlist.tracks[0], self.core.playback.current_track.get()) + + def test_activate_empty_playlist_is_harmless(self): + self.assertEqual(0, self.core.tracklist.length.get()) + + playlists = self.mpris.GetPlaylists(0, 100, 'User', False) + playlist_id = playlists[2][0] + self.mpris.ActivatePlaylist(playlist_id) + + self.assertEqual(0, self.core.tracklist.length.get()) + self.assertEqual( + PlaybackState.STOPPED, self.core.playback.state.get()) + self.assertIsNone(self.core.playback.current_track.get()) + + def test_get_playlists_in_alphabetical_order(self): + result = self.mpris.GetPlaylists(0, 100, 'Alphabetical', False) + + self.assertEqual(3, len(result)) + + self.assertEqual('/com/mopidy/playlist/MR2W23LZHJRGC4Q_', result[0][0]) + self.assertEqual('bar', result[0][1]) + + self.assertEqual('/com/mopidy/playlist/MR2W23LZHJRGC6Q_', result[1][0]) + self.assertEqual('baz', result[1][1]) + + self.assertEqual('/com/mopidy/playlist/MR2W23LZHJTG63Y_', result[2][0]) + self.assertEqual('foo', result[2][1]) + + def test_get_playlists_in_reverse_alphabetical_order(self): + result = self.mpris.GetPlaylists(0, 100, 'Alphabetical', True) + + self.assertEqual(3, len(result)) + self.assertEqual('foo', result[0][1]) + self.assertEqual('baz', result[1][1]) + self.assertEqual('bar', result[2][1]) + + def test_get_playlists_in_modified_order(self): + result = self.mpris.GetPlaylists(0, 100, 'Modified', False) + + self.assertEqual(3, len(result)) + self.assertEqual('baz', result[0][1]) + self.assertEqual('bar', result[1][1]) + self.assertEqual('foo', result[2][1]) + + def test_get_playlists_in_reverse_modified_order(self): + result = self.mpris.GetPlaylists(0, 100, 'Modified', True) + + self.assertEqual(3, len(result)) + self.assertEqual('foo', result[0][1]) + self.assertEqual('bar', result[1][1]) + self.assertEqual('baz', result[2][1]) + + def test_get_playlists_in_user_order(self): + result = self.mpris.GetPlaylists(0, 100, 'User', False) + + self.assertEqual(3, len(result)) + self.assertEqual('foo', result[0][1]) + self.assertEqual('bar', result[1][1]) + self.assertEqual('baz', result[2][1]) + + def test_get_playlists_in_reverse_user_order(self): + result = self.mpris.GetPlaylists(0, 100, 'User', True) + + self.assertEqual(3, len(result)) + self.assertEqual('baz', result[0][1]) + self.assertEqual('bar', result[1][1]) + self.assertEqual('foo', result[2][1]) + + def test_get_playlists_slice_on_start_of_list(self): + result = self.mpris.GetPlaylists(0, 2, 'User', False) + + self.assertEqual(2, len(result)) + self.assertEqual('foo', result[0][1]) + self.assertEqual('bar', result[1][1]) + + def test_get_playlists_slice_later_in_list(self): + result = self.mpris.GetPlaylists(2, 2, 'User', False) + + self.assertEqual(1, len(result)) + self.assertEqual('baz', result[0][1]) + + def test_get_playlist_count_returns_number_of_playlists(self): + result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'PlaylistCount') + + self.assertEqual(3, result) + + def test_get_orderings_includes_alpha_modified_and_user(self): + result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'Orderings') + + self.assertIn('Alphabetical', result) + self.assertNotIn('Created', result) + self.assertIn('Modified', result) + self.assertNotIn('Played', result) + self.assertIn('User', result) + + def test_get_active_playlist_does_not_return_a_playlist(self): + result = self.mpris.Get(objects.PLAYLISTS_IFACE, 'ActivePlaylist') + valid, playlist = result + playlist_id, playlist_name, playlist_icon_uri = playlist + + self.assertEqual(False, valid) + self.assertEqual('/', playlist_id) + self.assertEqual('None', playlist_name) + self.assertEqual('', playlist_icon_uri)