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