Internet radio browser GUI for music/video streams from various directory services.

⌈⌋ ⎇ branch:  streamtuner2


st2.py at [4836962bd4]

File st2.py artifact 3d89c6ea6f part of check-in 4836962bd4


#!/usr/bin/env python
# encoding: UTF-8
# api: python
# type: application
# title: streamtuner2
# description: Directory browser for internet radio / audio streams
# depends: pygtk | pygi, threading, pyquery, kronos, requests
# version: 2.1.2
# author: mario salzer
# license: public domain
# url: http://freshmeat.net/projects/streamtuner2
# config: <env name="http_proxy" value="" description="proxy for HTTP access" />  <env name="XDG_CONFIG_HOME" description="relocates user .config subdirectory" />
# category: multimedia
# 
#
#
# Streamtuner2 is a GUI browser for internet radio directories. Various
# providers can be added, and streaming stations are usually grouped into
# music genres or categories. It starts external audio players for stream
# playing and streamripper for recording broadcasts.
#
# It's an independent rewrite of streamtuner1 in a scripting language. So
# it can be more easily extended and fixed. The use of PyQuery for HTML
# parsing makes this simpler and more robust.
#
# Stream lists are stored in JSON cache files.
#
#
#


""" project status """
#
# The application runs mostly stable. The GUI interfaces are workable.
# It's supposed to run on Gtk2 and Gtk3. Python3 support is still WIP.
# There haven't been any optimizations regarding memory usage and
# performance. The current internal API is vastly undocumented.
#
#  current bugs:
#   - audio- and list-format support is not very robust / needs better API
#   - not all keyboard shortcuts work
#
#  features:
#   - treeview lists are created from datamap[] structure and stream{} dicts
#   - channel categories are built-in defaults (can be freshened up however)
#   - config vars and cache data get stored as JSON in ~/.config/streamtuner2/
#
#  missing:
#   - localization
#
#  security notes:
#   - directory scrapers use fragile regular expressions - which is probably
#     not a security risk, but might lead to faulty data
#   - MEDIUM: little integrity checking for .pls / .m3u references and files
#   - minimal XML/SGML entity decoding (-> faulty data)
#   - MEDIUM: if system json module is not available, pseudo-json uses eval()
#     to read the config data -> limited risk, since it's only local files
#   - HIGH RISK: no verification of downloaded favicon image files (ico/png),
#     as they are passed to gtk.gdk.Pixbuf (OTOH data pre-filtered by Google)
#   - MEDIUM: audio players / decoders are easily affected by buffer overflows
#     from corrupt mp3/stream data, and streamtuner2 executes them
#      - but since that's the purpose -> no workaround
#



# standard modules
import sys
import os, os.path
import re
from collections import namedtuple
from copy import copy

# threading or processing module
try:
    from processing import Process as Thread
except:
    from threading import Thread
    Thread.stop = lambda self: None

# add library path
sys.path.insert(0, "/usr/share/streamtuner2")   # pre-defined directory for modules
sys.path.append(   "/usr/share/streamtuner2/bundle")   # external libraries
sys.path.insert(0, ".")   # development module path

# gtk modules
from mygtk import pygtk, gtk, gobject, ui_file, mygtk, ver as GTK_VER, ComboBoxText

# custom modules
from config import conf   # initializes itself, so all conf.vars are available right away
from config import __print__, dbg
import ahttp
import action  # needs workaround... (action.main=main)
import channels
from channels import *
import favicon


__version__ = "2.1.2"


