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