Artifact [31aa4c2365]
Artifact 31aa4c2365073b1a1d68e2b9f710020a3cb94ed1:
- Executable file
st2.py
— part of check-in
[ac8632bc29]
at
2014-06-03 00:29:43
on branch trunk
— Search dialog offers (x) all channels or (x) just current for server+cache scan
again. Removed search="" parameter from channels that don't implement it.
(To remove extraneous .has_search channel attribute again somewhen..)
External: Xiph IO cache ?search= should be changed to work on station titles instead of genres. (user: mario, size: 44946) [annotate] [blame] [check-ins using]
#!/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.1 # 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 # 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.1" # 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): # show search dialog def menu_search(self, w): self.search_dialog.show(); self.search_dialog_current.set_label("just %s" % main.current_channel) # 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 = [main.current_channel] 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["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 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() # # # #