# this represents the main window
# and also contains most application behaviour
main = None
class StreamTunerTwo(gtk.Builder):


        # object containers
        widgets = {}     # non-glade widgets (the manually instantiated ones)
        channels = {}    # channel modules
        features = {}    # non-channel plugins
        working = []     # threads
        add_signals = {} # channel gtk-handler signals
        hooks = {
            "play": [favicon.download_playing],  # observers queue here
            "init": [],
            "config_load": [],
            "config_save": [],
        }

        # status variables
        channel_names = ["bookmarks"]    # order of channel notebook tabs
        current_channel = "bookmarks"    # currently selected channel name (as index in self.channels{})


        # constructor
        def __init__(self):

            # gtkrc stylesheet
            self.load_theme(), gui_startup(1/20.0)

            # instantiate gtk/glade widgets in current object
            gtk.Builder.__init__(self)
            gtk.Builder.add_from_file(self, conf.find_in_dirs([".", conf.share], ui_file)), gui_startup(2/20.0)
            # manual gtk operations
            self.extensionsCTM.set_submenu(self.extensions)  # duplicates Station>Extension menu into stream context menu

            # initialize channels
            self.channels = {
              "bookmarks": bookmarks(parent=self),   # this the remaining built-in channel
              #"shoutcast": None,#shoutcast(parent=self),
            }
            gui_startup(3/20.0)
            self.load_plugin_channels()   # append other channel modules / plugins


            # load application state (widget sizes, selections, etc.)
            try:
                winlayout = conf.load("window")
                if (winlayout):
                    mygtk.app_restore(self, winlayout)
                # selection values
                winstate = conf.load("state")
                if (winstate):
                    for id in winstate.keys():
                        self.channels[id].current = winstate[id]["current"]
                        self.channels[id].shown = winlayout[id+"_list"].get("row:selected", 0)   # actually just used as boolean flag (for late loading of stream list), selection bar has been positioned before already
            except:
                pass # fails for disabled/reordered plugin channels

            # late plugin initializations
            gui_startup(17/20.0)
            [callback(self) for callback in self.hooks["init"]]

            # display current open channel/notebook tab
            gui_startup(18/20.0)
            self.current_channel = self.current_channel_gtk()
            try: self.channel().first_show()
            except: __print__(dbg.INIT, "main.__init__: current_channel.first_show() initialization error")

      
            # bind gtk/glade event names to functions
            gui_startup(19/20.0)
            self.connect_signals(dict( list({
                "gtk_main_quit" : self.gtk_main_quit,                # close window
                # treeviews / notebook
                "on_stream_row_activated" : self.on_play_clicked,    # double click in a streams list
                "on_category_clicked": self.on_category_clicked,     # new selection in category list
                "on_notebook_channels_switch_page": self.channel_switch,   # channel notebook tab changed
                "station_context_menu": lambda tv,ev: station_context_menu(tv,ev),
                # toolbar
                "on_play_clicked" : self.on_play_clicked,
                "on_record_clicked": self.on_record_clicked,
                "on_homepage_stream_clicked": self.on_homepage_stream_clicked,
                "on_reload_clicked": self.on_reload_clicked,
                "on_stop_clicked": self.on_stop_clicked,
                "on_homepage_channel_clicked" : self.on_homepage_channel_clicked,
                "double_click_channel_tab": self.on_homepage_channel_clicked,
                # menu
                "menu_toolbar_standard": lambda w: (self.toolbar.unset_style(), self.toolbar.unset_icon_size()),
                "menu_toolbar_style_icons": lambda w: (self.toolbar.set_style(gtk.TOOLBAR_ICONS)),
                "menu_toolbar_style_both": lambda w: (self.toolbar.set_style(gtk.TOOLBAR_BOTH)),
                "menu_toolbar_size_small": lambda w: (self.toolbar.set_icon_size(gtk.ICON_SIZE_SMALL_TOOLBAR)),
                "menu_toolbar_size_medium": lambda w: (self.toolbar.set_icon_size(gtk.ICON_SIZE_DND)),
                "menu_toolbar_size_large": lambda w: (self.toolbar.set_icon_size(gtk.ICON_SIZE_DIALOG)),
                # win_config
                "menu_properties": config_dialog.open,
                "config_cancel": config_dialog.hide,
                "config_save": config_dialog.save,
                "config_play_list_edit_col0": lambda w,path,txt: (config_dialog.list_edit(self.config_play, path, 0, txt)),
                "config_play_list_edit_col1": lambda w,path,txt: (config_dialog.list_edit(self.config_play, path, 1, txt)),
                "config_record_list_edit_col0": lambda w,path,txt: (config_dialog.list_edit(self.config_record, path, 0, txt)),
                "config_record_list_edit_col1": lambda w,path,txt: (config_dialog.list_edit(self.config_record, path, 1, txt)),
                # else
                "update_categories": self.update_categories,
                "update_favicons": self.update_favicons,
                "app_state": self.app_state,
                "bookmark": self.bookmark,
                "save_as": self.save_as,
                "menu_about": lambda w: AboutStreamtuner2(),
                "menu_help": action.action.help,
                "menu_onlineforum": lambda w: action.browser("http://sourceforge.net/projects/streamtuner2/forums/forum/1173108"),
                "menu_fossilwiki": lambda w: action.browser("http://fossil.include-once.org/streamtuner2/"),
                "menu_projhomepage": lambda w: action.browser("http://milki.include-once.org/streamtuner2/"),
               # "menu_bugreport": lambda w: BugReport(),
                "menu_copy": self.menu_copy,
                "delete_entry": self.delete_entry,
                # search dialog
                "quicksearch_set": search.quicksearch_set,
                "search_open": search.menu_search,
                "search_go": search.cache_search,
                "search_srv": search.server_search,
                "search_cancel": search.cancel,
                "true": lambda w,*args: True,
                # win_streamedit
                "streamedit_open": streamedit.open,
                "streamedit_save": streamedit.save,
                "streamedit_new": streamedit.new,
                "streamedit_cancel": streamedit.cancel,
            }.items() ) + list( self.add_signals.items() ) ))
            
            # actually display main window
            gui_startup(98.9/100.0)
            self.win_streamtuner2.show()
            

          

        #-- Shortcut for glade.get_widget()
        # Allows access to widgets as direct attributes instead of using .get_widget()
        # Also looks in self.channels[] for the named channel plugins
        def __getattr__(self, name):
            if (name in self.channels):
                return self.channels[name]     # like self.shoutcast
            else:
                return self.get_object(name)   # or gives an error if neither exists


        # custom-named widgets are available from .widgets{} not via .get_widget()
        def get_widget(self, name):
            if name in self.widgets:
                return self.widgets[name]
            else:
                return gtk.Builder.get_object(self, name)
                


                
        # returns the currently selected directory/channel object
        def channel(self):
            return self.channels[self.current_channel]

            
        def current_channel_gtk(self):
            i = self.notebook_channels.get_current_page()
            try: return self.channel_names[i]
            except: return "bookmarks"


        # notebook tab clicked
        def channel_switch(self, notebook, page, page_num=0, *args):

            # can be called from channelmenu as well:
            if type(page) == str:
                self.current_channel = page
                self.notebook_channels.set_current_page(self.channel_names.index(page))
            # notebook invocation:
            else: #if type(page_num) == int:
                self.current_channel = self.channel_names[page_num]
            
            # if first selected, load current category
            try:
                __print__(dbg.PROC, "channel_switch: try .first_show", self.channel().module);
                self.channel().first_show()
            except:
                __print__(dbg.INIT, "channel .first_show() initialization error")


        # convert ListStore iter to row number
        def rowno(self):
            (model, iter) = self.model_iter()
            return model.get_path(iter)[0]


        # currently selected entry in stations list, return complete data dict
        def row(self):
            return self.channel().stations() [self.rowno()]

            
        # return ListStore object and Iterator for currently selected row in gtk.TreeView station list
        def model_iter(self):
            return self.channel().gtk_list.get_selection().get_selected()

            
        # fetches a single varname from currently selected station entry
        def selected(self, name="url"):
            return self.row().get(name)

                



        # play button
        def on_play_clicked(self, widget, event=None, *args):
            row = self.row()
            if row:
                self.channel().play(row)
                [callback(row) for callback in self.hooks["play"]]


        # streamripper
        def on_record_clicked(self, widget):
            row = self.row()
            action.record(row.get("url"), row.get("format", "audio/mpeg"), "url/direct", row=row)


        # browse stream
        def on_homepage_stream_clicked(self, widget):
            url = self.selected("homepage")             
            action.browser(url)

             
        # browse channel
        def on_homepage_channel_clicked(self, widget, event=2):
            if event == 2 or event.type == gtk.gdk._2BUTTON_PRESS:
                __print__(dbg.UI, "dblclick")
                action.browser(self.channel().homepage)            


        # reload stream list in current channel-category
        def on_reload_clicked(self, widget=None, reload=1):
            __print__(dbg.UI, "reload", reload, self.current_channel, self.channels[self.current_channel], self.channel().current)
            category = self.channel().current
            self.thread(
                lambda: (  self.channel().load(category,reload), reload and self.bookmarks.heuristic_update(self.current_channel,category)  )
            )

            
        # thread a function, add to worker pool (for utilizing stop button)
        def thread(self, target, *args):
            thread = Thread(target=target, args=args)
            thread.start()
            self.working.append(thread)


        # stop reload/update threads
        def on_stop_clicked(self, widget):
            while self.working:
                thread = self.working.pop()
                thread.stop()

        
        # click in category list
        def on_category_clicked(self, widget, event, *more):
            category = self.channel().currentcat()
            __print__(dbg.UI, "on_category_clicked", category, self.current_channel)
            self.on_reload_clicked(None, reload=0)
            pass


        # add current selection to bookmark store
        def bookmark(self, widget):
            self.bookmarks.add(self.row())
            # code to update current list (set icon just in on-screen liststore, it would be updated with next display() anyhow - and there's no need to invalidate the ls cache, because that's referenced by model anyhow)
            try:
                (model,iter) = self.model_iter()
                model.set_value(iter, 0, gtk.STOCK_ABOUT)
            except:
                pass
            # refresh bookmarks tab
            self.bookmarks.load(self.bookmarks.default)


        # reload category tree
        def update_categories(self, widget):
            Thread(target=self.channel().reload_categories).start()
            

        # menu invocation: refresh favicons for all stations in current streams category
        def update_favicons(self, widget):
            entries = self.channel().stations()
            favicon.download_all(entries)


        # save a file            
        def save_as(self, widget):
            row = self.row()
            default_fn = row["title"] + ".m3u"
            fn = mygtk.save_file("Save Stream", None, default_fn, [(".m3u","*m3u"),(".pls","*pls"),(".xspf","*xspf"),(".smil","*smil"),(".asx","*asx"),("all files","*")])
            if fn:
                action.save(row, fn)
            pass


        # save current stream URL into clipboard
        def menu_copy(self, w):
            gtk.clipboard_get().set_text(self.selected("url"))


        # remove an entry
        def delete_entry(self, w):
            n = self.rowno()
            del self.channel().stations()[ n ]
            self.channel().switch()
            self.channel().save()


        # stream right click
        def station_context_menu(self, treeview, event):
            return station_context_menu(treeview, event) # wrapper to the static function
            




        # shortcut to statusbar
        # (hacked to work from within threads, circumvents the statusbar msg pool actually)
        def status(self, text="", sbar_msg=[]):
            # init
            sbar_cid = self.get_widget("statusbar").get_context_id("messages")
            # remove text
            while ((not text) and (type(text)==str) and len(sbar_msg)):
                sbar_msg.pop()
                mygtk.do(lambda:self.statusbar.pop(sbar_cid))
            # progressbar
            if (type(text)==float):
                if (text >= 999.0/1000):  # completed
                    mygtk.do(lambda:self.progress.hide())
                else:  # show percentage
                    mygtk.do(lambda:self.progress.show() or self.progress.set_fraction(text))
                    if (text <= 0):  # unknown state
                        mygtk.do(lambda:self.progress.pulse())
            # add text
            elif (type(text)==str):
                sbar_msg.append(1)
                mygtk.do(lambda:self.statusbar.push(sbar_cid, text))
            pass


        # load plugins from /usr/share/streamtuner2/channels/
        def load_plugin_channels(self):

            # find and order plugin files
            ls = channels.module_list()

            # step through
            for module in ls:
                gui_startup(2/10.0 + 7/10.0 * float(ls.index(module))/len(ls), "loading module "+module)
                                
                # skip module if disabled
                if conf.plugins.get(module, 1) == False:
                    __print__(dbg.STAT, "disabled plugin:", module)
                    continue
                
                # load plugin
                try:
                    plugin = __import__("channels."+module, None, None, [""])
                    plugin_class = plugin.__dict__[module]
                
                    # load .config settings from plugin
                    conf.add_plugin_defaults(plugin_class.config, module)

                    # add and initialize channel
                    if issubclass(plugin_class, GenericChannel):
                        self.channels[module] = plugin_class(parent=self)
                        if module not in self.channel_names:  # skip (glade) built-in channels
                            self.channel_names.append(module)
                    # other plugin types
                    else:
                        self.features[module] = plugin_class(parent=self)
                    
                except Exception as e:
                    __print__(dbg.INIT, "load_plugin_channels: error initializing:", module, ", exception:")
                    import traceback
                    traceback.print_exc()

            # default plugins
            conf.add_plugin_defaults(self.channels["bookmarks"].config, "bookmarks")


        # store window/widget states (sizes, selections, etc.)
        def app_state(self, widget):
            # gtk widget states
            widgetnames = ["win_streamtuner2", "toolbar", "notebook_channels", ] \
                        + [id+"_list" for id in self.channel_names] + [id+"_cat" for id in self.channel_names]
            conf.save("window", mygtk.app_state(wTree=self, widgetnames=widgetnames), nice=1)
            # object vars
            channelopts = {} #dict([(id, {"current":self.channels[id].current}) for id in self.channel_names])
            for id in self.channels.keys():
                if (self.channels[id]):
                    channelopts[id] = {"current":self.channels[id].current}
            conf.save("state", channelopts, nice=1)


        # apply gtkrc stylesheet
        def load_theme(self):
            if conf.get("theme"):
                for dir in (conf.dir, conf.share, "/usr/share"):
                    f = dir + "/themes/" + conf.theme + "/gtk-2.0/gtkrc"
                    if os.path.exists(f):
                        gtk.rc_parse(f)
                pass


        # end application and gtk+ main loop
        def gtk_main_quit(self, widget, *x):
            if conf.auto_save_appstate:
                try:  # doesn't work with gtk3 yet (probably just hooking at the wrong time)
                    self.app_state(widget)
                except:
                    None
            gtk.main_quit()






                




