1"""
2mps-youtube.
3
4https://github.com/np1/mps-youtube
5
6Copyright (C) 2014 nagev
7
8This program is free software: you can redistribute it and/or modify
9it under the terms of the GNU General Public License as published by
10the Free Software Foundation, either version 3 of the License, or
11(at your option) any later version.
12
13This program is distributed in the hope that it will be useful,
14but WITHOUT ANY WARRANTY; without even the implied warranty of
15MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16GNU General Public License for more details.
17
18You should have received a copy of the GNU General Public License
19along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21"""
22
23import json
24import socket
25import time
26import copy
27import re
28import os
29from threading import Thread
30
31import dbus
32import dbus.service
33from dbus.mainloop.glib import DBusGMainLoop
34
35
36IDENTITY = 'mps-youtube'
37
38BUS_NAME = 'org.mpris.MediaPlayer2.' + IDENTITY + '.instance' + str(os.getpid())
39ROOT_INTERFACE = 'org.mpris.MediaPlayer2'
40PLAYER_INTERFACE = 'org.mpris.MediaPlayer2.Player'
41PROPERTIES_INTERFACE = 'org.freedesktop.DBus.Properties'
42MPRIS_PATH = '/org/mpris/MediaPlayer2'
43
44class Mpris2Controller:
45
46    """
47        Controller for various MPRIS objects.
48    """
49
50    def __init__(self):
51        """
52            Constructs an MPRIS controller. Note, you must call acquire()
53        """
54        # Do not import in main process to prevent conflict with pyperclip
55        # (https://github.com/mps-youtube/mps-youtube/issues/461)
56        from gi.repository import GLib
57
58        self.mpris = None
59        self.bus = None
60        self.main_loop = GLib.MainLoop()
61
62    def release(self):
63        """
64            Releases all objects from D-Bus and unregisters the bus
65        """
66        if self.mpris is not None:
67            self.mpris.remove_from_connection()
68        self.mpris = None
69        if self.bus is not None:
70            self.bus.get_bus().release_name(self.bus.get_name())
71
72    def acquire(self):
73        """
74            Connects to D-Bus and registers all components
75        """
76        self._acquire_bus()
77        self._add_interfaces()
78
79    def run(self, connection):
80        """
81            Runs main loop, processing all calls
82            binds on connection (Pipe) and listens player changes
83        """
84        t = Thread(target=self._run_main_loop)
85        t.daemon = True
86        t.start()
87        self.listenstatus(connection)
88
89    def listenstatus(self, conn):
90        """
91            Notifies interfaces that player connection changed
92        """
93        while True:
94            try:
95                data = conn.recv()
96                if isinstance(data, tuple):
97                    name, val = data
98                    if name == 'socket':
99                        Thread(target=self.mpris.bindmpv, args=(val,)).start()
100                    elif name == 'mplayer-fifo':
101                        self.mpris.bindfifo(val)
102                    elif name == 'mpv-fifo':
103                        self.mpris.bindfifo(val, mpv=True)
104                    else:
105                        self.mpris.setproperty(name, val)
106            except IOError:
107                break
108            except KeyboardInterrupt:
109                pass
110
111    def _acquire_bus(self):
112        """
113            Connect to D-Bus and set self.bus to be a valid connection
114        """
115        if self.bus is not None:
116            self.bus.get_bus().request_name(BUS_NAME)
117        else:
118            self.bus = dbus.service.BusName(BUS_NAME,
119                bus=dbus.SessionBus(mainloop=DBusGMainLoop()))
120
121    def _add_interfaces(self):
122        """
123            Connects all interfaces to D-Bus
124        """
125        self.mpris = Mpris2MediaPlayer(self.bus)
126
127    def _run_main_loop(self):
128        """
129            Runs glib main loop, ignoring keyboard interrupts
130        """
131        while True:
132            try:
133                self.main_loop.run()
134            except KeyboardInterrupt:
135                pass
136
137
138class Mpris2MediaPlayer(dbus.service.Object):
139
140    """
141        main dbus object for MPRIS2
142        implementing interfaces:
143            org.mpris.MediaPlayer2
144            org.mpris.MediaPlayer2.Player
145    """
146
147    def __init__(self, bus):
148        """
149            initializes mpris object on dbus
150        """
151        dbus.service.Object.__init__(self, bus, MPRIS_PATH)
152        self.socket = None
153        self.fifo = None
154        self.mpv = False
155        self.properties = {
156            ROOT_INTERFACE : {
157                'read_only' : {
158                    'CanQuit' : False,
159                    'CanSetFullscreen' : False,
160                    'CanRaise' : False,
161                    'HasTrackList' : False,
162                    'Identity' : IDENTITY,
163                    'DesktopEntry' : 'mps-youtube',
164                    'SupportedUriSchemes' : dbus.Array([], 's', 1),
165                    'SupportedMimeTypes' : dbus.Array([], 's', 1),
166                },
167                'read_write' : {
168                    'Fullscreen' : False,
169                },
170            },
171            PLAYER_INTERFACE : {
172                'read_only' : {
173                    'PlaybackStatus' : 'Stopped',
174                    'Metadata' : { 'mpris:trackid' : dbus.ObjectPath(
175                                '/CurrentPlaylist/UnknownTrack', variant_level=1) },
176                    'Position' : dbus.Int64(0),
177                    'MinimumRate' : 1.0,
178                    'MaximumRate' : 1.0,
179                    'CanGoNext' : True,
180                    'CanGoPrevious' : True,
181                    'CanPlay' : True,
182                    'CanPause' : True,
183                    'CanSeek' : True,
184                    'CanControl' : True,
185                },
186                'read_write' : {
187                    'Rate' : 1.0,
188                    'Volume' : 1.0,
189                },
190            },
191        }
192
193    def bindmpv(self, sockpath):
194        """
195            init JSON IPC for new versions of mpv >= 0.7
196        """
197        self.mpv = True
198        self.socket = socket.socket(socket.AF_UNIX)
199        # wait on socket initialization
200        tries = 0
201        while tries < 10:
202            time.sleep(.5)
203            try:
204                self.socket.connect(sockpath)
205                break
206            except socket.error:
207                pass
208            tries += 1
209        else:
210            return
211
212        try:
213            observe_full = False
214            self._sendcommand(["observe_property", 1, "time-pos"])
215
216            for line in self.socket.makefile():
217                resp = json.loads(line)
218
219                # deals with bug in mpv 0.7 - 0.7.3
220                if resp.get('event') == 'property-change' and not observe_full:
221                    self._sendcommand(["observe_property", 2, "volume"])
222                    self._sendcommand(["observe_property", 3, "pause"])
223                    self._sendcommand(["observe_property", 4, "seeking"])
224                    observe_full = True
225
226                if resp.get('event') == 'property-change':
227                    self.setproperty(resp['name'], resp['data'])
228
229        except socket.error:
230            self.socket = None
231            self.mpv = False
232
233    def bindfifo(self, fifopath, mpv=False):
234        """
235            init command fifo for mplayer and old versions of mpv
236        """
237        time.sleep(1) # give it some time so fifo could be properly created
238        try:
239            self.fifo = open(fifopath, 'w')
240            self._sendcommand(['get_property', 'volume'])
241            self.mpv = mpv
242
243        except IOError:
244            self.fifo = None
245
246    def setproperty(self, name, val):
247        """
248            Properly sets properties on player interface
249
250            don't use this method from dbus interface, all values should
251            be set from player (to keep them correct)
252        """
253        if name == 'pause':
254            oldval = self.properties[PLAYER_INTERFACE]['read_only']['PlaybackStatus']
255            newval = None
256            if val:
257                newval = 'Paused'
258            else:
259                newval = 'Playing'
260
261            if newval != oldval:
262                self.properties[PLAYER_INTERFACE]['read_only']['PlaybackStatus'] = newval
263                self.PropertiesChanged(PLAYER_INTERFACE, { 'PlaybackStatus': newval }, [])
264
265        elif name == 'stop':
266            oldval = self.properties[PLAYER_INTERFACE]['read_only']['PlaybackStatus']
267            newval = None
268            if val:
269                newval = 'Stopped'
270            else:
271                newval = 'Playing'
272
273            if newval != oldval:
274                self.properties[PLAYER_INTERFACE]['read_only']['PlaybackStatus'] = newval
275                self.PropertiesChanged(PLAYER_INTERFACE, { 'PlaybackStatus': newval },
276                    ['Metadata', 'Position'])
277
278        elif name == 'volume' and val is not None:
279            oldval = self.properties[PLAYER_INTERFACE]['read_write']['Volume']
280            newval = float(val) / 100
281
282            if newval != oldval:
283                self.properties[PLAYER_INTERFACE]['read_write']['Volume'] = newval
284                self.PropertiesChanged(PLAYER_INTERFACE, { 'Volume': newval }, [])
285
286        elif name == 'time-pos' and val:
287            oldval = self.properties[PLAYER_INTERFACE]['read_only']['Position']
288            newval = dbus.Int64(val * 10**6)
289
290            if newval != oldval:
291                self.properties[PLAYER_INTERFACE]['read_only']['Position'] = newval
292            if abs(newval - oldval) >= 4 * 10**6:
293                self.Seeked(newval)
294
295        elif name == 'metadata' and val:
296            trackid, title, length, arturl, artist, album = val
297            # sanitize ytid - it uses '-_' which are not valid in dbus paths
298            trackid_sanitized = re.sub('[^a-zA-Z0-9]', '', trackid)
299            yturl = 'https://www.youtube.com/watch?v=' + trackid
300
301            oldval = self.properties[PLAYER_INTERFACE]['read_only']['Metadata']
302            newval = {
303                'mpris:trackid' : dbus.ObjectPath(
304                    '/CurrentPlaylist/ytid/' + trackid_sanitized, variant_level=1),
305                'mpris:length' : dbus.Int64(length * 10**6, variant_level=1),
306                'mpris:artUrl' : dbus.String(arturl, variant_level=1),
307                'xesam:title' : dbus.String(title, variant_level=1),
308                'xesam:artist' : dbus.Array(artist, 's', 1),
309                'xesam:album' : dbus.String(album, variant_level=1),
310                'xesam:url' : dbus.String(yturl, variant_level=1),
311            }
312
313            if newval != oldval:
314                self.properties[PLAYER_INTERFACE]['read_only']['Metadata'] = newval
315                self.PropertiesChanged(PLAYER_INTERFACE, { 'Metadata': newval }, [])
316
317        elif name == 'seeking':
318            # send signal to keep time-pos synced between player and client
319            if not val:
320                self.Seeked(self.properties[PLAYER_INTERFACE]['read_only']['Position'])
321
322    def _sendcommand(self, command):
323        """
324            sends commands to binded player
325        """
326        if self.socket:
327            self.socket.send(json.dumps({"command": command}).encode() + b'\n')
328        elif self.fifo:
329            command = command[:]
330            for x, i in enumerate(command):
331                if i is True:
332                    command[x] = 'yes' if self.mpv else 1
333                elif i is False:
334                    command[x] = 'no' if self.mpv else 0
335
336            cmd = " ".join([str(i) for i in command]) + '\n'
337            self.fifo.write(cmd)
338            self.fifo.flush()
339
340    #
341    # implementing org.mpris.MediaPlayer2
342    #
343
344    @dbus.service.method(dbus_interface=ROOT_INTERFACE)
345    def Raise(self):
346        """
347            Brings the media player's user interface to the front using
348            any appropriate mechanism available.
349        """
350        pass
351
352    @dbus.service.method(dbus_interface=ROOT_INTERFACE)
353    def Quit(self):
354        """
355            Causes the media player to stop running.
356        """
357        pass
358
359    #
360    # implementing org.mpris.MediaPlayer2.Player
361    #
362
363    @dbus.service.method(dbus_interface=PLAYER_INTERFACE)
364    def Next(self):
365        """
366            Skips to the next track in the tracklist.
367        """
368        self._sendcommand(["quit"])
369
370    @dbus.service.method(PLAYER_INTERFACE)
371    def Previous(self):
372        """
373            Skips to the previous track in the tracklist.
374        """
375        self._sendcommand(["quit", 42])
376
377    @dbus.service.method(PLAYER_INTERFACE)
378    def Pause(self):
379        """
380            Pauses playback.
381            If playback is already paused, this has no effect.
382        """
383        if self.mpv:
384            self._sendcommand(["set_property", "pause", True])
385        else:
386            if self.properties[PLAYER_INTERFACE]['read_only']['PlaybackStatus'] != 'Paused':
387                self._sendcommand(['pause'])
388
389    @dbus.service.method(PLAYER_INTERFACE)
390    def PlayPause(self):
391        """
392            Pauses playback.
393            If playback is already paused, resumes playback.
394        """
395        if self.mpv:
396            self._sendcommand(["cycle", "pause"])
397        else:
398            self._sendcommand(["pause"])
399
400    @dbus.service.method(PLAYER_INTERFACE)
401    def Stop(self):
402        """
403            Stops playback.
404        """
405        self._sendcommand(["quit", 43])
406
407    @dbus.service.method(PLAYER_INTERFACE)
408    def Play(self):
409        """
410            Starts or resumes playback.
411        """
412        if self.mpv:
413            self._sendcommand(["set_property", "pause", False])
414        else:
415            if self.properties[PLAYER_INTERFACE]['read_only']['PlaybackStatus'] != 'Playing':
416                self._sendcommand(['pause'])
417
418    @dbus.service.method(PLAYER_INTERFACE, in_signature='x')
419    def Seek(self, offset):
420        """
421            Offset - x (offset)
422                The number of microseconds to seek forward.
423
424            Seeks forward in the current track by the specified number
425            of microseconds.
426        """
427        self._sendcommand(["seek", offset / 10**6])
428
429    @dbus.service.method(PLAYER_INTERFACE, in_signature='ox')
430    def SetPosition(self, track_id, position):
431        """
432            TrackId - o (track_id)
433                The currently playing track's identifier.
434                If this does not match the id of the currently-playing track,
435                the call is ignored as "stale".
436            Position - x (position)
437                Track position in microseconds.
438
439            Sets the current track position in microseconds.
440        """
441        if track_id == self.properties[PLAYER_INTERFACE]['read_only']['Metadata']['mpris:trackid']:
442            self._sendcommand(["seek", position / 10**6, 'absolute' if self.mpv else 2])
443
444    @dbus.service.method(PLAYER_INTERFACE, in_signature='s')
445    def OpenUri(self, uri):
446        """
447            Uri - s (uri)
448                Uri of the track to load.
449
450            Opens the Uri given as an argument.
451        """
452        pass
453
454    @dbus.service.signal(PLAYER_INTERFACE, signature='x')
455    def Seeked(self, position):
456        """
457            Position - x (position)
458                The new position, in microseconds.
459
460            Indicates that the track position has changed in a way that
461            is inconsistant with the current playing state.
462        """
463        pass
464
465    #
466    # implementing org.freedesktop.DBus.Properties
467    #
468
469    @dbus.service.method(dbus_interface=PROPERTIES_INTERFACE,
470                         in_signature='ss', out_signature='v')
471    def Get(self, interface_name, property_name):
472        """
473            getter for org.freedesktop.DBus.Properties on this object
474        """
475        return self.GetAll(interface_name)[property_name]
476
477    @dbus.service.method(dbus_interface=PROPERTIES_INTERFACE,
478                         in_signature='s', out_signature='a{sv}')
479    def GetAll(self, interface_name):
480        """
481            getter for org.freedesktop.DBus.Properties on this object
482        """
483        if interface_name in self.properties:
484            t = copy.copy(self.properties[interface_name]['read_only'])
485            t.update(self.properties[interface_name]['read_write'])
486
487            return t
488        else:
489            raise dbus.exceptions.DBusException(
490                'com.example.UnknownInterface',
491                'This object does not implement the %s interface'
492                % interface_name)
493
494    @dbus.service.method(dbus_interface=PROPERTIES_INTERFACE,
495                         in_signature='ssv')
496    def Set(self, interface_name, property_name, new_value):
497        """
498            setter for org.freedesktop.DBus.Properties on this object
499        """
500        if interface_name in self.properties:
501            if property_name in self.properties[interface_name]['read_write']:
502                if property_name == 'Volume':
503                    self._sendcommand(["set_property", "volume", new_value * 100])
504                    if self.fifo: # fix for mplayer (force update)
505                        self._sendcommand(['get_property', 'volume'])
506        else:
507            raise dbus.exceptions.DBusException(
508                'com.example.UnknownInterface',
509                'This object does not implement the %s interface'
510                % interface_name)
511
512    @dbus.service.signal(dbus_interface=PROPERTIES_INTERFACE,
513                         signature='sa{sv}as')
514    def PropertiesChanged(self, interface_name, changed_properties,
515                          invalidated_properties):
516        """
517            signal for org.freedesktop.DBus.Properties on this object
518
519            this informs of changed properties
520        """
521        pass
522
523class MprisConnection(object):
524    """
525        Object encapsulating pipe for communication with Mpris2Controller.
526        This object wraps send to ensure communicating process never crashes,
527        even when Mpris2Controller existed or crashed.
528    """
529    def __init__(self, connection):
530        super(MprisConnection, self).__init__()
531        self.connection = connection
532
533    def send(self, obj):
534        """
535            Send an object to the other end of the connection
536        """
537        if self.connection:
538            try:
539                self.connection.send(obj)
540            except BrokenPipeError:
541                self.connection = None
542                print('MPRIS process exited or crashed.')
543
544
545def main(connection):
546    """
547        runs mpris interface and listens for changes
548        connection - pipe to communicate with this module
549    """
550
551    try:
552        mprisctl = Mpris2Controller()
553    except ImportError: # gi.repository import GLib
554        print("could not load MPRIS interface. missing libraries.")
555        return
556    try:
557        mprisctl.acquire()
558    except dbus.exceptions.DBusException:
559        print('mpris interface couldn\'t be initialized. Is dbus properly configured?')
560        return
561    mprisctl.run(connection)
562    mprisctl.release()
563