1# -*- coding: utf-8 -*- 2# 3# gPodder extension for listening to notifications from MPRIS-capable 4# players and translating them to gPodder's Media Player D-Bus API 5# 6# Copyright (c) 2013-2014 Dov Feldstern <dovdevel@gmail.com> 7# 8# This program is free software: you can redistribute it and/or modify 9# it under the terms of the GNU General Public License as published by 10# the Free Software Foundation, either version 3 of the License, or 11# (at your option) any later version. 12# 13# This program is distributed in the hope that it will be useful, 14# but WITHOUT ANY WARRANTY; without even the implied warranty of 15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16# GNU General Public License for more details. 17# 18# You should have received a copy of the GNU General Public License 19# along with this program. If not, see <http://www.gnu.org/licenses/>. 20 21import collections 22import logging 23import time 24import urllib.error 25import urllib.parse 26import urllib.request 27 28import dbus 29import dbus.service 30 31import gpodder 32 33logger = logging.getLogger(__name__) 34_ = gpodder.gettext 35 36__title__ = _('MPRIS Listener') 37__description__ = _('Convert MPRIS notifications to gPodder Media Player D-Bus API') 38__authors__ = 'Dov Feldstern <dovdevel@gmail.com>' 39__doc__ = 'https://gpodder.github.io/docs/extensions/mprislistener.html' 40__category__ = 'desktop-integration' 41__only_for__ = 'freedesktop' 42 43USECS_IN_SEC = 1000000 44 45TrackInfo = collections.namedtuple('TrackInfo', 46 ['uri', 'length', 'status', 'pos', 'rate']) 47 48 49def subsecond_difference(usec1, usec2): 50 return usec1 is not None and usec2 is not None and abs(usec1 - usec2) < USECS_IN_SEC 51 52 53class CurrentTrackTracker(object): 54 '''An instance of this class is responsible for tracking the state of the 55 currently playing track -- it's playback status, playing position, etc. 56 ''' 57 def __init__(self, notifier): 58 self.uri = None 59 self.length = None 60 self.pos = None 61 self.rate = None 62 self.status = None 63 self._notifier = notifier 64 self._prev_notif = () 65 66 def _calc_update(self): 67 68 now = time.time() 69 70 logger.debug('CurrentTrackTracker: calculating at %d (status: %r)', 71 now, self.status) 72 73 try: 74 if self.status != 'Playing': 75 logger.debug('CurrentTrackTracker: not currently playing, no change') 76 return 77 if self.pos is None or self.rate is None: 78 logger.debug('CurrentTrackTracker: unknown pos/rate, no change') 79 return 80 logger.debug('CurrentTrackTracker: %f @%f (diff: %f)', 81 self.pos, self.rate, now - self._last_time) 82 self.pos = self.pos + self.rate * (now - self._last_time) * USECS_IN_SEC 83 finally: 84 self._last_time = now 85 86 def update_needed(self, current, updated): 87 for field in updated: 88 if field == 'pos': 89 if not subsecond_difference(updated['pos'], current['pos']): 90 return True 91 elif updated[field] != current[field]: 92 return True 93 # no unequal field was found, no new info here! 94 return False 95 96 def update(self, **kwargs): 97 98 # check if there is any new info here -- if not, no need to update! 99 100 cur = self.getinfo()._asdict() 101 if not self.update_needed(cur, kwargs): 102 return 103 104 # there *is* new info, go ahead and update... 105 106 uri = kwargs.pop('uri', None) 107 if uri is not None: 108 length = kwargs.pop('length') # don't know how to handle uri with no length 109 if uri != cur['uri']: 110 # if this is a new uri, and the previous state was 'Playing', 111 # notify that the previous track has stopped before updating to 112 # the new track. 113 if cur['status'] == 'Playing': 114 logger.debug('notify Stopped: new uri: old %s new %s', 115 cur['uri'], uri) 116 self.notify_stop() 117 self.uri = uri 118 self.length = float(length) 119 120 if 'pos' in kwargs: 121 # If the position is being updated, and the current status was Playing 122 # If the status *is* playing, and *was* playing, but the position 123 # has changed discontinuously, notify a stop for the old position 124 if (cur['status'] == 'Playing' and 125 ('status' not in kwargs or kwargs['status'] == 'Playing') and not 126 subsecond_difference(cur['pos'], kwargs['pos'])): 127 logger.debug('notify Stopped: playback discontinuity:' + 128 'calc: %f observed: %f', cur['pos'], kwargs['pos']) 129 self.notify_stop() 130 131 if ((kwargs['pos']) == 0 and 132 self.pos is not None and 133 self.length is not None and 134 (self.length - USECS_IN_SEC) < self.pos and 135 self.pos < (self.length + 2 * USECS_IN_SEC)): 136 logger.debug('pos=0 end of stream (calculated pos: %f/%f [%f])', 137 self.pos / USECS_IN_SEC, self.length / USECS_IN_SEC, 138 (self.pos / USECS_IN_SEC) - (self.length / USECS_IN_SEC)) 139 self.pos = self.length 140 kwargs.pop('pos') # remove 'pos' even though we're not using it 141 else: 142 if self.pos is not None and self.length is not None: 143 logger.debug("%r %r", self.pos, self.length) 144 logger.debug('pos=0 not end of stream (calculated pos: %f/%f [%f])', 145 self.pos / USECS_IN_SEC, self.length / USECS_IN_SEC, 146 (self.pos / USECS_IN_SEC) - (self.length / USECS_IN_SEC)) 147 self.pos = kwargs.pop('pos') 148 149 if 'status' in kwargs: 150 self.status = kwargs.pop('status') 151 152 if 'rate' in kwargs: 153 self.rate = kwargs.pop('rate') 154 155 if kwargs: 156 logger.error('unexpected update fields %r', kwargs) 157 158 # notify about the current state 159 if self.status == 'Playing': 160 self.notify_playing() 161 else: 162 logger.debug('notify Stopped: status %s', self.status) 163 self.notify_stop() 164 165 def getinfo(self): 166 self._calc_update() 167 return TrackInfo(self.uri, self.length, self.status, self.pos, self.rate) 168 169 def notify_stop(self): 170 self.notify('Stopped') 171 172 def notify_playing(self): 173 self.notify('Playing') 174 175 def notify(self, status): 176 if (self.uri is None or 177 self.pos is None or 178 self.status is None or 179 self.length is None or 180 self.length <= 0): 181 return 182 pos = self.pos // USECS_IN_SEC 183 parsed_url = urllib.parse.urlparse(self.uri) 184 if (not parsed_url.scheme) or parsed_url.scheme == 'file': 185 file_uri = urllib.request.url2pathname(urllib.parse.urlparse(self.uri).path).encode('utf-8') 186 else: 187 file_uri = self.uri 188 total_time = self.length // USECS_IN_SEC 189 190 if status == 'Stopped': 191 end_position = pos 192 start_position = self._notifier.start_position 193 if self._prev_notif != (start_position, end_position, total_time, file_uri): 194 self._notifier.PlaybackStopped(start_position, end_position, 195 total_time, file_uri) 196 self._prev_notif = (start_position, end_position, total_time, file_uri) 197 198 elif status == 'Playing': 199 start_position = pos 200 if self._prev_notif != (start_position, file_uri): 201 self._notifier.PlaybackStarted(start_position, file_uri) 202 self._prev_notif = (start_position, file_uri) 203 self._notifier.start_position = start_position 204 205 logger.info('CurrentTrackTracker: %s: %r %s', status, self, file_uri) 206 207 def __repr__(self): 208 return '%s: %s at %d/%d (@%f)' % ( 209 self.uri or 'None', 210 self.status or 'None', 211 (self.pos or 0) // USECS_IN_SEC, 212 (self.length or 0) // USECS_IN_SEC, 213 self.rate or 0) 214 215 216class MPRISDBusReceiver(object): 217 INTERFACE_PROPS = 'org.freedesktop.DBus.Properties' 218 SIGNAL_PROP_CHANGE = 'PropertiesChanged' 219 PATH_MPRIS = '/org/mpris/MediaPlayer2' 220 INTERFACE_MPRIS = 'org.mpris.MediaPlayer2.Player' 221 SIGNAL_SEEKED = 'Seeked' 222 OTHER_MPRIS_INTERFACES = ['org.mpris.MediaPlayer2', 223 'org.mpris.MediaPlayer2.TrackList', 224 'org.mpris.MediaPlayer2.Playlists'] 225 226 def __init__(self, bus, notifier): 227 self.bus = bus 228 self.cur = CurrentTrackTracker(notifier) 229 self.bus.add_signal_receiver(self.on_prop_change, 230 self.SIGNAL_PROP_CHANGE, 231 self.INTERFACE_PROPS, 232 None, 233 self.PATH_MPRIS, 234 sender_keyword='sender') 235 self.bus.add_signal_receiver(self.on_seeked, 236 self.SIGNAL_SEEKED, 237 self.INTERFACE_MPRIS, 238 None, 239 None) 240 241 def stop_receiving(self): 242 self.bus.remove_signal_receiver(self.on_prop_change, 243 self.SIGNAL_PROP_CHANGE, 244 self.INTERFACE_PROPS, 245 None, 246 self.PATH_MPRIS) 247 self.bus.remove_signal_receiver(self.on_seeked, 248 self.SIGNAL_SEEKED, 249 self.INTERFACE_MPRIS, 250 None, 251 None) 252 253 def on_prop_change(self, interface_name, changed_properties, 254 invalidated_properties, path=None, sender=None): 255 if interface_name != self.INTERFACE_MPRIS: 256 if interface_name not in self.OTHER_MPRIS_INTERFACES: 257 logger.warn('unexpected interface: %s, props=%r', interface_name, list(changed_properties.keys())) 258 return 259 if sender is None: 260 logger.warn('No sender associated to D-Bus signal, please report a bug') 261 return 262 263 collected_info = {} 264 logger.debug("on_prop_change %r", changed_properties.keys()) 265 if 'PlaybackStatus' in changed_properties: 266 collected_info['status'] = str(changed_properties['PlaybackStatus']) 267 if 'Metadata' in changed_properties: 268 logger.debug("Metadata %r", changed_properties['Metadata'].keys()) 269 # on stop there is no xesam:url 270 if 'xesam:url' in changed_properties['Metadata']: 271 collected_info['uri'] = changed_properties['Metadata']['xesam:url'] 272 collected_info['length'] = changed_properties['Metadata'].get('mpris:length', 0.0) 273 if 'Rate' in changed_properties: 274 collected_info['rate'] = changed_properties['Rate'] 275 # Fix #788 pos=0 when Stopped resulting in not saving position on VLC quit 276 if changed_properties.get('PlaybackStatus') != 'Stopped': 277 collected_info['pos'] = self.query_position(sender) 278 279 if 'status' not in collected_info: 280 collected_info['status'] = str(self.query_status(sender)) 281 logger.debug('collected info: %r', collected_info) 282 283 self.cur.update(**collected_info) 284 285 def on_seeked(self, position): 286 logger.debug('seeked to pos: %f', position) 287 self.cur.update(pos=position) 288 289 def query_position(self, sender): 290 proxy = self.bus.get_object(sender, self.PATH_MPRIS) 291 props = dbus.Interface(proxy, self.INTERFACE_PROPS) 292 return props.Get(self.INTERFACE_MPRIS, 'Position') 293 294 def query_status(self, sender): 295 proxy = self.bus.get_object(sender, self.PATH_MPRIS) 296 props = dbus.Interface(proxy, self.INTERFACE_PROPS) 297 return props.Get(self.INTERFACE_MPRIS, 'PlaybackStatus') 298 299 300class gPodderNotifier(dbus.service.Object): 301 def __init__(self, bus, path): 302 dbus.service.Object.__init__(self, bus, path) 303 self.start_position = 0 304 305 @dbus.service.signal(dbus_interface='org.gpodder.player', signature='us') 306 def PlaybackStarted(self, start_position, file_uri): 307 logger.info('PlaybackStarted: %s: %d', file_uri, start_position) 308 309 @dbus.service.signal(dbus_interface='org.gpodder.player', signature='uuus') 310 def PlaybackStopped(self, start_position, end_position, total_time, file_uri): 311 logger.info('PlaybackStopped: %s: %d--%d/%d', 312 file_uri, start_position, end_position, total_time) 313 314 315# Finally, this is the extension, which just pulls this all together 316class gPodderExtension: 317 318 def __init__(self, container): 319 self.container = container 320 self.path = '/org/gpodder/player/notifier' 321 self.notifier = None 322 self.rcvr = None 323 324 def on_load(self): 325 if gpodder.dbus_session_bus is None: 326 logger.debug("dbus session bus not available, not loading") 327 else: 328 self.session_bus = gpodder.dbus_session_bus 329 self.notifier = gPodderNotifier(self.session_bus, self.path) 330 self.rcvr = MPRISDBusReceiver(self.session_bus, self.notifier) 331 332 def on_unload(self): 333 if self.notifier is not None: 334 self.notifier.remove_from_connection(self.session_bus, self.path) 335 if self.rcvr is not None: 336 self.rcvr.stop_receiving() 337