# auxiliary window: about dialog
class AboutStreamtuner2:
        # about us
        def __init__(self):
            a = gtk.AboutDialog()
            a.set_version(__version__)
            a.set_name("streamtuner2")
            a.set_license("Public Domain\n\nNo Strings Attached.\nUnrestricted distribution,\nmodification, use.")
            a.set_authors(["Mario Salzer <http://mario.include-once.org/>\n\nConcept based on streamtuner 0."+"99."+"99 from\nJean-Yves Lefort, of which some code remains\nin the Google stations plugin.\n<http://www.nongnu.org/streamtuner/>\n\nMyOggRadio plugin based on cooperation\nwith Christian Ehm. <http://ehm-edv.de/>"])
            a.set_website("http://milki.include-once.org/streamtuner2/")
            a.connect("response", lambda a, ok: ( a.hide(), a.destroy() ) )
            a.show()
            

            
# right click in streams/stations TreeView
def station_context_menu(treeview, event):
            # right-click ?
            if event.button >= 3:
                path = treeview.get_path_at_pos(int(event.x), int(event.y))[0]
                treeview.grab_focus()
                treeview.set_cursor(path, None, False)
                main.streamactions.popup(
                      parent_menu_shell=None, parent_menu_item=None, func=None,
                      button=event.button, activate_time=event.time,
                      data=None
                )
                return None
            # we need to pass on to normal left-button signal handler
            else:
                return False
