ADDED action.py Index: action.py ================================================================== --- action.py +++ action.py @@ -0,0 +1,272 @@ +# +# encoding: UTF-8 +# api: streamtuner2 +# type: functions +# title: play/record actions +# description: Starts audio applications, guesses MIME types for URLs +# +# +# Multimedia interface for starting audio players or browser. +# +# +# Each channel plugin has a .listtype which describes the linked +# audio playlist format. It's audio/x-scpls mostly, seldomly m3u, +# but sometimes url/direct if the entry[url] directly leads to the +# streaming server. +# As fallback there is a regex which just looks for URLs in the +# given resource (works for m3u/pls/xspf/asx/...). There is no +# actual url "filename" extension guessing. +# +# +# + + +import re +import os +import http +from config import conf +import platform + + +#from channels import __print__ +def __print__(*args): + if conf.debug: + print(" ".join([str(a) for a in args])) + + +main = None + + +#-- media actions --------------------------------------------- +# +# implements "play" and "record" methods, +# but also "browser" for web URLs +# +class action: + + # streamlink formats + lt = {"asx":"video/x-ms-asf", "pls":"audio/x-scpls", "m3u":"audio/x-mpegurl", "xspf":"application/xspf+xml", "href":"url/http", "ram":"audio/x-pn-realaudio", "smil":"application/smil"} + # media formats + mf = {"mp3":"audio/mp3", "ogg":"audio/ogg", "aac":"audio/aac"} + + + # web + @staticmethod + def browser(url): + __print__( conf.browser ) + os.system(conf.browser + " '" + action.quote(url) + "' &") + + # os shell cmd escaping + @staticmethod + def quote(s): + return "%r" % s + + + # calls player for stream url and format + @staticmethod + def play(url, audioformat="audio/mp3", listformat="text/x-href"): + if (url): + url = action.url(url, listformat) + if (audioformat): + if audioformat == "audio/mpeg": + audioformat = "audio/mp3" # internally we use the more user-friendly moniker + cmd = conf.play.get(audioformat, conf.play.get("*/*", "vlc %u")) + __print__( "play", url, cmd ) + try: + action.run( action.interpol(cmd, url) ) + except: + pass + + @staticmethod + def run(cmd): + __print__( cmd ) + os.system(cmd + (" &" if platform.system()!="Windows" else "")) + + + # streamripper + @staticmethod + def record(url, audioformat="audio/mp3", listformat="text/x-href", append="", row={}): + __print__( "record", url ) + cmd = conf.record.get(audioformat, conf.record.get("*/*", None)) + try: action.run( action.interpol(cmd, url, row) + append ) + except: pass + + + # save as .m3u + @staticmethod + def save(row, fn, listformat="audio/x-scpls"): + # modify stream url + row["url"] = action.url(row["url"], listformat) + stream_urls = action.extract_urls(row["url"], listformat) + # output format + if (re.search("\.m3u", fn)): + txt = "#M3U\n" + for url in stream_urls: + txt += http.fix_url(url) + "\n" + # output format + elif (re.search("\.pls", fn)): + txt = "[playlist]\n" + "numberofentries=1\n" + for i,u in enumerate(stream_urls): + i = str(i + 1) + txt += "File"+i + "=" + u + "\n" + txt += "Title"+i + "=" + row["title"] + "\n" + txt += "Length"+i + "=-1\n" + txt += "Version=2\n" + # output format + elif (re.search("\.xspf", fn)): + txt = '' + "\n" + txt += '' + "\n" + txt += '' + "\n" + for attr,tag in [("title","title"), ("homepage","info"), ("playing","annotation"), ("description","annotation")]: + if row.get(attr): + txt += " <"+tag+">" + xmlentities(row[attr]) + "\n" + txt += " \n" + for u in stream_urls: + txt += ' ' + xmlentities(u) + '' + "\n" + txt += " \n\n" + # output format + elif (re.search("\.json", fn)): + row["stream_urls"] = stream_urls + txt = str(row) # pseudo-json (python format) + # output format + elif (re.search("\.asx", fn)): + txt = "\n" \ + + " " + xmlentities(row["title"]) + "\n" \ + + " \n" \ + + " " + xmlentities(row["title"]) + "\n" \ + + " \n" \ + + " \n" \ + + " \n\n" + # output format + elif (re.search("\.smil", fn)): + txt = "\n\n \n\n" \ + + "\n \n \n\n\n" + # unknown + else: + txt = "" + # write + if txt: + f = open(fn, "wb") + f.write(txt) + f.close() + pass + + + # replaces instances of %u, %l, %pls with urls / %g, %f, %s, %m, %m3u or local filenames + @staticmethod + def interpol(cmd, url, row={}): + # inject other meta fields + if row: + for field in row: + cmd = cmd.replace("%"+field, "%r" % row.get(field)) + # add default if cmd has no %url placeholder + if cmd.find("%") < 0: + cmd = cmd + " %m3u" + # standard placeholders + if (re.search("%(url|pls|[ulr])", cmd)): + cmd = re.sub("%(url|pls|[ulr])", action.quote(url), cmd) + if (re.search("%(m3u|[fgm])", cmd)): + cmd = re.sub("%(m3u|[fgm])", action.quote(action.m3u(url)), cmd) + if (re.search("%(srv|[ds])", cmd)): + cmd = re.sub("%(srv|[ds])", action.quote(action.srv(url)), cmd) + return cmd + + + # eventually transforms internal URN/IRI to URL + @staticmethod + def url(url, listformat): + if (listformat == "audio/x-scpls"): + url = url + elif (listformat == "text/x-urn-streamtuner2-script"): + url = main.special.stream_url(url) + else: + url = url + return url + + + # download a .pls resource and extract urls + @staticmethod + def pls(url): + text = http.get(url) + __print__( "pls_text=", text ) + return re.findall("\s*File\d*\s*=\s*(\w+://[^\s]+)", text, re.I) + # currently misses out on the titles + + # get a single direct ICY stream url (extract either from PLS or M3U) + @staticmethod + def srv(url): + return action.extract_urls(url)[0] + + + # retrieve real stream urls from .pls or .m3u links + @staticmethod + def extract_urls(pls, listformat="__not_used_yet__"): + # extract stream address from .pls URL + if (re.search("\.pls", pls)): #audio/x-scpls + return action.pls(pls) + elif (re.search("\.asx", pls)): #video/x-ms-asf + return re.findall(" 3 and stream_id != "XXXXXX") + + # check if there are any urls in a given file + @staticmethod + def has_urls(tmp_fn): + if os.path.exists(tmp_fn): + return open(tmp_fn, "r").read().find("http://") > 0 + + + # create a local .m3u file from it + @staticmethod + def m3u(pls): + + # temp filename + (tmp_fn, unique) = action.tmp_fn(pls) + # does it already exist? + if tmp_fn and unique and conf.reuse_m3u and action.has_urls(tmp_fn): + return tmp_fn + + # download PLS + __print__( "pls=",pls ) + url_list = action.extract_urls(pls) + __print__( "urls=", url_list ) + + # output URL list to temporary .m3u file + if (len(url_list)): + #tmp_fn = + f = open(tmp_fn, "w") + f.write("#M3U\n") + f.write("\n".join(url_list) + "\n") + f.close() + # return path/name of temporary file + return tmp_fn + else: + __print__( "error, there were no URLs in ", pls ) + raise "Empty PLS" + + # open help browser + @staticmethod + def help(*args): + + os.system("yelp /usr/share/doc/streamtuner2/help/ &") + #or action.browser("/usr/share/doc/streamtuner2/") + +#class action + + ADDED channels/__init__.py Index: channels/__init__.py ================================================================== --- channels/__init__.py +++ channels/__init__.py @@ -0,0 +1,9 @@ +# +# encoding: UTF-8 +# api: python +# type: R +# + + +from channels._generic import * +from channels._generic import __print__ ADDED channels/_generic.py Index: channels/_generic.py ================================================================== --- channels/_generic.py +++ channels/_generic.py @@ -0,0 +1,544 @@ +# +# encoding: UTF-8 +# api: streamtuner2 +# type: class +# title: channel objects +# description: base functionality for channel modules +# version: 1.0 +# author: mario +# license: public domain +# +# +# GenericChannel implements the basic GUI functions and defines +# the default channel data structure. It implements base and +# fallback logic for all other channel implementations. +# +# Built-in channels derive directly from generic. Additional +# channels don't have a pre-defined Notebook tab in the glade +# file. They derive from the ChannelPlugins class instead, which +# adds the required gtk Widgets manually. +# + + +import gtk +from mygtk import mygtk +from config import conf +import http +import action +import favicon +import os.path +import xml.sax.saxutils +import re +import copy + + + +# dict==object +class struct(dict): + def __init__(self, *xargs, **kwargs): + self.__dict__ = self + self.update(kwargs) + [self.update(x) for x in xargs] + pass + + +# generic channel module --------------------------------------- +class GenericChannel(object): + + # desc + api = "streamtuner2" + module = "generic" + title = "GenericChannel" + version = 1.0 + homepage = "http://milki.inlcude-once.org/streamtuner2/" + base_url = "" + listformat = "audio/x-scpls" + audioformat = "audio/mp3" # fallback value + config = [] + + # categories + categories = ["empty", ] + current = "" + default = "empty" + 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 + + # gui + data + streams = {} #meta information dicts + liststore = {} #gtk data structure + gtk_list = None #gtk widget + gtk_cat = None #gtk widget + + # mapping of stream{} data into gtk treeview/treestore representation + datamap = [ + # coltitle width [ datasrc key, type, renderer, attrs ] [cellrenderer2], ... + ["", 20, ["state", str, "pixbuf", {}], ], + ["Genre", 65, ['genre', str, "t", {}], ], + ["Station Title",275,["title", str, "text", {"strikethrough":11, "cell-background":12, "cell-background-set":13}], ["favicon",gtk.gdk.Pixbuf,"pixbuf",{"width":20}], ], + ["Now Playing",185, ["playing", str, "text", {"strikethrough":11}], ], + ["Listeners", 45, ["listeners", int, "t", {"strikethrough":11}], ], +# ["Max", 45, ["max", int, "t", {}], ], + ["Bitrate", 35, ["bitrate", int, "t", {}], ], + ["Homepage", 160, ["homepage", str, "t", {"underline":10}], ], + [False, 25, ["---url", str, "t", {"strikethrough":11}], ], + [False, 0, ["---format", str, None, {}], ], + [False, 0, ["favourite", bool, None, {}], ], + [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 + + # regex + rx_www_url = re.compile("""(www(\.\w+[\w-]+){2,}|(\w+[\w-]+[ ]?\.)+(com|FM|net|org|de|PL|fr|uk))""", re.I) + + + # constructor + def __init__(self, parent=None): + + #self.streams = {} + self.gtk_list = None + self.gtk_cat = None + + # only if streamtuner2 is run in graphical mode + if (parent): + self.cache() + self.gui(parent) + pass + + + # called before application shutdown + # some plugins might override this, to save their current streams[] data + def shutdown(self): + pass + #__del__ = shutdown + + + # returns station entries from streams[] for .current category + def stations(self): + return self.streams.get(self.current, []) + def rowno(self): + pass + def row(self): + pass + + + # read previous channel/stream data, if there is any + def cache(self): + # stream list + cache = conf.load("cache/" + self.module) + if (cache): + self.streams = cache + # categories + cache = conf.load("cache/categories_" + self.module) + if (cache): + self.categories = cache + pass + + + # initialize Gtk widgets / data objects + def gui(self, parent): + #print(self.module + ".gui()") + + # save reference to main window/glade API + self.parent = parent + self.gtk_list = parent.get_widget(self.module+"_list") + self.gtk_cat = parent.get_widget(self.module+"_cat") + + # category tree + self.display_categories() + #mygtk.tree(self.gtk_cat, self.categories, title="Category", icon=gtk.STOCK_OPEN); + + # update column names + for field,title in self.titles.iteritems(): + self.update_datamap(field, title=title) + + # prepare stream list + if (not self.rowmap): + for row in self.datamap: + for x in xrange(2, len(row)): + self.rowmap.append(row[x][0]) + + # load default category + if (self.current): + self.load(self.current) + else: + mygtk.columns(self.gtk_list, self.datamap, [{}]) + + # add to main menu + mygtk.add_menu(parent.channelmenuitems, self.title, lambda w: parent.channel_switch(w, self.module) or 1) + + + # make private copy of .datamap and modify field (title= only ATM) + 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 + + + # switch stream category, + # load data, + # update treeview content + def load(self, category, force=False): + + # get data from cache or download + if (force or not self.streams.has_key(category)): + new_streams = self.update_streams(category) + + if new_streams: + + # modify + [self.postprocess(row) for row in new_streams] + + # don't lose forgotten streams + if conf.retain_deleted: + self.streams[category] = new_streams + self.deleted_streams(new_streams, self.streams.get(category,[])) + else: + self.streams[category] = new_streams + + # save in cache + self.save() + + # invalidate gtk list cache + #if (self.liststore.has_key(category)): + # del self.liststore[category] + + else: + # parse error + self.parent.status("category parsed empty.") + self.streams[category] = [{"title":"no contents found on directory server","bitrate":0,"max":0,"listeners":0,"playing":"error","favourite":0,"deleted":0}] + print("oooops, parser returned nothing for category " + category) + + # assign to treeview model + #self.streams[self.default] = [] + #if (self.liststore.has_key(category)): # was already loded before + # self.gtk_list.set_model(self.liststore[category]) + #else: # currently list is new, had not been converted to gtk array before + # self.liststore[category] = \ + mygtk.do(lambda:mygtk.columns(self.gtk_list, self.datamap, self.prepare(self.streams[category]))) + + # set pointer + self.current = category + pass + + # store current streams data + def save(self): + conf.save("cache/" + self.module, self.streams, gz=1) + + + # called occasionally while retrieving and parsing + def update_streams_partially_done(self, entries): + mygtk.do(lambda: mygtk.columns(self.gtk_list, self.datamap, 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 (row.has_key("url") and (row.get("url") not in new)): + row["deleted"] = 1 + diff.append(row) + return diff + + + # prepare data for display + def prepare(self, streams): + for i,row in enumerate(streams): + # oh my, at least it's working + # at start the bookmarks module isn't fully registered at instantiation in parent.channels{} - might want to do that step by step rather + # then display() is called too early to take effect - load() & co should actually be postponed to when a notebook tab gets selected first + # => might be fixed now, 1.9.8 + # state icon: bookmark star + if (conf.show_bookmarks and self.parent.channels.has_key("bookmarks") and self.parent.bookmarks.is_in(streams[i].get("url", "file:///tmp/none"))): + streams[i]["favourite"] = 1 + + # state icon: INFO or DELETE + if (not row.get("state")): + if row.get("favourite"): + streams[i]["state"] = gtk.STOCK_ABOUT + if conf.retain_deleted and row.get("deleted"): + streams[i]["state"] = gtk.STOCK_DELETE + + # guess homepage url + #self.postprocess(row) + + # favicons? + if conf.show_favicons: + homepage_url = row.get("homepage") + # check for availability of PNG file, inject local icons/ filename + if homepage_url and favicon.available(homepage_url): + streams[i]["favicon"] = favicon.file(homepage_url) + + return streams + + + # data preparations directly after reload + def postprocess(self, row): + + # remove non-homepages from shoutcast + if row.get("homepage") and row["homepage"].find("//yp.shoutcast.")>0: + row["homepage"] = "" + + # deduce homepage URLs from title + # by looking for www.xyz.com domain names + if not row.get("homepage"): + url = self.rx_www_url.search(row.get("title", "")) + if url: + url = url.group(0).lower().replace(" ", "") + url = (url if url.find("www.") == 0 else "www."+url) + row["homepage"] = http.fix_url(url) + + return row + + + + # reload current stream from web directory + def reload(self): + self.load(self.current, force=1) + def switch(self): + self.load(self.current, force=0) + + + # display .current category, once notebook/channel tab is first opened + def first_show(self): + print("first_show ", self.module) + print 1 + print self.shown + if (self.shown != 55555): + print 2 + + # if category tree is empty, initialize it + if not self.categories: + print 3 + #self.parent.thread(self.reload_categories) + print("reload categories"); + self.reload_categories() + self.display_categories() + self.current = self.categories.keys()[0] + print self.current + self.load(self.current) + + # load current category + else: + print 4 + self.load(self.current) + + # put selection/cursor on last position + try: + print 5 + self.gtk_list.get_selection().select_path(self.shown) + except: + pass + + # this method will only be invoked once + self.shown = 55555 + + + # update categories, save, and display + def reload_categories(self): + + # get data and save + self.update_categories() + conf.save("cache/categories_"+self.module, self.categories) + + # display outside of this non-main thread + mygtk.do(self.display_categories) + + # insert content into gtk category list + def display_categories(self): + + # remove any existing columns + if self.gtk_cat: + [self.gtk_cat.remove_column(c) for c in self.gtk_cat.get_columns()] + # rebuild gtk.TreeView + mygtk.tree(self.gtk_cat, self.categories, title="Category", icon=gtk.STOCK_OPEN); + + # if it's a short list of categories, there's probably subfolders + if len(self.categories) < 20: + self.gtk_cat.expand_all() + + # select any first element + self.gtk_cat.get_selection().select_path("0") #set_cursor + self.currentcat() + + + # selected category + 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 + + + + + #--------------------------- actions --------------------------------- + + # invoke action.play, + # can be overridden to provide channel-specific "play" alternative + def play(self, row): + if row.get("url"): + + # parameters + audioformat = row.get("format", self.audioformat) + listformat = row.get("listformat", self.listformat) + + # invoke audio player + action.action.play(row["url"], audioformat, listformat) + + + + + #--------------------------- utility functions ----------------------- + + + + # remove html from string + def strip_tags(self, s): + return re.sub("<.+?>", "", s) + + # 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/mpeg":"audio/mp3", # 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", + "mpeg":"mp3", "mp":"mp3", "mp2":"mp3", "mpc":"mp3", "mps":"mp3", + "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.action.lt) # 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 + + # remove SGML/XML entities + def entity_decode(self, s): + return xml.sax.saxutils.unescape(s) + + # convert special characters to &xx; escapes + def xmlentities(self, s): + return xml.sax.saxutils.escape(s) + + + + + + + + + +# channel plugin without glade-pre-defined notebook tab +# +class ChannelPlugin(GenericChannel): + + module = "abstract" + title = "New Tab" + version = 0.1 + + + def gui(self, parent): + + # name id + module = self.module + + if parent: + # two panes + vbox = gtk.HPaned() + vbox.show() + # category treeview + sw1 = gtk.ScrolledWindow() + sw1.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) + sw1.set_property("width_request", 150) + sw1.show() + tv1 = gtk.TreeView() + tv1.set_property("width_request", 75) + tv1.set_property("enable_tree_lines", True) + tv1.connect("button_release_event", parent.on_category_clicked) + tv1.show() + sw1.add(tv1) + vbox.pack1(sw1, resize=False, shrink=True) + # stream list + sw2 = gtk.ScrolledWindow() + sw2.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC) + sw2.show() + tv2 = gtk.TreeView() + tv2.set_property("width_request", 200) + tv2.set_property("enable_tree_lines", True) + tv2.connect("row_activated", parent.on_play_clicked) + tv2.show() + sw2.add(tv2) + vbox.pack2(sw2, resize=True, shrink=True) + + # prepare label + label = gtk.HBox() + label.set_property("visible", True) + fn = "/usr/share/streamtuner2/channels/" + self.module + ".png" + if os.path.exists(fn): + icon = gtk.Image() + icon.set_property("pixbuf", gtk.gdk.pixbuf_new_from_file(fn)) + icon.set_property("icon-size", 1) + icon.set_property("visible", True) + label.pack_start(icon, expand=False, fill=True) + if self.title: + text = gtk.Label(self.title) + text.set_property("visible", True) + label.pack_start(text, expand=True, fill=True) + + # pack it into an event container to catch double-clicks + ev_label = gtk.EventBox() + ev_label.add(label) + ev_label.connect('event', parent.on_homepage_channel_clicked) + + # add notebook tab + tab = parent.notebook_channels.append_page(vbox, ev_label) + + # to widgets + self.gtk_cat = tv1 + parent.widgets[module + "_cat"] = tv1 + self.gtk_list = tv2 + parent.widgets[module + "_list"] = tv2 + parent.widgets["v_" + module] = vbox + parent.widgets["c_" + module] = ev_label + tv2.connect('button-press-event', parent.station_context_menu) + + + + # double-click catch + + + + # add module to list + #parent.channels[module] = None + #parent.channel_names.append(module) + """ -> already taken care of in main.load_plugins() """ + + # superclass + GenericChannel.gui(self, parent) + + + + + + +# wrapper for all print statements +def __print__(*args): + if conf.debug: + print(" ".join([str(a) for a in args])) + +__debug_print__ = __print__ + + + + ADDED channels/basicch.py Index: channels/basicch.py ================================================================== --- channels/basicch.py +++ channels/basicch.py @@ -0,0 +1,305 @@ + +# api: streamtuner2 +# title: basic.ch channel +# +# +# +# Porting ST1 plugin senseless, old parsing method wasn't working any longer. Static +# realaudio archive is not available anymore. +# +# Needs manual initialisation of categories first. +# + + +import re +import http +from config import conf +from channels import * +from xml.sax.saxutils import unescape + + + + + + + + + + + + + + +# basic.ch broadcast archive +class basicch (ChannelPlugin): + + # description + title = "basic.ch" + module = "basicch" + homepage = "http://www.basic.ch/" + version = 0.3 + base = "http://basic.ch/" + + # keeps category titles->urls + catmap = {} + categories = [] #"METAMIX", "reload category tree!", ["menu > channel > reload cat..."]] + #titles = dict(listeners=False, bitrate=False) + + + # read previous channel/stream data, if there is any + def cache(self): + ChannelPlugin.cache(self) + # catmap + cache = conf.load("cache/catmap_" + self.module) + if (cache): + self.catmap = cache + pass + + + # refresh category list + def update_categories(self): + + html = http.get(self.base + "shows.cfm") + + rx_current = re.compile(r""" + ([^<>]+) + \s*([^<>]+)
]+)">(METAMIX[^<]+) + \s+([^<>]+)
(http://[^<">]+)\s* + ([^<>]+)\s* + ([^<>]+) + """, re.S|re.X) + + entries = [] + + #-- update categories first + if not len(self.catmap): + self.update_categories() + + #-- frontpage mixes + if cat == "METAMIX": + for uu in rx_metamix.findall(http.get(self.base)): + (url, title, genre) = uu + entries.append({ + "genre": genre, + "title": title, + "url": url, + "format": "audio/mp3", + "homepage": self.homepage, + }) + + #-- pseudo entry + elif cat=="shows": + entries = [{"title":"shows","homepage":self.homepage+"shows.cfm"}] + + #-- fetch playlist.xml + else: + + # category name "Xsound & Ymusic" becomes "Xsoundandymusic" + id = cat.replace("&", "and").replace(" ", "") + id = id.lower().capitalize() + + catinfo = self.catmap.get(cat, {"id":"", "genre":""}) + + # extract + html = http.get(self.base + "playlist/" + id + ".xml") + for uu in rx_playlist.findall(html): # you know, we could parse this as proper xml + (url, artist, title) = uu # but hey, lazyness works too + entries.append({ + "url": url, + "title": artist, + "playing": title, + "genre": catinfo["genre"], + "format": "audio/mp3", + "homepage": self.base + "shows.cfm?showid=" + catinfo["id"], + }) + + # done + return entries + + + + + + + + + + + +# basic.ch broadcast archive +class basicch_old_static: #(ChannelPlugin): + + # description + title = "basic.ch" + module = "basicch" + homepage = "http://www.basic.ch/" + version = 0.2 + base = "http://basic.ch/" + + # keeps category titles->urls + catmap = {} + + + # read previous channel/stream data, if there is any + def cache(self): + ChannelPlugin.cache(self) + # catmap + cache = conf.load("cache/catmap_" + self.module) + if (cache): + self.catmap = cache + pass + + + # refresh category list + def update_categories(self): + + html = http.get(self.base + "downtest.cfm") + + rx_current = re.compile(""" + href="javascript:openWindow.'([\w.?=\d]+)'[^>]+> + (\w+[^<>]+)(\w+[^<>]+)]+)" + """, re.S|re.X) + + rx_archive = re.compile(""" + href="javascript:openWindow.'([\w.?=\d]+)'[^>]+>.+? + color="000000">(\w+[^<>]+)(\w+[^<>]+)(\d\d\.\d\d\.\d\d).+? + href="(http://[^">]+|/ram/\w+.ram)"[^>]*>([^<>]+) + .+? (>(\w+[^<]*)G, R, N +# + + + +import keybinder +from config import conf +import action +import random + + + +# register a key +class global_key(object): + + module = "global_key" + title = "keyboard shortcut" + version = 0.2 + + config = [ + dict(name="switch_key", type="text", value="XF86Forward", description="global key for switching radio"), + dict(name="switch_channel", type="text", value="bookmarks:favourite", description="station list to alternate in"), + dict(name="switch_random", type="boolean", value=0, description="pick random channel, instead of next"), + ] + last = 0 + + + # register + def __init__(self, parent): + self.parent = parent + try: + for i,keyname in enumerate(conf.switch_key.split(",")): # allow multiple keys + keybinder.bind(keyname, self.switch, ((-1 if i else +1))) # forward +1 or backward -1 + except: + print("Key could not be registered") + + + # key event + def switch(self, num, *any): + + # bookmarks, favourite + channel, cat = conf.switch_channel.split(":") + + # get list + streams = self.parent.channels[channel].streams[cat] + + # pickrandom + if conf.switch_random: + self.last = random.randint(0, len(streams)-1) + + # or iterate over list + else: + self.last = self.last + num + if self.last >= len(streams): + self.last = 0 + elif self.last < 0: + self.last = len(streams)-1 + + # play + i = self.last + action.action.play(streams[i]["url"], streams[i]["format"]) + + # set pointer in gtk.TreeView + if self.parent.channels[channel].current == cat: + self.parent.channels[channel].gtk_list.get_selection().select_path(i) + + + ADDED channels/google.py Index: channels/google.py ================================================================== --- channels/google.py +++ channels/google.py @@ -0,0 +1,166 @@ +# +# encoding: ISO-8859-1 +# api: streamtuner2 +# title: google stations +# description: Looks up web radio stations from DMOZ/Google directory +# depends: channels, re, http +# version: 0.1 +# author: Mario, original: Jean-Yves Lefort +# +# This is a plugun from streamtuner1. It has been rewritten for the +# more mundane plugin API of streamtuner2 - reimplementing ST seemed +# to much work. +# Also it has been rewritten to query DMOZ directly. Google required +# the use of fake User-Agents for access, and the structure on DMOZ +# is simpler (even if less HTML-compliant). DMOZ probably is kept +# more up-to-date as well. +# PS: we need to check out //musicmoz.org/ +# + + +# Copyright (c) 2003, 2004 Jean-Yves Lefort +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions +# are met: +# 1. Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# 3. Neither the name of Jean-Yves Lefort nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND +# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, +# INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS +# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +# TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY +# OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +# POSSIBILITY OF SUCH DAMAGE. + + +import re, os, gtk +from channels import * +from xml.sax.saxutils import unescape as entity_decode, escape as xmlentities +import http + + +### constants ################################################################# + + +GOOGLE_DIRECTORY_ROOT = "http://www.dmoz.org" +CATEGORIES_URL_POSTFIX = "/Arts/Music/Sound_Files/MP3/Streaming/Stations/" +#GOOGLE_DIRECTORY_ROOT = "http://directory.google.com" +#CATEGORIES_URL_POSTFIX = "/Top/Arts/Music/Sound_Files/MP3/Streaming/Stations/" +GOOGLE_STATIONS_HOME = GOOGLE_DIRECTORY_ROOT + CATEGORIES_URL_POSTFIX + +"""
  • Jazz""" +re_category = re.compile('()([^:]+?)()', re.I|re.M) + +#re_stream = re.compile('^(.*)') +#re_description = re.compile('^
    (.*?)') +"""
  • Atlanta Blue Sky - Rock and alternative streaming audio. Live real-time requests.""" +re_stream_desc = re.compile('^
  • ([^<>]+)( - )?([^<>\n\r]+)', re.M|re.I) + + +###### + + +# Google Stations is actually now DMOZ Stations +class google(ChannelPlugin): + + # description + title = "Google" + module = "google" + homepage = GOOGLE_STATIONS_HOME + version = 0.2 + + # config data + config = [ +# {"name": "theme", "type": "text", "value":"Tactile", "description":"Streamtuner2 theme; no this isn't a google-specific option. But you know, the plugin options are a new toy."}, +# {"name": "flag2", "type": "boolean", "value":1, "description":"oh see, an unused checkbox"} + ] + + + # category map + categories = ['Google/DMOZ Stations', 'Alternative', 'Ambient', 'Classical', 'College', 'Country', 'Dance', 'Experimental', 'Gothic', 'Industrial', 'Jazz', 'Local', 'Lounge', 'Metal', 'New Age', 'Oldies', 'Old-Time Radio', 'Pop', 'Punk', 'Rock', '80s', 'Soundtracks', 'Talk', 'Techno', 'Urban', 'Variety', 'World'] + catmap = [('Google/DMOZ Stations', '__main', '/Arts/Radio/Internet/'), ['Alternative', 'Alternative', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Rock/Alternative/'], ['Ambient', 'Ambient', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Ambient/'], ['Classical', 'Classical', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Classical/'], ['College', 'College', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/College/'], ['Country', 'Country', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Country/'], ['Dance', 'Dance', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Dance/'], ['Experimental', 'Experimental', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Experimental/'], ['Gothic', 'Gothic', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Rock/Gothic/'], ['Industrial', 'Industrial', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Rock/Industrial/'], ['Jazz', 'Jazz', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Jazz/'], ['Local', 'Local', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Local/'], ['Lounge', 'Lounge', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Lounge/'], ['Metal', 'Metal', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Rock/Metal/'], ['New Age', 'New Age', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/New_Age/'], ['Oldies', 'Oldies', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Rock/Oldies/'], ['Old-Time Radio', 'Old-Time Radio', '/Arts/Radio/Formats/Old-Time_Radio/Streaming_MP3_Stations/'], ['Pop', 'Pop', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Pop/'], ['Punk', 'Punk', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Rock/Punk/'], ['Rock', 'Rock', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Rock/'], ['80s', '80s', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/80s/'], ['Soundtracks', 'Soundtracks', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Soundtracks/'], ['Talk', 'Talk', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Talk/'], ['Techno', 'Techno', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Dance/Techno/'], ['Urban', 'Urban', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Urban/'], ['Variety', 'Variety', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/Variety/'], ['World', 'World', '/Arts/Music/Sound_Files/MP3/Streaming/Stations/World/']] + + + #def __init__(self, parent): + # #self.update_categories() + # ChannelPlugin.__init__(self, parent) + + + # refresh category list + def update_categories(self): + + # interim data structure for categories (label, google-id/name, url) + categories = [ + ("Google/DMOZ Stations", "__main", "/Arts/Radio/Internet/"), + ] + + # fetch and extract list + html = http.get(GOOGLE_DIRECTORY_ROOT + CATEGORIES_URL_POSTFIX) + + for row in re_category.findall(html): + if row: + name = entity_decode(row[2]) + label = name + + href = entity_decode(row[0]) + if href[0] != "/": + href = CATEGORIES_URL_POSTFIX + href + + categories.append([label, name, href]) + + # return + self.catmap = categories + self.categories = [x[0] for x in categories] + pass + # actually saving this into _categories and _catmap.json would be nice + # ... + + + + # download links from dmoz listing + def update_streams(self, cat, force=0): + + # result list + ls = [] + + # get //dmoz.org/HREF for category name + try: + (label, name, href) = [x for x in self.catmap if x[0]==cat][0] + except: + return ls # wrong category + + # download + html = http.get(GOOGLE_DIRECTORY_ROOT + href) + + # filter + for row in re_stream_desc.findall(html): + + if row: + row = { + "homepage": entity_decode(row[0]), + "title": entity_decode(row[1]), + "playing": entity_decode(row[3]), + } + ls.append(row) + + + # final list for current category + return ls + + + ADDED channels/internet_radio_org_uk.py Index: channels/internet_radio_org_uk.py ================================================================== --- channels/internet_radio_org_uk.py +++ channels/internet_radio_org_uk.py @@ -0,0 +1,151 @@ +# +# api: streamtuner2 +# title: internet-radio.org.uk +# description: io channel +# version: 0.1 +# +# +# Might become new main plugin +# +# +# + + + +from channels import * +import re +from config import conf +import http +from pq import pq + + + + +# streams and gui +class internet_radio_org_uk (ChannelPlugin): + + + # description + title = "InternetRadio" + module = "internet_radio_org_uk" + homepage = "http://www.internet-radio.org.uk/" + version = 0.1 + listformat = "audio/x-scpls" + + # settings + config = [ + {"name":"internetradio_max_pages", "type":"int", "value":5, "description":"How many pages to fetch and read."}, + ] + + + # category map + categories = [] + current = "" + default = "" + + + # load genres + def update_categories(self): + + html = http.get(self.homepage) + rx = re.compile("""]+value="/stations/[-+&.\w\s%]+/">([^<]+)""") + + self.categories = rx.findall(html) + + + + + + # fetch station lists + def update_streams(self, cat, force=0): + + entries = [] + if cat not in self.categories: + return [] + + # regex + #rx_div = re.compile('(.+?)', re.S) + rx_data = re.compile(""" + (?:M3U|PLS)',\s*'(http://[^']+)' + .*? +

    ([^\n]*?) + .*? + (?:href="(http://[^"]+)"[^>]+target="_blank"[^>]*)? + >\s* + \s*(\w[^<]+)[<\n] + .*? + playing\s*:\s*([^<\n]+) + .*? + (\d+)\s*Kbps + (?:
    (\d+)\s*Listeners)? + """, re.S|re.X) + #rx_homepage = re.compile('href="(http://[^"]+)"[^>]+target="_blank"') + rx_pages = re.compile('href="/stations/[-+\w%\d\s]+/page(\d+)">\d+') + rx_numbers = re.compile("(\d+)") + self.parent.status("downloading category pages...") + + + # multiple pages + page = 1 + max = int(conf.internetradio_max_pages) + max = (max if max > 1 else 1) + while page <= max: + + # fetch + html = http.get(self.homepage + "stations/" + cat.lower().replace(" ", "%20") + "/" + ("page"+str(page) if page>1 else "")) + + + # regex parsing? + if not conf.pyquery: + # step through + for uu in rx_data.findall(html): + (url, genre, homepage, title, playing, bitrate, listeners) = uu + + # transform data + entries.append({ + "url": url, + "genre": self.strip_tags(genre), + "homepage": http.fix_url(homepage), + "title": title, + "playing": playing, + "bitrate": int(bitrate), + "listeners": int(listeners if listeners else 0), + "format": "audio/mp3", # there is no stream info on that, but internet-radio.org.uk doesn't seem very ogg-friendly anyway, so we assume the default here + }) + + # DOM parsing + else: + # the streams are arranged in table rows + doc = pq(html) + for dir in (pq(e) for e in doc("tr.stream")): + + bl = dir.find("td[align=right]").text() + bl = rx_numbers.findall(str(bl) + " 0 0") + + entries.append({ + "title": dir.find("b").text(), + "homepage": http.fix_url(dir.find("a.url").attr("href")), + "url": dir.find("a").eq(2).attr("href"), + "genre": dir.find("td").eq(0).text(), + "bitrate": int(bl[0]), + "listeners": int(bl[1]), + "format": "audio/mp3", + "playing": dir.find("td").eq(1).children().remove().end().text()[13:].strip(), + }) + + # next page? + if str(page+1) not in rx_pages.findall(html): + max = 0 + else: + page = page + 1 + + # keep listview updated while searching + self.update_streams_partially_done(entries) + try: self.parent.status(float(page)/float(max)) + except: """there was a div by zero bug report despite max=1 precautions""" + + # fin + self.parent.status() + return entries + + ADDED channels/jamendo.py Index: channels/jamendo.py ================================================================== --- channels/jamendo.py +++ channels/jamendo.py @@ -0,0 +1,160 @@ + +# api: streamtuner2 +# title: jamendo browser +# +# For now this is really just a browser, doesn't utilizt the jamendo API yet. +# Requires more rework of streamtuner2 list display to show album covers. +# + + +import re +import http +from config import conf +from channels import * +from xml.sax.saxutils import unescape + + + + + + + + + + + + + + +# jamendo CC music sharing site +class jamendo (ChannelPlugin): + + # description + title = "Jamendo" + module = "jamendo" + homepage = "http://www.jamendo.com/" + version = 0.2 + + base = "http://www.jamendo.com/en/" + listformat = "url/http" + + categories = [] #"top 100", "reload category tree!", ["menu > channel > reload.."]] + titles = dict( title="Artist", playing="Album/Song", bitrate=False, listeners=False ) + + config = [ + {"name":"jamendo_stream_format", "value":"ogg2", "type":"text", "description":"streaming format, 'ogg2' or 'mp31'"} + ] + + + + + # refresh category list + def update_categories(self): + + html = http.get(self.base + "tags") + + rx_current = re.compile(r""" + ]+rel="tag"[^>]+href="(http://www.jamendo.com/\w\w/tag/[\w\d]+)"[^>]*>([\w\d]+) + """, re.S|re.X) + + + #-- categories + tags = [] + for uu in rx_current.findall(html): + (href, title) = uu + tags.append(title) + + self.categories = [ + "top 100", + "radios", + "tags", tags + ] + + + + # download links from dmoz listing + def update_streams(self, cat, force=0): + + entries = [] + + # top list + if cat == "top" or cat == "top 100": + html = http.get(self.base + "top") + + rx_top = re.compile(""" + ]+src="(http://imgjam.com/albums/[\w\d]+/\d+/covers/1.\d+.jpg)" + .*? + + \s*]+src="(http://imgjam.com/albums/[\w\d]+/\d+/covers/1.\d+.jpg)" + .*? /tag/([\w\d]+)" + """, re.X|re.S) + + for uu in rx_tag.findall(html): + (artist, title, album, album_id, cover, tag) = uu + entries.append({ + "title": artist, + "playing": title, + "homepage": album, + "url": self.track_url(album_id, conf.jamendo_stream_format, "album"), + "favicon": self.cover(cover), + "genre": tag, + "format": self.stream_mime(), + }) + + # done + return entries + + + # smaller album link + def cover(self, url): + return url.replace(".100",".50").replace(".130",".50") + + # track id to download url + def track_url(self, track_id, fmt="ogg2", track="track", urltype="redirect"): + # track = "album" + # fmt = "mp31" + # urltype = "m3u" + return "http://api.jamendo.com/get2/stream/"+track+"/"+urltype+"/?id="+track_id+"&streamencoding="+fmt + + # audio/* + def stream_mime(self): + if conf.jamendo_stream_format.find("og") >= 0: + return "audio/ogg" + else: + return "audio/mp3" + + ADDED channels/links.py Index: channels/links.py ================================================================== --- channels/links.py +++ channels/links.py @@ -0,0 +1,78 @@ +# +# api: streamtuner2 +# title: links to directory services +# description: provides a simple list of homepages for directory services +# version: 0.1 +# priority: rare +# +# +# Simply adds a "links" entry in bookmarks tab, where known channels +# and some others are listed with homepage links. +# +# + + +from channels import * +import copy + + + +# hooks into main.bookmarks +class links (object): + + # plugin info + module = "links" + title = "Links" + version = 0.1 + + + # configuration settings + config = [ ] + + # list + streams = [ ] + default = { + "radio.de": "http://www.radio.de/", + "musicgoal": "http://www.musicgoal.com/", + "streamfinder": "http://www.streamfinder.com/", + "last.fm": "http://www.last.fm/", + "rhapsody (US-only)": "http://www.rhapsody.com/", + "pandora (US-only)": "http://www.pandora.com/", + "radiotower": "http://www.radiotower.com/", + "pirateradio": "http://www.pirateradionetwork.com/", + "R-L": "http://www.radio-locator.com/", + "radio station world": "http://radiostationworld.com/", + "surfmusik.de": "http://www.surfmusic.de/", + } + + + # prepare gui + def __init__(self, parent): + if parent: + + # target channel + bookmarks = parent.bookmarks + if not bookmarks.streams.get(self.module): + bookmarks.streams[self.module] = [] + bookmarks.add_category(self.module) + + + # collect links from channel plugins + for name,channel in parent.channels.iteritems(): + try: + self.streams.append({ + "favourite": 1, + "title": channel.title, + "homepage": channel.homepage, + }) + except: pass + for title,homepage in self.default.iteritems(): + self.streams.append({ + "title": title, + "homepage": homepage, + }) + + # add to bookmarks + bookmarks.streams[self.module] = self.streams + + ADDED channels/live365.py Index: channels/live365.py ================================================================== --- channels/live365.py +++ channels/live365.py @@ -0,0 +1,161 @@ + + + + + +# streamtuner2 modules +from config import conf +from mygtk import mygtk +import http +from channels import * +from channels import __print__ + +# python modules +import re +import xml.dom.minidom +from xml.sax.saxutils import unescape as entity_decode, escape as xmlentities +import gtk +import copy +import urllib + + +# channel live365 +class live365(ChannelPlugin): + + + # desc + api = "streamtuner2" + module = "live365" + title = "Live365" + version = 0.1 + homepage = "http://www.live365.com/" + base_url = "http://www.live365.com/" + listformat = "url/http" + mediatype = "audio/mpeg" + + # content + categories = ['Alternative', ['Britpop', 'Classic Alternative', 'College', 'Dancepunk', 'Dream Pop', 'Emo', 'Goth', 'Grunge', 'Indie Pop', 'Indie Rock', 'Industrial', 'Lo-Fi', 'Modern Rock', 'New Wave', 'Noise Pop', 'Post-Punk', 'Power Pop', 'Punk'], 'Blues', ['Acoustic Blues', 'Chicago Blues', 'Contemporary Blues', 'Country Blues', 'Delta Blues', 'Electric Blues', 'Cajun/Zydeco'], 'Classical', ['Baroque', 'Chamber', 'Choral', 'Classical Period', 'Early Classical', 'Impressionist', 'Modern', 'Opera', 'Piano', 'Romantic', 'Symphony'], 'Country', ['Alt-Country', 'Americana', 'Bluegrass', 'Classic Country', 'Contemporary Bluegrass', 'Contemporary Country', 'Honky Tonk', 'Hot Country Hits', 'Western'], 'Easy Listening', ['Exotica', 'Lounge', 'Orchestral Pop', 'Polka', 'Space Age Pop'], 'Electronic/Dance', ['Acid House', 'Ambient', 'Big Beat', 'Breakbeat', 'Disco', 'Downtempo', "Drum 'n' Bass", 'Electro', 'Garage', 'Hard House', 'House', 'IDM', 'Jungle', 'Progressive', 'Techno', 'Trance', 'Tribal', 'Trip Hop'], 'Folk', ['Alternative Folk', 'Contemporary Folk', 'Folk Rock', 'New Acoustic', 'Traditional Folk', 'World Folk'], 'Freeform', ['Chill', 'Experimental', 'Heartache', 'Love/Romance', 'Music To ... To', 'Party Mix', 'Patriotic', 'Rainy Day Mix', 'Reality', 'Shuffle/Random', 'Travel Mix', 'Trippy', 'Various', 'Women', 'Work Mix'], 'Hip-Hop/Rap', ['Alternative Rap', 'Dirty South', 'East Coast Rap', 'Freestyle', 'Gangsta Rap', 'Old School', 'Turntablism', 'Underground Hip-Hop', 'West Coast Rap'], 'Inspirational', ['Christian', 'Christian Metal', 'Christian Rap', 'Christian Rock', 'Classic Christian', 'Contemporary Gospel', 'Gospel', 'Praise/Worship', 'Sermons/Services', 'Southern Gospel', 'Traditional Gospel'], 'International', ['African', 'Arabic', 'Asian', 'Brazilian', 'Caribbean', 'Celtic', 'European', 'Filipino', 'Greek', 'Hawaiian/Pacific', 'Hindi', 'Indian', 'Japanese', 'Jewish', 'Mediterranean', 'Middle Eastern', 'North American', 'Soca', 'South American', 'Tamil', 'Worldbeat', 'Zouk'], 'Jazz', ['Acid Jazz', 'Avant Garde', 'Big Band', 'Bop', 'Classic Jazz', 'Cool Jazz', 'Fusion', 'Hard Bop', 'Latin Jazz', 'Smooth Jazz', 'Swing', 'Vocal Jazz', 'World Fusion'], 'Latin', ['Bachata', 'Banda', 'Bossa Nova', 'Cumbia', 'Latin Dance', 'Latin Pop', 'Latin Rap/Hip-Hop', 'Latin Rock', 'Mariachi', 'Merengue', 'Ranchera', 'Salsa', 'Tango', 'Tejano', 'Tropicalia'], 'Metal', ['Extreme Metal', 'Heavy Metal', 'Industrial Metal', 'Pop Metal/Hair', 'Rap Metal'], 'New Age', ['Environmental', 'Ethnic Fusion', 'Healing', 'Meditation', 'Spiritual'], 'Oldies', ['30s', '40s', '50s', '60s', '70s', '80s', '90s'], 'Pop', ['Adult Contemporary', 'Barbershop', 'Bubblegum Pop', 'Dance Pop', 'JPOP', 'Soft Rock', 'Teen Pop', 'Top 40', 'World Pop'], 'R&B/Urban', ['Classic R&B', 'Contemporary R&B', 'Doo Wop', 'Funk', 'Motown', 'Neo-Soul', 'Quiet Storm', 'Soul', 'Urban Contemporary'], 'Reggae', ['Contemporary Reggae', 'Dancehall', 'Dub', 'Pop-Reggae', 'Ragga', 'Reggaeton', 'Rock Steady', 'Roots Reggae', 'Ska'], 'Rock', ['Adult Album Alternative', 'British Invasion', 'Classic Rock', 'Garage Rock', 'Glam', 'Hard Rock', 'Jam Bands', 'Prog/Art Rock', 'Psychedelic', 'Rock & Roll', 'Rockabilly', 'Singer/Songwriter', 'Surf'], 'Seasonal/Holiday', ['Anniversary', 'Birthday', 'Christmas', 'Halloween', 'Hanukkah', 'Honeymoon', 'Valentine', 'Wedding'], 'Soundtracks', ['Anime', "Children's/Family", 'Original Score', 'Showtunes'], 'Talk', ['Comedy', 'Community', 'Educational', 'Government', 'News', 'Old Time Radio', 'Other Talk', 'Political', 'Scanner', 'Spoken Word', 'Sports']] + current = "" + default = "Pop" + empty = None + + # redefine + streams = {} + + + def __init__(self, parent=None): + + # override datamap fields //@todo: might need a cleaner method, also: do we really want the stream data in channels to be different/incompatible? + self.datamap = copy.deepcopy(self.datamap) + self.datamap[5][0] = "Rating" + self.datamap[5][2][0] = "rating" + self.datamap[3][0] = "Description" + self.datamap[3][2][0] = "description" + + # superclass + ChannelPlugin.__init__(self, parent) + + + # read category thread from /listen/browse.live + def update_categories(self): + self.categories = [] + + # fetch page + html = http.get("http://www.live365.com/index.live", feedback=self.parent.status); + rx_genre = re.compile(""" + href='/genres/([\w\d%+]+)'[^>]*> + ( (?:)? ) + ( \w[-\w\ /'.&]+ ) + ( (?:)? ) + """, re.X|re.S) + + # collect + last = [] + for uu in rx_genre.findall(html): + (link, sub, title, main) = uu + + # main + if main and not sub: + self.categories.append(title) + self.categories.append(last) + last = [] + # subcat + else: + last.append(title) + + # don't forget last entries + self.categories.append(last) + + + + # extract stream infos + def update_streams(self, cat, search=""): + + # search / url + if (not search): + url = "http://www.live365.com/cgi-bin/directory.cgi?genre=" + self.cat2tag(cat) + "&rows=200" #+"&first=1" + else: + url = "http://www.live365.com/cgi-bin/directory.cgi?site=..&searchdesc=" + urllib.quote(search) + "&searchgenre=" + self.cat2tag(cat) + "&x=0&y=0" + html = http.get(url, feedback=self.parent.status) + # we only need to download one page, because live365 always only gives 200 results + + # terse format + rx = re.compile(r""" + ['"]Launch\((\d+).*? + ['"](OK|PM_ONLY|SUBSCRIPTION).*? + href=['"](http://www.live365.com/stations/\w+)['"].*? + page['"]>([^<>]*).*? + CLASS="genre"[^>]*>(.+?).+? + =["']audioQuality.+?>\w+\s+(\d+)\w<.+? + >DrawListenerStars\((\d+),.+? + >DrawRatingStars\((\d+),\s+(\d+),.*? + ["']station_id=(\d+).+? + class=["']?desc-link[^>]+>([^<>]*)< + """, re.X|re.I|re.S|re.M) +# src="(http://www.live365.com/.+?/stationlogo\w+.jpg)".+? + + # append entries to result list + __print__( html ) + ls = [] + for row in rx.findall(html): + __print__( row ) + points = int(row[7]) + count = int(row[8]) + ls.append({ + "launch_id": row[0], + "sofo": row[1], # subscribe-or-fuck-off status flags + "state": ("" if row[1]=="OK" else gtk.STOCK_STOP), + "homepage": entity_decode(row[2]), + "title": entity_decode(row[3]), + "genre": self.strip_tags(row[4]), + "bitrate": int(row[5]), + "listeners": int(row[6]), + "max": 0, + "rating": (points + count**0.4) / (count - 0.001*(count-0.1)), # prevents division by null, and slightly weights (more votes are higher scored than single votes) + "rating_points": points, + "rating_count": count, + # id for URL: + "station_id": row[9], + "url": self.base_url + "play/" + row[9], + "description": entity_decode(row[10]), + #"playing": row[10], + # "deleted": row[0] != "OK", + }) + return ls + + # faster if we do it in _update() prematurely + #def prepare(self, ls): + # GenericChannel.prepare(ls) + # for row in ls: + # if (not row["state"]): + # row["state"] = (gtk.STOCK_STOP, "") [row["sofo"]=="OK"] + # return ls + + + # html helpers + def cat2tag(self, cat): + return urllib.quote(cat.lower()) #re.sub("[^a-z]", "", + def strip_tags(self, s): + return re.sub("<.+?>", "", s) + + ADDED channels/modarchive.py Index: channels/modarchive.py ================================================================== --- channels/modarchive.py +++ channels/modarchive.py @@ -0,0 +1,129 @@ + +# api: streamtuner2 +# title: modarchive browser +# +# +# Just a genre browser. +# +# MOD files dodn't work with all audio players. And with the default +# download method, it'll receive a .zip archive with embeded .mod file. +# VLC in */* seems to work fine however. +# + + +import re +import http +from config import conf +from channels import * +from channels import __print__ +from xml.sax.saxutils import unescape + + + + + + + + + + + + + + +# MODs +class modarchive (ChannelPlugin): + + # description + title = "modarchive" + module = "modarchive" + homepage = "http://www.modarchive.org/" + version = 0.1 + base = "http://modarchive.org/" + + # keeps category titles->urls + catmap = {} + categories = [] + + + + + # refresh category list + def update_categories(self): + + html = http.get("http://modarchive.org/index.php?request=view_genres") + + rx_current = re.compile(r""" + >\s+(\w[^<>]+)\s+ | + ]+query=(\d+)&[^>]+>(\w[^<]+) + """, re.S|re.X) + + + #-- archived shows + sub = [] + self.categories = [] + for uu in rx_current.findall(html): + (main, id, subname) = uu + if main: + if sub: + self.categories.append(sub) + sub = [] + self.categories.append(main) + else: + sub.append(subname) + self.catmap[subname] = id + # + + #-- keep catmap as cache-file, it's essential for redisplaying + self.save() + return + + + # saves .streams and .catmap + def save(self): + ChannelPlugin.save(self) + conf.save("cache/catmap_" + self.module, self.catmap) + + + # read previous channel/stream data, if there is any + def cache(self): + ChannelPlugin.cache(self) + # catmap + cache = conf.load("cache/catmap_" + self.module) + if (cache): + self.catmap = cache + pass + + + # download links from dmoz listing + def update_streams(self, cat, force=0): + + url = "http://modarchive.org/index.php?query="+self.catmap[cat]+"&request=search&search_type=genre" + html = http.get(url) + entries = [] + + rx_mod = re.compile(""" + href="(http://modarchive.org/data/downloads.php[?]moduleid=(\d+)[#][^"]+)" + .*? /formats/(\w+).png" + .*? title="([^">]+)">([^<>]+) + .*? >Rated\s*(\d+) + """, re.X|re.S) + + for uu in rx_mod.findall(html): + (url, id, fmt, title, file, rating) = uu + __print__( uu ) + entries.append({ + "genre": cat, + "url": url, + "id": id, + "format": self.mime_fmt(fmt) + "+zip", + "title": title, + "playing": file, + "listeners": int(rating), + "homepage": "http://modarchive.org/index.php?request=view_by_moduleid&query="+id, + }) + + # done + return entries + + ADDED channels/musicgoal.py Index: channels/musicgoal.py ================================================================== --- channels/musicgoal.py +++ channels/musicgoal.py @@ -0,0 +1,102 @@ +# +# api: streamtuner2 +# title: MUSICGOAL channel +# description: musicgoal.com/.de combines radio and podcast listings +# version: 0.1 +# status: experimental +# pre-config: +# +# Musicgoal.com is a radio and podcast directory. This plugin tries to use +# the new API for accessing listing data. +# +# + + + +# st2 modules +from config import conf +from mygtk import mygtk +import http +from channels import * + +# python modules +import re +import json + + + + +# I wonder what that is for --------------------------------------- +class musicgoal (ChannelPlugin): + + # desc + module = "musicgoal" + title = "MUSICGOAL" + version = 0.1 + homepage = "http://www.musicgoal.com/" + base_url = homepage + listformat = "url/direct" + + # settings + config = [ + ] + api_podcast = "http://www.musicgoal.com/api/?todo=export&todo2=%s&cat=%s&format=json&id=1000259223&user=streamtuner&pass=tralilala" + api_radio = "http://www.musicgoal.com/api/?todo=playlist&format=json&genre=%s&id=1000259223&user=streamtuner&pass=tralilala" + + # categories are hardcoded + podcast = ["Arts", "Management", "Recreation", "Knowledge", "Nutrition", "Books", "Movies & TV", "Music", "News", "Business", "Poetry", "Politic", "Radio", "Science", "Science Fiction", "Religion", "Sport", "Technic", "Travel", "Health", "New"] + radio = ["Top radios", "Newcomer", "Alternative", "House", "Jazz", "Classic", "Metal", "Oldies", "Pop", "Rock", "Techno", "Country", "Funk", "Hip hop", "R&B", "Reggae", "Soul", "Indian", "Top40", "60s", "70s", "80s", "90s", "Sport", "Various", "Radio", "Party", "Christmas", "Firewall", "Auto DJ", "Audio-aacp", "Audio-ogg", "Video", "MyTop", "New", "World", "Full"] + categories = ["podcasts/", podcast, "radios/", radio] + #catmap = {"podcast": dict((i+1,v) for enumerate(self.podcast)), "radio": dict((i+1,v) for enumerate(self.radio))} + + + + # nop + def update_categories(self): + pass + + + # request json API + def update_streams(self, cat, search=""): + + # category type: podcast or radio + if cat in self.podcast: + grp = "podcast" + url = self.api_podcast % (grp, self.podcast.index(cat)+1) + elif cat in self.radio: + grp = "radio" + url = self.api_radio % cat.lower().replace(" ","").replace("&","") + else: + return [] + + # retrieve API data + data = http.ajax(url, None) + data = json.loads(data) + + # tranform datasets + if grp == "podcast": + return [{ + "genre": cat, + "title": row["titel"], + "homepage": row["url"], + "playing": str(row["typ"]), + #"id": row["id"], + #"listeners": int(row["2"]), + #"listformat": "text/html", + "url": "", + } for row in data] + else: + return [{ + "format": self.mime_fmt(row["ctype"]), + "genre": row["genre"] or cat, + "url": "http://%s:%s/%s" % (row["host"], row["port"], row["pfad"]), + "listformat": "url/direct", + "id": row["id"], + "title": row["name"], + "playing": row["song"], + "homepage": row.get("homepage") or row.get("url"), + } for row in data] + + + + ADDED channels/myoggradio.py Index: channels/myoggradio.py ================================================================== --- channels/myoggradio.py +++ channels/myoggradio.py @@ -0,0 +1,185 @@ +# +# api: streamtuner2 +# title: MyOggRadio channel plugin +# description: open source internet radio directory MyOggRadio +# version: 0.5 +# config: +# +# priority: standard +# category: channel +# depends: json, StringIO +# +# MyOggRadio is an open source radio station directory. Because this matches +# well with streamtuner2, there's now a project partnership. Shared streams can easily +# be downloaded in this channel plugin. And streamtuner2 users can easily share their +# favourite stations into the MyOggRadio directory. +# +# Beforehand an account needs to be configured in the settings. (Registration +# on myoggradio doesn't require an email address or personal information.) +# + + + +from channels import * +from config import conf +from action import action + +import re +import json +from StringIO import StringIO +import copy + + + +# open source radio sharing stie +class myoggradio(ChannelPlugin): + + # description + title = "MyOggRadio" + module = "myoggradio" + homepage = "http://www.myoggradio.org/" + api = "http://ehm.homelinux.org/MyOggRadio/" + version = 0.5 + listformat = "url/direct" + + # config data + config = [ + {"name":"myoggradio_login", "type":"text", "value":"user:password", "description":"Account for storing personal favourites."}, + {"name":"myoggradio_morph", "type":"boolean", "value":0, "description":"Convert pls/m3u into direct shoutcast url."}, + ] + + # hide unused columns + titles = dict(playing=False, listeners=False, bitrate=False) + + + # category map + categories = ['common', 'personal'] + default = 'common' + current = 'common' + + + + # prepare GUI + def __init__(self, parent): + ChannelPlugin.__init__(self, parent) + if parent: + mygtk.add_menu(parent.extensions, "Share in MyOggRadio", self.share) + + + + # this is simple, there are no categories + def update_categories(self): + pass + + + + # download links from dmoz listing + def update_streams(self, cat, force=0): + + # result list + entries = [] + + # common + if (cat == "common"): + # fetch + data = http.get(self.api + "common.json") + entries = json.load(StringIO(data)) + + # bookmarks + elif (cat == "personal") and self.user_pw(): + data = http.get(self.api + "favoriten.json?user=" + self.user_pw()[0]) + entries = json.load(StringIO(data)) + + # unknown + else: + self.parent.status("Unknown category") + pass + + # augment result list + for i,e in enumerate(entries): + entries[i]["homepage"] = self.api + "c_common_details.jsp?url=" + e["url"] + entries[i]["genre"] = cat + # send back + return entries + + + + # upload a single station entry to MyOggRadio + def share(self, *w): + + # get data + row = self.parent.row() + if row: + row = copy.copy(row) + + # convert PLS/M3U link to direct ICY stream url + if conf.myoggradio_morph and self.parent.channel().listformat != "url/direct": + row["url"] = http.fix_url(action.srv(row["url"])) + + # prevent double check-ins + if row["title"] in (r.get("title") for r in self.streams["common"]): + pass + elif row["url"] in (r.get("url") for r in self.streams["common"]): + pass + + # send + else: + self.parent.status("Sharing station URL...") + self.upload(row) + sleep(0.5) # artificial slowdown, else user will assume it didn't work + + # tell Gtk we've handled the situation + self.parent.status("Shared '" + row["title"][:30] + "' on MyOggRadio.org") + return True + + + # upload bookmarks + def send_bookmarks(self, entries=[]): + + for e in (entries if entries else parent.bookmarks.streams["favourite"]): + self.upload(e) + + + # send row to MyOggRadio + def upload(self, e, form=0): + if e: + login = self.user_pw() + submit = { + "user": login[0], # api + "passwort": login[1], # api + "url": e["url"], + "bemerkung": e["title"], + "genre": e["genre"], + "typ": e["format"][6:], + "eintragen": "eintragen", # form + } + + # just push data in, like the form does + if form: + self.login() + http.ajax(self.api + "c_neu.jsp", submit) + + # use JSON interface + else: + http.ajax(self.api + "commonadd.json?" + urllib.urlencode(submit)) + + + # authenticate against MyOggRadio + def login(self): + login = self.user_pw() + if login: + data = dict(zip(["benutzer", "passwort"], login)) + http.ajax(self.api + "c_login.jsp", data) + # let's hope the JSESSIONID cookie is kept + + + # returns login (user,pw) + def user_pw(self): + if conf.myoggradio_login != "user:password": + return conf.myoggradio_login.split(":") + else: pass + + + + + ADDED channels/punkcast.py Index: channels/punkcast.py ================================================================== --- channels/punkcast.py +++ channels/punkcast.py @@ -0,0 +1,102 @@ + +# api: streamtuner2 +# title: punkcast listing +# +# +# Disables itself per default. +# ST1 looked prettier with random images within. +# + + +import re +import http +from config import conf +import action +from channels import * +from channels import __print__ + + + + + +# disable plugin per default +if "punkcast" not in vars(conf): + conf.plugins["punkcast"] = 0 + + + + + + + + + +# basic.ch broadcast archive +class punkcast (ChannelPlugin): + + # description + title = "punkcast" + module = "punkcast" + homepage = "http://www.punkcast.com/" + version = 0.1 + + # keeps category titles->urls + catmap = {} + categories = ["list"] + default = "list" + current = "list" + + + + # don't do anything + def update_categories(self): + pass + + + # get list + def update_streams(self, cat, force=0): + + rx_link = re.compile(""" + + \s+]+ALT="([^<">]+)" + """, re.S|re.X) + + entries = [] + + #-- all from frontpage + for uu in rx_link.findall(http.get(self.homepage)): + (homepage, id, title) = uu + entries.append({ + "genre": "?", + "title": title, + "playing": "PUNKCAST #"+id, + "format": "audio/mp3", + "homepage": homepage, + }) + + # done + return entries + + + # special handler for play + def play(self, row): + + rx_sound = re.compile("""(http://[^"<>]+[.](mp3|ogg|m3u|pls|ram))""") + html = http.get(row["homepage"]) + + # look up ANY audio url + for uu in rx_sound.findall(html): + __print__( uu ) + (url, fmt) = uu + action.action.play(url, self.mime_fmt(fmt), "url/direct") + return + + # or just open webpage + action.action.browser(row["homepage"]) + + + + + + + ADDED channels/shoutcast.py Index: channels/shoutcast.py ================================================================== --- channels/shoutcast.py +++ channels/shoutcast.py @@ -0,0 +1,202 @@ +# +# api: streamtuner2 +# title: shoutcast +# description: Channel/tab for Shoutcast.com directory +# depends: pq, re, http +# version: 1.2 +# author: Mario +# original: Jean-Yves Lefort +# +# Shoutcast is a server software for audio streaming. It automatically spools +# station information on shoutcast.com, which this plugin can read out. But +# since the website format is often changing, we now use PyQuery HTML parsing +# in favour of regular expression (which still work, are faster, but not as +# reliable). +# +# This was previously a built-in channel plugin. It just recently was converted +# from a glade predefined GenericChannel into a ChannelPlugin. +# +# +# NOTES +# +# Just found out what Tunapie uses: +# http://www.shoutcast.com/sbin/newxml.phtml?genre=Top500 +# It's a simpler list format, no need to parse HTML. However, it also lacks +# homepage links. But maybe useful as alternate fallback... +# Also: +# http://www.shoutcast.com/sbin/newtvlister.phtml?alltv=1 +# http://www.shoutcast.com/sbin/newxml.phtml?search= +# +# +# + + +import http +import urllib +import re +from pq import pq +from config import conf +#from channels import * # works everywhere but in this plugin(???!) +import channels +__print__ = channels.__print__ + + + +# SHOUTcast data module ---------------------------------------- +class shoutcast(channels.ChannelPlugin): + + # desc + api = "streamtuner2" + module = "shoutcast" + title = "SHOUTcast" + version = 1.2 + homepage = "http://www.shoutcast.com/" + base_url = "http://shoutcast.com/" + listformat = "audio/x-scpls" + + # settings + config = [ + dict(name="pyquery", type="boolean", value=0, description="Use more reliable PyQuery HTML parsing\ninstead of faster regular expressions."), + dict(name="debug", type="boolean", value=0, description="enable debug output"), + ] + + # categories + categories = ['Alternative', ['Adult Alternative', 'Britpop', 'Classic Alternative', 'College', 'Dancepunk', 'Dream Pop', 'Emo', 'Goth', 'Grunge', 'Hardcore', 'Indie Pop', 'Indie Rock', 'Industrial', 'Modern Rock', 'New Wave', 'Noise Pop', 'Power Pop', 'Punk', 'Ska', 'Xtreme'], 'Blues', ['Acoustic Blues', 'Chicago Blues', 'Contemporary Blues', 'Country Blues', 'Delta Blues', 'Electric Blues'], 'Classical', ['Baroque', 'Chamber', 'Choral', 'Classical Period', 'Early Classical', 'Impressionist', 'Modern', 'Opera', 'Piano', 'Romantic', 'Symphony'], 'Country', ['Americana', 'Bluegrass', 'Classic Country', 'Contemporary Bluegrass', 'Contemporary Country', 'Honky Tonk', 'Hot Country Hits', 'Western'], 'Decades', ['30s', '40s', '50s', '60s', '70s', '80s', '90s'], 'Easy Listening', ['Exotica', 'Light Rock', 'Lounge', 'Orchestral Pop', 'Polka', 'Space Age Pop'], 'Electronic', ['Acid House', 'Ambient', 'Big Beat', 'Breakbeat', 'Dance', 'Demo', 'Disco', 'Downtempo', 'Drum and Bass', 'Electro', 'Garage', 'Hard House', 'House', 'IDM', 'Jungle', 'Progressive', 'Techno', 'Trance', 'Tribal', 'Trip Hop'], 'Folk', ['Alternative Folk', 'Contemporary Folk', 'Folk Rock', 'New Acoustic', 'Traditional Folk', 'World Folk'], 'Inspirational', ['Christian', 'Christian Metal', 'Christian Rap', 'Christian Rock', 'Classic Christian', 'Contemporary Gospel', 'Gospel', 'Southern Gospel', 'Traditional Gospel'], 'International', ['African', 'Arabic', 'Asian', 'Bollywood', 'Brazilian', 'Caribbean', 'Celtic', 'Chinese', 'European', 'Filipino', 'French', 'Greek', 'Hindi', 'Indian', 'Japanese', 'Jewish', 'Klezmer', 'Korean', 'Mediterranean', 'Middle Eastern', 'North American', 'Russian', 'Soca', 'South American', 'Tamil', 'Worldbeat', 'Zouk'], 'Jazz', ['Acid Jazz', 'Avant Garde', 'Big Band', 'Bop', 'Classic Jazz', 'Cool Jazz', 'Fusion', 'Hard Bop', 'Latin Jazz', 'Smooth Jazz', 'Swing', 'Vocal Jazz', 'World Fusion'], 'Latin', ['Bachata', 'Banda', 'Bossa Nova', 'Cumbia', 'Latin Dance', 'Latin Pop', 'Latin Rock', 'Mariachi', 'Merengue', 'Ranchera', 'Reggaeton', 'Regional Mexican', 'Salsa', 'Tango', 'Tejano', 'Tropicalia'], 'Metal', ['Black Metal', 'Classic Metal', 'Extreme Metal', 'Grindcore', 'Hair Metal', 'Heavy Metal', 'Metalcore', 'Power Metal', 'Progressive Metal', 'Rap Metal'], 'Misc', [], 'New Age', ['Environmental', 'Ethnic Fusion', 'Healing', 'Meditation', 'Spiritual'], 'Pop', ['Adult Contemporary', 'Barbershop', 'Bubblegum Pop', 'Dance Pop', 'Idols', 'JPOP', 'Oldies', 'Soft Rock', 'Teen Pop', 'Top 40', 'World Pop'], 'Public Radio', ['College', 'News', 'Sports', 'Talk'], 'Rap', ['Alternative Rap', 'Dirty South', 'East Coast Rap', 'Freestyle', 'Gangsta Rap', 'Hip Hop', 'Mixtapes', 'Old School', 'Turntablism', 'West Coast Rap'], 'Reggae', ['Contemporary Reggae', 'Dancehall', 'Dub', 'Ragga', 'Reggae Roots', 'Rock Steady'], 'Rock', ['Adult Album Alternative', 'British Invasion', 'Classic Rock', 'Garage Rock', 'Glam', 'Hard Rock', 'Jam Bands', 'Piano Rock', 'Prog Rock', 'Psychedelic', 'Rockabilly', 'Surf'], 'Soundtracks', ['Anime', 'Kids', 'Original Score', 'Showtunes', 'Video Game Music'], 'Talk', ['BlogTalk', 'Comedy', 'Community', 'Educational', 'Government', 'News', 'Old Time Radio', 'Other Talk', 'Political', 'Scanner', 'Spoken Word', 'Sports', 'Technology'], 'Themes', ['Adult', 'Best Of', 'Chill', 'Eclectic', 'Experimental', 'Female', 'Heartache', 'Instrumental', 'LGBT', 'Party Mix', 'Patriotic', 'Rainy Day Mix', 'Reality', 'Sexy', 'Shuffle', 'Travel Mix', 'Tribute', 'Trippy', 'Work Mix']] + #["default", [], 'TopTen', [], 'Alternative', ['College', 'Emo', 'Hardcore', 'Industrial', 'Punk', 'Ska'], 'Americana', ['Bluegrass', 'Blues', 'Cajun', 'Folk'], 'Classical', ['Contemporary', 'Opera', 'Symphonic'], 'Country', ['Bluegrass', 'New Country', 'Western Swing'], 'Electronic', ['Acid Jazz', 'Ambient', 'Breakbeat', 'Downtempo', 'Drum and Bass', 'House', 'Trance', 'Techno'], 'Hip Hop', ['Alternative', 'Hardcore', 'New School', 'Old School', 'Turntablism'], 'Jazz', ['Acid Jazz', 'Big Band', 'Classic', 'Latin', 'Smooth', 'Swing'], 'Pop/Rock', ['70s', '80s', 'Classic', 'Metal', 'Oldies', 'Pop', 'Rock', 'Top 40'], 'R&B/Soul', ['Classic', 'Contemporary', 'Funk', 'Smooth', 'Urban'], 'Spiritual', ['Alternative', 'Country', 'Gospel', 'Pop', 'Rock'], 'Spoken', ['Comedy', 'Spoken Word', 'Talk'], 'World', ['African', 'Asian', 'European', 'Latin', 'Middle Eastern', 'Reggae'], 'Other/Mixed', ['Eclectic', 'Film', 'Instrumental']] + current = "" + default = "Alternative" + empty = "" + + + # redefine + streams = {} + + + # extracts the category list from shoutcast.com, + # sub-categories are queried per 'AJAX' + def update_categories(self): + html = http.get(self.base_url) + self.categories = ["default"] + __print__( html ) + + #

    Radio Genres

    + rx_main = re.compile(r'
  • [\w\s]+
  • ', re.S) + rx_sub = re.compile(r'[\w\s\d]+') + for uu in rx_main.findall(html): + __print__(uu) + (id,name) = uu + name = urllib.unquote(name) + + # main category + self.categories.append(name) + + # sub entries + html = http.ajax("http://shoutcast.com/genre.jsp", {"genre":name, "id":id}) + __print__(html) + sub = rx_sub.findall(html) + self.categories.append(sub) + + # it's done + __print__(self.categories) + conf.save("cache/categories_shoutcast", self.categories) + pass + + + + #def strip_tags(self, s): + # rx = re.compile(""">(\w+)<""") + # return " ".join(rx.findall(s)) + + # downloads stream list from shoutcast for given category + def update_streams(self, cat, search=""): + + if (not cat or cat == self.empty): + __print__("nocat") + return [] + ucat = urllib.quote(cat) + + + # new extraction regex + if not conf.get("pyquery") or not pq: + rx_stream = re.compile(""" + ]+id="(\d+)".+? + ]+href="(http://[^">]+)"[^>]*>([^<>]+).+? + (?:Recently\s*played|Coming\s*soon|Now\s*playing):\s*([^<]*).+? + ners">(\d*)<.+? + bitrate">(\d*)<.+? + type">([MP3AAC]*) + """, re.S|re.I|re.X) + rx_next = re.compile("""onclick="showMoreGenre""") + + + + # loop + entries = [] + next = 0 + max = int(conf.max_streams) + count = max + while (next < max): + + # page + url = "http://www.shoutcast.com/genre-ajax/" + ucat + referer = url.replace("/genre-ajax", "/radio") + params = { "strIndex":"0", "count":str(count), "ajax":"true", "mode":"listeners", "order":"desc" } + html = http.ajax(url, params, referer) #,feedback=self.parent.status) + + __print__(html) + + # regular expressions + if not conf.get("pyquery") or not pq: + + # extract entries + self.parent.status("parsing document...") + __print__("loop-rx") + for uu in rx_stream.findall(html): + (id, homepage, title, playing, ls, bit, fmt) = uu + __print__(uu) + entries += [{ + "title": self.entity_decode(title), + "url": "http://yp.shoutcast.com/sbin/tunein-station.pls?id=" + id, + "homepage": http.fix_url(homepage), + "playing": self.entity_decode(playing), + "genre": cat, #self.strip_tags(uu[4]), + "listeners": int(ls), + "max": 0, #int(uu[6]), + "bitrate": int(bit), + "format": self.mime_fmt(fmt), + }] + + # PyQuery parsing + else: + # iterate over DOM + for div in (pq(e) for e in pq(html).find("div.dirlist")): + + entries.append({ + "title": div.find("a.clickabletitleGenre, div.stationcol a").attr("title"), + "url": div.find("a.playbutton, a.playbutton1, a.playimage").attr("href"), + "homepage": http.fix_url(div.find("a.playbutton.clickabletitle, a[target=_blank], a.clickabletitleGenre, a.clickabletitle, div.stationcol a, a").attr("href")), + "playing": div.find("div.playingtextGenre, div.playingtext").attr("title"), + "listeners": int(div.find("div.dirlistners").text()), + "bitrate": int(div.find("div.dirbitrate").text()), + "format": self.mime_fmt(div.find("div.dirtype").text()), + "max": 0, + "genre": cat, + # "title2": e.find("a.playbutton").attr("name"), + }) + + + # display partial results (not strictly needed anymore, because we fetch just one page) + self.parent.status() + self.update_streams_partially_done(entries) + + # more pages to load? + if (re.search(rx_next, html)): + next += count + else: + next = 99999 + + #fin + __print__(entries) + return entries + + ADDED channels/timer.py Index: channels/timer.py ================================================================== --- channels/timer.py +++ channels/timer.py @@ -0,0 +1,195 @@ +# +# api: streamtuner2 +# title: radio scheduler +# description: time play/record events for radio stations +# depends: kronos +# version: 0.5 +# config: +# category: features +# priority: optional +# support: unsupported +# +# Okay, while programming this, I missed the broadcast I wanted to hear. Again(!) +# But still this is a useful extension, as it allows recording and playing specific +# stations at a programmed time and interval. It accepts a natural language time +# string when registering a stream. (Via streams menu > extension > add timer) +# +# Programmed events are visible in "timer" under the "bookmarks" channel. Times +# are stored in the description field, and can thus be edited. However, after editing +# times manuall, streamtuner2 must be restarted for the changes to take effect. +# + + +from channels import * +import kronos +from mygtk import mygtk +from action import action +import copy + + + +# timed events (play/record) within bookmarks tab +class timer: + + # plugin info + module = "timer" + title = "Timer" + version = 0.5 + + + # configuration settings + config = [ + ] + timefield = "playing" + + + # kronos scheduler list + sched = None + + + + + # prepare gui + def __init__(self, parent): + if parent: + + # keep reference to main window + self.parent = parent + self.bookmarks = parent.bookmarks + + # add menu + mygtk.add_menu(self.parent.extensions, "Add timer for station", self.edit_timer) + + # target channel + if not self.bookmarks.streams.get("timer"): + self.bookmarks.streams["timer"] = [{"title":"--- timer events ---"}] + self.bookmarks.add_category("timer") + self.streams = self.bookmarks.streams["timer"] + + # widgets + parent.signal_autoconnect({ + "timer_ok": self.add_timer, + "timer_cancel": lambda w,*a: self.parent.timer_dialog.hide() or 1, + }) + + # prepare spool + self.sched = kronos.ThreadedScheduler() + for row in self.streams: + try: self.queue(row) + except Exception,e: print("queuing error", e) + self.sched.start() + + + # display GUI for setting timespec + def edit_timer(self, *w): + self.parent.timer_dialog.show() + self.parent.timer_value.set_text("Fri,Sat 20:00-21:00 play") + + # close dialog,get data + def add_timer(self, *w): + self.parent.timer_dialog.hide() + row = self.parent.row() + row = copy.copy(row) + + # add data + row["listformat"] = "url/direct" #self.parent.channel().listformat + if row.get(self.timefield): + row["title"] = row["title"] + " -- " + row[self.timefield] + row[self.timefield] = self.parent.timer_value.get_text() + + # store + self.save_timer(row) + + + # store row in timer database + def save_timer(self, row): + self.streams.append(row) + self.bookmarks.save() + self.queue(row) + pass + + + # add event to list + def queue(self, row): + + # chk + if not row.get(self.timefield) or not row.get("url"): + #print("NO TIME DATA", row) + return + + # extract timing parameters + _ = row[self.timefield] + days = self.days(_) + time = self.time(_) + duration = self.duration(_) + + # which action + if row[self.timefield].find("rec")>=0: + activity, action_method = "record", self.record + else: + activity, action_method = "play", self.play + + # add + task = self.sched.add_daytime_task(action_method, activity, days, None, time, kronos.method.threaded, [row], {}) + + #__print__( "queue", act, self.sched, (action_method, act, days, None, time, kronos.method.threaded, [row], {}), task.get_schedule_time(True) ) + + + + # converts Mon,Tue,... into numberics 1-7 + def days(self, s): + weekdays = ["su", "mo", "tu", "we", "th", "fr", "sa", "su"] + r = [] + for day in re.findall("\w\w+", s.lower()): + day = day[0:2] + if day in weekdays: + r.append(weekdays.index(day)) + return list(set(r)) + + # get start time 18:00 + def time(self, s): + r = re.search("(\d+):(\d+)", s) + return int(r.group(1)), int(r.group(2)) + + # convert "18:00-19:15" to minutes + def duration(self, s): + try: + r = re.search("(\d+:\d+)\s*(\.\.+|-+)\s*(\d+:\d+)", s) + start = self.time(r.group(1)) + end = self.time(r.group(3)) + duration = (end[0] - start[0]) * 60 + (end[1] - start[1]) + return int(duration) # in minutes + except: + return 0 # no limit + + # action wrapper + def play(self, row, *args, **kwargs): + action.play( + url = row["url"], + audioformat = row.get("format","audio/mp3"), + listformat = row.get("listformat","url/direct"), + ) + + # action wrapper + def record(self, row, *args, **kwargs): + #print("TIMED RECORD") + + # extra params + duration = self.duration(row.get(self.timefield)) + if duration: + append = " -a %S.%d.%q -l "+str(duration*60) # make streamripper record a whole broadcast + else: + append = "" + + # start recording + action.record( + url = row["url"], + audioformat = row.get("format","audio/mp3"), + listformat = row.get("listformat","url/direct"), + append = append, + ) + + def test(self, row, *args, **kwargs): + print("TEST KRONOS", row) + + ADDED channels/tv.py Index: channels/tv.py ================================================================== --- channels/tv.py +++ channels/tv.py @@ -0,0 +1,123 @@ +# +# api: streamtuner2 +# title: shoutcast TV +# description: TV listings from shoutcast +# version: 0.0 +# stolen-from: Tunapie.sf.net +# +# As seen in Tunapie, there are still TV listings on Shoutcast. This module +# adds a separate tab for them. Streamtuner2 is centrally and foremost about +# radio listings, so this plugin will remain one of the few exceptions. +# +# http://www.shoutcast.com/sbin/newtvlister.phtml?alltv=1 +# +# Pasing with lxml is dead simple in this case, so we use etree directly +# instead of PyQuery. Like with the Xiph plugin, downloaded streams are simply +# stored in .streams["all"] pseudo-category. +# +# icon: http://cemagraphics.deviantart.com/art/Little-Tv-Icon-96461135 + +from channels import * +import http +import lxml.etree + + + + +# TV listings from shoutcast.com +class tv(ChannelPlugin): + + # desc + api = "streamtuner2" + module = "tv" + title = "TV" + version = 0.1 + homepage = "http://www.shoutcast.com/" + base_url = "http://www.shoutcast.com/sbin/newtvlister.phtml?alltv=1" + play_url = "http://yp.shoutcast.com/sbin/tunein-station.pls?id=" + listformat = "audio/x-scpls" # video streams are NSV linked in PLS format + + # settings + config = [ + ] + + # categories + categories = ["all", "video"] + current = "" + default = "all" + empty = "" + + # redefine + streams = {} + + + # get complete list + def all(self): + r = [] + + # retrieve + xml = http.get(self.base_url) + + # split up entries + for station in lxml.etree.fromstring(xml): + + r.append({ + "title": station.get("name"), + "playing": station.get("ct"), + "id": station.get("id"), + "url": self.play_url + station.get("id"), + "format": "video/nsv", + "time": station.get("rt"), + "extra": station.get("load"), + "genre": station.get("genre"), + "bitrate": int(station.get("br")), + "listeners": int(station.get("lc")), + }) + + return r + + + # genre switch + def load(self, cat, force=False): + if force or not self.streams.get("all"): + self.streams["all"] = self.all() + ChannelPlugin.load(self, cat, force) + + + # update from the list + def update_categories(self): + + # update it always here: #if not self.streams.get("all"): + self.streams["all"] = self.all() + + # enumerate categories + c = {"all":100000} + for row in self.streams["all"]: + for genre in row["genre"].split(" "): + if len(genre)>2 and row["bitrate"]>=200: + c[genre] = c.get(genre, 0) + 1 + # append + self.categories = sorted(c, key=c.get, reverse=True) + + + + # extract from big list + def update_streams(self, cat, search=""): + + # autoload only if "all" category is missing + if not self.streams.get("all"): + self.streams["all"] = self.all() + + # return complete list as-is + if cat == "all": + return self.streams[cat] + + # search for category + else: + return [row for row in self.streams["all"] if row["genre"].find(cat)>=0] + + + + + + ADDED channels/xiph.py Index: channels/xiph.py ================================================================== --- channels/xiph.py +++ channels/xiph.py @@ -0,0 +1,303 @@ +# +# api: streamtuner2 +# title: Xiph.org +# description: Xiph/ICEcast radio directory +# version: 0.1 +# +# +# Xiph.org maintains the Ogg streaming standard and Vorbis audio compression +# format, amongst others. The ICEcast server is an alternative to SHOUTcast. +# But it turns out, that Xiph lists only MP3 streams, no OGG. And the directory +# is less encompassing than Shoutcast. +# +# +# +# + + + +# streamtuner2 modules +from config import conf +from mygtk import mygtk +import http +from channels import * +from channels import __print__ + +# python modules +import re +from xml.sax.saxutils import unescape as entity_decode, escape as xmlentities +import xml.dom.minidom + + + + +# I wonder what that is for --------------------------------------- +class xiph (ChannelPlugin): + + # desc + api = "streamtuner2" + module = "xiph" + title = "Xiph.org" + version = 0.1 + homepage = "http://dir.xiph.org/" + base_url = "http://dir.xiph.org/" + yp = "yp.xml" + listformat = "url/http" + config = [ + {"name":"xiph_min_bitrate", "value":64, "type":"int", "description":"minimum bitrate, filter anything below", "category":"filter"} + ] + + # content + categories = ["all", [], ] + current = "" + default = "all" + empty = None + + + # prepare category names + def __init__(self, parent=None): + + self.categories = ["all"] + self.filter = {} + for main in self.genres: + if (type(main) == str): + id = main.split("|") + self.categories.append(id[0].title()) + self.filter[id[0]] = main + else: + l = [] + for sub in main: + id = sub.split("|") + l.append(id[0].title()) + self.filter[id[0]] = sub + self.categories.append(l) + + # GUI + ChannelPlugin.__init__(self, parent) + + + # just counts genre tokens, does not automatically create a category tree from it + def update_categories(self): + g = {} + for row in self.streams["all"]: + for t in row["genre"].split(): + if g.has_key(t): + g[t] += 1 + else: + g[t] = 0 + g = [ [v[1],v[0]] for v in g.items() ] + g.sort() + g.reverse() + for row in g: + pass + __print__( ' "' + row[1] + '", #' + str(row[0]) ) + + + # xml dom node shortcut to text content + def x(self, entry, name): + e = entry.getElementsByTagName(name) + if (e): + if (e[0].childNodes): + return e[0].childNodes[0].data + + # convert bitrate string to integer + # (also convert "Quality \d+" to pseudo bitrate) + def bitrate(self, s): + uu = re.findall("(\d+)", s) + if uu: + br = uu[0] + if br > 10: + return int(br) + else: + return int(br * 25.6) + else: + return 0 + + # downloads stream list from shoutcast for given category + def update_streams(self, cat, search=""): + + # there is actually just a single category to download, + # all else are virtual + if (cat == "all"): + + #-- get data + yp = http.get(self.base_url + self.yp, 1<<22, feedback=self.parent.status) + + #-- extract + l = [] + for entry in xml.dom.minidom.parseString(yp).getElementsByTagName("entry"): + bitrate = self.bitrate(self.x(entry, "bitrate")) + if conf.xiph_min_bitrate and bitrate and bitrate >= int(conf.xiph_min_bitrate): + l.append({ + "title": str(self.x(entry, "server_name")), + "url": str(self.x(entry, "listen_url")), + "format": self.mime_fmt(str(self.x(entry, "server_type"))[6:]), + "bitrate": bitrate, + "channels": str(self.x(entry, "channels")), + "samplerate": str(self.x(entry, "samplerate")), + "genre": str(self.x(entry, "genre")), + "playing": str(self.x(entry, "current_song")), + "listeners": 0, + "max": 0, # this information is in the html view, but not in the yp.xml (seems pretty static, we might as well make it a built-in list) + "homepage": "", + }) + + # filter out a single subtree + else: + rx = re.compile(self.filter.get(cat.lower(), cat.lower())) + l = [] + for i,row in enumerate(self.streams["all"]): + if rx.search(row["genre"]): + l.append(row) + + # send back the list + return l + + + + + genres = [ + "scanner", #442 + "rock", #305 + [ + "metal|heavy", #36 + ], + "various", #286 + [ + "mixed", #96 + ], + "pop", #221 + [ + "top40|top|40|top 40", #32 + "charts|hits", #20+4 + "80s", #68 + "90s", #20 + "disco", #17 + "remixes", #10 + ], + "electronic|electro", #33 + [ + "dance", #161 + "house", #106 + "trance", #82 + "techno", #72 + "chillout", #16 + "lounge", #12 + ], + "alternative", #68 + [ + "college", #32 + "student", #20 + "progressive", #20 + ], + "classical|classic", #58+20 + "live", #57 + "jazz", #42 + [ + "blues", #19 + ], + "talk|spoken|speak", #41 + [ + "news", #39 + "public", #12 + "info", #5 + ], + "world|international", #25 + [ + "latin", #34 + "reggae", #12 + "indie", #12 + "folk", #9 + "schlager", #14 + "jungle", #13 + "country", #7 + "russian", #6 + ], + "hip hop|hip|hop", #34 + [ + "oldschool", #10 + "rap", + ], + "ambient", #34 + "adult", #33 + ## "music", #32 + "oldies", #31 + [ + "60s", #2 + "70s", #17 + ], + "religious", #4 + [ + "christian|bible", #14 + ], + "rnb|r&b", #12 + [ + "soul", #11 + "funk", #24 + "urban", #11 + ], + "other", #25 + [ + "deep", #14 + "soft", #12 + "minimal", #12 + "eclectic", #12 + "drum", #12 + "bass", #12 + "experimental", #11 + "hard", #10 + "funky", #10 + "downtempo", #10 + "slow", #9 + "break", #9 + "electronica", #8 + "dub", #8 + "retro", #7 + "punk", #7 + "psychedelic", #7 + "goa", #7 + "freeform", #7 + "c64", #7 + "breaks", #7 + "anime", #7 + "variety", #6 + "psytrance", #6 + "island", #6 + "downbeat", #6 + "underground", #5 + "newage", #5 + "gothic", #5 + "dnb", #5 + "club", #5 + "acid", #5 + "video", #4 + "trip", #4 + "pure", #4 + "industrial", #4 + "groove", #4 + "gospel", #4 + "gadanie", #4 + "french", #4 + "dark", #4 + "chill", #4 + "age", #4 + "wave", #3 + "vocal", #3 + "tech", #3 + "studio", #3 + "relax", #3 + "rave", #3 + "hardcore", #3 + "breakbeat", #3 + "avantgarde", #3 + "swing", #2 + "soundtrack", #2 + "salsa", #2 + "italian", #2 + "independant", #2 + "groovy", #2 + "european", #2 + "darkwave", #2 + ], + ] + ADDED cli.py Index: cli.py ================================================================== --- cli.py +++ cli.py @@ -0,0 +1,173 @@ +# +# api: streamtuner2 +# title: CLI interface +# description: allows to call streamtuner2 from the commandline +# status: experimental +# version: 0.3 +# +# Returns JSON data when queried. Usually returns cache data, but the +# "category" module always loads fresh info from the directory servers. +# +# Not all channel plugins are gtk-free yet. And some workarounds are +# used here to not upset channel plugins about a missing parent window. +# +# +# + + +import sys +#from channels import * +import http +import action +from config import conf +import json + + + + +# CLI +class StreamTunerCLI (object): + + + # plugin info + title = "CLI interface" + version = 0.3 + + + # channel plugins + channel_modules = ["shoutcast", "xiph", "internet_radio_org_uk", "jamendo", "myoggradio", "live365"] + current_channel = "cli" + plugins = {} # only populated sparsely by .stream() + + + # start + def __init__(self): + + # fake init + action.action.main = empty_parent() + action.action.main.current_channel = self.current_channel + + # check if enough arguments, else help + if len(sys.argv)<3: + a = self.help + # first cmdline arg == action + else: + command = sys.argv[1] + a = self.__getattribute__(command) + + # run + result = a(*sys.argv[2:]) + if result: + self.json(result) + + + # show help + def help(self, *args): + print(""" +syntax: streamtuner2 action [channel] "stream title" + + from cache: + streamtuner2 stream shoutcast frequence + streamtuner2 dump xiph + streamtuner2 play "..." + streamtuner2 url "..." + load fresh: + streamtuner2 category shoutcast "Top 40" + streamtuner2 categories xiph + """) + + # prints stream data from cache + def stream(self, *args): + + # optional channel name, title + if len(args) > 1: + (channel_list, title) = args + channel_list = channel_list.split(",") + else: + title = list(args).pop() + channel_list = self.channel_modules + + # walk through channel plugins, categories, rows + title = title.lower() + for channel in channel_list: + self.current_channel = channel + c = self.channel(channel) + self.plugins[channel] = c + c.cache() + for cat in c.streams: + for row in c.streams[cat]: + if row and row.get("title","").lower().find(title)>=0: + return(row) + + # just get url + def url(self, *args): + row = self.stream(*args) + if row.get("url"): + print(row["url"]) + + # run player + def play(self, *args): + row = self.stream(*args) + if row.get("url"): + #action.action.play(row["url"], audioformat=row.get("format","audio/mp3")) + self.plugins[self.current_channel].play(row) + + # return cache data 1:1 + def dump(self, channel): + c = self.channel(channel) + c.cache() + return c.streams + + + # load from server + def category(self, module, cat): + c = self.channel(module) + r = c.update_streams(cat) + [c.postprocess(row) for row in r] + return r + + # load from server + def categories(self, module): + c = self.channel(module) + c.cache() + r = c.update_categories() + if not r: + r = c.categories + if c.__dict__.get("empty"): + del r[0] + return r + + + # load module + def channel(self, module): + plugin = __import__("channels."+module, None, None, [""]) + plugin_class = plugin.__dict__[module] + p = plugin_class(None) + p.parent = empty_parent() + return p + + # load all channel modules + def channels(self, channels=None): + if channels: + channels = channels.split(",") + else: + channels = self.channel_modules + return (self.channel(module) for module in channels) + + # pretty print json + def json(self, dat): + print(json.dumps(dat, sort_keys=True, indent=2)) + + + + + +# trap for some main window calls +class empty_parent (object): + channel = {} + null = lambda *a: None + status = null + thread = null + + + ADDED config.py Index: config.py ================================================================== --- config.py +++ config.py @@ -0,0 +1,179 @@ +# +# encoding: UTF-8 +# api: streamtuner2 +# type: class +# title: global config object +# description: reads ~/.config/streamtuner/*.json files +# +# In the main application or module files which need access +# to a global conf object, just import this module as follows: +# +# from config import conf +# +# Here conf is already an instantiation of the underlying +# Config class. +# + + +import os +import sys +import pson +import gzip + + +#-- create a single instance of config object +conf = object() + + + +#-- global configuration data --------------------------------------------- +class ConfigDict(dict): + + + # 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 + + # prepare + self.defaults() + self.xdg() + + # runtime + dirs = ["/usr/share/streamtuner2", "/usr/local/share/streamtuner2", sys.path[0], "."] + self.share = [d for d in dirs if os.path.exists(d)][0] + + # settings from last session + last = self.load("settings") + if (last): + self.update(last) + # store defaults in file + else: + self.save("settings") + self.firstrun = 1 + + # some defaults + def defaults(self): + self.browser = "sensible-browser" + self.play = { + "audio/mp3": "audacious ", # %u for url to .pls, %g for downloaded .m3u + "audio/ogg": "audacious ", + "audio/aac": "amarok -l ", + "audio/x-pn-realaudio": "vlc ", + "audio/*": "totem ", + "*/*": "vlc %srv", + } + self.record = { + "*/*": "x-terminal-emulator -e streamripper %srv", + # x-terminal-emulator -e streamripper %srv -d /home/***USERNAME***/Musik + } + self.plugins = { + "bookmarks": 1, # built-in plugins, cannot be disabled + "shoutcast": 1, + "punkcast": 0, # disable per default + } + self.tmp = os.environ.get("TEMP", "/tmp") + self.max_streams = "120" + self.show_bookmarks = 1 + self.show_favicons = 1 + self.load_favicon = 1 + self.heuristic_bookmark_update = 1 + self.retain_deleted = 1 + self.auto_save_appstate = 1 + self.theme = "" #"MountainDew" + self.debug = False + self.channel_order = "shoutcast, xiph, internet_radio_org_uk, jamendo, myoggradio, .." + self.reuse_m3u = 1 + self.google_homepage = 1 + + + # each plugin has a .config dict list, we add defaults here + def add_plugin_defaults(self, config, module=""): + + # options + for opt in config: + if ("name" in opt) and ("value" in opt) and (opt["name"] not in vars(self)): + self.__dict__[opt["name"]] = opt["value"] + + # plugin state + if module and module not in conf.plugins: + conf.plugins[module] = 1 + + + + # http://standards.freedesktop.org/basedir-spec/basedir-spec-0.6.html + def xdg(self): + home = os.environ.get("HOME", self.tmp) + config = os.environ.get("XDG_CONFIG_HOME", home+"/.config") + + # storage dir + self.dir = config + "/" + "streamtuner2" + + # create if necessary + if (not os.path.exists(self.dir)): + os.makedirs(self.dir) + + + # store some configuration list/dict into a file + def save(self, name="settings", data=None, gz=0, nice=0): + name = name + ".json" + if (data == None): + data = dict(self.__dict__) # ANOTHER WORKAROUND: typecast to plain dict(), else json filter_data sees it as object and str()s it + nice = 1 + # check for subdir + if (name.find("/") > 0): + subdir = name[0:name.find("/")] + subdir = self.dir + "/" + subdir + if (not os.path.exists(subdir)): + os.mkdir(subdir) + open(subdir+"/.nobackup", "w").close() + # write + file = self.dir + "/" + name + # .gz or normal file + if gz: + f = gzip.open(file+".gz", "w") + if os.path.exists(file): + os.unlink(file) + else: + f = open(file, "w") + # encode + pson.dump(data, f, indent=(4 if nice else None)) + f.close() + + + # retrieve data from config file + def load(self, name): + name = name + ".json" + file = self.dir + "/" + name + try: + # .gz or normal file + if os.path.exists(file + ".gz"): + f = gzip.open(file + ".gz", "r") + elif os.path.exists(file): + f = open(file, "r") + else: + return # file not found + # decode + r = pson.load(f) + f.close() + return r + except (Exception), e: + print("PSON parsing error (in "+name+")", e) + + + # recursive dict update + def update(self, with_new_data): + for key,value in with_new_data.iteritems(): + if type(value) == dict: + self[key].update(value) + else: + self[key] = value + # descends into sub-dicts instead of wiping them with subkeys + + + +#-- actually fill global conf instance +conf = ConfigDict() + + ADDED favicon.py Index: favicon.py ================================================================== --- favicon.py +++ favicon.py @@ -0,0 +1,384 @@ +# +# encoding: utf-8 +# api: python +# title: favicon download +# description: retrieves favicons for station homepages, plus utility code for display preparation +# config: +# +# +# +# type: module +# +# +# This module fetches favicon.ico files and prepares .png images for each domain +# in the stations list. Homepage URLs are used for this. +# +# Files end up in: +# /home/user/.config/streamtuner2/icons/www.example.org.png +# +# Currently relies on Google conversion service, because urllib+PIL conversion +# method is still flaky, and a bit slower. Future version might use imagemagick. +# + + +always_google = 1 # use favicon service for speed +only_google = 1 # if that fails, try our other/slower methods? +delete_google_stub = 1 # don't keep placeholder images +google_placeholder_filesizes = (726,896) + + +import os, os.path +import urllib +import re +import urlparse +from config import conf +try: from processing import Process as Thread +except: from threading import Thread +import http + + + +# ensure that we don't try to download a single favicon twice per session, +# if it's not available the first time, we won't get it after switching stations back and forth +tried_urls = [] + + + + +# walk through entries +def download_all(entries): + t = Thread(target= download_thread, args= ([entries])) + t.start() +def download_thread(entries): + for e in entries: + # try just once + if e.get("homepage") in tried_urls: + pass + # retrieve specific img url as favicon + elif e.get("img"): + pass + # favicon from homepage URL + elif e.get("homepage"): + download(e["homepage"]) + # remember + tried_urls.append(e.get("homepage")) + pass + +# download a single favicon for currently playing station +def download_playing(row): + if conf.google_homepage and not row.get("homepage"): + google_find_homepage(row) + if conf.load_favicon and row.get("homepage"): + download_all([row]) + pass + + + +#--- unrelated --- +def google_find_homepage(row): + """ Searches for missing homepage URL via Google. """ + if row.get("url") not in tried_urls: + tried_urls.append(row.get("url")) + + rx_t = re.compile('^(([^-:]+.?){1,2})') + rx_u = re.compile('"(http://[^"]+)" class=l') + + # extract first title parts + title = rx_t.search(row["title"]) + if title: + title = title.group(0).replace(" ", "%20") + + # do a google search + html = http.ajax("http://www.google.de/search?hl=de&q="+title, None) + + # find first URL hit + url = rx_u.search(html) + if url: + row["homepage"] = http.fix_url(url.group(1)) + pass +#----------------- + + + +# extract domain name +def domain(url): + if url.startswith("http://"): + return url[7:url.find("/", 8)] # we assume our URLs are fixed already (http://example.org/ WITH trailing slash!) + else: + return "null" + +# local filename +def name(url): + return domain(url) + ".png" + +# local filename +def file(url): + icon_dir = conf.dir + "/icons" + if not os.path.exists(icon_dir): + os.mkdir(icon_dir) + open(icon_dir+"/.nobackup", "w").close() + return icon_dir + "/" + name(url) + +# does the favicon exist +def available(url): + return os.path.exists(file(url)) + + + + +# download favicon for given URL +def download(url): + + # skip if .png for domain already exists + if available(url): + return + + + # fastest method, so default to google for now + if always_google: + google_ico2png(url) + if available(url) or only_google: + return + + try: # look for /favicon.ico first + #print("favicon.ico") + direct_download("http://"+domain(url)+"/favicon.ico", file(url)) + + except: + try: # extract facicon filename from website + #print("html ") + html_download(url) + + except: # fallback + #print("google ico2png") + google_ico2png(url) + + + + +# retrieve PNG via Google ico2png +def google_ico2png(url): + + #try: + GOOGLE = "http://www.google.com/s2/favicons?domain=" + (fn, headers) = urllib.urlretrieve(GOOGLE+domain(url), file(url)) + + # test for stub image + if delete_google_stub and (filesize(fn) in google_placeholder_filesizes): + os.remove(fn) + + +def filesize(fn): + return os.stat(fn).st_size + + + +# mime magic +def filetype(fn): + f = open(fn, "rb") + bin = f.read(4) + f.close() + if bin[1:3] == "PNG": + return "image/png" + else: + return "*/*" + + + +# favicon.ico +def direct_download(favicon, fn): + +# try: + # URL download + r = urllib.urlopen(favicon) + headers = r.info() + + # abort on + if r.getcode() >= 300: + raise "HTTP error", r.getcode() + if not headers["Content-Type"].lower().find("image/"): + raise "can't use text/* content" + + # save file + fn_tmp = fn+".tmp" + f = open(fn_tmp, "wb") + f.write(r.read(32768)) + f.close() + + # check type + if headers["Content-Type"].lower()=="image/png" and favicon.find(".png") and filetype(fn)=="image/png": + pngresize(fn_tmp) + os.mv(fn_tmp, fn) + else: + ico2png(fn_tmp, fn) + os.remove(fn_tmp) + + # except: + # "File not found" and False + + + +# peek at URL, download favicon.ico +def html_download(url): + + + # + #try: + # download html, look for @href in + r = urllib.urlopen(url) + html = r.read(4096) + r.close() + rx = re.compile("""]+rel\s*=\s*"?\s*(?:shortcut\s+|fav)?icon[^<>]+href=["'](?P[^<>"']+)["'<>\s].""") + favicon = "".join(rx.findall(html)) + + # url or + if favicon.startswith("http://"): + None + # just /pathname + else: + favicon = urlparse.urljoin(url, favicon) + #favicon = "http://" + domain(url) + "/" + favicon + + # download + direct_download(favicon, file(url)) + + + + + + +# +# title: workaround for PIL.Image to preserve the transparency for .ico import +# +# http://stackoverflow.com/questions/987916/how-to-determine-the-transparent-color-index-of-ico-image-with-pil +# http://djangosnippets.org/snippets/1287/ +# +# Author: dc +# Posted: January 17, 2009 +# Languag: Python +# Django Version: 1.0 +# Tags: pil image ico +# Score: 2 (after 2 ratings) +# + +import operator +import struct + +try: + from PIL import BmpImagePlugin, PngImagePlugin, Image +except Exception, e: + print("no PIL", e) + always_google = 1 + only_google = 1 + + +def load_icon(file, index=None): + ''' + Load Windows ICO image. + + See http://en.wikipedia.org/w/index.php?oldid=264332061 for file format + description. + ''' + if isinstance(file, basestring): + file = open(file, 'rb') + + try: + header = struct.unpack('<3H', file.read(6)) + except: + raise IOError('Not an ICO file') + + # Check magic + if header[:2] != (0, 1): + raise IOError('Not an ICO file') + + # Collect icon directories + directories = [] + for i in xrange(header[2]): + directory = list(struct.unpack('<4B2H2I', file.read(16))) + for j in xrange(3): + if not directory[j]: + directory[j] = 256 + + directories.append(directory) + + if index is None: + # Select best icon + directory = max(directories, key=operator.itemgetter(slice(0, 3))) + else: + directory = directories[index] + + # Seek to the bitmap data + file.seek(directory[7]) + + prefix = file.read(16) + file.seek(-16, 1) + + if PngImagePlugin._accept(prefix): + # Windows Vista icon with PNG inside + image = PngImagePlugin.PngImageFile(file) + else: + # Load XOR bitmap + image = BmpImagePlugin.DibImageFile(file) + if image.mode == 'RGBA': + # Windows XP 32-bit color depth icon without AND bitmap + pass + else: + # Patch up the bitmap height + image.size = image.size[0], image.size[1] >> 1 + d, e, o, a = image.tile[0] + image.tile[0] = d, (0, 0) + image.size, o, a + + # Calculate AND bitmap dimensions. See + # http://en.wikipedia.org/w/index.php?oldid=264236948#Pixel_storage + # for description + offset = o + a[1] * image.size[1] + stride = ((image.size[0] + 31) >> 5) << 2 + size = stride * image.size[1] + + # Load AND bitmap + file.seek(offset) + string = file.read(size) + mask = Image.fromstring('1', image.size, string, 'raw', + ('1;I', stride, -1)) + + image = image.convert('RGBA') + image.putalpha(mask) + + return image + + + + +# convert .ico file to .png format +def ico2png(ico, png_fn): + #print("ico2png", ico, png, image) + + try: # .ico + image = load_icon(ico, None) + except: # automatic img file type guessing + image = Image.open(ico) + + # resize + if image.size[0] > 16: + image.resize((16, 16), Image.ANTIALIAS) + + # .png format + image.save(png_fn, "PNG", quality=98) + + +# resize an image +def pngresize(fn, x=16, y=16): + image = Image.open(fn) + if image.size[0] > x: + image.resize((x, y), Image.ANTIALIAS) + image.save(fn, "PNG", quality=98) + + + + +#-- test +if __name__ == "__main__": + import sys + download(sys.argv[1]) + + ADDED http.py Index: http.py ================================================================== --- http.py +++ http.py @@ -0,0 +1,221 @@ +# +# encoding: UTF-8 +# api: streamtuner2 +# type: functions +# title: http download / methods +# description: http utility +# version: 1.3 +# +# Provides a http GET method with gtk.statusbar() callback. +# And a function to add trailings slashes on http URLs. +# +# The latter code is pretty much unreadable. But let's put the +# blame on urllib2, the most braindamaged code in the Python +# standard library. +# + + +import urllib2 +from urllib import urlencode +import config +from channels import __print__ + + + +#-- url download --------------------------------------------- + + + +#-- chains to progress meter and status bar in main window +feedback = None + +# sets either text or percentage, so may take two parameters +def progress_feedback(*args): + + # use reset values if none given + if not args: + args = ["", 1.0] + + # send to main win + if feedback: + try: [feedback(d) for d in args] + except: pass + + + + +#-- GET +def get(url, maxsize=1<<19, feedback="old"): + __print__("GET", url) + + # statusbar info + progress_feedback(url, 0.0) + + # read + content = "" + f = urllib2.urlopen(url) + max = 222000 # mostly it's 200K, but we don't get any real information + read_size = 1 + + # multiple steps + while (read_size and len(content) < maxsize): + + # partial read + add = f.read(8192) + content = content + add + read_size = len(add) + + # set progress meter + progress_feedback(float(len(content)) / float(max)) + + # done + + # clean statusbar + progress_feedback() + + # fin + __print__(len(content)) + return content + + + + + +#-- fix invalid URLs +def fix_url(url): + if url is None: + url = "" + if len(url): + # remove whitespace + url = url.strip() + # add scheme + if (url.find("://") < 0): + url = "http://" + url + # add mandatory path + if (url.find("/", 10) < 0): + url = url + "/" + return url + + + + +# default HTTP headers for AJAX/POST request +default_headers = { + "User-Agent": "streamtuner2/0.4 (X11; U; Linux AMD64; en; rv:1.5.0.1) like WinAmp/2.1 but not like Googlebot/2.1", #"Mozilla/5.0 (X11; U; Linux x86_64; de; rv:1.9.2.6) Gecko/20100628 Ubuntu/10.04 (lucid) Firefox/3.6.6", + "Accept": "*/*;q=0.5, audio/*, url/*", + "Accept-Language": "en-US,en,de,es,fr,it,*;q=0.1", + "Accept-Encoding": "gzip,deflate", + "Accept-Charset": "ISO-8859-1,utf-8;q=0.7,*;q=0.1", + "Keep-Alive": "115", + "Connection": "keep-alive", + #"Content-Length", "56", + #"Cookie": "s_pers=%20s_getnr%3D1278607170446-Repeat%7C1341679170446%3B%20s_nrgvo%3DRepeat%7C1341679170447%3B; s_sess=%20s_cc%3Dtrue%3B%20s_sq%3Daolshtcst%252Caolsvc%253D%252526pid%25253Dsht%25252520%2525253A%25252520SHOUTcast%25252520Radio%25252520%2525257C%25252520Search%25252520Results%252526pidt%25253D1%252526oid%25253Dfunctiononclick%25252528event%25252529%2525257BshowMoreGenre%25252528%25252529%2525253B%2525257D%252526oidt%25253D2%252526ot%25253DDIV%3B; aolDemoChecked=1.849061", + "Pragma": "no-cache", + "Cache-Control": "no-cache", +} + + + +# simulate ajax calls +def ajax(url, post, referer=""): + + # request + headers = default_headers + headers.update({ + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + "Referer": (referer if referer else url), + }) + if type(post) == dict: + post = urlencode(post) + request = urllib2.Request(url, post, headers) + + # open url + __print__( vars(request) ) + progress_feedback(url, 0.2) + r = urllib2.urlopen(request) + + # get data + __print__( r.info() ) + progress_feedback(0.5) + data = r.read() + progress_feedback() + return data + + + +# http://techknack.net/python-urllib2-handlers/ +from gzip import GzipFile +from StringIO import StringIO +class ContentEncodingProcessor(urllib2.BaseHandler): + """A handler to add gzip capabilities to urllib2 requests """ + + # add headers to requests + def http_request(self, req): + req.add_header("Accept-Encoding", "gzip, deflate") + return req + + # decode + def http_response(self, req, resp): + old_resp = resp + # gzip + if resp.headers.get("content-encoding") == "gzip": + gz = GzipFile( + fileobj=StringIO(resp.read()), + mode="r" + ) + resp = urllib2.addinfourl(gz, old_resp.headers, old_resp.url, old_resp.code) + resp.msg = old_resp.msg + # deflate + if resp.headers.get("content-encoding") == "deflate": + gz = StringIO( deflate(resp.read()) ) + resp = urllib2.addinfourl(gz, old_resp.headers, old_resp.url, old_resp.code) # 'class to add info() and geturl() methods to an open file.' + resp.msg = old_resp.msg + return resp + +# deflate support +import zlib +def deflate(data): # zlib only provides the zlib compress format, not the deflate format; + try: # so on top of all there's this workaround: + return zlib.decompress(data, -zlib.MAX_WBITS) + except zlib.error: + return zlib.decompress(data) + + + + + + + +#-- init for later use +if urllib2: + + # config 1 + handlers = [None, None, None] + + # base + handlers[0] = urllib2.HTTPHandler() + if config.conf.debug: + handlers[0].set_http_debuglevel(3) + + # content-encoding + handlers[1] = ContentEncodingProcessor() + + # store cookies at runtime + import cookielib + cj = cookielib.CookieJar() + handlers[2] = urllib2.HTTPCookieProcessor( cj ) + + # inject into urllib2 + urllib2.install_opener( urllib2.build_opener(*handlers) ) + + + + +# alternative function names +AJAX=ajax +POST=ajax +GET=get +URL=fix_url + + ADDED kronos.py Index: kronos.py ================================================================== --- kronos.py +++ kronos.py @@ -0,0 +1,578 @@ +"""Module that provides a cron-like task scheduler. + +This task scheduler is designed to be used from inside your own program. +You can schedule Python functions to be called at specific intervals or +days. It uses the standard 'sched' module for the actual task scheduling, +but provides much more: + +* repeated tasks (at intervals, or on specific days) +* error handling (exceptions in tasks don't kill the scheduler) +* optional to run scheduler in its own thread or separate process +* optional to run a task in its own thread or separate process + +If the threading module is available, you can use the various Threaded +variants of the scheduler and associated tasks. If threading is not +available, you could still use the forked variants. If fork is also +not available, all processing is done in a single process, sequentially. + +There are three Scheduler classes: + + Scheduler ThreadedScheduler ForkedScheduler + +You usually add new tasks to a scheduler using the add_interval_task or +add_daytime_task methods, with the appropriate processmethod argument +to select sequential, threaded or forked processing. NOTE: it is impossible +to add new tasks to a ForkedScheduler, after the scheduler has been started! +For more control you can use one of the following Task classes +and use schedule_task or schedule_task_abs: + + IntervalTask ThreadedIntervalTask ForkedIntervalTask + SingleTask ThreadedSingleTask ForkedSingleTask + WeekdayTask ThreadedWeekdayTask ForkedWeekdayTask + MonthdayTask ThreadedMonthdayTask ForkedMonthdayTask + +Kronos is the Greek God of Time. + +Kronos scheduler (c) Irmen de Jong. +This version has been extracted from the Turbogears source repository +and slightly changed to be completely stand-alone again. Also some fixes +have been made to make it work on Python 2.6 (sched module changes). +The version in Turbogears is based on the original stand-alone Kronos. +This is open-source software, released under the MIT Software License: +http://www.opensource.org/licenses/mit-license.php + +""" + +__version__="2.0" + +__all__ = [ + "DayTaskRescheduler", + "ForkedIntervalTask", + "ForkedMonthdayTask", + "ForkedScheduler", + "ForkedSingleTask", + "ForkedTaskMixin", + "ForkedWeekdayTask", + "IntervalTask", + "MonthdayTask", + "Scheduler", + "SingleTask", + "Task", + "ThreadedIntervalTask", + "ThreadedMonthdayTask", + "ThreadedScheduler", + "ThreadedSingleTask", + "ThreadedTaskMixin", + "ThreadedWeekdayTask", + "WeekdayTask", + "add_interval_task", + "add_monthday_task", + "add_single_task", + "add_weekday_task", + "cancel", + "method", +] + +import os +import sys +import sched +import time +import traceback +import weakref + +class method: + sequential="sequential" + forked="forked" + threaded="threaded" + +class Scheduler: + """The Scheduler itself.""" + + def __init__(self): + self.running=True + self.sched = sched.scheduler(time.time, self.__delayfunc) + + def __delayfunc(self, delay): + # This delay function is basically a time.sleep() that is + # divided up, so that we can check the self.running flag while delaying. + # there is an additional check in here to ensure that the top item of + # the queue hasn't changed + if delay<10: + time.sleep(delay) + else: + toptime = self._getqueuetoptime() + endtime = time.time() + delay + period = 5 + stoptime = endtime - period + while self.running and stoptime > time.time() and \ + self._getqueuetoptime() == toptime: + time.sleep(period) + if not self.running or self._getqueuetoptime() != toptime: + return + now = time.time() + if endtime > now: + time.sleep(endtime - now) + + def _acquire_lock(self): + pass + + def _release_lock(self): + pass + + def add_interval_task(self, action, taskname, initialdelay, interval, + processmethod, args, kw): + """Add a new Interval Task to the schedule. + + A very short initialdelay or one of zero cannot be honored, you will + see a slight delay before the task is first executed. This is because + the scheduler needs to pick it up in its loop. + + """ + if initialdelay < 0 or interval < 1: + raise ValueError("Delay or interval must be >0") + # Select the correct IntervalTask class. Not all types may be available! + if processmethod == method.sequential: + TaskClass = IntervalTask + elif processmethod == method.threaded: + TaskClass = ThreadedIntervalTask + elif processmethod == method.forked: + TaskClass = ForkedIntervalTask + else: + raise ValueError("Invalid processmethod") + if not args: + args = [] + if not kw: + kw = {} + task = TaskClass(taskname, interval, action, args, kw) + self.schedule_task(task, initialdelay) + return task + + def add_single_task(self, action, taskname, initialdelay, processmethod, + args, kw): + """Add a new task to the scheduler that will only be executed once.""" + if initialdelay < 0: + raise ValueError("Delay must be >0") + # Select the correct SingleTask class. Not all types may be available! + if processmethod == method.sequential: + TaskClass = SingleTask + elif processmethod == method.threaded: + TaskClass = ThreadedSingleTask + elif processmethod == method.forked: + TaskClass = ForkedSingleTask + else: + raise ValueError("Invalid processmethod") + if not args: + args = [] + if not kw: + kw = {} + task = TaskClass(taskname, action, args, kw) + self.schedule_task(task, initialdelay) + return task + + def add_daytime_task(self, action, taskname, weekdays, monthdays, timeonday, + processmethod, args, kw): + """Add a new Day Task (Weekday or Monthday) to the schedule.""" + if weekdays and monthdays: + raise ValueError("You can only specify weekdays or monthdays, " + "not both") + if not args: + args = [] + if not kw: + kw = {} + if weekdays: + # Select the correct WeekdayTask class. + # Not all types may be available! + if processmethod == method.sequential: + TaskClass = WeekdayTask + elif processmethod == method.threaded: + TaskClass = ThreadedWeekdayTask + elif processmethod == method.forked: + TaskClass = ForkedWeekdayTask + else: + raise ValueError("Invalid processmethod") + task=TaskClass(taskname, weekdays, timeonday, action, args, kw) + if monthdays: + # Select the correct MonthdayTask class. + # Not all types may be available! + if processmethod == method.sequential: + TaskClass = MonthdayTask + elif processmethod == method.threaded: + TaskClass = ThreadedMonthdayTask + elif processmethod == method.forked: + TaskClass = ForkedMonthdayTask + else: + raise ValueError("Invalid processmethod") + task=TaskClass(taskname, monthdays, timeonday, action, args, kw) + firsttime=task.get_schedule_time(True) + self.schedule_task_abs(task, firsttime) + return task + + def schedule_task(self, task, delay): + """Add a new task to the scheduler with the given delay (seconds). + + Low-level method for internal use. + + """ + if self.running: + # lock the sched queue, if needed + self._acquire_lock() + try: + task.event = self.sched.enter(delay, 0, task, + (weakref.ref(self),) ) + finally: + self._release_lock() + else: + task.event = self.sched.enter(delay, 0, task, + (weakref.ref(self),) ) + + def schedule_task_abs(self, task, abstime): + """Add a new task to the scheduler for the given absolute time value. + + Low-level method for internal use. + + """ + if self.running: + # lock the sched queue, if needed + self._acquire_lock() + try: + task.event = self.sched.enterabs(abstime, 0, task, + (weakref.ref(self),) ) + finally: + self._release_lock() + else: + task.event = self.sched.enterabs(abstime, 0, task, + (weakref.ref(self),) ) + + def start(self): + """Start the scheduler.""" + self._run() + + def stop(self): + """Remove all pending tasks and stop the Scheduler.""" + self.running = False + self._clearschedqueue() + + def cancel(self, task): + """Cancel given scheduled task.""" + self.sched.cancel(task.event) + + if sys.version_info>=(2,6): + # code for sched module of python 2.6+ + def _getqueuetoptime(self): + return self.sched._queue[0].time + def _clearschedqueue(self): + self.sched._queue[:] = [] + else: + # code for sched module of python 2.5 and older + def _getqueuetoptime(self): + return self.sched.queue[0][0] + def _clearschedqueue(self): + self.sched.queue[:] = [] + + def _run(self): + # Low-level run method to do the actual scheduling loop. + while self.running: + try: + self.sched.run() + except Exception,x: + print >>sys.stderr, "ERROR DURING SCHEDULER EXECUTION",x + print >>sys.stderr, "".join( + traceback.format_exception(*sys.exc_info())) + print >>sys.stderr, "-" * 20 + # queue is empty; sleep a short while before checking again + if self.running: + time.sleep(5) + + +class Task: + """Abstract base class of all scheduler tasks""" + + def __init__(self, name, action, args, kw): + """This is an abstract class!""" + self.name=name + self.action=action + self.args=args + self.kw=kw + + def __call__(self, schedulerref): + """Execute the task action in the scheduler's thread.""" + try: + self.execute() + except Exception,x: + self.handle_exception(x) + self.reschedule(schedulerref()) + + def reschedule(self, scheduler): + """This method should be defined in one of the sub classes!""" + raise NotImplementedError("You're using the abstract base class 'Task'," + " use a concrete class instead") + + def execute(self): + """Execute the actual task.""" + self.action(*self.args, **self.kw) + + def handle_exception(self, exc): + """Handle any exception that occured during task execution.""" + print >>sys.stderr, "ERROR DURING TASK EXECUTION", exc + print >>sys.stderr, "".join(traceback.format_exception(*sys.exc_info())) + print >>sys.stderr, "-" * 20 + + +class SingleTask(Task): + """A task that only runs once.""" + + def reschedule(self, scheduler): + pass + + +class IntervalTask(Task): + """A repeated task that occurs at certain intervals (in seconds).""" + + def __init__(self, name, interval, action, args=None, kw=None): + Task.__init__(self, name, action, args, kw) + self.interval = interval + + def reschedule(self, scheduler): + """Reschedule this task according to its interval (in seconds).""" + scheduler.schedule_task(self, self.interval) + + +class DayTaskRescheduler: + """A mixin class that contains the reschedule logic for the DayTasks.""" + + def __init__(self, timeonday): + self.timeonday = timeonday + + def get_schedule_time(self, today): + """Calculate the time value at which this task is to be scheduled.""" + now = list(time.localtime()) + if today: + # schedule for today. let's see if that is still possible + if (now[3], now[4]) >= self.timeonday: + # too bad, it will be tomorrow + now[2] += 1 + else: + # tomorrow + now[2] += 1 + # set new time on day (hour,minute) + now[3], now[4] = self.timeonday + # seconds + now[5] = 0 + return time.mktime(now) + + def reschedule(self, scheduler): + """Reschedule this task according to the daytime for the task. + + The task is scheduled for tomorrow, for the given daytime. + + """ + # (The execute method in the concrete Task classes will check + # if the current day is a day on which the task must run). + abstime = self.get_schedule_time(False) + scheduler.schedule_task_abs(self, abstime) + + +class WeekdayTask(DayTaskRescheduler, Task): + """A task that is called at specific days in a week (1-7), at a fixed time + on the day. + + """ + + def __init__(self, name, weekdays, timeonday, action, args=None, kw=None): + if type(timeonday) not in (list, tuple) or len(timeonday) != 2: + raise TypeError("timeonday must be a 2-tuple (hour,minute)") + if type(weekdays) not in (list, tuple): + raise TypeError("weekdays must be a sequence of weekday numbers " + "1-7 (1 is Monday)") + DayTaskRescheduler.__init__(self, timeonday) + Task.__init__(self, name, action, args, kw) + self.days = weekdays + + def execute(self): + # This is called every day, at the correct time. We only need to + # check if we should run this task today (this day of the week). + weekday = time.localtime().tm_wday + 1 + if weekday in self.days: + self.action(*self.args, **self.kw) + + +class MonthdayTask(DayTaskRescheduler, Task): + """A task that is called at specific days in a month (1-31), at a fixed + time on the day. + + """ + + def __init__(self, name, monthdays, timeonday, action, args=None, kw=None): + if type(timeonday) not in (list, tuple) or len(timeonday) != 2: + raise TypeError("timeonday must be a 2-tuple (hour,minute)") + if type(monthdays) not in (list, tuple): + raise TypeError("monthdays must be a sequence of monthdays numbers " + "1-31") + DayTaskRescheduler.__init__(self, timeonday) + Task.__init__(self, name, action, args, kw) + self.days = monthdays + + def execute(self): + # This is called every day, at the correct time. We only need to + # check if we should run this task today (this day of the month). + if time.localtime().tm_mday in self.days: + self.action(*self.args, **self.kw) + + +try: + import threading + + class ThreadedScheduler(Scheduler): + """A Scheduler that runs in its own thread.""" + + def __init__(self): + Scheduler.__init__(self) + # we require a lock around the task queue + self._lock = threading.Lock() + + def start(self): + """Splice off a thread in which the scheduler will run.""" + self.thread = threading.Thread(target=self._run) + self.thread.setDaemon(True) + self.thread.start() + + def stop(self): + """Stop the scheduler and wait for the thread to finish.""" + Scheduler.stop(self) + try: + self.thread.join() + except AttributeError: + pass + + def _acquire_lock(self): + """Lock the thread's task queue.""" + self._lock.acquire() + + def _release_lock(self): + """Release the lock on th ethread's task queue.""" + self._lock.release() + + + class ThreadedTaskMixin: + """A mixin class to make a Task execute in a separate thread.""" + + def __call__(self, schedulerref): + """Execute the task action in its own thread.""" + threading.Thread(target=self.threadedcall).start() + self.reschedule(schedulerref()) + + def threadedcall(self): + # This method is run within its own thread, so we have to + # do the execute() call and exception handling here. + try: + self.execute() + except Exception,x: + self.handle_exception(x) + + class ThreadedIntervalTask(ThreadedTaskMixin, IntervalTask): + """Interval Task that executes in its own thread.""" + pass + + class ThreadedSingleTask(ThreadedTaskMixin, SingleTask): + """Single Task that executes in its own thread.""" + pass + + class ThreadedWeekdayTask(ThreadedTaskMixin, WeekdayTask): + """Weekday Task that executes in its own thread.""" + pass + + class ThreadedMonthdayTask(ThreadedTaskMixin, MonthdayTask): + """Monthday Task that executes in its own thread.""" + pass + +except ImportError: + # threading is not available + pass + + +if hasattr(os, "fork"): + import signal + + class ForkedScheduler(Scheduler): + """A Scheduler that runs in its own forked process.""" + + def __del__(self): + if hasattr(self, "childpid"): + os.kill(self.childpid, signal.SIGKILL) + + def start(self): + """Fork off a new process in which the scheduler will run.""" + pid = os.fork() + if pid == 0: + # we are the child + signal.signal(signal.SIGUSR1, self.signalhandler) + self._run() + os._exit(0) + else: + # we are the parent + self.childpid = pid + # can no longer insert in the scheduler queue + del self.sched + + def stop(self): + """Stop the scheduler and wait for the process to finish.""" + os.kill(self.childpid, signal.SIGUSR1) + os.waitpid(self.childpid, 0) + + def signalhandler(self, sig, stack): + Scheduler.stop(self) + + + class ForkedTaskMixin: + """A mixin class to make a Task execute in a separate process.""" + + def __call__(self, schedulerref): + """Execute the task action in its own process.""" + pid = os.fork() + if pid == 0: + # we are the child + try: + self.execute() + except Exception,x: + self.handle_exception(x) + os._exit(0) + else: + # we are the parent + self.reschedule(schedulerref()) + + + class ForkedIntervalTask(ForkedTaskMixin, IntervalTask): + """Interval Task that executes in its own process.""" + pass + + class ForkedSingleTask(ForkedTaskMixin, SingleTask): + """Single Task that executes in its own process.""" + pass + + class ForkedWeekdayTask(ForkedTaskMixin, WeekdayTask): + """Weekday Task that executes in its own process.""" + pass + + class ForkedMonthdayTask(ForkedTaskMixin, MonthdayTask): + """Monthday Task that executes in its own process.""" + pass + + + +if __name__=="__main__": + def testaction(arg): + print ">>>TASK",arg,"sleeping 3 seconds" + time.sleep(3) + print "<< 0): + col.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED) + col.set_fixed_width(desc[1]) + + # loop through cells + for var in xrange(2, len(desc)): + cell = desc[var] + # cell renderer + if (cell[2] == "pixbuf"): + rend = gtk.CellRendererPixbuf() # img cell + if (cell[1] == str): + cell[3]["stock_id"] = datapos # for stock icons + expand = False + else: + pix_entry = datapos + cell[3]["pixbuf"] = datapos + else: + rend = gtk.CellRendererText() # text cell + cell[3]["text"] = datapos + #col.set_sort_column_id(datapos) # only on textual cells + + # attach cell to column + col.pack_end(rend, expand=cell[3].get("expand",True)) + # apply attributes + for attr,val in cell[3].iteritems(): + col.add_attribute(rend, attr, val) + # next + datapos += 1 + + # add column to treeview + widget.append_column(col) + # finalize widget + widget.set_search_column(5) #?? + widget.set_search_column(4) #?? + widget.set_search_column(3) #?? + widget.set_search_column(2) #?? + widget.set_search_column(1) #?? + #widget.set_reorderable(True) + + # add data? + if (entries): + #- expand datamap + vartypes = [] #(str, str, bool, str, int, int, gtk.gdk.Pixbuf, str, int) + rowmap = [] #["title", "desc", "bookmarked", "name", "count", "max", "img", ...] + if (not rowmap): + for desc in datamap: + for var in xrange(2, len(desc)): + vartypes.append(desc[var][1]) # content types + rowmap.append(desc[var][0]) # dict{} column keys in entries[] list + # create gtk array storage + ls = gtk.ListStore(*vartypes) # could be a TreeStore, too + + # prepare for missing values, and special variable types + defaults = { + str: "", + unicode: u"", + bool: False, + int: 0, + gtk.gdk.Pixbuf: gtk.gdk.pixbuf_new_from_data("\0\0\0\0",gtk.gdk.COLORSPACE_RGB,True,8,1,1,4) + } + if gtk.gdk.Pixbuf in vartypes: + pix_entry = vartypes.index(gtk.gdk.Pixbuf) + + # sort data into gtk liststore array + for row in entries: + + # generate ordered list from dictionary, using rowmap association + row = [ row.get( skey , defaults[vartypes[i]] ) for i,skey in enumerate(rowmap) ] + + # autotransform string -> gtk image object + if (pix_entry and type(row[pix_entry]) == str): + row[pix_entry] = ( gtk.gdk.pixbuf_new_from_file(row[pix_entry]) if os.path.exists(row[pix_entry]) else defaults[gtk.gdk.Pixbuf] ) + + try: + # add + ls.append(row) # had to be adapted for real TreeStore (would require additional input for grouping/level/parents) + + except: + # brute-force typecast + ls.append( [ty(va) for va,ty in zip(row,vartypes)] ) + + # apply array to widget + widget.set_model(ls) + return ls + + pass + + + + + #-- treeview for categories + # + # simple two-level treeview display in one column + # with entries = [main,[sub,sub], title,[...],...] + # + @staticmethod + def tree(widget, entries, title="category", icon=gtk.STOCK_DIRECTORY): + # list types + ls = gtk.TreeStore(str, str) + # add entries + for entry in entries: + if (type(entry) == str): + main = ls.append(None, [entry, icon]) + else: + for sub_title in entry: + ls.append(main, [sub_title, icon]) + # just one column + tvcolumn = gtk.TreeViewColumn(title); + widget.append_column(tvcolumn) + # inner display: icon & string + pix = gtk.CellRendererPixbuf() + txt = gtk.CellRendererText() + # position + tvcolumn.pack_start(pix, expand=False) + tvcolumn.pack_end(txt, expand=True) + # select array content source in treestore + tvcolumn.set_attributes(pix, stock_id=1) + tvcolumn.set_attributes(txt, text=0) + # finalize + widget.set_model(ls) + tvcolumn.set_sort_column_id(0) + #tvcolumn.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED) + #tvcolumn.set_fixed_width(125]) + widget.set_search_column(0) + #widget.expand_all() + #widget.expand_row("3", False) + #print(widget.row_expanded("3")) + return ls + + + + + #-- save window size and widget properties + # + # needs a list of widgetnames + # e.g. pickle.dump(mygtk.app_state(...), open(os.environ["HOME"]+"/.config/app_winstate", "w")) + # + @staticmethod + def app_state(wTree, widgetnames=["window1", "treeview2", "vbox17"]): + r = {} # restore array + for wn in widgetnames: + r[wn] = {} + w = wTree.get_widget(wn) + t = type(w) +# print(wn, w, t) + # extract different information from individual widget types + if t == gtk.Window: + r[wn]["size"] = list(w.get_size()) + #print("WINDOW SIZE", list(w.get_size()), r[wn]) + if t == gtk.Widget: + r[wn]["name"] = w.get_name() + # gtk.TreeView + if t == gtk.TreeView: + r[wn]["columns:width"] = [] + for col in w.get_columns(): + r[wn]["columns:width"].append( col.get_width() ) + # - Rows + r[wn]["rows:expanded"] = [] + for i in xrange(0,50): + if w.row_expanded(str(i)): + r[wn]["rows:expanded"].append(i) + # - selected + (model, paths) = w.get_selection().get_selected_rows() + if paths: + r[wn]["row:selected"] = paths[0] + # gtk.Toolbar + if t == gtk.Toolbar: + r[wn]["icon_size"] = int(w.get_icon_size()) + r[wn]["style"] = int(w.get_style()) + # gtk.Notebook + if t == gtk.Notebook: + r[wn]["page"] = w.get_current_page() + #print(r) + return r + + + #-- restore window and widget properties + # + # requires only the previously saved widget state dict + # + @staticmethod + def app_restore(wTree, r=None): + for wn in r.keys(): # widgetnames + w = wTree.get_widget(wn) + if (not w): + continue + t = type(w) + for method,args in r[wn].iteritems(): + # gtk.Window + if method == "size": + w.resize(args[0], args[1]) + # gtk.TreeView + if method == "columns:width": + for i,col in enumerate(w.get_columns()): + if (i < len(args)): + col.set_fixed_width(args[i]) + # - Rows + if method == "rows:expanded": + w.collapse_all() + for i in args: + w.expand_row(str(i), False) + # - selected + if method == "row:selected": + w.get_selection().select_path(tuple(args)) + # gtk.Toolbar + if method == "icon_size": + w.set_icon_size(args) + if method == "style": + w.set_style(args) + # gtk.Notebook + if method == "page": + w.set_current_page(args) + + pass + + + + #-- Save-As dialog + # + @staticmethod + def save_file(title="Save As", parent=None, fn="", formats=[("*","*")]): + c = gtk.FileChooserDialog(title, parent, action=gtk.FILE_CHOOSER_ACTION_SAVE, buttons=(gtk.STOCK_CANCEL, 0, gtk.STOCK_SAVE, 1)) + # params + if fn: + c.set_current_name(fn) + fn = "" + for fname,ftype in formats: + f = gtk.FileFilter() + f.set_name(fname) + f.add_pattern(ftype) + c.add_filter(f) + # display + if c.run(): + fn = c.get_filename() # return filaname + c.destroy() + return fn + + + + # pass updates from another thread, ensures that it is called just once + @staticmethod + def do(lambda_func): + gobject.idle_add(lambda: lambda_func() and False) + + + # adds background color to widget, + # eventually wraps it into a gtk.Window, if it needs a container + @staticmethod + def bg(w, color="", where=["bg"]): + """ this method should be called after widget creation, and before .add()ing it to container """ + if color: + # wrap unstylable widgets into EventBox + if not isinstance(w, gtk.Window): + wrap = gtk.EventBox() + wrap.add(w) + wrap.set_property("visible", True) + w = wrap + # copy style object, modify settings + s = w.get_style().copy() + c = w.get_colormap().alloc_color(color) + for state in (gtk.STATE_NORMAL, gtk.STATE_SELECTED): + s.bg[state] = c + w.set_style(s) + # probably redundant, but better safe than sorry: + w.modify_bg(gtk.STATE_NORMAL, c) + # return modified or wrapped widget + return w + + + @staticmethod + def add_menu(menuwidget, label, action): + m = gtk.MenuItem(label) + m.connect("activate", action) + m.show() + menuwidget.add(m) + + + # gtk.messagebox + @staticmethod + def msg(text, style=gtk.MESSAGE_INFO, buttons=gtk.BUTTONS_CLOSE): + m = gtk.MessageDialog(None, 0, style, buttons, message_format=text) + m.show() + m.connect("response", lambda *w: m.destroy()) + + + ADDED pq.py Index: pq.py ================================================================== --- pq.py +++ pq.py @@ -0,0 +1,46 @@ +# +# type: interface +# api: python +# title: PyQuery pq +# description: shortcut to PyQuery w/ extensions +# +# + + +import config + + + +# load pyquery +try: + + from pyquery import PyQuery as pq + + # pq.each_pq = lambda self,func: self.each( lambda i,html: func( pq(html, parser="html") ) ) + + +except Exception, e: + + # disable use + pq = None + config.conf.pyquery = False + + # error hint + print("LXML is missing\n", e) + print("\n") + print("Please install the packages python-lxml and python-pyquery from your distributions software manager.\n") + + + # let's invoke packagekit? + """ + try: + import packagekit.client + pkc = packagekit.client.PackageKitClient() + pkc.install_packages([pkc.search_name(n) for n in ["python-lxml", "python-pyquery"]]) + + + except: + print("no LXML") + """ + + ADDED pson.py Index: pson.py ================================================================== --- pson.py +++ pson.py @@ -0,0 +1,96 @@ +# +# encoding: UTF-8 +# api: python +# type: functions +# title: json emulation +# description: simplify usage of some gtk widgets +# version: 1.7 +# author: mario +# license: public domain +# +# +# This module provides the JSON api. If the python 2.6 module +# isn't available, it provides an emulation using str() and +# eval() and Python notation. (The representations are close.) +# +# Additionally it filters out any left-over objects. Sometimes +# pygtk-objects crawled into the streams[] lists, because rows +# might have been queried from the widgets. +# + + +#-- reading and writing json (for the config module) ---------------------------------- + + +# try to load the system module first +try: + from json import dump as json_dump, load as json_load +except: + print("no native Python JSON module") + + +#except: + # pseudo-JSON implementation + # - the basic python data types dict,list,str,int are mostly identical to json + # - therefore a basic str() conversion is enough for writing + # - for reading the more bothersome eval() is used + # - it's however no severe security problem here, because we're just reading + # local config files (written by us) and accept no data from outside / web + # NOTE: This code is only used, if the Python json module (since 2.6) isn't there. + + +# store object in string representation into filepointer +def dump(obj, fp, indent=0): + + obj = filter_data(obj) + + try: + return json_dump(obj, fp, indent=indent, sort_keys=(indent and indent>0)) + except: + return fp.write(str(obj)) + # .replace("'}, ", "'},\n ") # add whitespace + # .replace("', ", "',\n ")) + # .replace("': [{'", "':\n[\n{'") + pass + + +# load from filepointer, decode string into dicts/list +def load(fp): + try: + #print("try json") + r = json_load(fp) + r = filter_data(r) # turn unicode() strings back into str() - pygtk does not accept u"strings" + except: + #print("fall back on pson") + fp.seek(0) + r = eval(fp.read(1<<27)) # max 128 MB + # print("fake json module: in python variable dump notation") + + if r == None: + r = {} + return r + + +# removes any objects, turns unicode back into str +def filter_data(obj): + if type(obj) in (int, float, bool, str): + return obj +# elif type(obj) == str: #->str->utf8->str +# return str(unicode(obj)) + elif type(obj) == unicode: + return str(obj) + elif type(obj) in (list, tuple, set): + obj = list(obj) + for i,v in enumerate(obj): + obj[i] = filter_data(v) + elif type(obj) == dict: + for i,v in obj.iteritems(): + i = filter_data(i) + obj[i] = filter_data(v) + else: + print("invalid object in data, converting to string: ", type(obj), obj) + obj = str(obj) + return obj + + + ADDED st2.py Index: st2.py ================================================================== --- st2.py +++ st2.py @@ -0,0 +1,1133 @@ +#!/usr/bin/env python +# encoding: UTF-8 +# api: python +# type: application +# title: streamtuner2 +# description: directory browser for internet radio / audio streams +# depends: gtk, pygtk, xml.dom.minidom, threading, lxml, pyquery, kronos +# version: 2.0.8 +# author: mario salzer +# license: public domain +# url: http://freshmeat.net/projects/streamtuner2 +# config: +# 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 """ +# +# Cumulative development time is two months now, but the application +# runs mostly stable already. The GUI interfaces are workable. +# There haven't been any optimizations regarding memory usage and +# performance. The current internal API is acceptable. Documentation is +# coming up. +# +# current bugs: +# - audio- and list-format support is not very robust / needs better API +# - lots of GtkWarning messages +# - not all keyboard shortcuts work +# - in-list search doesn't work in our treeviews (???) +# - JSON files are only trouble: loading of data files might lead to more +# errors now, even if pson module still falls back on old method +# (unicode strings from json.load are useless to us, require typecasts) +# (nonsupport of tuples led to regression in mygtk.app_restore) +# (sometimes we receive 8bit-content, which the json module can't save) +# +# 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 +# +# still help wanted on: +# - any of the above +# - new plugins (local file viewer) +# - nicer logo (or donations accepted to consult graphics designer) +# + + + +# standard modules +import sys +import os, os.path +import re +import copy +import urllib + +# threading or processing module +try: + from processing import Process as Thread +except: + from threading import Thread + Thread.stop = lambda self: None + +# gtk modules +import pygtk +import gtk +import gtk.glade +import gobject + + +# custom modules +sys.path.insert(0, "/usr/share/streamtuner2") # pre-defined directory for modules +sys.path.insert(0, ".") # pre-defined directory for modules +from config import conf # initializes itself, so all conf.vars are available right away +from mygtk import mygtk # gtk treeview +import http +import action # needs workaround... (action.main=main) +from channels import * +from channels import __print__ +import favicon +#from pq import pq + + + +# this represents the main window +# and also contains most application behaviour +main = None +class StreamTunerTwo(gtk.glade.XML): + + + # object containers + widgets = {} # non-glade widgets (the manually instantiated ones) + channels = {} # channel modules + features = {} # non-channel plugins + working = [] # threads + + # 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(0.05) + + # instantiate gtk/glade widgets in current object + gtk.glade.XML.__init__(self, ("st2.glade" if os.path.exists("st2.glade") else conf.share+"/st2.glade")), gui_startup(0.10) + # 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(0.15) + 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 + + # display current open channel/notebook tab + gui_startup(0.90) + self.current_channel = self.current_channel_gtk() + try: self.channel().first_show() + except: print("channel .first_show() initialization error") + + + # bind gtk/glade event names to functions + gui_startup(0.95) + self.signal_autoconnect({ + "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)), + # else + "menu_properties": config_dialog.open, + "config_cancel": config_dialog.hide, + "config_save": config_dialog.save, + "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_bugreport": lambda w: BugReport(), + "menu_copy": self.menu_copy, + "delete_entry": self.delete_entry, + "quicksearch_set": search.quicksearch_set, + "search_open": search.menu_search, + "search_go": search.start, + "search_srv": search.start, + "search_google": search.google, + "search_cancel": search.cancel, + "true": lambda w,*args: True, + "streamedit_open": streamedit.open, + "streamedit_save": streamedit.save, + "streamedit_new": streamedit.new, + "streamedit_cancel": streamedit.cancel, + }) + + # actually display main window + gui_startup(0.99) + self.win_streamtuner2.show() + + # WHY DON'T YOU WANT TO WORK?! + #self.shoutcast.gtk_list.set_enable_search(True) + #self.shoutcast.gtk_list.set_search_column(4) + + + + + #-- Shortcut fo 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 (self.channels.has_key(name)): + return self.channels[name] # like self.shoutcast + else: + return self.get_widget(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 self.widgets.has_key(name): + return self.widgets[name] + else: + return gtk.glade.XML.get_widget(self, name) + + + + + # returns the currently selected directory/channel object + def channel(self): + #try: + return self.channels[self.current_channel] + #except Exception,e: + # print(e) + # self.notebook_channels.set_current_page(0) + # self.current_channel = "bookmarks" + # return self.channels["bookmarks"] + + 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("try: .first_show", self.channel().module); + print(self.channel().first_show) + print(self.channel().first_show()) + except: + print("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) + favicon.download_playing(row) + + # streamripper + def on_record_clicked(self, widget): + row = self.row() + action.record(row.get("url"), "audio/mp3", "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__("dblclick") + action.browser(self.channel().homepage) + + # reload stream list in current channel-category + def on_reload_clicked(self, widget=None, reload=1): + __print__("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__("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 >= 1.0): # 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.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 plugin files + ls = os.listdir(conf.share + "/channels/") + ls = [fn[:-3] for fn in ls if re.match("^[a-z][\w\d_]+\.py$", fn)] + + # resort with tab order + order = [module.strip() for module in conf.channel_order.lower().replace(".","_").replace("-","_").split(",")] + ls = [module for module in (order) if (module in ls)] + [module for module in (ls) if (module not in order)] + + # step through + for module in ls: + gui_startup(0.2 + 0.7 * float(ls.index(module))/len(ls), "loading module "+module) + + # skip module if disabled + if conf.plugins.get(module, 1) == False: + __print__("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, e: + print("error initializing:", module) + print(e) + + # default plugins + conf.add_plugin_defaults(self.channels["bookmarks"].config, "bookmarks") + #conf.add_plugin_defaults(self.channels["shoutcast"].config, "shoutcast") + + # 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: + self.app_state(widget) + gtk.main_quit() + + + + + + + + + + + +# auxiliary window: about dialog +class AboutStreamtuner2: + # about us + def __init__(self): + a = gtk.AboutDialog() + a.set_version("2.0.8") + a.set_name("streamtuner2") + a.set_license("Public Domain\n\nNo Strings Attached.\nUnrestricted distribution,\nmodification, use.") + a.set_authors(["Mario Salzer \n\nConcept based on streamtuner 0.99.99 from\nJean-Yves Lefort, of which some code remains\nin the Google stations plugin.\n\n\nMyOggRadio plugin based on cooperation\nwith Christian Ehm. "]) + 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(None, None, None, event.button, event.time) + 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 main.__dict__.has_key(name): + return main.__dict__[name] + elif StreamTunerTwo.__dict__.has_key(name): + 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(); + + # hide dialog box again + def cancel(self, *args): + self.search_dialog.hide() + return True # stop any other gtk handlers + #self.search_dialog.hide() #if conf.hide_searchdialog + + + # perform search + def start(self, *w): + self.cancel() + + # prepare variables + self.q = self.search_full.get_text().lower() + entries = [] + main.bookmarks.streams["search"] = [] + + # which fields? + fields = ["title", "playing", "genre", "homepage", "url", "extra", "favicon", "format"] + if not self.search_in_all.get_active(): + fields = [f for f in fields if (main.get_widget("search_in_"+f) and main.get_widget("search_in_"+f).get_active())] + # channels? + channels = main.channel_names[:] + if not self.search_channel_all.get_active(): + channels = [c for c in channels if main.get_widget("search_channel_"+c).get_active()] + + # step through channels + for c in channels: + if main.channels[c] and main.channels[c].streams: # skip disabled plugins + + # categories + for cat in main.channels[c].streams.keys(): + + # stations + for row in main.channels[c].streams[cat]: + + # assemble text fields + text = " ".join([row.get(f, " ") for f in fields]) + + # compare + if text.lower().find(self.q) >= 0: + + # add result + entries.append(row) + + + # display "search" in "bookmarks" + 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_query(self, w): + "unimplemented" + + # don't search at all, open a web browser + def google(self, w): + self.cancel() + action.browser("http://www.google.com/search?q=" + self.search_full.get_text()) + + + # 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): + row = main.row() + for name in ("title", "playing", "genre", "homepage", "url", "favicon", "format", "extra"): + w = main.get_widget("streamedit_" + name) + if w: + w.set_text((str(row.get(name)) if row.get(name) else "")) + self.win_streamedit.show() + + # copy widget contents to stream + def save(self, w): + row = main.row() + for name in ("title", "playing", "genre", "homepage", "url", "favicon", "format", "extra"): + w = main.get_widget("streamedit_" + name) + if w: + row[name] = w.get_text() + 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/mp3", "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): + self.add_plugins() + self.apply(conf.__dict__, "config_", 0) + #self.win_config.modify_bg(gtk.STATE_NORMAL, gtk.gdk.color_parse('#443399')) + self.combobox_theme() + self.win_config.show() + + def hide(self, *args): + self.win_config.hide() + return True + + # set/load values between gtk window and conf. dict + def apply(self, config, prefix="config_", save=0): + for key,val in config.iteritems(): + # map non-alphanumeric chars from config{} to underscores in according gtk widget names + id = re.sub("[^\w]", "_", key) + w = main.get_widget(prefix + id) + __print__("config_save", save, prefix+id, w, val) + # recurse into dictionaries, transform: conf.play.audio/mp3 => conf.play_audio_mp3 + if (type(val) == dict): + self.apply(val, prefix + id + "_", save) + # load or set gtk.Entry text field + elif (w and save and type(w)==gtk.Entry): + config[key] = w.get_text() + elif (w and type(w)==gtk.Entry): + w.set_text(str(val)) + elif (w and save): + config[key] = w.get_active() + elif (w): + w.set_active(bool(val)) + pass + + # fill combobox + def combobox_theme(self): + # self.theme.combo_box_new_text() + # 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)] + # add to combobox + for num,themename in enumerate(themes): + self.theme.append_text(themename) + if conf.theme == themename: + self.theme.set_active(num) + # erase this function, so it only ever gets called once + self.combobox_theme = lambda: None + + # retrieve currently selected value + def apply_theme(self): + if self.theme.get_active() >= 0: + conf.theme = self.theme.get_model()[ self.theme.get_active()][0] + main.load_theme() + + # add configuration setting definitions from plugins + once = 0 + def add_plugins(self): + if self.once: + return + + for name,enabled in conf.plugins.iteritems(): + + # add plugin load entry + if name: + label = ("enable ⎗ %s channel" if self.channels.get(name) else "use ⎗ %s plugin") + cb = gtk.ToggleButton(label=label % name) + self.add_( "config_plugins_"+name, cb )#, label=None, color="#ddd" ) + + # 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) + + # display checkbox or text entry + if opt["type"] == "boolean": + cb = gtk.CheckButton(opt["description"]) + #cb.set_line_wrap(True) + self.add_( "config_"+opt["name"], cb ) + else: + self.add_( "config_"+opt["name"], gtk.Entry(), opt["description"] ) + + # spacer + self.add_( "filler_pl_"+name, gtk.HSeparator() ) + self.once = 1 + + # put gtk widgets into config dialog notebook + def add_(self, id, w, label=None, color=""): + w.set_property("visible", True) + main.widgets[id] = w + if label: + w.set_width_chars(10) + label = gtk.Label(label) + label.set_property("visible", True) + label.set_line_wrap(True) + label.set_size_request(250, -1) + vbox = gtk.HBox(homogeneous=False, spacing=10) + vbox.set_property("visible", True) + vbox.pack_start(w, expand=False, fill=False) + vbox.pack_start(label, expand=True, fill=True) + w = vbox + if color: + w = mygtk.bg(w, color) + self.plugin_options.pack_start(w) + + + # save config + def save(self, widget): + self.apply(conf.__dict__, "config_", 1) + self.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 + api = "streamtuner2" + 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", ] + current = "favourite" + default = "favourite" + streams = {"favourite":[], "search":[], "scripts":[], "timer":[], } + + # 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, []) + + # initial display + def first_show(self): + if not self.streams["favourite"]: + self.cache() + # all entries just come from "bookmarks.json" + def cache(self): + # stream list + cache = conf.load(self.module) + if (cache): + 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(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["url"],row) for row in fav) + # walk through all channels/streams + for chname,channel in main.channels.iteritems(): + for cat,streams in channel.streams.iteritems(): + + # 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.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.destroy() + except: return + + + + +#-- run main --------------------------------------------- +if __name__ == "__main__": + + #-- global configuration settings + "conf = Config()" # already happened with "from config import conf" + + # graphical + if len(sys.argv) < 2: + + + # prepare for threading in Gtk+ callbacks + gobject.threads_init() + gui_startup(0.05) + + # 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 + http.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(1.00) + gtk.main() + + + # invoke command-line interface + else: + import cli + cli.StreamTunerCLI() + + + + +# +# +# +#