1# Copyright 2011 Joe Wreschnig, Christoph Reiter 2# 2013-2020 Nick Boultbee 3# 4# This program is free software; you can redistribute it and/or modify 5# it under the terms of the GNU General Public License as published by 6# the Free Software Foundation; either version 2 of the License, or 7# (at your option) any later version. 8 9import os 10import sys 11import bz2 12import itertools 13from functools import reduce 14from http.client import HTTPException 15from os.path import splitext 16from typing import List 17from urllib.request import urlopen 18 19import re 20from gi.repository import Gtk, GLib, Pango 21from senf import text2fsn 22 23from quodlibet.util.dprint import print_d, print_e 24 25import quodlibet 26from quodlibet import _ 27from quodlibet import qltk 28from quodlibet import util 29from quodlibet import config 30 31from quodlibet.browsers import Browser 32from quodlibet.formats.remote import RemoteFile 33from quodlibet.formats._audio import TAG_TO_SORT, MIGRATE, AudioFile 34from quodlibet.library import SongLibrary 35from quodlibet.query import Query 36from quodlibet.qltk.getstring import GetStringDialog 37from quodlibet.qltk.songsmenu import SongsMenu 38from quodlibet.qltk.notif import Task 39from quodlibet.qltk import Icons, ErrorMessage, WarningMessage 40from quodlibet.util import copool, connect_destroy, sanitize_tags, \ 41 connect_obj, escape 42from quodlibet.util.i18n import numeric_phrase 43from quodlibet.util.path import uri_is_valid 44from quodlibet.util.string import decode, encode 45from quodlibet.util import print_w 46from quodlibet.qltk.views import AllTreeView 47from quodlibet.qltk.searchbar import SearchBarBox 48from quodlibet.qltk.completion import LibraryTagCompletion 49from quodlibet.qltk.x import MenuItem, Align, ScrolledWindow 50from quodlibet.qltk.x import SymbolicIconImage 51from quodlibet.qltk.menubutton import MenuButton 52 53 54STATION_LIST_URL = \ 55 "https://quodlibet.github.io/radio/radiolist.bz2" 56STATIONS_FAV = os.path.join(quodlibet.get_user_dir(), "stations") 57STATIONS_ALL = os.path.join(quodlibet.get_user_dir(), "stations_all") 58 59# TODO: - Do the update in a thread 60# - Ranking: reduce duplicate stations (max 3 URLs per station) 61# prefer stations that match a genre? 62 63# Migration path for pickle 64sys.modules["browsers.iradio"] = sys.modules[__name__] 65 66 67class IRFile(RemoteFile): 68 multisong = True 69 can_add = False 70 71 format = "Radio Station" 72 73 __CAN_CHANGE = "title artist grouping".split() 74 75 def __get(self, base_call, key, *args, **kwargs): 76 if key == "title" and "title" not in self and "organization" in self: 77 return base_call("organization", *args, **kwargs) 78 79 # split title by " - " if no artist tag is present and 80 # this is not the main song: common format for shoutcast stations 81 if not self.multisong and key in ("title", "artist") and \ 82 "title" in self and "artist" not in self: 83 title = base_call("title").split(" - ", 1) 84 if len(title) > 1: 85 return (key == "title" and title[-1]) or title[0] 86 87 if key in ("artist", TAG_TO_SORT["artist"]) and \ 88 not base_call(key, *args) and "website" in self: 89 return base_call("website", *args) 90 91 if key == "~format" and "audio-codec" in self: 92 return "%s (%s)" % (self.format, 93 base_call("audio-codec", *args, **kwargs)) 94 return base_call(key, *args, **kwargs) 95 96 def __call__(self, key, *args, **kwargs): 97 base_call = super(IRFile, self).__call__ 98 return self.__get(base_call, key, *args, **kwargs) 99 100 def get(self, key, *args, **kwargs): 101 base_call = super(IRFile, self).get 102 return self.__get(base_call, key, *args, **kwargs) 103 104 def write(self): 105 pass 106 107 def to_dump(self): 108 # dump without title 109 title = None 110 if "title" in self: 111 title = self["title"] 112 del self["title"] 113 dump = super(IRFile, self).to_dump() 114 if title is not None: 115 self["title"] = title 116 117 # add all generated tags 118 lines = dump.splitlines() 119 for tag in ["title", "artist", "~format"]: 120 value = self.get(tag) 121 if value is not None: 122 lines.append(encode(tag) + b"=" + encode(value)) 123 return b"\n".join(lines) 124 125 def can_change(self, k=None): 126 if self.streamsong: 127 if k is None: 128 return [] 129 else: 130 return False 131 else: 132 if k is None: 133 return self.__CAN_CHANGE 134 else: 135 return k in self.__CAN_CHANGE 136 137 138def ParsePLS(file): 139 data = {} 140 141 lines = file.read().decode('utf-8', 'replace').splitlines() 142 143 if not lines or "[playlist]" not in lines.pop(0): 144 return [] 145 146 for line in lines: 147 try: 148 head, val = line.strip().split("=", 1) 149 except (TypeError, ValueError): 150 continue 151 else: 152 head = head.lower() 153 if head.startswith("length") and val == "-1": 154 continue 155 else: 156 data[head] = val 157 158 count = 1 159 files = [] 160 warnings = [] 161 while True: 162 if "file%d" % count in data: 163 filename = text2fsn(data["file%d" % count]) 164 if filename.lower()[-4:] in [".pls", ".m3u", "m3u8"]: 165 warnings.append(filename) 166 else: 167 irf = IRFile(filename) 168 for key in ["title", "genre", "artist"]: 169 try: 170 irf[key] = data["%s%d" % (key, count)] 171 except KeyError: 172 pass 173 try: 174 irf["~#length"] = int(data["length%d" % count]) 175 except (KeyError, TypeError, ValueError): 176 pass 177 files.append(irf) 178 else: 179 break 180 count += 1 181 182 if warnings: 183 WarningMessage( 184 None, _("Unsupported file type"), 185 _("Station lists can only contain locations of stations, " 186 "not other station lists or playlists. The following locations " 187 "cannot be loaded:\n%s") % 188 "\n ".join(map(util.escape, warnings)) 189 ).run() 190 191 return files 192 193 194def ParseM3U(fileobj): 195 files = [] 196 pending_title = None 197 lines = fileobj.read().decode('utf-8', 'replace').splitlines() 198 for line in lines: 199 line = line.strip() 200 if line.startswith("#EXTINF:"): 201 try: 202 pending_title = line.split(",", 1)[1] 203 except IndexError: 204 pending_title = None 205 elif line.startswith("http"): 206 irf = IRFile(text2fsn(line)) 207 if pending_title: 208 irf["title"] = pending_title 209 pending_title = None 210 files.append(irf) 211 return files 212 213 214def _get_stations_from(uri: str) -> List[IRFile]: 215 """Fetches the URI content and extracts IRFiles 216 :raise: `OSError`, or other socket-type errors 217 :return: or else a list of stations found (possibly empty)""" 218 219 irfs = [] 220 221 if (uri.lower().endswith(".pls") 222 or uri.lower().endswith(".m3u") 223 or uri.lower().endswith(".m3u8")): 224 if not re.match('^([^/:]+)://', uri): 225 # Assume HTTP if no protocol given. See #2731 226 uri = 'http://' + uri 227 print_d("Assuming http: %s" % uri) 228 229 # Error handling outside 230 sock = None 231 try: 232 sock = urlopen(uri, timeout=10) 233 234 _, ext = splitext(uri.lower()) 235 if ext == ".pls": 236 irfs = ParsePLS(sock) 237 elif ext in (".m3u", ".m3u8"): 238 irfs = ParseM3U(sock) 239 finally: 240 if sock: 241 sock.close() 242 else: 243 try: 244 irfs = [IRFile(uri)] 245 except ValueError as msg: 246 ErrorMessage(None, _("Unable to add station"), msg).run() 247 248 return irfs 249 250 251def download_taglist(callback, cofuncid, step=1024 * 10): 252 """Generator for loading the bz2 compressed tag list. 253 254 Calls callback with the decompressed data or None in case of 255 an error.""" 256 257 with Task(_("Internet Radio"), _("Downloading station list")) as task: 258 if cofuncid: 259 task.copool(cofuncid) 260 261 try: 262 response = urlopen(STATION_LIST_URL) 263 except (EnvironmentError, HTTPException) as e: 264 print_e("Failed fetching from %s" % STATION_LIST_URL, e) 265 GLib.idle_add(callback, None) 266 return 267 try: 268 size = int(response.info().get("content-length", 0)) 269 except ValueError: 270 size = 0 271 272 decomp = bz2.BZ2Decompressor() 273 274 data = b"" 275 temp = b"" 276 read = 0 277 while temp or not data: 278 read += len(temp) 279 280 if size: 281 task.update(float(read) / size) 282 else: 283 task.pulse() 284 yield True 285 286 try: 287 data += decomp.decompress(temp) 288 temp = response.read(step) 289 except (IOError, EOFError): 290 data = None 291 break 292 response.close() 293 294 yield True 295 296 stations = None 297 if data: 298 stations = parse_taglist(data) 299 300 GLib.idle_add(callback, stations) 301 302 303def parse_taglist(data): 304 """Parses a dump file like list of tags and returns a list of IRFiles 305 306 uri=http://... 307 tag=value1 308 tag2=value 309 tag=value2 310 uri=http://... 311 ... 312 313 """ 314 315 stations = [] 316 station = None 317 318 for l in data.split(b"\n"): 319 if not l: 320 continue 321 key = l.split(b"=")[0] 322 value = l.split(b"=", 1)[1] 323 key = decode(key) 324 value = decode(value) 325 if key == "uri": 326 if station: 327 stations.append(station) 328 station = IRFile(value) 329 continue 330 331 san = list(sanitize_tags({key: value}, stream=True).items()) 332 if not san: 333 continue 334 335 key, value = san[0] 336 if key == "~listenerpeak": 337 key = "~#listenerpeak" 338 value = int(value) 339 340 if not station: 341 continue 342 343 if isinstance(value, str): 344 if value not in station.list(key): 345 station.add(key, value) 346 else: 347 station[key] = value 348 349 if station: 350 stations.append(station) 351 352 return stations 353 354 355class AddNewStation(GetStringDialog): 356 def __init__(self, parent): 357 super(AddNewStation, self).__init__( 358 parent, _("New Station"), 359 _("Enter the location of an Internet radio station:"), 360 button_label=_("_Add"), button_icon=Icons.LIST_ADD) 361 362 def _verify_clipboard(self, text): 363 # try to extract a URI from the clipboard 364 for line in text.splitlines(): 365 line = line.strip() 366 367 if uri_is_valid(line): 368 return line 369 370 371class GenreFilter(object): 372 STAR = ["genre", "organization"] 373 374 # This probably needs improvements 375 GENRES = { 376 "electronic": ( 377 _("Electronic"), 378 "|(electr,house,techno,trance,/trip.?hop/,&(drum,n,bass),chill," 379 "dnb,minimal,/down(beat|tempo)/,&(dub,step))"), 380 "rap": (_("Hip Hop / Rap"), "|(&(hip,hop),rap)"), 381 "oldies": (_("Oldies"), r"|(/[2-9]0\S?s/,oldies)"), 382 "r&b": (_("R&B"), r"/r(\&|n)b/"), 383 "japanese": (_("Japanese"), "|(anime,jpop,japan,jrock)"), 384 "indian": (_("Indian"), "|(bollywood,hindi,indian,bhangra)"), 385 "religious": ( 386 _("Religious"), 387 "|(religious,christian,bible,gospel,spiritual,islam)"), 388 "charts": (_("Charts"), "|(charts,hits,top)"), 389 "turkish": (_("Turkish"), "|(turkish,turkce)"), 390 "reggae": (_("Reggae / Dancehall"), r"|(/reggae([^\w]|$)/,dancehall)"), 391 "latin": (_("Latin"), "|(latin,salsa)"), 392 "college": (_("College Radio"), "|(college,campus)"), 393 "talk_news": (_("Talk / News"), "|(news,talk)"), 394 "ambient": (_("Ambient"), "|(ambient,easy)"), 395 "jazz": (_("Jazz"), "|(jazz,swing)"), 396 "classical": (_("Classical"), "classic"), 397 "pop": (_("Pop"), None), 398 "alternative": (_("Alternative"), None), 399 "metal": (_("Metal"), None), 400 "country": (_("Country"), None), 401 "news": (_("News"), None), 402 "schlager": (_("Schlager"), None), 403 "funk": (_("Funk"), None), 404 "indie": (_("Indie"), None), 405 "blues": (_("Blues"), None), 406 "soul": (_("Soul"), None), 407 "lounge": (_("Lounge"), None), 408 "punk": (_("Punk"), None), 409 "reggaeton": (_("Reggaeton"), None), 410 "slavic": ( 411 _("Slavic"), 412 "|(narodna,albanian,manele,shqip,kosova)"), 413 "greek": (_("Greek"), None), 414 "gothic": (_("Gothic"), None), 415 "rock": (_("Rock"), None), 416 } 417 418 # parsing all above takes 350ms on an atom, so only generate when needed 419 __CACHE = {} 420 421 def keys(self): 422 return self.GENRES.keys() 423 424 def query(self, key): 425 if key not in self.__CACHE: 426 text, filter_ = self.GENRES[key] 427 if filter_ is None: 428 filter_ = key 429 self.__CACHE[key] = Query(filter_, star=self.STAR) 430 return self.__CACHE[key] 431 432 def text(self, key): 433 return self.GENRES[key][0] 434 435 436class CloseButton(Gtk.Button): 437 """Reimplementation of 3.10 close button for InfoBar.""" 438 439 def __init__(self): 440 image = Gtk.Image(visible=True, can_focus=False, 441 icon_name="window-close-symbolic") 442 443 super(CloseButton, self).__init__( 444 visible=False, can_focus=True, image=image, 445 relief=Gtk.ReliefStyle.NONE, valign=Gtk.Align.CENTER) 446 447 ctx = self.get_style_context() 448 ctx.add_class("raised") 449 ctx.add_class("close") 450 451 452class QuestionBar(Gtk.InfoBar): 453 """A widget which suggest to download the radio list if 454 no radio stations are present. 455 456 Connect to Gtk.InfoBar::response and check for RESPONSE_LOAD 457 as response id. 458 """ 459 460 RESPONSE_LOAD = 1 461 462 def __init__(self): 463 super(QuestionBar, self).__init__() 464 self.connect("response", self.__response) 465 self.set_message_type(Gtk.MessageType.QUESTION) 466 467 label = Gtk.Label(label=_( 468 "Would you like to load a list of popular radio stations?")) 469 label.set_line_wrap(True) 470 label.show() 471 content = self.get_content_area() 472 content.add(label) 473 474 self.add_button(_("_Load Stations"), self.RESPONSE_LOAD) 475 self.set_show_close_button(True) 476 477 def __response(self, bar, response_id): 478 if response_id == Gtk.ResponseType.CLOSE: 479 bar.hide() 480 481 482class InternetRadio(Browser, util.InstanceTracker): 483 484 __stations = None 485 __fav_stations = None 486 __librarian = None 487 488 __filter = None 489 490 name = _("Internet Radio") 491 accelerated_name = _("_Internet Radio") 492 keys = ["InternetRadio"] 493 priority = 16 494 uses_main_library = False 495 headers = "title artist ~people grouping genre website ~format " \ 496 "channel-mode".split() 497 498 TYPE, ICON_NAME, KEY, NAME = range(4) 499 TYPE_FILTER, TYPE_ALL, TYPE_FAV, TYPE_SEP, TYPE_NOCAT = range(5) 500 STAR = ["artist", "title", "website", "genre", "comment"] 501 502 @classmethod 503 def _init(klass, library): 504 klass.__librarian = library.librarian 505 506 klass.__stations = SongLibrary("iradio-remote") 507 klass.__stations.load(STATIONS_ALL) 508 509 klass.__fav_stations = SongLibrary("iradio") 510 klass.__fav_stations.load(STATIONS_FAV) 511 512 klass.filters = GenreFilter() 513 514 @classmethod 515 def _destroy(klass): 516 if klass.__stations.dirty: 517 klass.__stations.save() 518 klass.__stations.destroy() 519 klass.__stations = None 520 521 if klass.__fav_stations.dirty: 522 klass.__fav_stations.save() 523 klass.__fav_stations.destroy() 524 klass.__fav_stations = None 525 526 klass.__librarian = None 527 528 klass.filters = None 529 530 def finalize(self, restored): 531 if not restored: 532 # Select "All Stations" by default 533 def sel_all(row): 534 return row[self.TYPE] == self.TYPE_ALL 535 self.view.select_by_func(sel_all, one=True) 536 537 def __inhibit(self): 538 self.view.get_selection().handler_block(self.__changed_sig) 539 540 def __uninhibit(self): 541 self.view.get_selection().handler_unblock(self.__changed_sig) 542 543 def __destroy(self, *args): 544 if not self.instances(): 545 self._destroy() 546 547 def __init__(self, library): 548 super(InternetRadio, self).__init__(spacing=12) 549 self.set_orientation(Gtk.Orientation.VERTICAL) 550 551 if not self.instances(): 552 self._init(library) 553 self._register_instance() 554 555 self.connect('destroy', self.__destroy) 556 557 completion = LibraryTagCompletion(self.__stations) 558 self.accelerators = Gtk.AccelGroup() 559 self.__searchbar = search = SearchBarBox(completion=completion, 560 accel_group=self.accelerators) 561 search.connect('query-changed', self.__filter_changed) 562 563 menu = Gtk.Menu() 564 new_item = MenuItem(_(u"_New Station…"), Icons.LIST_ADD) 565 new_item.connect('activate', self.__add) 566 menu.append(new_item) 567 update_item = MenuItem(_("_Update Stations"), Icons.VIEW_REFRESH) 568 update_item.connect('activate', self.__update) 569 menu.append(update_item) 570 menu.show_all() 571 572 button = MenuButton( 573 SymbolicIconImage(Icons.EMBLEM_SYSTEM, Gtk.IconSize.MENU), 574 arrow=True) 575 button.set_menu(menu) 576 577 def focus(widget, *args): 578 qltk.get_top_parent(widget).songlist.grab_focus() 579 search.connect('focus-out', focus) 580 581 # treeview 582 scrolled_window = ScrolledWindow() 583 scrolled_window.show() 584 scrolled_window.set_shadow_type(Gtk.ShadowType.IN) 585 self.view = view = AllTreeView() 586 view.show() 587 view.set_headers_visible(False) 588 scrolled_window.set_policy( 589 Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) 590 scrolled_window.add(view) 591 model = Gtk.ListStore(int, str, str, str) 592 593 model.append(row=[self.TYPE_ALL, Icons.FOLDER, "__all", 594 _("All Stations")]) 595 model.append(row=[self.TYPE_SEP, Icons.FOLDER, "", ""]) 596 #Translators: Favorite radio stations 597 model.append(row=[self.TYPE_FAV, Icons.FOLDER, "__fav", 598 _("Favorites")]) 599 model.append(row=[self.TYPE_SEP, Icons.FOLDER, "", ""]) 600 601 filters = self.filters 602 for text, k in sorted([(filters.text(k), k) for k in filters.keys()]): 603 model.append(row=[self.TYPE_FILTER, Icons.EDIT_FIND, k, text]) 604 605 model.append(row=[self.TYPE_NOCAT, Icons.FOLDER, 606 "nocat", _("No Category")]) 607 608 def separator(model, iter, data): 609 return model[iter][self.TYPE] == self.TYPE_SEP 610 view.set_row_separator_func(separator, None) 611 612 def search_func(model, column, key, iter, data): 613 return key.lower() not in model[iter][column].lower() 614 view.set_search_column(self.NAME) 615 view.set_search_equal_func(search_func, None) 616 617 column = Gtk.TreeViewColumn("genres") 618 column.set_sizing(Gtk.TreeViewColumnSizing.FIXED) 619 620 renderpb = Gtk.CellRendererPixbuf() 621 renderpb.props.xpad = 3 622 column.pack_start(renderpb, False) 623 column.add_attribute(renderpb, "icon-name", self.ICON_NAME) 624 625 render = Gtk.CellRendererText() 626 render.set_property('ellipsize', Pango.EllipsizeMode.END) 627 view.append_column(column) 628 column.pack_start(render, True) 629 column.add_attribute(render, "text", self.NAME) 630 631 view.set_model(model) 632 633 # selection 634 selection = view.get_selection() 635 selection.set_mode(Gtk.SelectionMode.MULTIPLE) 636 self.__changed_sig = connect_destroy(selection, 'changed', 637 util.DeferredSignal(lambda x: self.activate())) 638 639 box = Gtk.HBox(spacing=6) 640 box.pack_start(search, True, True, 0) 641 box.pack_start(button, False, True, 0) 642 self._searchbox = Align(box, left=0, right=6, top=6) 643 self._searchbox.show_all() 644 645 def qbar_response(infobar, response_id): 646 if response_id == infobar.RESPONSE_LOAD: 647 self.__update() 648 649 self.qbar = QuestionBar() 650 self.qbar.connect("response", qbar_response) 651 if self._is_library_empty(): 652 self.qbar.show() 653 654 pane = qltk.ConfigRHPaned("browsers", "internetradio_pos", 0.4) 655 pane.show() 656 pane.pack1(scrolled_window, resize=False, shrink=False) 657 songbox = Gtk.VBox(spacing=6) 658 songbox.pack_start(self._searchbox, False, True, 0) 659 self._songpane_container = Gtk.VBox() 660 self._songpane_container.show() 661 songbox.pack_start(self._songpane_container, True, True, 0) 662 songbox.pack_start(self.qbar, False, True, 0) 663 songbox.show() 664 pane.pack2(songbox, resize=True, shrink=False) 665 self.pack_start(pane, True, True, 0) 666 self.show() 667 668 def _is_library_empty(self): 669 return not len(self.__stations) and not len(self.__fav_stations) 670 671 def pack(self, songpane): 672 container = Gtk.VBox() 673 container.add(self) 674 self._songpane_container.add(songpane) 675 return container 676 677 def unpack(self, container, songpane): 678 self._songpane_container.remove(songpane) 679 container.remove(self) 680 681 def __update(self, *args): 682 self.qbar.hide() 683 copool.add(download_taglist, self.__update_done, 684 cofuncid="radio-load", funcid="radio-load") 685 686 def __update_done(self, stations): 687 if not stations: 688 print_w("Loading remote station list failed.") 689 return 690 691 # filter stations based on quality, listenercount 692 def filter_stations(station): 693 peak = station.get("~#listenerpeak", 0) 694 if peak < 10: 695 return False 696 aac = "AAC" in station("~format") 697 bitrate = station("~#bitrate", 50) 698 if (aac and bitrate < 40) or (not aac and bitrate < 60): 699 return False 700 return True 701 stations = filter(filter_stations, stations) 702 703 # group them based on the title 704 groups = {} 705 for s in stations: 706 key = s("~title~artist") 707 groups.setdefault(key, []).append(s) 708 709 # keep at most 2 URLs for each group 710 stations = [] 711 for key, sub in groups.items(): 712 sub.sort(key=lambda s: s.get("~#listenerpeak", 0), reverse=True) 713 stations.extend(sub[:2]) 714 715 # only keep the ones in at least one category 716 all_ = [self.filters.query(k) for k in self.filters.keys()] 717 assert all_ 718 anycat_filter = reduce(lambda x, y: x | y, all_) 719 stations = list(filter(anycat_filter.search, stations)) 720 721 # remove listenerpeak 722 for s in stations: 723 s.pop("~#listenerpeak", None) 724 725 # update the libraries 726 stations = dict(((s.key, s) for s in stations)) 727 # don't add ones that are in the fav list 728 for fav in self.__fav_stations.keys(): 729 stations.pop(fav, None) 730 731 # separate 732 o, n = set(self.__stations.keys()), set(stations) 733 to_add, to_change, to_remove = n - o, o & n, o - n 734 del o, n 735 736 # migrate stats 737 to_change = [stations.pop(k) for k in to_change] 738 for new in to_change: 739 old = self.__stations[new.key] 740 # clear everything except stats 741 AudioFile.reload(old) 742 # add new metadata except stats 743 for k in (x for x in new.keys() if x not in MIGRATE): 744 old[k] = new[k] 745 746 to_add = [stations.pop(k) for k in to_add] 747 to_remove = [self.__stations[k] for k in to_remove] 748 749 self.__stations.remove(to_remove) 750 self.__stations.changed(to_change) 751 self.__stations.add(to_add) 752 753 def __filter_changed(self, bar, text, restore=False): 754 self.__filter = Query(text, self.STAR) 755 756 if not restore: 757 self.activate() 758 759 def __get_selected_libraries(self): 760 """Returns the libraries to search in depending on the 761 filter selection""" 762 763 selection = self.view.get_selection() 764 model, rows = selection.get_selected_rows() 765 types = [model[row][self.TYPE] for row in rows] 766 libs = [self.__fav_stations] 767 if types != [self.TYPE_FAV]: 768 libs.append(self.__stations) 769 770 return libs 771 772 def __get_selection_filter(self): 773 """Returns a filter object for the current selection or None 774 if nothing should be filtered""" 775 776 selection = self.view.get_selection() 777 model, rows = selection.get_selected_rows() 778 779 filter_ = None 780 for row in rows: 781 type_ = model[row][self.TYPE] 782 if type_ == self.TYPE_FILTER: 783 key = model[row][self.KEY] 784 current_filter = self.filters.query(key) 785 if current_filter: 786 if filter_: 787 filter_ |= current_filter 788 else: 789 filter_ = current_filter 790 elif type_ == self.TYPE_NOCAT: 791 # if notcat is selected, combine all filters, negate and merge 792 all_ = [self.filters.query(k) for k in self.filters.keys()] 793 nocat_filter = all_ and -reduce(lambda x, y: x | y, all_) 794 if nocat_filter: 795 if filter_: 796 filter_ |= nocat_filter 797 else: 798 filter_ = nocat_filter 799 elif type_ == self.TYPE_ALL: 800 filter_ = None 801 break 802 803 return filter_ 804 805 def unfilter(self): 806 self.filter_text("") 807 808 def __add_fav(self, songs): 809 songs = [s for s in songs if s in self.__stations] 810 type(self).__librarian.move( 811 songs, self.__stations, self.__fav_stations) 812 813 def __remove_fav(self, songs): 814 songs = [s for s in songs if s in self.__fav_stations] 815 type(self).__librarian.move( 816 songs, self.__fav_stations, self.__stations) 817 818 def __add(self, button): 819 parent = qltk.get_top_parent(self) 820 uri = (AddNewStation(parent).run(clipboard=True) or "").strip() 821 if uri != "": 822 self.__add_station(uri) 823 824 def __add_station(self, uri): 825 try: 826 irfs = _get_stations_from(uri) 827 except EnvironmentError as e: 828 print_d("Got %s from %s" % (e, uri)) 829 msg = ("Couldn't add URL: <b>%s</b>)\n\n<tt>%s</tt>" 830 % (escape(str(e)), escape(uri))) 831 ErrorMessage(None, _("Unable to add station"), msg).run() 832 return 833 if not irfs: 834 ErrorMessage( 835 None, _("No stations found"), 836 _("No Internet radio stations were found at %s.") % 837 util.escape(uri)).run() 838 return 839 840 irfs = set(irfs) - set(self.__fav_stations) 841 if not irfs: 842 WarningMessage( 843 None, _("Unable to add station"), 844 _("All stations listed are already in your library.")).run() 845 846 if irfs: 847 self.__fav_stations.add(irfs) 848 849 def Menu(self, songs, library, items): 850 in_fav = False 851 in_all = False 852 for song in songs: 853 if song in self.__fav_stations: 854 in_fav = True 855 elif song in self.__stations: 856 in_all = True 857 if in_fav and in_all: 858 break 859 860 iradio_items = [] 861 button = MenuItem(_("Add to Favorites"), Icons.LIST_ADD) 862 button.set_sensitive(in_all) 863 connect_obj(button, 'activate', self.__add_fav, songs) 864 iradio_items.append(button) 865 button = MenuItem(_("Remove from Favorites"), Icons.LIST_REMOVE) 866 button.set_sensitive(in_fav) 867 connect_obj(button, 'activate', self.__remove_fav, songs) 868 iradio_items.append(button) 869 870 items.append(iradio_items) 871 menu = SongsMenu(self.__librarian, songs, playlists=False, remove=True, 872 queue=False, items=items) 873 return menu 874 875 def restore(self): 876 text = config.gettext("browsers", "query_text") 877 self.__searchbar.set_text(text) 878 if Query(text).is_parsable: 879 self.__filter_changed(self.__searchbar, text, restore=True) 880 881 keys = config.get("browsers", "radio").splitlines() 882 883 def select_func(row): 884 return row[self.TYPE] != self.TYPE_SEP and row[self.KEY] in keys 885 886 self.__inhibit() 887 view = self.view 888 if not view.select_by_func(select_func): 889 for row in view.get_model(): 890 if row[self.TYPE] == self.TYPE_FAV: 891 view.set_cursor(row.path) 892 break 893 self.__uninhibit() 894 895 def __get_filter(self): 896 filter_ = self.__get_selection_filter() 897 text_filter = self.__filter or Query("") 898 899 if filter_: 900 filter_ &= text_filter 901 else: 902 filter_ = text_filter 903 904 return filter_ 905 906 def can_filter_text(self): 907 return True 908 909 def filter_text(self, text): 910 self.__searchbar.set_text(text) 911 if Query(text).is_parsable: 912 self.__filter_changed(self.__searchbar, text) 913 self.activate() 914 915 def get_filter_text(self): 916 return self.__searchbar.get_text() 917 918 def activate(self): 919 filter_ = self.__get_filter() 920 libs = self.__get_selected_libraries() 921 songs = filter_.filter(itertools.chain(*libs)) 922 self.songs_selected(songs) 923 924 def active_filter(self, song): 925 for lib in self.__get_selected_libraries(): 926 if song in lib: 927 break 928 else: 929 return False 930 931 filter_ = self.__get_filter() 932 933 if filter_: 934 return filter_.search(song) 935 return True 936 937 def save(self): 938 text = self.__searchbar.get_text() 939 config.settext("browsers", "query_text", text) 940 941 selection = self.view.get_selection() 942 model, rows = selection.get_selected_rows() 943 names = filter(None, [model[row][self.KEY] for row in rows]) 944 config.set("browsers", "radio", "\n".join(names)) 945 946 def scroll(self, song): 947 # nothing we care about 948 if song not in self.__stations and song not in self.__fav_stations: 949 return 950 951 path = None 952 for row in self.view.get_model(): 953 if row[self.TYPE] == self.TYPE_FILTER: 954 if self.filters.query(row[self.KEY]).search(song): 955 path = row.path 956 break 957 else: 958 # in case nothing matches, select all 959 path = (0,) 960 961 self.view.set_cursor(path) 962 self.view.scroll_to_cell(path, use_align=True, row_align=0.5) 963 964 def status_text(self, count, time=None): 965 return numeric_phrase("%(count)d station", "%(count)d stations", 966 count, 'count') 967 968 969from quodlibet import app 970if not app.player or app.player.can_play_uri("http://"): 971 browsers = [InternetRadio] 972else: 973 browsers = [] 974