# this works better as callback function than as class - because of False/Object result for event trigger




# encapsulates references to gtk objects AND properties in main window
class auxiliary_window(object):
        def __getattr__(self, name):
            if name in main.__dict__:
                return main.__dict__[name]
            elif name in StreamTunerTwo.__dict__:
                return StreamTunerTwo.__dict__[name]
            else:
                return main.get_widget(name)
""" allows to use self. and main. almost interchangably """



# aux win: search dialog (keeps search text in self.q)
# and also: quick search textbox (uses main.q instead)
class search (auxiliary_window):

        # either current channel, or last channel (avoid searching in bookmarks)
        current = None

        # show search dialog   
        def menu_search(self, w):
            self.search_dialog.show();
            if not self.current or main.current_channel != "bookmarks":
                self.current = main.current_channel
                self.search_dialog_current.set_label("just %s" % main.channels[self.current].title)


        # hide dialog box again
        def cancel(self, *args):
            self.search_dialog.hide()
            return True  # stop any other gtk handlers
            

        # prepare variables
        def prepare_search(self):
            main.status("Searching... Stand back.")
            self.cancel()
            self.q = self.search_full.get_text().lower()
            if self.search_dialog_all.get_active():
                self.targets = main.channels.keys()
            else:
                self.targets = [self.current]
            main.bookmarks.streams["search"] = []
            
        # perform search
        def cache_search(self, *w):
            self.prepare_search()
            entries = []
            # which fields?
            fields = ["title", "playing", "homepage"]
            for i,cn in enumerate([main.channels[c] for c in self.targets]):
                if cn.streams:  # skip disabled plugins
                    # categories
                    for cat in cn.streams.keys():
                        # stations
                        for row in cn.streams[cat]:
                            # assemble text fields to compare
                            text = " ".join([row.get(f, " ") for f in fields])
                            if text.lower().find(self.q) >= 0:
                                row = copy(row)
                                row["genre"] = c + " " + row.get("genre", "")
                                entries.append(row)
            self.show_results(entries)

        # display "search" in "bookmarks"
        def show_results(self, entries):
            main.status(1.0)
            main.channel_switch(None, "bookmarks", 0)
            main.bookmarks.set_category("search")
            # insert data and show
            main.channels["bookmarks"].streams["search"] = entries   # we have to set it here, else .currentcat() might reset it 
            main.bookmarks.load("search")
            
            
        # live search on directory server homepages
        def server_search(self, w):
            self.prepare_search()
            entries = []
            for i,cn in enumerate([main.channels[c] for c in self.targets]):
                if cn.has_search:  # "search" in cn.update_streams.func_code.co_varnames:
                    __print__(dbg.PROC, "has_search:", cn.module)
                    try:
                        add = cn.update_streams(cat=None, search=self.q)
                        for row in add:
                            row["genre"] = cn.title + " " + row.get("genre", "")
                        entries += add
                    except:
                        continue
                #main.status(main, 1.0 * i / 15)
            self.show_results(entries)


        # search text edited in text entry box
        def quicksearch_set(self, w, *eat, **up):
            
            # keep query string
            main.q = self.search_quick.get_text().lower()

            # get streams
            c = main.channel()
            rows = c.stations()
            col = c.rowmap.index("search_col") # this is the gtk.ListStore index # which contains the highlighting color

            # callback to compare (+highlight) rows
            m = c.gtk_list.get_model()
            m.foreach(self.quicksearch_treestore, (rows, main.q, col, col+1))
        search_set = quicksearch_set
            
            
            
        # callback that iterates over whole gtk treelist,
        # looks for search string and applies TreeList color and flag if found
        def quicksearch_treestore(self, model, path, iter, extra_data):
            i = path[0]
            (rows, q, color, flag) = extra_data

            # compare against interesting content fields:
            text = rows[i].get("title", "") + " " + rows[i].get("homepage", "")
            # config.quicksearch_fields
            text = text.lower()

            # simple string match (probably doesn't need full search expression support)
            if len(q) and text.find(q) >= 0:
               model.set_value(iter, color, "#fe9")  # highlighting color
               model.set_value(iter, flag, True) # background-set flag
            # color = 12 in liststore, flag = 13th position
            else:
               model.set_value(iter, color, "")   # for some reason the cellrenderer colors get applied to all rows, even if we specify an iter (=treelist position?, not?)
               model.set_value(iter, flag, False)   # that's why we need the secondary -set option

            #??
            return False



