mpris: Implement the playlists interface (fixes #229)
This commit is contained in:
parent
a7be82463a
commit
b8c7703c79
@ -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
|
||||
|
||||
@ -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 <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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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', ''))
|
||||
|
||||
171
tests/frontends/mpris/playlists_interface_test.py
Normal file
171
tests/frontends/mpris/playlists_interface_test.py
Normal file
@ -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)
|
||||
Loading…
Reference in New Issue
Block a user