Index: Makefile ================================================================== --- Makefile +++ Makefile @@ -82,7 +82,7 @@ $(INST) help/str*2.1 -t /usr/share/man/man1/ # start locally st2: run run: - python -B -3 ./st2.py -D + python -B ./st2.py -D Index: channels/__init__.py ================================================================== --- channels/__init__.py +++ channels/__init__.py @@ -39,36 +39,38 @@ import re import copy import inspect -# Only export plugin classes +# Only export plugin classes and a few utility functions __all__ = [ - "GenericChannel", "ChannelPlugin", "use_rx", + "GenericChannel", "ChannelPlugin", "use_rx", "mime_fmt", "entity_decode", "strip_tags", "nl", "unhtml", "to_int" ] __path__.insert(0, conf.dir + "/plugins") -# generic channel module --------------------------------------- +# Generic channel module class GenericChannel(object): # control attributes meta = { "config": [] } base_url = "" listformat = "pls" audioformat = "audio/mpeg" # fallback value has_search = False - # categories - categories = [] - catmap = {} - shown = None # last selected entry in stream list, also indicator if notebook tab has been selected once / stream list of current category been displayed yet + # Categories + categories = [] # Category names or subcategory groups in [] lists + catmap = {} # Map category names to channel/service-internal ids + shown = None # Just a state flag for .first_show() now - # gui + data + # Stream list streams = {} # Station list dict, associates each genre to a list of stream rows + + # Gtk widgets gtk_list = None # Gtk widget for station treeview gtk_cat = None # Gtk widget for category columns ls = None # ListStore for station treeview rowmap = None # Preserve streams-datamap pix_entry = None # ListStore entry that contains favicon @@ -90,22 +92,26 @@ [False, 0, ["deleted", bool, None, {}], ], [False, 0, ["search_col", str, None, {}], ], [False, 0, ["search_set", bool, None, {}], ], ] rowmap = [] # [state,genre,title,...] field enumeration still needed separately - titles = {} # for easier adapting of column titles in datamap + titles = {} # For easier adapting of column titles in datamap - # for empty grouping / categories + # For empty grouping / categories placeholder = [dict(genre="./.", title="Subcategory placeholder", playing="./.", url="none:", listeners=0, bitrate=0, homepage="", state="gtkfolder")] empty_stub = [dict(genre="./.", title="No categories found (HTTP error)", playing="Try Channel→Reload Categories later..", url="none:", listeners=0, bitrate=0, homepage="", state="gtk-stop")] nothing_found = [dict(genre="./.", title="No contents found on directory server", playing="Notice", listeners=0, bitrate=0, state="gtk-info")] - # regex + # Title to homepage regex rx_www_url = re.compile("""(www(\.\w+[\w-]+){2,}|(\w+[\w-]+[ ]?\.)+(com|FM|net|org|de|PL|fr|uk))""", re.I) + # Hooks for station list updating + prepare_filters = [] # run prior columns() display + postprocess_filters = [] # called after update_streams() - #-- keep track of currently selected genre/category + + # Keep track of currently selected genre/category __current = None @property def current(self): return self.__current @current.setter @@ -172,14 +178,16 @@ # Initialize stations TreeView self.columns([]) # add to main menu uikit.add_menu([parent.channelmenuitems], self.meta["title"], lambda w: parent.channel_switch_by_name(self.module) or 1) + # Just wraps uikit.columns() to retain liststore, rowmap and pix_entry def columns(self, entries=None): self.ls, self.rowmap, self.pix_entry = uikit.columns(self.gtk_list, self.datamap, entries, show_favicons=conf.show_favicons) + # Statusbar stub (defers to parent/main window, if in GUI mode) def status(self, *args, **kw): if self.parent: self.parent.status(*args, **kw) else: log.INFO("status():", *v) @@ -186,20 +194,21 @@ #--------------------- streams/model data accesss --------------------------- - # traverse category TreeModel to set current, expand parent nodes + + # Traverse category TreeModel to set current, expand parent nodes def select_current(self, name): log.UI("reselect .current category in treelist:", name) model = self.gtk_cat.get_model() # [Gtk3] Warning: g_object_ref_sink: assertion 'object->ref_count >= 1' failed # ERROR:../../gi/pygobject.c:688:pygobject_register_wrapper: assertion failed: (gself->obj->ref_count >= 1) iter = model.get_iter_first() self.iter_cats(name, model, iter) - # iterate over children to find current category + # Iterate over children to find current category def iter_cats(self, name, model, iter): while iter: val = model.get_value(iter, 0) if val == name: #log.UI("FOUND CATEGORY", name, "→select") @@ -212,11 +221,11 @@ if found: self.gtk_cat.expand_row(model.get_path(iter), 0) return True iter = model.iter_next(iter) - # selected category + # Selected category (current state from Gtk TreeModel) def currentcat(self): (model, iter) = self.gtk_cat.get_selection().get_selected() if (type(iter) == gtk.TreeIter): self.current = model.get_value(iter, 0) return self.current @@ -255,11 +264,12 @@ #------------------------ base implementations ----------------------------- - # read previous channel/stream data, if there is any + + # Read previous channel/stream data, if there's any def cache(self): # stream list cache = conf.load("cache/" + self.module) if (cache): self.streams = cache @@ -271,21 +281,37 @@ cache = conf.load("cache/catmap_" + self.module) if (cache): self.catmap = cache pass + # Store current streams data + def save(self): + conf.save("cache/" + self.module, self.streams, gz=1) + - # make private copy of .datamap and modify field (title= only ATM) + # Create private copy of .datamap and modify entries (title= rewrites) def update_datamap(self, search="name", title=None): if self.datamap == GenericChannel.datamap: self.datamap = copy.deepcopy(self.datamap) for i,row in enumerate(self.datamap): if row[2][0] == search: row[0] = title - # Called on switching genre/category. + # Reload current station list + def reload(self): + self.load(self.current, force=1) + def switch(self): + self.load(self.current, force=0) + + # Update streams pane if currently selected (used by bookmarks.links channel) + def reload_if_current(self, category): + if self.current == category: + self.reload() + + + # Called on switching genre/category, or loading a genre for the first time. # Either fetches new stream data, or displays list from cache. def load(self, category, force=False, y=None): # called to early if not category: @@ -335,36 +361,17 @@ # unset statusbar self.status() - # store current streams data - def save(self): - conf.save("cache/" + self.module, self.streams, gz=1) - - - # called occasionally while retrieving and parsing + # Called occasionally (by some plugins) while updating station list def update_streams_partially_done(self, entries): if gtk_ver == 3 and not conf.nothreads: pass else: # kills Gtk3 too easily uikit.do(self.columns, entries) - - - # finds differences in new/old streamlist, marks deleted with flag - def deleted_streams(self, new, old): - diff = [] - new = [row.get("url","http://example.com/") for row in new] - for row in old: - if ("url" in row and (row.get("url") not in new)): - row["deleted"] = 1 - diff.append(row) - return diff - - - # Prepare stream list for display - prepare_filters = [] + # Prepare stream list for display (called immediataly before .columns() refreshing) def prepare(self, streams): for f in self.prepare_filters: map(f, streams) return streams @@ -377,13 +384,12 @@ row["state"] = gtk.STOCK_ABOUT if row.get("deleted"): row["state"] = gtk.STOCK_DELETE - # Stream list preparations - invoked directly after reload(), + # Stream list cleanup - invoked directly after reload(), # callbacks can remove entries, or just update fields. - postprocess_filters = [] def postprocess(self, streams): for f in self.postprocess_filters: streams = [row for row in streams if f(row, self)] return streams @@ -401,25 +407,24 @@ url = (url if url.find("www.") == 0 else "www."+url) row["homepage"] = ahttp.fix_url(url) print row return True - - - # reload current stream from web directory - def reload(self): - self.load(self.current, force=1) - def switch(self): - self.load(self.current, force=0) - - # update streams pane if currently selected (used by bookmarks.links channel) - def reload_if_current(self, category): - if self.current == category: - self.reload() - - - # display .current category, once notebook/channel tab is first opened + + # Finds differences in new/old streamlist, marks deleted with flag + def deleted_streams(self, new, old): + diff = [] + new = [row.get("url","http://example.com/") for row in new] + for row in old: + if ("url" in row and (row.get("url") not in new)): + row["deleted"] = 1 + diff.append(row) + return diff + + + + # Display .current category, once notebook/channel tab is first opened def first_show(self): # Already processed if (self.shown == 55555): return @@ -460,11 +465,11 @@ return self.str_from_struct(d.keys()) or self.str_from_struct(d.values()) elif isinstance(d, (list, tuple)): return d[0] if len(d) else None - # update categories, save, and display + # Update categories, save, and display def reload_categories(self): # get data and save self.update_categories() if self.categories: @@ -474,11 +479,11 @@ # display outside of this non-main thread uikit.do(self.display_categories) - # insert content into gtk category list + # Refresh category treeview def display_categories(self): log.UI("{}.display_categories(), uikit.tree(#{}), expand_all(#<20), select_current(={})".format(self.module, len(self.categories), self.current)) # rebuild gtk.TreeView uikit.tree(self.gtk_cat, self.categories, title="Category", icon=gtk.STOCK_OPEN) @@ -543,37 +548,13 @@ action.record(row, audioformat, listformat) return row - #--------------------------- utility functions ----------------------- - - - - - # convert audio format nick/shortnames to mime types, e.g. "OGG" to "audio/ogg" - def mime_fmt(self, s): - # clean string - s = s.lower().strip() - # rename - map = { - "audio/mp3":"audio/mpeg", # Note the real mime type is /mpeg, but /mp3 is more understandable in the GUI - "ogg":"ogg", "ogm":"ogg", "xiph":"ogg", "vorbis":"ogg", "vnd.xiph.vorbis":"ogg", - "mp3":"mpeg", "mp":"mpeg", "mp2":"mpeg", "mpc":"mpeg", "mps":"mpeg", - "aac+":"aac", "aacp":"aac", - "realaudio":"x-pn-realaudio", "real":"x-pn-realaudio", "ra":"x-pn-realaudio", "ram":"x-pn-realaudio", "rm":"x-pn-realaudio", - # yes, we do video - "flv":"video/flv", "mp4":"video/mp4", - } - #map.update(action.listfmt_t) # list type formats (.m3u .pls and .xspf) - if map.get(s): - s = map[s] - # add prefix: - if s.find("/") < 1: - s = "audio/" + s - # - return s + + + @@ -761,6 +742,31 @@ # Combine html tag, escapes and whitespace cleanup def unhtml(str): return nl(entity_decode(strip_tags(str))) + +# Convert audio format nick/shortnames to mime types, e.g. "OGG" to "audio/ogg" +# (only used by few plugin meanwhile, could be merged with action. module now) +def mime_fmt(s): + # clean string + s = s.lower().strip() + # rename + map = { + "audio/mp3":"audio/mpeg", # Note the real mime type is /mpeg, but /mp3 is more understandable in the GUI + "ogg":"ogg", "ogm":"ogg", "xiph":"ogg", "vorbis":"ogg", "vnd.xiph.vorbis":"ogg", + "mp3":"mpeg", "mp":"mpeg", "mp2":"mpeg", "mpc":"mpeg", "mps":"mpeg", + "aac+":"aac", "aacp":"aac", + "realaudio":"x-pn-realaudio", "real":"x-pn-realaudio", "ra":"x-pn-realaudio", "ram":"x-pn-realaudio", "rm":"x-pn-realaudio", + # yes, we do video + "flv":"video/flv", "mp4":"video/mp4", + } + #map.update(action.listfmt_t) # list type formats (.m3u .pls and .xspf) + if map.get(s): + s = map[s] + # add prefix: + if s.find("/") < 1: + s = "audio/" + s + # + return s + Index: channels/favicon.py ================================================================== --- channels/favicon.py +++ channels/favicon.py @@ -151,10 +151,11 @@ ok = fav_from_homepage(row["homepage"], favicon_fn) # Update TreeView if ok: self.update_pixstore(row, pixstore, i) + row["favicon"] = favicon_fn # catch HTTP Timeouts etc., so update_all() row processing just continues.. except Exception as e: log.WARN("favicon.update_rows():", e) pass Index: channels/live365.py ================================================================== --- channels/live365.py +++ channels/live365.py @@ -1,10 +1,10 @@ # api: streamtunter2 # title: Live365 # description: Around 5000 categorized internet radio streams, some paid ad-free ones. -# version: 0.3 +# version: 0.4 # type: channel # category: radio # url: http://www.live365.com/ # config: - # priority: optional @@ -16,11 +16,20 @@ # AOuvCHp1NcmFMK6ERx0wEvGmou5OtLJSrJUV7n/wEWY4TOLKCHsnlfV8hq+hgc3eL4l9O4y7sYGErgyIofqaYEHdqbvljzWx+v6HaI5ERSCE2Pen0pE4Wh22NUXxOx1Exq+yOTVRobRMzSxGZqa6zUI/3uefYcOrsq0JEqokhcRAkhSSuBti+gw5F9qxSw+wuRrpbp6e # XVQBLi+vXatfngseeuGlE/5TjUQX7pCSNoZbkNIUUrqCWRCg8u0OKC9h/L2Oz0OjP73xP5g+PXeyq+R4Q+eR5qfJ8maz/ds8AIGqoySNBMs3xvhjbKT7wvfT/8K0bwHAxy31QcWRIaA1v/ww3kA+0V8mAQb8+UX9bV99tw/nfwAe2WTAAcikxQAAAABJRU5ErkJggg== # # Live365 lists around 5000 radio stations. Some are paid # entries and require a logon. This plugins tries to filter -# thoise out. +# those out. + + +# Non-tracking cookies: +# +# box_mc |bitrate=256|ls=3|hasFlash=Y|ab=viphp:G3|POC=8|mvd=2|SUPMsg=| (desktop player off) +# |ab=viphp:G3|POC=10|bitrate=256|mvd=2|ls=3|hasFlash=Y|SUPMsg=| (desktop player on) +# pg_mc |hp=A|darg=|curl=|curlt=Live365 - My Live365 - Listen Settings| +# session_mc |plr=N|site=web| +# player_mc |Vol=50| # streamtuner2 modules from config import * from uikit import * @@ -79,15 +88,15 @@ # extract stream infos def update_streams(self, cat): - # Retrieve genere index pages + # Retrieve genre index pages html = "" for i in [1, 17, 33, 49]: url = "http://www.live365.com/cgi-bin/directory.cgi?first=%i&site=web&mode=3&genre=%s&charset=UTF-8&target=content" % (i, cat.lower()) - html += ahttp.get(url, feedback=self.parent.status) + html += ahttp.get(url) # Extract from JavaScript rx = re.compile(r""" stn.set\( " (\w+) ", \s+ " ((?:[^"\\]+|\\.)*) "\); \s+ """, re.X|re.I|re.S|re.M) Index: channels/xiph.py ================================================================== --- channels/xiph.py +++ channels/xiph.py @@ -148,11 +148,11 @@ self.status("Yes, XML parsing isn't much faster either.", timeout=20) for entry in xml.dom.minidom.parseString(yp).getElementsByTagName("entry"): buffy.append({ "title": x(entry, "server_name"), "url": x(entry, "listen_url"), - "format": self.mime_fmt(x(entry, "server_type")[6:]), + "format": mime_fmt(x(entry, "server_type")[6:]), "bitrate": bitrate(x(entry, "bitrate")), "channels": x(entry, "channels"), "samplerate": x(entry, "samplerate"), "genre": x(entry, "genre"), "playing": x(entry, "current_song"), @@ -229,11 +229,11 @@ playing = unhtml(playing), url = "http://dir.xiph.org{}".format(url), listformat = "xspf", listeners = int(listeners), bitrate = bitrate(bits), - format = self.mime_fmt(guess_format(fmt)), + format = mime_fmt(guess_format(fmt)), )) return r Index: config.py ================================================================== --- config.py +++ config.py @@ -77,11 +77,11 @@ # start def __init__(self): # object==dict means conf.var is conf["var"] - self.__dict__ = self # let's pray this won't leak memory due to recursion issues + self.__dict__ = self # prepare self.defaults() self.xdg() Index: contrib/file.py ================================================================== --- contrib/file.py +++ contrib/file.py @@ -175,11 +175,11 @@ meta = { "title": fn, "filename": fn, "url": "file://" + dir + "/" + fn, "genre": "", - "format": self.mime_fmt(fn[-3:]), + "format": mime_fmt(fn[-3:]), "editable": True, } # add ID3 meta.update(mutagen_postprocess(get_meta(dir + "/" + fn) or {})) return meta Index: contrib/listenlive.py ================================================================== --- contrib/listenlive.py +++ contrib/listenlive.py @@ -77,10 +77,10 @@ playing = location, title = unhtml(title), url = url, genre = genre[0] if genre else cat, bitrate = int(bitrate), - format = self.mime_fmt(format), + format = mime_fmt(format), )) return r Index: contrib/punkcast.py ================================================================== --- contrib/punkcast.py +++ contrib/punkcast.py @@ -81,11 +81,11 @@ # look up ANY audio url for uu in rx_sound.findall(html): log.DATA( uu ) (url, fmt) = uu - action.play(url, self.mime_fmt(fmt), "srv") + action.play(url, mime_fmt(fmt), "srv") return # or just open webpage action.browser(row["homepage"])