search = search()
# instantiates itself




# aux win: stream data editing dialog
class streamedit (auxiliary_window):


        # show stream data editing dialog
        def open(self, mw):
            config_dialog.load_config(main.row(), "streamedit_")
            self.win_streamedit.show()


        # copy widget contents to stream
        def save(self, w):
            config_dialog.save_config(main.row(), "streamedit_")
            main.channel().save()
            self.cancel(w)

            
        # add a new list entry, update window
        def new(self, w):
            s = main.channel().stations()
            s.append({"title":"new", "url":"", "format":"audio/mpeg", "genre":"", "listeners":1});
            main.channel().switch() # update display
            main.channel().gtk_list.get_selection().select_path(str(len(s)-1)); # set cursor to last row
            self.open(w)


        # hide window
        def cancel(self, *w):
            self.win_streamedit.hide()
            return True

streamedit = streamedit()
# instantiates itself





# aux win: settings UI
class config_dialog (auxiliary_window):


        # Display win_config, pre-fill text fields from global conf. object
        def open(self, widget):
            if self.first_open:
                self.add_plugins()
                self.combobox_theme()
                self.first_open = 0
                self.win_config.resize(565, 625)
            self.load_config(conf.__dict__, "config_")
            self.load_config(conf.plugins, "config_plugins_")
            [callback() for callback in self.hooks["config_load"]]
            self.win_config.show()
        first_open = 1

        # Hide window
        def hide(self, *args):
            self.win_config.hide()
            return True

        
        # Load values from conf. store into gtk widgets
        def load_config(self, config, prefix="config_"):
            for key,val in config.items():
                w = main.get_widget(prefix + key)
                if w:
                    # input field
                    if type(w) is gtk.Entry:
                        w.set_text(str(val))
                    # checkmark
                    elif type(w) is gtk.CheckButton:
                        w.set_active(bool(val))
                    # dropdown
                    elif type(w) is ComboBoxText:
                        w.set_default(val)
                    # list
                    elif type(w) is gtk.ListStore:
                        w.clear()
                        for k,v in val.items():
                            w.append([k, v, True])
                        w.append(["", "", True])
                __print__(dbg.CONF, "config load", prefix+key, val, type(w))

        # Store gtk widget valus back into conf. dict
        def save_config(self, config, prefix="config_", save=0):
            for key,val in config.items():
                w = main.get_widget(prefix + key)
                if w:
                    # text
                    if type(w) is gtk.Entry:
                        config[key] = w.get_text()
                    # pre-defined text
                    elif type(w) is ComboBoxText:
                        config[key] = w.get_active_text()
                    # boolean
                    elif type(w) is gtk.CheckButton:
                        config[key] = w.get_active()
                    # dict
                    elif type(w) is gtk.ListStore:
                        config[key] = {}
                        for row in w:
                            if row[0] and row[1]:
                                config[key][row[0]] = row[1]
                __print__(dbg.CONF, "config save", prefix+key, val)

        
        # Generic Gtk callback to update ListStore when entries get edited
        def list_edit(self, liststore, path, column, new_text):
            liststore[path][column] = new_text
            # The signal_connect() dict actually prepares individual lambda functions
            # to bind the correct ListStore and column id.
            

        # list of Gtk themes in dropdown
        def combobox_theme(self):
            # find themes
            themedirs = (conf.share+"/themes", conf.dir+"/themes", "/usr/share/themes")
            themes = ["no theme"]
            [[themes.append(e) for e in os.listdir(dir)] for dir in themedirs if os.path.exists(dir)]
            __print__(dbg.STAT, themes)
            # add dropdown
            main.widgets["theme"] = ComboBoxText(themes)
            self.theme_cb_placeholder.pack_start(self.theme)
            self.theme_cb_placeholder.pack_end(mygtk.label(""))


        # retrieve currently selected value
        def apply_theme(self):
            conf.theme = self.theme.get_active_text()
            main.load_theme()


        # add configuration setting definitions from plugins
        def add_plugins(self):

            for name,meta in channels.module_meta().items():

                # add plugin load entry
                if name:
                    cb = gtk.CheckButton(name)
                    cb.get_children()[0].set_markup("<b>%s</b> <i>(%s)</i> %s\n<small>%s</small>" % (meta["title"], meta["type"], meta.get("version", ""), meta["description"]))
                    self.add_( "config_plugins_"+name, cb )

                # look up individual plugin options, if loaded
                if self.channels.get(name) or self.features.get(name):
                    c = self.channels.get(name) or self.features.get(name)
                    for opt in c.config:

                        # default values are already in conf[] dict (now done in conf.add_plugin_defaults)
                        color = opt.get("color", None)
                            
                        # display checkbox
                        if opt["type"] == "boolean":
                            cb = gtk.CheckButton(opt["description"])
                            self.add_( "config_"+opt["name"], cb, color=color )
                        # drop down list
                        elif opt["type"] == "select":
                            cb = ComboBoxText(ComboBoxText.parse_options(opt["select"])) # custom mygtk widget
                            self.add_( "config_"+opt["name"], cb, opt["description"], color )
                        # text entry
                        else:
                            self.add_( "config_"+opt["name"], gtk.Entry(), opt["description"], color )

                # spacer 
                self.add_( "filler_pl_"+name, gtk.HSeparator() )


        # Put config widgets into config dialog notebook
        def add_(self, id, w, label=None, color=""):
            w.set_property("visible", True)
            main.widgets[id] = w
            if label:
                if type(w) is gtk.Entry:
                    w.set_width_chars(11)
                w = mygtk.hbox(w, mygtk.label(label))
            if color:
                w = mygtk.bg(w, color)
            self.plugin_options.pack_start(w)
        
        # save config
        def save(self, widget):
            self.save_config(conf.__dict__, "config_")
            self.save_config(conf.plugins, "config_plugins_")
            [callback() for callback in main.hooks["config_save"]]
            config_dialog.apply_theme()
            conf.save(nice=1)
            self.hide()
                  
