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