1# Copyright (C) 2006-2007 Aren Olson
2#                    2011 Brian Parma
3#                    2020 Rok Mandeljc
4#
5# This program is free software; you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation; either version 2, or (at your option)
8# any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; if not, write to the Free Software
17# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18
19import functools
20from gettext import gettext as _
21import http.client
22import logging
23import pickle
24import os
25import time
26
27from gi.repository import Gtk
28from gi.repository import GObject
29
30from xl import collection, event, trax, common, providers, settings, xdg
31from xlgui.panel.collection import CollectionPanel
32from xlgui.widgets import dialogs, menu, menuitems
33from xlgui import main
34
35from .client import DAAPClient
36from . import daapclientprefs
37
38
39logger = logging.getLogger(__name__)
40
41_smi = menu.simple_menu_item
42_sep = menu.simple_separator
43
44
45# Check for python-zeroconf
46try:
47    import zeroconf
48
49    ZEROCONF = True
50    ZEROCONF_VERSION = [int(v) for v in zeroconf.__version__.split('.')[:2]]
51
52    # ServiceInfo.parsed_addresses() and IPVersion enum were introduced
53    # in v.0.24
54    ZEROCONF_LEGACY = ZEROCONF_VERSION < [0, 24]
55    if ZEROCONF_LEGACY:
56        import socket  # for inet_ntoa
57except ImportError:
58    ZEROCONF = False
59
60
61# detect authentication support in python-daap
62try:
63    tmp = DAAPClient()
64    tmp.connect("spam", "eggs", "sausage")  # dummy login
65    del tmp
66except TypeError:
67    AUTH = False
68except Exception:
69    AUTH = True
70
71
72class DaapZeroconfInterface(GObject.GObject):
73    __gsignals__ = {
74        'connect': (GObject.SignalFlags.RUN_LAST, None, (GObject.TYPE_PYOBJECT,))
75    }
76
77    def new_share_menu_item(self, menu_name, service_name, address, port):
78        """
79        This function is called to add a server to the connect menu.
80        """
81
82        if not self.menu:
83            return
84
85        menu_item = _smi(
86            menu_name,
87            ['sep'],
88            menu_name,
89            callback=lambda *_x: self.clicked(service_name, address, port),
90        )
91        self.menu.add_item(menu_item)
92
93    def clear_share_menu_items(self):
94        """
95        This function is used to clear all the menu items out of a menu.
96        """
97
98        if not self.menu:
99            return
100
101        items_to_remove = [
102            item
103            for item in self.menu._items
104            if item.name not in ('manual', 'history', 'sep')
105        ]
106        for item in items_to_remove:
107            self.menu.remove_item(item)
108
109    def rebuild_share_menu_items(self):
110        """
111        This function fills the menu with known servers.
112        """
113        self.clear_share_menu_items()
114
115        show_ipv6 = settings.get_option('plugin/daapclient/ipv6', False)
116        items = []
117
118        for key, info in self.services.items():
119            # Strip the service type from fully-qualified service name
120            service_name = info.name
121            if service_name.endswith(info.type):
122                service_name = service_name[: -(len(info.type) + 1)]
123
124            if ZEROCONF_LEGACY:
125                # Legacy mode: returns only a single IPv4 address
126                addresses = [socket.inet_ntoa(info.address)]
127            else:
128                # Retrieve IP address(es)
129                if show_ipv6:
130                    # Both IPv4 and IPv6
131                    addresses = info.parsed_addresses(zeroconf.IPVersion.All)
132                else:
133                    # IPv4 only
134                    addresses = info.parsed_addresses(zeroconf.IPVersion.V4Only)
135
136            # Generate one menu entry for each available address.
137            # NOTE: in its current implementation (v.0.25.1), zeroconf
138            # appears to always return at most one IPv4 and one IPv6
139            # address, even if the service advertises multiple addresses.
140            # This appears to be tied to record caching, which keeps
141            # track of only the last parsed IPv4 and IPv6 address.
142            for address in addresses:
143                # gstreamer can't handle link-local ipv6
144                if address.startswith('fe80:'):
145                    continue
146
147                menu_name = '{0} ({1})'.format(service_name, address)
148                items.append((menu_name, service_name, address, info.port))
149
150        # Create menu items
151        for item in items:
152            self.new_share_menu_item(*item)
153
154    def clicked(self, service_name, address, port):
155        """
156        This function is called in response to a menu_item click.
157        Fire away.
158        """
159        GObject.idle_add(self.emit, "connect", (service_name, address, port))
160
161    def on_service_state_change(self, service_type, name, state_change, **kwargs):
162        # The zeroconf module explicitly passes callback arguments via
163        # keywords, and the 'zeroconf' keyword argument clashes with the
164        # module name. Hence the ugly work-around via **kwargs...
165        zc = kwargs['zeroconf']
166
167        logger.info("DAAP share '{0}': state changed to {1}".format(name, state_change))
168
169        # zeroconf.ServiceStateChange.Updated was introduced in v.0.23
170        add_update_states = [zeroconf.ServiceStateChange.Added]
171        if hasattr(zeroconf.ServiceStateChange, 'Updated'):
172            add_update_states.append(zeroconf.ServiceStateChange.Updated)
173
174        if state_change in add_update_states:
175            info = zc.get_service_info(service_type, name)
176            if not info:
177                return
178
179            self.services[name] = info
180        elif state_change is zeroconf.ServiceStateChange.Removed:
181            del self.services[name]
182
183        self.rebuild_share_menu_items()
184
185    def __init__(self, _exaile, _menu):
186        """
187        Sets up the zeroconf listener.
188        """
189        GObject.GObject.__init__(self)
190        self.services = {}
191        self.menu = _menu
192
193        if ZEROCONF_LEGACY:
194            logger.info("Using zeroconf legacy API")
195            zc = zeroconf.Zeroconf()
196        else:
197            logger.info("Using zeroconf new API")
198            zc = zeroconf.Zeroconf(ip_version=zeroconf.IPVersion.All)
199
200        self.browser = zeroconf.ServiceBrowser(
201            zc, '_daap._tcp.local.', handlers=[self.on_service_state_change]
202        )
203
204
205class DaapHistory(common.LimitedCache):
206    def __init__(self, limit=5, location=None, menu=None, callback=None):
207        common.LimitedCache.__init__(self, limit)
208
209        if location is None:
210            location = os.path.join(xdg.get_cache_dir(), 'daaphistory.dat')
211        self.location = location
212        self.menu = menu
213        self.callback = callback
214
215        self.load()
216
217    def __setitem__(self, item, value):
218        common.LimitedCache.__setitem__(self, item, value)
219
220        # add new menu item
221        if self.menu is not None and self.callback is not None:
222            menu_item = _smi(
223                'hist' + item,
224                ['sep'],
225                item,
226                callback=lambda *_x: self.callback(None, value + (None,)),
227            )
228            self.menu.add_item(menu_item)
229
230    def load(self):
231        try:
232            with open(self.location, 'rb') as f:
233                try:
234                    d = pickle.load(f)
235                    self.update(d)
236                except (IOError, EOFError):
237                    # no file
238                    pass
239        except (IOError):
240            # file not present
241            pass
242
243    def save(self):
244        with open(self.location, 'wb') as f:
245            pickle.dump(self.cache, f, common.PICKLE_PROTOCOL)
246
247
248class DaapManager:
249    """
250        DaapManager is a class that manages DaapConnections, both manual
251    and auto-discovered.
252    """
253
254    def __init__(self, exaile, _menu, autodiscover):
255        """
256        Init!  Create manual menu item, and connect to interface signal.
257        """
258        self.exaile = exaile
259        self.autodiscover = autodiscover
260        self.panels = {}
261
262        hmenu = menu.Menu(None)
263
264        def hmfactory(_menu, _parent, _context):
265            item = Gtk.MenuItem.new_with_mnemonic(_('History'))
266            item.set_submenu(hmenu)
267            sens = settings.get_option('plugin/daapclient/history', True)
268            item.set_sensitive(sens)
269            return item
270
271        _menu.add_item(
272            _smi('manual', [], _('Manually...'), callback=self.manual_connect)
273        )
274        _menu.add_item(menu.MenuItem('history', hmfactory, ['manual']))
275        _menu.add_item(_sep('sep', ['history']))
276
277        if autodiscover is not None:
278            autodiscover.connect("connect", self.connect_share)
279
280        self.history = DaapHistory(5, menu=hmenu, callback=self.connect_share)
281
282    def connect_share(self, obj, args):
283        """
284        This function is called when a user wants to connec to
285        a DAAP share.  It creates a new panel for the share, and
286        requests a track list.
287        `args` is a tuple of (name, address, port, service)
288        """
289        name, address, port = args  # unpack tuple
290        user_agent = self.exaile.get_user_agent_string(__name__)
291        conn = DaapConnection(name, address, port, user_agent)
292
293        conn.connect()
294        library = DaapLibrary(conn)
295        panel = NetworkPanel(self.exaile.gui.main.window, library, self)
296        #    cst = CollectionScanThread(None, panel.net_collection, panel)
297        #    cst.start()
298        panel.refresh()  # threaded
299        providers.register('main-panel', panel)
300        self.panels[name] = panel
301
302        # history
303        if settings.get_option('plugin/daapclient/history', True):
304            self.history[name] = (name, address, port)
305            self.history.save()
306
307    def disconnect_share(self, name):
308        """
309        This function is called to disconnect a previously connected
310        share.  It calls the DAAP disconnect, and removes the panel.
311        """
312
313        panel = self.panels[name]
314        #    panel.library.daap_share.disconnect()
315        panel.daap_share.disconnect()
316        #    panel.net_collection.remove_library(panel.library)
317        providers.unregister('main-panel', panel)
318        del self.panels[name]
319
320    def manual_connect(self, *_args):
321        """
322        This function is called when the user selects the manual
323        connection option from the menu.  It requests a host/ip to
324        connect to.
325        """
326        dialog = dialogs.TextEntryDialog(
327            _("Enter IP address and port for share"), _("Enter IP address and port.")
328        )
329        resp = dialog.run()
330
331        if resp == Gtk.ResponseType.OK:
332            loc = dialog.get_value().strip()
333            host = loc
334
335            # the port will be anything after the last :
336            p = host.rfind(":")
337
338            # ipv6 literals should have a closing brace before the port
339            b = host.rfind("]")
340
341            if p > b:
342                try:
343                    port = int(host[p + 1 :])
344                    host = host[:p]
345                except ValueError:
346                    logger.error('non-numeric port specified')
347                    return
348            else:
349                port = 3689  # if no port specified, use default DAAP port
350
351            # if it's an ipv6 host with brackets, strip them
352            if host and host[0] == '[' and host[-1] == ']':
353                host = host[1:-1]
354            self.connect_share(None, (loc, host, port, None))
355
356    def refresh_share(self, name):
357        panel = self.panels[name]
358        rev = panel.daap_share.session.revision
359
360        # check for changes
361        panel.daap_share.session.update()
362        logger.debug(
363            'DAAP Server %s returned revision %d ( old: %d ) after update request'
364            % (name, panel.daap_share.session.revision, rev)
365        )
366
367        # if changes, refresh
368        if rev != panel.daap_share.session.revision:
369            logger.info(
370                'DAAP Server %s changed, refreshing... (revision %d)'
371                % (name, panel.daap_share.session.revision)
372            )
373            panel.refresh()
374
375    def close(self, remove=False):
376        """
377        This function disconnects active DaapConnections, and optionally
378        removes the panels from the UI.
379        """
380        # disconnect active shares
381        for panel in self.panels.values():
382            panel.daap_share.disconnect()
383
384            # there's no point in doing this if we're just shutting down, only on
385            # disable
386            if remove:
387                providers.unregister('main-panel', panel)
388
389
390class DaapConnection:
391    """
392    A connection to a DAAP share.
393    """
394
395    def __init__(self, name, server, port, user_agent):
396        # if it's an ipv6 address
397        if ':' in server and server[0] != '[':
398            server = '[' + server + ']'
399
400        self.all = []
401        self.session = None
402        self.connected = False
403        self.tracks = None
404        self.server = server
405        self.port = port
406        self.name = name
407        self.auth = False
408        self.password = None
409        self.user_agent = user_agent
410
411    def connect(self, password=None):
412        """
413        Connect, login, and retrieve the track list.
414        """
415        try:
416            client = DAAPClient()
417            if AUTH and password:
418                client.connect(self.server, self.port, password, self.user_agent)
419            else:
420                client.connect(self.server, self.port, None, self.user_agent)
421            self.session = client.login()
422            self.connected = True
423        #        except DAAPError:
424        except Exception:
425            logger.exception(
426                'failed to connect to ({0},{1})'.format(self.server, self.port)
427            )
428
429            self.auth = True
430            self.connected = False
431            raise
432
433    def disconnect(self):
434        """
435        Disconnect, clean up.
436        """
437        try:
438            self.session.logout()
439        except Exception:
440            pass
441        self.session = None
442        self.tracks = None
443        self.database = None
444        self.all = []
445        self.connected = False
446
447    def reload(self):
448        """
449        Reload the tracks from the server
450        """
451        self.tracks = None
452        self.database = None
453        self.all = []
454        self.get_database()
455
456        t = time.time()
457        self.convert_list()
458        logger.debug('{0} tracks loaded in {1}s'.format(len(self.all), time.time() - t))
459
460    def get_database(self):
461        """
462        Get a DAAP database and its track list.
463        """
464        if self.session:
465            self.database = self.session.library()
466            self.get_tracks(1)
467
468    def get_tracks(self, reset=False):
469        """
470        Get the track list from a DAAP database
471        """
472        if reset or self.tracks is None:
473            if self.database is None:
474                self.database = self.session.library()
475            self.tracks = self.database.tracks()
476
477        return self.tracks
478
479    def convert_list(self):
480        """
481        Converts the DAAP track database into Exaile Tracks.
482        """
483        # Convert DAAPTrack's attributes to Tracks.
484        eqiv = {
485            'title': 'minm',
486            'artist': 'asar',
487            'album': 'asal',
488            'tracknumber': 'astn',
489            'date': 'asyr',
490            'discnumber': 'asdn',
491            'albumartist': 'asaa',
492        }
493        #            'genre':'asgn','enc':'asfm','bitrate':'asbr'}
494
495        for tr in self.tracks:
496            if tr is not None:
497                # http://<server>:<port>/databases/<dbid>/items/<id>.<type>?session-id=<sessionid>
498
499                uri = "http://%s:%s/databases/%s/items/%s.%s?session-id=%s" % (
500                    self.server,
501                    self.port,
502                    self.database.id,
503                    tr.id,
504                    tr.type,
505                    self.session.sessionid,
506                )
507
508                # Don't scan tracks because gio is slow!
509                temp = trax.Track(uri, scan=False)
510
511                for field in eqiv.keys():
512                    try:
513                        tag = '%s' % tr.atom.getAtom(eqiv[field])
514                        if tag != 'None':
515                            temp.set_tag_raw(field, [tag], notify_changed=False)
516
517                    except Exception:
518                        if field == 'tracknumber':
519                            temp.set_tag_raw('tracknumber', [0], notify_changed=False)
520
521                # TODO: convert year (asyr) here as well, what's the formula?
522                try:
523                    temp.set_tag_raw(
524                        "__length",
525                        tr.atom.getAtom('astm') // 1000,
526                        notify_changed=False,
527                    )
528                except Exception:
529                    temp.set_tag_raw("__length", 0, notify_changed=False)
530
531                self.all.append(temp)
532
533    @common.threaded
534    def get_track(self, track_id, filename):
535        """
536        Save the track with track_id to filename
537        """
538        for t in self.tracks:
539            if t.id == track_id:
540                try:
541                    t.save(filename)
542                except http.client.CannotSendRequest:
543                    Gtk.MessageDialog(
544                        buttons=Gtk.ButtonsType.OK,
545                        message_type=Gtk.MessageType.INFO,
546                        modal=True,
547                        text=_(
548                            """This server does not support multiple connections.
549You must stop playback before downloading songs."""
550                        ),
551                        transient_for=main.mainwindow().window,
552                    )
553                    return
554
555
556class DaapLibrary(collection.Library):
557    """
558    Library subclass for better management of collection??
559    Or something to do with devices or somesuch. Ask Aren.
560    """
561
562    def __init__(self, daap_share, col=None):
563        #        location = "http://%s:%s/databasese/%s/items/" % (daap_share.server, daap_share.port, daap_share.database.id)
564        # Libraries need locations...
565        location = "http://%s:%s/" % (daap_share.server, daap_share.port)
566        collection.Library.__init__(self, location)
567        self.daap_share = daap_share
568        # self.collection = col
569
570    def rescan(self, notify_interval=None, force_update=False):
571        """
572        Called when a library needs to refresh its track list.
573
574        The force_update parameter is not applicable and is ignored.
575        """
576        if self.collection is None:
577            return True
578
579        if self.scanning:
580            return
581        t = time.time()
582        logger.info('Scanning library: %s' % self.daap_share.name)
583        self.scanning = True
584
585        # DAAP gives us all the tracks in one dump
586        self.daap_share.reload()
587        if self.daap_share.all:
588            count = len(self.daap_share.all)
589        else:
590            count = 0
591
592        if count > 0:
593            logger.info(
594                'Adding %d tracks from %s. (%f s)'
595                % (count, self.daap_share.name, time.time() - t)
596            )
597            self.collection.add_tracks(self.daap_share.all)
598
599        if notify_interval is not None:
600            event.log_event('tracks_scanned', self, count)
601
602        # track removal?
603        self.scanning = False
604        # return True
605
606    # Needed to be overriden for who knows why (exceptions)
607    def _count_files(self):
608        count = 0
609        if self.daap_share:
610            count = len(self.daap_share.all)
611
612        return count
613
614
615class NetworkPanel(CollectionPanel):
616    """
617    A panel that displays a collection of tracks from the DAAP share.
618    """
619
620    def __init__(self, parent, library, mgr):
621        """
622        Expects a parent Gtk.Window, and a daap connection.
623        """
624
625        self.name = str(library.daap_share.name)
626        self.daap_share = library.daap_share
627        self.net_collection = collection.Collection(self.name)
628        self.net_collection.add_library(library)
629        CollectionPanel.__init__(
630            self,
631            parent,
632            self.net_collection,
633            self.name,
634            _show_collection_empty_message=False,
635            label=self.name,
636        )
637
638        self.all = []
639
640        self.connect_id = None
641
642        self.menu = menu.Menu(self)
643
644        def get_tracks_func(*_args):
645            return self.tree.get_selected_tracks()
646
647        self.menu.add_item(menuitems.AppendMenuItem('append', [], get_tracks_func))
648        self.menu.add_item(
649            menuitems.EnqueueMenuItem('enqueue', ['append'], get_tracks_func)
650        )
651        self.menu.add_item(
652            menuitems.PropertiesMenuItem('props', ['enqueue'], get_tracks_func)
653        )
654        self.menu.add_item(_sep('sep', ['props']))
655        self.menu.add_item(
656            _smi(
657                'refresh',
658                ['sep'],
659                _('Refresh Server List'),
660                callback=lambda *x: mgr.refresh_share(self.name),
661            )
662        )
663        self.menu.add_item(
664            _smi(
665                'disconnect',
666                ['refresh'],
667                _('Disconnect from Server'),
668                callback=lambda *x: mgr.disconnect_share(self.name),
669            )
670        )
671
672    @common.threaded
673    def refresh(self):
674        """
675        This is called to refresh the track list.
676        """
677        # Since we don't use a ProgressManager/Thingy, we have to call these w/out
678        #  a ScanThread
679        self.net_collection.rescan_libraries()
680        GObject.idle_add(self._refresh_tags_in_tree)
681
682    def save_selected(self, widget=None, event=None):
683        """
684        Save the selected tracks to disk.
685        """
686        items = self.get_selected_items()
687        dialog = Gtk.FileChooserDialog(
688            _("Select a Location for Saving"),
689            main.mainwindow().window,
690            Gtk.FileChooserAction.SELECT_FOLDER,
691            (
692                Gtk.STOCK_OPEN,
693                Gtk.ResponseType.OK,
694                Gtk.STOCK_CANCEL,
695                Gtk.ResponseType.CANCEL,
696            ),
697        )
698        dialog.set_current_folder(xdg.get_last_dir())
699        dialog.set_select_multiple(False)
700        result = dialog.run()
701        dialog.hide()
702
703        if result == Gtk.ResponseType.OK:
704            folder = dialog.get_current_folder()
705            self.save_items(items, folder)
706
707    @common.threaded
708    def save_items(self, items, folder):
709        for i in items:
710            tnum = i.get_track()
711            if tnum < 10:
712                tnum = "0%s" % tnum
713            else:
714                tnum = str(tnum)
715            filename = "%s%s%s - %s.%s" % (folder, os.sep, tnum, i.get_title(), i.type)
716            i.connection.get_track(i.daapid, filename)
717
718
719#                print "DAAP: saving track %s to %s."%(i.daapid, filename)
720
721
722class DaapClientPlugin:
723
724    __exaile = None
725    __manager = None
726
727    def enable(self, exaile):
728        """
729        Plugin Enabled.
730        """
731        self.__exaile = exaile
732
733    def on_gui_loaded(self):
734        event.add_callback(self.__on_settings_changed, 'plugin_daapclient_option_set')
735
736        menu_ = menu.Menu(None)
737
738        providers.register(
739            'menubar-tools-menu', _sep('plugin-sep', ['track-properties'])
740        )
741
742        item = _smi('daap', ['plugin-sep'], _('Connect to DAAP...'), submenu=menu_)
743        providers.register('menubar-tools-menu', item)
744
745        autodiscover = None
746        if ZEROCONF:
747            try:
748                autodiscover = DaapZeroconfInterface(self.__exaile, menu_)
749            except RuntimeError:
750                logger.warning('zeroconf interface could not be initialized')
751        else:
752            logger.warning(
753                'python-zeroconf is not available; disabling DAAP share auto-discovery!'
754            )
755
756        self.__manager = DaapManager(self.__exaile, menu_, autodiscover)
757
758    def teardown(self, exaile):
759        """
760        Exaile Shutdown.
761        """
762        # disconnect from active shares
763        if self.__manager is not None:
764            self.__manager.close()
765            self.__manager = None
766
767    def disable(self, exaile):
768        """
769        Plugin Disabled.
770        """
771        self.teardown(exaile)
772
773        for item in providers.get('menubar-tools-menu'):
774            if item.name == 'daap':
775                providers.unregister('menubar-tools-menu', item)
776                break
777
778    def get_preferences_pane(self):
779        return daapclientprefs
780
781    def __on_settings_changed(self, event, setting, option):
782        if option == 'plugin/daapclient/ipv6' and self.__manager is not None:
783            self.__manager.autodiscover.rebuild_share_menu_items()
784
785
786plugin_class = DaapClientPlugin
787
788# vi: et ts=4 sts=4 sw=4
789