config_dialog = config_dialog()
# instantiates itself





























# class GenericChannel:
#
#   is in channels/__init__.py
#





#-- favourite lists                                            ------------------------------------------
#
# This module lists static content from ~/.config/streamtuner2/bookmarks.json;
# its data list is queried by other plugins to add 'star' icons.
#
# Some feature extensions inject custom categories[] into streams{}
# e.g. "search" adds its own category once activated, as does the "timer" plugin.
#
class bookmarks(GenericChannel):


        # desc
        module = "bookmarks"
        title = "bookmarks"
        version = 0.4
        base_url = "file:.config/streamtuner2/bookmarks.json"
        listformat = "*/*"

        
        # i like this
        config = [
            {"name":"like_my_bookmarks", "type":"boolean", "value":0, "description":"I like my bookmarks"},
        ]


        # content
        categories = ["favourite", ]  # timer, links, search, and links show up as needed
        current = "favourite"
        default = "favourite"
        streams = {"favourite":[], "search":[], "scripts":[], "timer":[], "history":[], }
        

        # cache list, to determine if a PLS url is bookmarked
        urls = []


        # this channel does not actually retrieve/parse data from anywhere
        def update_categories(self):
            pass
        def update_streams(self, cat):
            return self.streams.get(cat, [])

            
        # streams are already loaded at instantiation
        def first_show(self):
            pass


        # all entries just come from "bookmarks.json"
        def cache(self):
            # stream list
            cache = conf.load(self.module)
            if (cache):
                __print__(dbg.PROC, "load bookmarks.json")
                self.streams = cache
            


        # save to cache file
        def save(self):
            conf.save(self.module, self.streams, nice=1)


        # checks for existence of an URL in bookmarks store,
        # this method is called by other channel modules' display() method
        def is_in(self, url, once=1):
            if (not self.urls):
                self.urls = [row.get("url","urn:x-streamtuner2:no") for row in self.streams["favourite"]]
            return url in self.urls


        # called from main window / menu / context menu,
        # when bookmark is to be added for a selected stream entry
        def add(self, row):
        
            # normalize data (this row originated in a gtk+ widget)
            row["favourite"] = 1
            if row.get("favicon"):
               row["favicon"] = favicon.file(row.get("homepage"))
            if not row.get("listformat"):
                row["listformat"] = main.channel().listformat
               
            # append to storage
            self.streams["favourite"].append(row)
            self.save()
            self.load(self.default)
            self.urls.append(row["url"])


        # simplified gtk TreeStore display logic (just one category for the moment, always rebuilt)
        def load(self, category, force=False):
            #self.liststore[category] = \
            __print__(dbg.UI, category, self.streams.keys())
            mygtk.columns(self.gtk_list, self.datamap, self.prepare(self.streams.get(category,[])))


        # select a category in treeview
        def add_category(self, cat):
            if cat not in self.categories: # add category if missing
                self.categories.append(cat)
                self.display_categories()


        # change cursor
        def set_category(self, cat):
            self.add_category(cat)
            self.gtk_cat.get_selection().select_path(str(self.categories.index(cat)))
            return self.currentcat()
            
            
        # update bookmarks from freshly loaded streams data
        def heuristic_update(self, updated_channel, updated_category):

            if not conf.heuristic_bookmark_update: return
            __print__(dbg.ERR, "heuristic bookmark update")
            save = 0
            fav = self.streams["favourite"]
        
            # First we'll generate a list of current bookmark stream urls, and then
            # remove all but those from the currently UPDATED_channel + category.
            # This step is most likely redundant, but prevents accidently re-rewriting
            # stations that are in two channels (=duplicates with different PLS urls).
            check = {"http//": "[row]"}
            check = dict((row.get("url", "http//"),row) for row in fav)
            # walk through all channels/streams
            for chname,channel in main.channels.items():
                for cat,streams in channel.streams.items():

                    # keep the potentially changed rows
                    if (chname == updated_channel) and (cat == updated_category):
                        freshened_streams = streams

                    # remove unchanged urls/rows
                    else:
                        unchanged_urls = (row.get("url") for row in streams)
                        for url in unchanged_urls:
                            if url in check:
                                del check[url]
                                # directory duplicates could unset the check list here,
                                # so we later end up doing a deep comparison


            # now the real comparison,
            # where we compare station titles and homepage url to detect if a bookmark is an old entry
            for row in freshened_streams:
                url = row.get("url")
                
                # empty entry (google stations), or stream still in current favourites
                if not url or url in check:
                    pass

                # need to search
                else:
                    title = row.get("title")
                    homepage = row.get("homepage")
                    for i,old in enumerate(fav):

                        # skip if new url already in streams
                        if url == old.get("url"):
                            pass   # This is caused by channel duplicates with identical PLS links.
                        
                        # on exact matches (but skip if url is identical anyway)
                        elif title == old["title"] and homepage == old.get("homepage",homepage):
                            # update stream url
                            fav[i]["url"] = url
                            save = 1
                            
                        # more text similarity heuristics might go here
                        else:
                            pass
            
            # if there were changes
            if save: self.save()













