2014-05-04 01:59:25 +02:00
|
|
|
# -*- coding: utf-8 -*-
|
|
|
|
#
|
|
|
|
# gPodder extension for listening to notifications from MPRIS-capable
|
|
|
|
# players and translating them to gPodder's Media Player D-Bus API
|
|
|
|
#
|
|
|
|
# Copyright (c) 2013-2014 Dov Feldstern <dovdevel@gmail.com>
|
|
|
|
#
|
|
|
|
# This program is free software: you can redistribute it and/or modify
|
|
|
|
# it under the terms of the GNU General Public License as published by
|
|
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
|
|
# (at your option) any later version.
|
2018-02-01 07:59:22 +01:00
|
|
|
#
|
2014-05-04 01:59:25 +02:00
|
|
|
# This program is distributed in the hope that it will be useful,
|
|
|
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
|
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
|
|
# GNU General Public License for more details.
|
2018-02-01 07:59:22 +01:00
|
|
|
#
|
2014-05-04 01:59:25 +02:00
|
|
|
# You should have received a copy of the GNU General Public License
|
|
|
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
|
|
|
|
import collections
|
|
|
|
import logging
|
|
|
|
import time
|
2018-05-05 23:50:37 +02:00
|
|
|
import urllib.error
|
2017-03-25 21:41:49 +01:00
|
|
|
import urllib.parse
|
2018-05-05 23:50:37 +02:00
|
|
|
import urllib.request
|
|
|
|
|
|
|
|
import dbus
|
|
|
|
import dbus.service
|
|
|
|
|
|
|
|
import gpodder
|
2014-05-04 01:59:25 +02:00
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_ = gpodder.gettext
|
|
|
|
|
|
|
|
__title__ = _('MPRIS Listener')
|
|
|
|
__description__ = _('Convert MPRIS notifications to gPodder Media Player D-Bus API')
|
|
|
|
__authors__ = 'Dov Feldstern <dovdevel@gmail.com>'
|
2020-05-25 18:39:00 +02:00
|
|
|
__doc__ = 'https://gpodder.github.io/docs/extensions/mprislistener.html'
|
2014-05-04 01:59:25 +02:00
|
|
|
__category__ = 'desktop-integration'
|
2017-04-16 10:25:46 +02:00
|
|
|
__only_for__ = 'freedesktop'
|
2014-05-04 01:59:25 +02:00
|
|
|
|
|
|
|
USECS_IN_SEC = 1000000
|
|
|
|
|
|
|
|
TrackInfo = collections.namedtuple('TrackInfo',
|
|
|
|
['uri', 'length', 'status', 'pos', 'rate'])
|
|
|
|
|
2018-02-11 00:22:00 +01:00
|
|
|
|
2014-05-04 18:45:36 +02:00
|
|
|
def subsecond_difference(usec1, usec2):
|
2017-04-01 15:31:01 +02:00
|
|
|
return usec1 is not None and usec2 is not None and abs(usec1 - usec2) < USECS_IN_SEC
|
2018-01-30 14:04:28 +01:00
|
|
|
|
2018-02-11 00:22:00 +01:00
|
|
|
|
2014-05-04 01:59:25 +02:00
|
|
|
class CurrentTrackTracker(object):
|
|
|
|
'''An instance of this class is responsible for tracking the state of the
|
|
|
|
currently playing track -- it's playback status, playing position, etc.
|
|
|
|
'''
|
|
|
|
def __init__(self, notifier):
|
|
|
|
self.uri = None
|
|
|
|
self.length = None
|
|
|
|
self.pos = None
|
|
|
|
self.rate = None
|
|
|
|
self.status = None
|
|
|
|
self._notifier = notifier
|
|
|
|
self._prev_notif = ()
|
|
|
|
|
|
|
|
def _calc_update(self):
|
|
|
|
|
|
|
|
now = time.time()
|
|
|
|
|
2014-05-04 18:23:35 +02:00
|
|
|
logger.debug('CurrentTrackTracker: calculating at %d (status: %r)',
|
|
|
|
now, self.status)
|
2014-05-04 01:59:25 +02:00
|
|
|
|
|
|
|
try:
|
|
|
|
if self.status != 'Playing':
|
|
|
|
logger.debug('CurrentTrackTracker: not currently playing, no change')
|
|
|
|
return
|
|
|
|
if self.pos is None or self.rate is None:
|
|
|
|
logger.debug('CurrentTrackTracker: unknown pos/rate, no change')
|
|
|
|
return
|
2014-05-04 18:23:35 +02:00
|
|
|
logger.debug('CurrentTrackTracker: %f @%f (diff: %f)',
|
|
|
|
self.pos, self.rate, now - self._last_time)
|
2014-05-04 01:59:25 +02:00
|
|
|
self.pos = self.pos + self.rate * (now - self._last_time) * USECS_IN_SEC
|
|
|
|
finally:
|
|
|
|
self._last_time = now
|
2018-01-30 14:04:28 +01:00
|
|
|
|
2014-05-04 23:32:56 +02:00
|
|
|
def update_needed(self, current, updated):
|
|
|
|
for field in updated:
|
|
|
|
if field == 'pos':
|
|
|
|
if not subsecond_difference(updated['pos'], current['pos']):
|
|
|
|
return True
|
|
|
|
elif updated[field] != current[field]:
|
|
|
|
return True
|
|
|
|
# no unequal field was found, no new info here!
|
|
|
|
return False
|
|
|
|
|
2014-05-04 01:59:25 +02:00
|
|
|
def update(self, **kwargs):
|
|
|
|
|
|
|
|
# check if there is any new info here -- if not, no need to update!
|
|
|
|
|
|
|
|
cur = self.getinfo()._asdict()
|
2014-05-04 23:32:56 +02:00
|
|
|
if not self.update_needed(cur, kwargs):
|
|
|
|
return
|
2014-05-04 01:59:25 +02:00
|
|
|
|
2014-05-04 23:32:56 +02:00
|
|
|
# there *is* new info, go ahead and update...
|
2014-05-04 01:59:25 +02:00
|
|
|
|
|
|
|
uri = kwargs.pop('uri', None)
|
|
|
|
if uri is not None:
|
2018-05-17 08:39:56 +02:00
|
|
|
length = kwargs.pop('length') # don't know how to handle uri with no length
|
2014-05-04 01:59:25 +02:00
|
|
|
if uri != cur['uri']:
|
|
|
|
# if this is a new uri, and the previous state was 'Playing',
|
|
|
|
# notify that the previous track has stopped before updating to
|
|
|
|
# the new track.
|
|
|
|
if cur['status'] == 'Playing':
|
2014-05-04 18:23:35 +02:00
|
|
|
logger.debug('notify Stopped: new uri: old %s new %s',
|
|
|
|
cur['uri'], uri)
|
2014-05-04 01:59:25 +02:00
|
|
|
self.notify_stop()
|
|
|
|
self.uri = uri
|
|
|
|
self.length = float(length)
|
|
|
|
|
|
|
|
if 'pos' in kwargs:
|
|
|
|
# If the position is being updated, and the current status was Playing
|
|
|
|
# If the status *is* playing, and *was* playing, but the position
|
|
|
|
# has changed discontinuously, notify a stop for the old position
|
2022-09-06 22:28:39 +02:00
|
|
|
if (cur['status'] == 'Playing'
|
|
|
|
and ('status' not in kwargs or kwargs['status'] == 'Playing') and not
|
2018-05-01 14:22:18 +02:00
|
|
|
subsecond_difference(cur['pos'], kwargs['pos'])):
|
2022-09-06 22:28:39 +02:00
|
|
|
logger.debug('notify Stopped: playback discontinuity:'
|
|
|
|
+ 'calc: %r observed: %r', cur['pos'], kwargs['pos'])
|
2014-05-04 01:59:25 +02:00
|
|
|
self.notify_stop()
|
|
|
|
|
2022-09-06 22:28:39 +02:00
|
|
|
if ((kwargs['pos']) <= 0
|
|
|
|
and self.pos is not None
|
|
|
|
and self.length is not None
|
|
|
|
and (self.length - USECS_IN_SEC) < self.pos
|
|
|
|
and self.pos < (self.length + 2 * USECS_IN_SEC)):
|
2018-10-08 20:42:30 +02:00
|
|
|
logger.debug('pos=0 end of stream (calculated pos: %f/%f [%f])',
|
2014-05-04 18:23:35 +02:00
|
|
|
self.pos / USECS_IN_SEC, self.length / USECS_IN_SEC,
|
2018-04-15 18:59:20 +02:00
|
|
|
(self.pos / USECS_IN_SEC) - (self.length / USECS_IN_SEC))
|
2014-05-04 01:59:25 +02:00
|
|
|
self.pos = self.length
|
2018-05-17 08:39:56 +02:00
|
|
|
kwargs.pop('pos') # remove 'pos' even though we're not using it
|
2014-05-04 01:59:25 +02:00
|
|
|
else:
|
2018-10-06 16:54:27 +02:00
|
|
|
if self.pos is not None and self.length is not None:
|
2014-05-04 18:23:35 +02:00
|
|
|
logger.debug("%r %r", self.pos, self.length)
|
2018-10-08 20:42:30 +02:00
|
|
|
logger.debug('pos=0 not end of stream (calculated pos: %f/%f [%f])',
|
2014-05-04 18:23:35 +02:00
|
|
|
self.pos / USECS_IN_SEC, self.length / USECS_IN_SEC,
|
2018-04-15 18:59:20 +02:00
|
|
|
(self.pos / USECS_IN_SEC) - (self.length / USECS_IN_SEC))
|
2022-04-26 09:25:53 +02:00
|
|
|
newpos = kwargs.pop('pos')
|
|
|
|
self.pos = newpos if newpos >= 0 else 0
|
2014-05-04 01:59:25 +02:00
|
|
|
|
|
|
|
if 'status' in kwargs:
|
|
|
|
self.status = kwargs.pop('status')
|
|
|
|
|
|
|
|
if 'rate' in kwargs:
|
|
|
|
self.rate = kwargs.pop('rate')
|
|
|
|
|
|
|
|
if kwargs:
|
2014-05-04 18:23:35 +02:00
|
|
|
logger.error('unexpected update fields %r', kwargs)
|
2014-05-04 01:59:25 +02:00
|
|
|
|
|
|
|
# notify about the current state
|
|
|
|
if self.status == 'Playing':
|
|
|
|
self.notify_playing()
|
|
|
|
else:
|
2022-04-04 10:42:17 +02:00
|
|
|
logger.debug('notify Stopped: status %r', self.status)
|
2014-05-04 01:59:25 +02:00
|
|
|
self.notify_stop()
|
|
|
|
|
|
|
|
def getinfo(self):
|
|
|
|
self._calc_update()
|
|
|
|
return TrackInfo(self.uri, self.length, self.status, self.pos, self.rate)
|
|
|
|
|
|
|
|
def notify_stop(self):
|
|
|
|
self.notify('Stopped')
|
|
|
|
|
|
|
|
def notify_playing(self):
|
|
|
|
self.notify('Playing')
|
|
|
|
|
|
|
|
def notify(self, status):
|
2022-09-06 22:28:39 +02:00
|
|
|
if (self.uri is None
|
|
|
|
or self.pos is None
|
|
|
|
or self.status is None
|
|
|
|
or self.length is None
|
|
|
|
or self.length <= 0):
|
2014-05-04 01:59:25 +02:00
|
|
|
return
|
|
|
|
pos = self.pos // USECS_IN_SEC
|
2018-10-06 16:16:52 +02:00
|
|
|
parsed_url = urllib.parse.urlparse(self.uri)
|
|
|
|
if (not parsed_url.scheme) or parsed_url.scheme == 'file':
|
|
|
|
file_uri = urllib.request.url2pathname(urllib.parse.urlparse(self.uri).path).encode('utf-8')
|
|
|
|
else:
|
|
|
|
file_uri = self.uri
|
2014-05-04 01:59:25 +02:00
|
|
|
total_time = self.length // USECS_IN_SEC
|
2018-01-30 14:04:28 +01:00
|
|
|
|
2014-05-04 01:59:25 +02:00
|
|
|
if status == 'Stopped':
|
|
|
|
end_position = pos
|
|
|
|
start_position = self._notifier.start_position
|
|
|
|
if self._prev_notif != (start_position, end_position, total_time, file_uri):
|
|
|
|
self._notifier.PlaybackStopped(start_position, end_position,
|
|
|
|
total_time, file_uri)
|
|
|
|
self._prev_notif = (start_position, end_position, total_time, file_uri)
|
|
|
|
|
|
|
|
elif status == 'Playing':
|
|
|
|
start_position = pos
|
|
|
|
if self._prev_notif != (start_position, file_uri):
|
|
|
|
self._notifier.PlaybackStarted(start_position, file_uri)
|
|
|
|
self._prev_notif = (start_position, file_uri)
|
|
|
|
self._notifier.start_position = start_position
|
|
|
|
|
2018-10-06 16:54:27 +02:00
|
|
|
logger.info('CurrentTrackTracker: %s: %r %s', status, self, file_uri)
|
2014-05-04 01:59:25 +02:00
|
|
|
|
|
|
|
def __repr__(self):
|
|
|
|
return '%s: %s at %d/%d (@%f)' % (
|
|
|
|
self.uri or 'None',
|
|
|
|
self.status or 'None',
|
2017-02-14 15:50:07 +01:00
|
|
|
(self.pos or 0) // USECS_IN_SEC,
|
|
|
|
(self.length or 0) // USECS_IN_SEC,
|
2014-05-04 01:59:25 +02:00
|
|
|
self.rate or 0)
|
2018-01-30 14:04:28 +01:00
|
|
|
|
|
|
|
|
2014-05-04 01:59:25 +02:00
|
|
|
class MPRISDBusReceiver(object):
|
|
|
|
INTERFACE_PROPS = 'org.freedesktop.DBus.Properties'
|
|
|
|
SIGNAL_PROP_CHANGE = 'PropertiesChanged'
|
|
|
|
PATH_MPRIS = '/org/mpris/MediaPlayer2'
|
|
|
|
INTERFACE_MPRIS = 'org.mpris.MediaPlayer2.Player'
|
|
|
|
SIGNAL_SEEKED = 'Seeked'
|
2018-05-01 14:22:18 +02:00
|
|
|
OTHER_MPRIS_INTERFACES = ['org.mpris.MediaPlayer2',
|
|
|
|
'org.mpris.MediaPlayer2.TrackList',
|
|
|
|
'org.mpris.MediaPlayer2.Playlists']
|
2014-05-04 01:59:25 +02:00
|
|
|
|
|
|
|
def __init__(self, bus, notifier):
|
|
|
|
self.bus = bus
|
|
|
|
self.cur = CurrentTrackTracker(notifier)
|
|
|
|
self.bus.add_signal_receiver(self.on_prop_change,
|
|
|
|
self.SIGNAL_PROP_CHANGE,
|
|
|
|
self.INTERFACE_PROPS,
|
|
|
|
None,
|
2018-10-06 16:54:27 +02:00
|
|
|
self.PATH_MPRIS,
|
|
|
|
sender_keyword='sender')
|
2014-05-04 01:59:25 +02:00
|
|
|
self.bus.add_signal_receiver(self.on_seeked,
|
|
|
|
self.SIGNAL_SEEKED,
|
|
|
|
self.INTERFACE_MPRIS,
|
|
|
|
None,
|
2018-12-15 22:36:55 +01:00
|
|
|
None)
|
2014-05-04 01:59:25 +02:00
|
|
|
|
|
|
|
def stop_receiving(self):
|
|
|
|
self.bus.remove_signal_receiver(self.on_prop_change,
|
|
|
|
self.SIGNAL_PROP_CHANGE,
|
|
|
|
self.INTERFACE_PROPS,
|
|
|
|
None,
|
|
|
|
self.PATH_MPRIS)
|
|
|
|
self.bus.remove_signal_receiver(self.on_seeked,
|
|
|
|
self.SIGNAL_SEEKED,
|
|
|
|
self.INTERFACE_MPRIS,
|
|
|
|
None,
|
|
|
|
None)
|
|
|
|
|
|
|
|
def on_prop_change(self, interface_name, changed_properties,
|
2018-10-06 16:54:27 +02:00
|
|
|
invalidated_properties, path=None, sender=None):
|
2014-05-04 01:59:25 +02:00
|
|
|
if interface_name != self.INTERFACE_MPRIS:
|
2017-01-08 17:39:52 +01:00
|
|
|
if interface_name not in self.OTHER_MPRIS_INTERFACES:
|
2022-04-17 11:07:51 +02:00
|
|
|
logger.warning('unexpected interface: %s, props=%r', interface_name, list(changed_properties.keys()))
|
2014-05-04 01:59:25 +02:00
|
|
|
return
|
2018-10-06 16:54:27 +02:00
|
|
|
if sender is None:
|
2022-04-17 11:07:51 +02:00
|
|
|
logger.warning('No sender associated to D-Bus signal, please report a bug')
|
2018-10-06 16:54:27 +02:00
|
|
|
return
|
2018-01-30 14:04:28 +01:00
|
|
|
|
2014-05-04 01:59:25 +02:00
|
|
|
collected_info = {}
|
2018-10-06 16:54:27 +02:00
|
|
|
logger.debug("on_prop_change %r", changed_properties.keys())
|
2017-03-25 21:41:49 +01:00
|
|
|
if 'PlaybackStatus' in changed_properties:
|
2014-05-04 01:59:25 +02:00
|
|
|
collected_info['status'] = str(changed_properties['PlaybackStatus'])
|
2017-03-25 21:41:49 +01:00
|
|
|
if 'Metadata' in changed_properties:
|
2018-10-06 16:54:27 +02:00
|
|
|
logger.debug("Metadata %r", changed_properties['Metadata'].keys())
|
2017-01-08 17:39:52 +01:00
|
|
|
# on stop there is no xesam:url
|
2017-03-26 04:26:54 +02:00
|
|
|
if 'xesam:url' in changed_properties['Metadata']:
|
2017-01-08 17:39:52 +01:00
|
|
|
collected_info['uri'] = changed_properties['Metadata']['xesam:url']
|
2018-12-15 22:36:55 +01:00
|
|
|
collected_info['length'] = changed_properties['Metadata'].get('mpris:length', 0.0)
|
2017-03-25 21:41:49 +01:00
|
|
|
if 'Rate' in changed_properties:
|
2014-05-04 01:59:25 +02:00
|
|
|
collected_info['rate'] = changed_properties['Rate']
|
2020-04-26 16:29:16 +02:00
|
|
|
# Fix #788 pos=0 when Stopped resulting in not saving position on VLC quit
|
|
|
|
if changed_properties.get('PlaybackStatus') != 'Stopped':
|
2022-04-28 09:19:46 +02:00
|
|
|
try:
|
|
|
|
collected_info['pos'] = self.query_property(sender, 'Position')
|
|
|
|
except dbus.exceptions.DBusException:
|
|
|
|
pass
|
2017-03-25 21:41:49 +01:00
|
|
|
if 'status' not in collected_info:
|
2022-04-28 09:19:46 +02:00
|
|
|
try:
|
|
|
|
collected_info['status'] = str(self.query_property(
|
|
|
|
sender, 'PlaybackStatus'))
|
|
|
|
except dbus.exceptions.DBusException:
|
|
|
|
pass
|
2014-05-04 01:59:25 +02:00
|
|
|
|
2022-04-28 09:19:46 +02:00
|
|
|
logger.debug('collected info: %r', collected_info)
|
2014-05-04 01:59:25 +02:00
|
|
|
self.cur.update(**collected_info)
|
|
|
|
|
|
|
|
def on_seeked(self, position):
|
2014-05-04 18:23:35 +02:00
|
|
|
logger.debug('seeked to pos: %f', position)
|
2014-05-04 01:59:25 +02:00
|
|
|
self.cur.update(pos=position)
|
|
|
|
|
2022-04-28 09:19:46 +02:00
|
|
|
def query_property(self, sender, prop):
|
2018-10-06 16:54:27 +02:00
|
|
|
proxy = self.bus.get_object(sender, self.PATH_MPRIS)
|
2014-05-04 01:59:25 +02:00
|
|
|
props = dbus.Interface(proxy, self.INTERFACE_PROPS)
|
2022-04-28 09:19:46 +02:00
|
|
|
return props.Get(self.INTERFACE_MPRIS, prop)
|
2014-05-04 01:59:25 +02:00
|
|
|
|
2018-02-11 00:22:00 +01:00
|
|
|
|
2014-05-04 01:59:25 +02:00
|
|
|
class gPodderNotifier(dbus.service.Object):
|
|
|
|
def __init__(self, bus, path):
|
|
|
|
dbus.service.Object.__init__(self, bus, path)
|
2017-04-01 22:15:38 +02:00
|
|
|
self.start_position = 0
|
2014-05-04 01:59:25 +02:00
|
|
|
|
|
|
|
@dbus.service.signal(dbus_interface='org.gpodder.player', signature='us')
|
|
|
|
def PlaybackStarted(self, start_position, file_uri):
|
2014-05-04 18:23:35 +02:00
|
|
|
logger.info('PlaybackStarted: %s: %d', file_uri, start_position)
|
2014-05-04 01:59:25 +02:00
|
|
|
|
|
|
|
@dbus.service.signal(dbus_interface='org.gpodder.player', signature='uuus')
|
|
|
|
def PlaybackStopped(self, start_position, end_position, total_time, file_uri):
|
2014-05-04 18:23:35 +02:00
|
|
|
logger.info('PlaybackStopped: %s: %d--%d/%d',
|
|
|
|
file_uri, start_position, end_position, total_time)
|
2018-01-30 14:04:28 +01:00
|
|
|
|
|
|
|
|
2014-05-04 01:59:25 +02:00
|
|
|
# Finally, this is the extension, which just pulls this all together
|
|
|
|
class gPodderExtension:
|
|
|
|
|
|
|
|
def __init__(self, container):
|
|
|
|
self.container = container
|
|
|
|
self.path = '/org/gpodder/player/notifier'
|
2018-02-15 20:56:35 +01:00
|
|
|
self.notifier = None
|
|
|
|
self.rcvr = None
|
2014-05-04 01:59:25 +02:00
|
|
|
|
|
|
|
def on_load(self):
|
2018-02-15 20:56:35 +01:00
|
|
|
if gpodder.dbus_session_bus is None:
|
|
|
|
logger.debug("dbus session bus not available, not loading")
|
|
|
|
else:
|
|
|
|
self.session_bus = gpodder.dbus_session_bus
|
|
|
|
self.notifier = gPodderNotifier(self.session_bus, self.path)
|
|
|
|
self.rcvr = MPRISDBusReceiver(self.session_bus, self.notifier)
|
2014-05-04 01:59:25 +02:00
|
|
|
|
|
|
|
def on_unload(self):
|
2018-02-15 20:56:35 +01:00
|
|
|
if self.notifier is not None:
|
|
|
|
self.notifier.remove_from_connection(self.session_bus, self.path)
|
|
|
|
if self.rcvr is not None:
|
|
|
|
self.rcvr.stop_receiving()
|