1# Copyright 2012,2013 Christoph Reiter <reiter.christoph@gmail.com> 2# 3# This program is free software; you can redistribute it and/or modify 4# it under the terms of the GNU General Public License as published by 5# the Free Software Foundation; either version 2 of the License, or 6# (at your option) any later version. 7 8import os 9import sys 10 11if os.name == "nt" or sys.platform == "darwin": 12 from quodlibet.plugins import PluginNotSupportedError 13 raise PluginNotSupportedError 14 15from gi.repository import Gtk, GdkPixbuf 16from senf import fsn2uri 17 18import dbus 19import dbus.service 20 21from quodlibet import _ 22from quodlibet import app 23from quodlibet.plugins.events import EventPlugin 24from quodlibet.pattern import Pattern 25from quodlibet.qltk import Icons 26from quodlibet.util.dbusutils import DBusIntrospectable, DBusProperty 27from quodlibet.util.dbusutils import dbus_unicode_validate as unival 28from quodlibet.util import NamedTemporaryFile 29 30BASE_PATH = "/org/gnome/UPnP/MediaServer2" 31BUS_NAME = "org.gnome.UPnP.MediaServer2.QuodLibet" 32 33 34class MediaServer(EventPlugin): 35 PLUGIN_ID = "mediaserver" 36 PLUGIN_NAME = _("UPnP AV Media Server") 37 PLUGIN_DESC = _("Exposes all albums to the Rygel UPnP Media Server " 38 "through the MediaServer2 D-Bus interface.") 39 PLUGIN_ICON = Icons.NETWORK_WORKGROUP 40 41 def PluginPreferences(self, parent): 42 vbox = Gtk.VBox(spacing=12) 43 44 conf_exp = _("Ensure the following is in your rygel config file " 45 "(~/.config/rygel.conf):") 46 conf_cont = ("[External]\n" 47 "enabled=true\n\n" 48 "[org.gnome.UPnP.MediaServer2.QuodLibet]\n" 49 "enabled=true") 50 51 exp_lbl = Gtk.Label(label=conf_exp) 52 exp_lbl.set_selectable(True) 53 exp_lbl.set_line_wrap(True) 54 exp_lbl.set_alignment(0, 0) 55 56 conf_lbl = Gtk.Label() 57 conf_lbl.set_selectable(True) 58 conf_lbl.set_alignment(0, 0) 59 conf_lbl.set_markup("<span font='mono'>{}</span>".format(conf_cont)) 60 61 vbox.pack_start(exp_lbl, True, False, 0) 62 vbox.pack_start(conf_lbl, True, False, 0) 63 return vbox 64 65 def enabled(self): 66 try: 67 dbus.SessionBus() 68 except dbus.DBusException: 69 self.objects = [] 70 return 71 72 entry = EntryObject() 73 albums = AlbumsObject(entry, app.library) 74 song = SongObject(app.library, [albums]) 75 icon = Icon(entry) 76 77 self.objects = [entry, albums, song, icon] 78 79 def disabled(self): 80 for obj in self.objects: 81 obj.remove_from_connection() 82 83 for obj in self.objects: 84 obj.destroy() 85 86 del self.objects 87 88 import gc 89 gc.collect() 90 91 92class DBusPropertyFilter(DBusProperty): 93 """Adds some methods to support the MediaContainer property filtering.""" 94 95 def get_properties_for_filter(self, interface, filter_): 96 props = self.get_properties(interface) 97 if "*" not in filter_: 98 props = [p for p in props if p[1] in filter_] 99 return props 100 101 def get_values(self, properties, path="/"): 102 result = {} 103 for iface, prop in properties: 104 result[prop] = self.get_value(iface, prop, path) 105 return result 106 107 108class MediaContainer(object): 109 IFACE = "org.gnome.UPnP.MediaContainer2" 110 ISPEC_PROP = """ 111<property type="u" name="ChildCount" access="read"/> 112<property type="u" name="ItemCount" access="read"/> 113<property type="u" name="ContainerCount" access="read"/> 114<property type="b" name="Searchable" access="read"/> 115<property type="o" name="Icon" access="read"/> 116""" 117 ISPEC = """ 118<method name="ListChildren"> 119 <arg type="u" name="offset" direction="in"/> 120 <arg type="u" name="max" direction="in"/> 121 <arg type="as" name="filter" direction="in"/> 122 <arg type="aa{sv}" name="arg_3" direction="out"/> 123</method> 124<method name="ListContainers"> 125 <arg type="u" name="offset" direction="in"/> 126 <arg type="u" name="max" direction="in"/> 127 <arg type="as" name="filter" direction="in"/> 128 <arg type="aa{sv}" name="arg_3" direction="out"/> 129</method> 130<method name="ListItems"> 131 <arg type="u" name="offset" direction="in"/> 132 <arg type="u" name="max" direction="in"/> 133 <arg type="as" name="filter" direction="in"/> 134 <arg type="aa{sv}" name="arg_3" direction="out"/> 135</method> 136<method name="SearchObjects"> 137 <arg type="s" name="query" direction="in"/> 138 <arg type="u" name="offset" direction="in"/> 139 <arg type="u" name="max" direction="in"/> 140 <arg type="as" name="filter" direction="in"/> 141 <arg type="aa{sv}" name="arg_4" direction="out"/> 142</method> 143 144<signal name="Updated"/> 145""" 146 147 def __init__(self, optional=tuple()): 148 self.set_introspection(MediaContainer.IFACE, MediaContainer.ISPEC) 149 150 props = ["ChildCount", "ItemCount", "ContainerCount", "Searchable"] 151 props += list(optional) 152 self.set_properties(MediaContainer.IFACE, MediaContainer.ISPEC_PROP, 153 wl=props) 154 155 self.implement_interface(MediaContainer.IFACE, MediaObject.IFACE) 156 157 def emit_updated(self, path="/"): 158 self.Updated(rel=path) 159 160 @dbus.service.method(IFACE, in_signature="uuas", out_signature="aa{sv}", 161 rel_path_keyword="path") 162 def ListChildren(self, offset, max_, filter_, path): 163 if self.SUPPORTS_MULTIPLE_OBJECT_PATHS: 164 return self.list_children(offset, max_, filter_, path) 165 return self.list_children(offset, max_, filter_) 166 167 @dbus.service.method(IFACE, in_signature="uuas", out_signature="aa{sv}", 168 rel_path_keyword="path") 169 def ListContainers(self, offset, max_, filter_, path): 170 if self.SUPPORTS_MULTIPLE_OBJECT_PATHS: 171 return self.list_containers(offset, max_, filter_, path) 172 return self.list_containers(offset, max_, filter_) 173 174 @dbus.service.method(IFACE, in_signature="uuas", out_signature="aa{sv}", 175 rel_path_keyword="path") 176 def ListItems(self, offset, max_, filter_, path): 177 if self.SUPPORTS_MULTIPLE_OBJECT_PATHS: 178 return self.list_items(offset, max_, filter_, path) 179 return self.list_items(offset, max_, filter_) 180 181 @dbus.service.method(IFACE, in_signature="suuas", out_signature="aa{sv}", 182 rel_path_keyword="path") 183 def SearchObjects(self, query, offset, max_, filter_, path): 184 return [] 185 186 @dbus.service.signal(IFACE, rel_path_keyword="rel") 187 def Updated(self, rel=""): 188 pass 189 190 191class MediaObject(object): 192 IFACE = "org.gnome.UPnP.MediaObject2" 193 ISPEC = """ 194<property type="o" name="Parent" access="read"/> 195<property type="s" name="Type" access="read"/> 196<property type="o" name="Path" access="read"/> 197<property type="s" name="DisplayName" access="read"/> 198""" 199 parent = None 200 201 def __init__(self, parent=None): 202 self.set_properties(MediaObject.IFACE, MediaObject.ISPEC) 203 self.parent = parent or self 204 205 206class MediaItem(object): 207 IFACE = "org.gnome.UPnP.MediaItem2" 208 ISPEC = """ 209<property type="as" name="URLs" access="read"/> 210<property type="s" name="MIMEType" access="read"/> 211 212<property type="x" name="Size" access="read"/> 213<property type="s" name="Artist" access="read"/> 214<property type="s" name="Album" access="read"/> 215<property type="s" name="Date" access="read"/> 216<property type="s" name="Genre" access="read"/> 217<property type="s" name="DLNAProfile" access="read"/> 218 219<property type="i" name="Duration" access="read"/> 220<property type="i" name="Bitrate" access="read"/> 221<property type="i" name="SampleRate" access="read"/> 222<property type="i" name="BitsPerSample" access="read"/> 223 224<property type="i" name="Width" access="read"/> 225<property type="i" name="Height" access="read"/> 226<property type="i" name="ColorDepth" access="read"/> 227<property type="i" name="PixelWidth" access="read"/> 228<property type="i" name="PixelHeight" access="read"/> 229<property type="o" name="Thumbnail" access="read"/> 230 231<property type="o" name="AlbumArt" access="read"/> 232 233<property type="i" name="TrackNumber" access="read"/> 234""" 235 236 def __init__(self, optional=tuple()): 237 props = ["URLs", "MIMEType"] + list(optional) 238 self.set_properties(MediaItem.IFACE, MediaItem.ISPEC, wl=props) 239 self.implement_interface(MediaItem.IFACE, MediaObject.IFACE) 240 241 242class EntryObject(MediaContainer, MediaObject, DBusPropertyFilter, 243 DBusIntrospectable, dbus.service.Object): 244 PATH = BASE_PATH + "/QuodLibet" 245 DISPLAY_NAME = "@REALNAME@'s Quod Libet on @HOSTNAME@" 246 247 def __init__(self): 248 self.__sub = [] 249 250 DBusIntrospectable.__init__(self) 251 DBusPropertyFilter.__init__(self) 252 MediaObject.__init__(self) 253 MediaContainer.__init__(self, optional=["Icon"]) 254 255 bus = dbus.SessionBus() 256 name = dbus.service.BusName(BUS_NAME, bus) 257 dbus.service.Object.__init__(self, bus, self.PATH, name) 258 259 def get_property(self, interface, name): 260 if interface == MediaContainer.IFACE: 261 if name == "ChildCount": 262 return len(self.__sub) 263 elif name == "ItemCount": 264 return 0 265 elif name == "ContainerCount": 266 return len(self.__sub) 267 elif name == "Searchable": 268 return False 269 elif name == "Icon": 270 return Icon.PATH 271 elif interface == MediaObject.IFACE: 272 if name == "Parent": 273 return self.parent.PATH 274 elif name == "Type": 275 return "container" 276 elif name == "Path": 277 return self.PATH 278 elif name == "DisplayName": 279 return self.DISPLAY_NAME 280 281 def destroy(self): 282 # break cycle 283 del self.__sub 284 del self.parent 285 286 def register_child(self, child): 287 self.__sub.append(child) 288 self.emit_properties_changed(MediaContainer.IFACE, 289 ["ChildCount", "ContainerCount"]) 290 291 def list_containers(self, offset, max_, filter_): 292 props = self.get_properties_for_filter(MediaContainer.IFACE, filter_) 293 end = (max_ and offset + max_) or None 294 295 result = [] 296 for sub in self.__sub[offset:end]: 297 result.append(sub.get_values(props)) 298 return result 299 300 list_children = list_containers 301 302 def list_items(self, offset, max_, filter_): 303 return [] 304 305SUPPORTED_SONG_PROPERTIES = ("Size", "Artist", "Album", "Date", "Genre", 306 "Duration", "TrackNumber") 307 308 309class DummySongObject(MediaItem, MediaObject, DBusPropertyFilter, 310 DBusIntrospectable): 311 """ A dummy song object that is not exported on the bus, but supports 312 the usual interfaces. 313 314 You need to assign a real song before using it, and have to pass 315 a path prefix. 316 317 The path of the song is /org/gnome/UPnP/MediaServer2/Song/<PREFIX>/SongID 318 This lets us reconstruct the original parent path: 319 /org/gnome/UPnP/MediaServer2/<PREFIX> 320 321 atm. a prefix can look like "Albums/123456" 322 """ 323 324 SUPPORTS_MULTIPLE_OBJECT_PATHS = False 325 __pattern = Pattern( 326 "<discnumber|<discnumber>.><tracknumber>. <title>") 327 328 def __init__(self, parent): 329 DBusIntrospectable.__init__(self) 330 DBusPropertyFilter.__init__(self) 331 MediaObject.__init__(self, parent) 332 MediaItem.__init__(self, optional=SUPPORTED_SONG_PROPERTIES) 333 334 def set_song(self, song, prefix): 335 self.__song = song 336 self.__prefix = prefix 337 338 def get_property(self, interface, name): 339 if interface == MediaObject.IFACE: 340 if name == "Parent": 341 return BASE_PATH + "/" + self.__prefix 342 elif name == "Type": 343 return "music" 344 elif name == "Path": 345 path = SongObject.PATH 346 path += "/" + self.__prefix + "/" + str(id(self.__song)) 347 return path 348 elif name == "DisplayName": 349 return unival(self.__song.comma("title")) 350 elif interface == MediaItem.IFACE: 351 if name == "URLs": 352 return [self.__song("~uri")] 353 elif name == "MIMEType": 354 mimes = self.__song.mimes 355 return mimes and mimes[0] 356 elif name == "Size": 357 return self.__song("~#filesize") 358 elif name == "Artist": 359 return unival(self.__song.comma("artist")) 360 elif name == "Album": 361 return unival(self.__song.comma("album")) 362 elif name == "Date": 363 return unival(self.__song.comma("date")) 364 elif name == "Genre": 365 return unival(self.__song.comma("genre")) 366 elif name == "Duration": 367 return self.__song("~#length") 368 elif name == "TrackNumber": 369 return self.__song("~#track", 0) 370 371 372class DummyAlbumObject(MediaContainer, MediaObject, DBusPropertyFilter, 373 DBusIntrospectable): 374 375 SUPPORTS_MULTIPLE_OBJECT_PATHS = False 376 __pattern = Pattern("<albumartist|<~albumartist~album>|<~artist~album>>") 377 378 def __init__(self, parent): 379 DBusIntrospectable.__init__(self) 380 DBusPropertyFilter.__init__(self) 381 MediaObject.__init__(self, parent) 382 MediaContainer.__init__(self) 383 self.__song = DummySongObject(self) 384 385 def get_dummy(self, song): 386 self.__song.set_song(song, "Albums/" + str(id(self.__album))) 387 return self.__song 388 389 def set_album(self, album): 390 self.__album = album 391 self.PATH = self.parent.PATH + "/" + str(id(album)) 392 393 def get_property(self, interface, name): 394 if interface == MediaContainer.IFACE: 395 if name == "ChildCount" or name == "ItemCount": 396 return len(self.__album.songs) 397 elif name == "ContainerCount": 398 return 0 399 elif name == "Searchable": 400 return False 401 elif interface == MediaObject.IFACE: 402 if name == "Parent": 403 return self.parent.PATH 404 elif name == "Type": 405 return "container" 406 elif name == "Path": 407 return self.PATH 408 elif name == "DisplayName": 409 return unival(self.__pattern % self.__album) 410 411 def list_containers(self, offset, max_, filter_): 412 return [] 413 414 def list_items(self, offset, max_, filter_): 415 songs = sorted(self.__album.songs, key=lambda s: s.sort_key) 416 dummy = self.get_dummy(None) 417 props = dummy.get_properties_for_filter(MediaItem.IFACE, filter_) 418 end = (max_ and offset + max_) or None 419 420 result = [] 421 for song in songs[offset:end]: 422 result.append(self.get_dummy(song).get_values(props)) 423 return result 424 425 list_children = list_items 426 427 428class SongObject(MediaItem, MediaObject, DBusProperty, DBusIntrospectable, 429 dbus.service.FallbackObject): 430 PATH = BASE_PATH + "/Song" 431 432 def __init__(self, library, users): 433 DBusIntrospectable.__init__(self) 434 DBusProperty.__init__(self) 435 MediaObject.__init__(self, None) 436 MediaItem.__init__(self, optional=SUPPORTED_SONG_PROPERTIES) 437 438 bus = dbus.SessionBus() 439 self.ref = dbus.service.BusName(BUS_NAME, bus) 440 dbus.service.FallbackObject.__init__(self, bus, self.PATH) 441 442 self.__library = library 443 self.__map = dict((id(v), v) for v in self.__library.values()) 444 self.__reverse = dict((v, k) for k, v in self.__map.items()) 445 446 self.__song = DummySongObject(self) 447 448 self.__users = users 449 450 signals = [ 451 ("changed", self.__songs_changed), 452 ("removed", self.__songs_removed), 453 ("added", self.__songs_added), 454 ] 455 self.__sigs = map( 456 lambda x: self.__library.connect(x[0], x[1]), signals) 457 458 def __songs_changed(self, lib, songs): 459 # We don't know what changed, so get all properties 460 props = [p[1] for p in self.get_properties(MediaItem.IFACE)] 461 462 for song in songs: 463 song_id = str(id(song)) 464 # https://github.com/quodlibet/quodlibet/issues/id=1127 465 # XXX: Something is emitting wrong changed events.. 466 # ignore song_ids we don't know for now 467 if song_id not in self.__map: 468 continue 469 for user in self.__users: 470 # ask the user for the prefix with which the song is used 471 prefix = user.get_prefix(song) 472 path = "/" + prefix + "/" + song_id 473 self.emit_properties_changed(MediaItem.IFACE, props, path) 474 475 def __songs_added(self, lib, songs): 476 for song in songs: 477 new_id = id(song) 478 self.__map[new_id] = song 479 self.__reverse[song] = new_id 480 481 def __songs_removed(self, lib, songs): 482 for song in songs: 483 del self.__map[self.__reverse[song]] 484 del self.__reverse[song] 485 486 def destroy(self): 487 for signal_id in self.__sigs: 488 self.__library.disconnect(signal_id) 489 490 def get_dummy(self, song, prefix): 491 self.__song.set_song(song, prefix) 492 return self.__song 493 494 def get_property(self, interface, name, path): 495 # extract the prefix 496 prefix, song_id = path[1:].rsplit("/", 1) 497 song = self.__map[int(song_id)] 498 return self.get_dummy(song, prefix).get_property(interface, name) 499 500 501class AlbumsObject(MediaContainer, MediaObject, DBusPropertyFilter, 502 DBusIntrospectable, dbus.service.FallbackObject): 503 PATH = BASE_PATH + "/Albums" 504 DISPLAY_NAME = "Albums" 505 506 def __init__(self, parent, library): 507 DBusIntrospectable.__init__(self) 508 DBusPropertyFilter.__init__(self) 509 MediaObject.__init__(self, parent) 510 MediaContainer.__init__(self) 511 512 bus = dbus.SessionBus() 513 self.ref = dbus.service.BusName(BUS_NAME, bus) 514 dbus.service.FallbackObject.__init__(self, bus, self.PATH) 515 516 parent.register_child(self) 517 518 self.__library = library.albums 519 self.__library.load() 520 521 self.__map = dict((id(v), v) for v in self.__library.values()) 522 self.__reverse = dict((v, k) for k, v in self.__map.items()) 523 524 signals = [ 525 ("changed", self.__albums_changed), 526 ("removed", self.__albums_removed), 527 ("added", self.__albums_added), 528 ] 529 self.__sigs = map( 530 lambda x: self.__library.connect(x[0], x[1]), signals) 531 532 self.__dummy = DummyAlbumObject(self) 533 534 def get_dummy(self, album): 535 self.__dummy.set_album(album) 536 return self.__dummy 537 538 def get_path_dummy(self, path): 539 return self.get_dummy(self.__map[int(path[1:])]) 540 541 def __albums_changed(self, lib, albums): 542 for album in albums: 543 rel_path = "/" + str(id(album)) 544 self.emit_updated(rel_path) 545 self.emit_properties_changed( 546 MediaContainer.IFACE, 547 ["ChildCount", "ItemCount", "DisplayName"], 548 rel_path) 549 550 def __albums_added(self, lib, albums): 551 for album in albums: 552 new_id = id(album) 553 self.__map[new_id] = album 554 self.__reverse[album] = new_id 555 self.emit_updated() 556 self.emit_properties_changed(MediaContainer.IFACE, 557 ["ChildCount", "ContainerCount"]) 558 559 def __albums_removed(self, lib, albums): 560 for album in albums: 561 del self.__map[self.__reverse[album]] 562 del self.__reverse[album] 563 self.emit_updated() 564 self.emit_properties_changed(MediaContainer.IFACE, 565 ["ChildCount", "ContainerCount"]) 566 567 def get_prefix(self, song): 568 album = self.__library[song.album_key] 569 return "Albums/" + str(id(album)) 570 571 def destroy(self): 572 for signal_id in self.__sigs: 573 self.__library.disconnect(signal_id) 574 575 def __get_albums_property(self, interface, name): 576 if interface == MediaContainer.IFACE: 577 if name == "ChildCount": 578 return len(self.__library) 579 elif name == "ItemCount": 580 return 0 581 elif name == "ContainerCount": 582 return len(self.__library) 583 elif name == "Searchable": 584 return False 585 elif interface == MediaObject.IFACE: 586 if name == "Parent": 587 return self.parent.PATH 588 elif name == "Type": 589 return "container" 590 elif name == "Path": 591 return self.PATH 592 elif name == "DisplayName": 593 return self.DISPLAY_NAME 594 595 def get_property(self, interface, name, path): 596 if path == "/": 597 return self.__get_albums_property(interface, name) 598 599 return self.get_path_dummy(path).get_property(interface, name) 600 601 def __list_albums(self, offset, max_, filter_): 602 props = self.get_properties_for_filter(MediaContainer.IFACE, filter_) 603 albums = sorted(self.__library, key=lambda a: a.sort) 604 end = (max_ and offset + max_) or None 605 606 result = [] 607 for album in albums[offset:end]: 608 result.append(self.get_dummy(album).get_values(props)) 609 return result 610 611 def list_containers(self, offset, max_, filter_, path): 612 if path == "/": 613 return self.__list_albums(offset, max_, filter_) 614 return [] 615 616 def list_items(self, offset, max_, filter_, path): 617 if path != "/": 618 return self.get_path_dummy(path).list_items(offset, max_, filter_) 619 return [] 620 621 def list_children(self, offset, max_, filter_, path): 622 if path == "/": 623 return self.__list_albums(offset, max_, filter_) 624 return self.get_path_dummy(path).list_children(offset, max_, filter_) 625 626 627class Icon(MediaItem, MediaObject, DBusProperty, DBusIntrospectable, 628 dbus.service.Object): 629 PATH = BASE_PATH + "/Icon" 630 631 SIZE = 160 632 633 def __init__(self, parent): 634 DBusIntrospectable.__init__(self) 635 DBusProperty.__init__(self) 636 MediaObject.__init__(self, parent=parent) 637 MediaItem.__init__(self, optional=["Height", "Width", "ColorDepth"]) 638 639 bus = dbus.SessionBus() 640 name = dbus.service.BusName(BUS_NAME, bus) 641 dbus.service.Object.__init__(self, bus, self.PATH, name) 642 643 # https://bugzilla.gnome.org/show_bug.cgi?id=669677 644 self.implement_interface("org.gnome.UPnP.MediaItem1", MediaItem.IFACE) 645 646 # load into a pixbuf 647 theme = Gtk.IconTheme.get_default() 648 pixbuf = theme.load_icon(Icons.QUODLIBET, Icon.SIZE, 0) 649 650 # make sure the size is right 651 pixbuf = pixbuf.scale_simple(Icon.SIZE, Icon.SIZE, 652 GdkPixbuf.InterpType.BILINEAR) 653 self.__depth = pixbuf.get_bits_per_sample() 654 655 # save and keep reference 656 self.__f = f = NamedTemporaryFile() 657 pixbuf.savev(f.name, "png", [], []) 658 659 def get_property(self, interface, name): 660 if interface == MediaObject.IFACE: 661 if name == "Parent": 662 return EntryObject.PATH 663 elif name == "Type": 664 return "image" 665 elif name == "Path": 666 return Icon.PATH 667 elif name == "DisplayName": 668 return r"I'm an icon \o/" 669 elif interface == MediaItem.IFACE: 670 if name == "URLs": 671 return [fsn2uri(self.__f.name)] 672 elif name == "MIMEType": 673 return "image/png" 674 elif name == "Width" or name == "Height": 675 return Icon.SIZE 676 elif name == "ColorDepth": 677 return self.__depth 678 679 def destroy(self): 680 pass 681