#-- startup progress bar
progresswin, progressbar = 0, 0
def gui_startup(p=0/100.0, msg="streamtuner2 is starting"):

    global progresswin,progressbar
    if not progresswin:

        # GtkWindow "progresswin"
        progresswin = gtk.Window()
        progresswin.set_property("title", "streamtuner2")
        progresswin.set_property("default_width", 300)
        progresswin.set_property("width_request", 300)
        progresswin.set_property("default_height", 30)
        progresswin.set_property("height_request", 30)
        #progresswin.set_property("window_position", "center")
        progresswin.set_property("decorated", False)
        progresswin.set_property("visible", True)

        # GtkProgressBar "progressbar"
        progressbar = gtk.ProgressBar()
        progressbar.set_property("visible", True)
        progressbar.set_property("show_text", True)
        progressbar.set_property("text", msg)
        progresswin.add(progressbar)
        progresswin.show_all()

    try:
      if p<1:
        progressbar.set_fraction(p)
        progressbar.set_property("text", msg)
        while gtk.events_pending(): gtk.main_iteration(False)
      else:
        progresswin.hide()
    except: return




#-- run main                                ---------------------------------------------
if __name__ == "__main__":


    # graphical
    if len(sys.argv) < 2 or "--gtk3" in sys.argv:
    
        
        # prepare for threading in Gtk+ callbacks
        gobject.threads_init()
        gui_startup(1/100.0)
        
        # prepare main window
        main = StreamTunerTwo()
        
        # module coupling
        action.main = main      # action (play/record) module needs a reference to main window for gtk interaction and some URL/URI callbacks
        action = action.action  # shorter name
        ahttp.feedback = main.status  # http module gives status feedbacks too
        
        # first invocation
        if (conf.get("firstrun")):
            config_dialog.open(None)
            del conf.firstrun


        # run
        gui_startup(100/100.0)
        gtk.main()
        
        
    # invoke command-line interface
    else:
        import cli
        cli.StreamTunerCLI()




#
#
#
#