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