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