streamtuner2/action.py010064400017500001750000000240071144024012400146660ustar00takakitakaki# # 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 ) 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 streamtuner2/cli.py010064400017500001750000000105121142703500000141540ustar00takakitakaki# # 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 streamtuner2/config.py010064400017500001750000000133151143063512600146670ustar00takakitakaki# # 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", } 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() streamtuner2/favicon.py010064400017500001750000000226051143061053600150470ustar00takakitakaki# # 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]) streamtuner2/http.py010064400017500001750000000131271142510112100143650ustar00takakitakaki# # 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 streamtuner2/kronos.py010064400017500001750000000464521142222255500147440ustar00takakitakaki"""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()) 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() 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()) streamtuner2/pq.py010064400017500001750000000015151142110055300140300ustar00takakitakaki# # 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" """ streamtuner2/pson.py010064400017500001750000000060431143063527600144070ustar00takakitakaki# # 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 streamtuner2/st2.py010075500017500001750000001231041146775757600141630ustar00takakitakaki#!/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.append("/usr/share/streamtuner2") # 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, 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_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: 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): 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.erphesfurt.de/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", "") 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) # 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() # # # # streamtuner2/st2.glade010064400017500001750000003661541143057122700145730ustar00takakitakaki 500 330 streamtuner2 980 775 /usr/share/pixmaps/streamtuner2.png applications-multimedia streamtuner2 True True True True True _Station True True True bookmark True gtk-save-as True True True gtk-edit True True True True Extensions True True True True gtk-quit True True True gtk-edit True True True True gtk-copy True True True gtk-delete True True True gtk-find True True True True True _Toolbar True True gtk-revert-to-saved True True True True True Only Symbols True True With Text True True True Small True True Medium True True Large True True Save states True gtk-properties True True True True _Channel True True True Homepage of directory service True True Reload True True this will take a few minutes Update favicons... True True Reload Category Tree True True gtk-help True True True True True documentation True True online forum True gtk-about True True True False 0 True True both 6 True play gtk-media-play False True True record gtk-media-record False True True station gtk-home False True True False True True reload gtk-refresh False True True stop gtk-cancel False True 0 True True 10 0.10000000149011612 gtk-find False 20 1 1 0 True GDK_EXPOSURE_MASK | GDK_POINTER_MOTION_MASK | GDK_POINTER_MOTION_HINT_MASK | GDK_BUTTON_MOTION_MASK | GDK_BUTTON1_MOTION_MASK | GDK_BUTTON2_MOTION_MASK | GDK_BUTTON3_MOTION_MASK | GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK | GDK_KEY_PRESS_MASK | GDK_KEY_RELEASE_MASK | GDK_ENTER_NOTIFY_MASK | GDK_LEAVE_NOTIFY_MASK | GDK_FOCUS_CHANGE_MASK | GDK_STRUCTURE_MASK | GDK_PROPERTY_CHANGE_MASK | GDK_VISIBILITY_NOTIFY_MASK | GDK_PROXIMITY_IN_MASK | GDK_PROXIMITY_OUT_MASK | GDK_SUBSTRUCTURE_MASK | GDK_SCROLL_MASK 15 streamtuner2.png False 1 False False 0 True True True True True True 150 True True automatic automatic 75 True True True False True True True automatic automatic 200 True True True True True True gtk-indent 0 True bookmarks 1 False tab 1 True True 2 True False 0 75 True 0.28000000000000003 loading... False 1 20 True 2 False 2 False 2 True True play True True record True True bookmark True True Extensions True True True save True True edit True True True station homepage True 565 650 streamtuner settings False center True /usr/share/pixmaps/streamtuner2.png True 520 510 True True left True 28 28 30 True 136 350 471 True 13 2 True AAC+ audio files audio/aac 3 4 True Realaudio audio/x-real 4 5 True All other audio file formats are handled by this application. audio/* 5 6 True audio/* 9 10 True Catch-all entry. If the exact media (audio/video) file type is unknown, this entry is used. */* 6 7 200 20 True True 1 2 3 4 200 20 True True 1 2 4 5 200 20 True True 1 2 5 6 200 20 True True 1 2 6 7 True Here %u or %pls return a direct URL to the shoutcast link. And %g or %m3u provide a local .m3u file for the audio player, what's usually better. True 1 2 7 8 True <b>Recording</b> True 1 2 8 9 200 20 True True Streamripper records MP3 audio streams. It is run in a commandline window to be able to watch its current activities. 1 2 9 10 True 1 2 10 11 True <b>Web Browser</b> True 1 2 11 12 True Web addresses url/http 12 13 200 20 True True Set this to firefox, chrome or opera, if you want to change the used web browser. 1 2 12 13 200 20 True True 1 2 1 2 True MP3 audio format audio/mp3 1 2 200 20 True True 1 2 2 3 True OGG Vorbis audio audio/ogg 2 3 True <b>Format</b> True True <b>Audio Player</b> True 1 2 25 25 True Application settings influence which audio players are used for playing radio streams. Player False tab True 340 457 True 12 2 show bookmark star for favourites in stream lists True True False You can bookmark favourite radio stations. If a station is in the bookmarks list, it gets displayed with a star in all other channel tabs if this option is set. True 1 2 1 2 True Reduces category refresh and download time by limiting the number of stations in the radio stream lists. Note that most channels have a built-in limit anyway. Sometimes it's not possible to fetch longer station lists from directory services. True True 5 4 120 out 0 True 6 limit stream number per channel / category 1 1 2 retain deleted stations in list True True False Some directories regularily remove stale radio stations. With this option set, streamtuner2 does not remove those entries if you reload a category. True 1 2 5 6 display favicons for individual music stations True True False Show favicons for radio stations which have a homepage. Note that favicons are not loaded automatically. You must either manually initiate the favicon update in the channels menu or with the next setting. True 1 2 2 3 load favicon for played stations True True False If you click a station for playing, it's favicon is downloaded automatically. Note that it isn't diplayed automatically. Favicons are displayed once you reload or reselect a category. True 1 2 3 4 update favorites from freshened stream urls True True False If you click a station for playing, it's favicon is downloaded automatically. Note that it isn't diplayed automatically. Favicons are displayed once you reload or reselect a category. True 1 2 6 7 google for homepage URL if missing True True False Some channels don't provide station homepage links. In such cases it is guessed from the titles. Alternatively it can be googled automatically when playing a station. True 1 2 7 8 True True True Determines in which sequence the channel plugins are loaded, and thus in which order they appear in the main window notebook. ordering of channel tabs 0 True True List of comma separated plugin names. These are the base filenames of the channel plugins. Note that they are all-lowercase and mostly use underscores instead of dots and hyphens. 1 1 2 11 12 automatically save window state True True False True Makes streamtuner2 remember the window layout. This incudes the current channel tab and category/stream selection. True 1 2 10 11 16 True True Apply a custom or system coloring scheme. Not all themes are compatible. Select "no theme" to use system default. True 11 use Gtk+ theme (needs restart) 0 190 16 True False False 1 1 2 9 10 25 25 1 True Graphical user interface settings influence mostly the display of the station lists. Display 1 False tab True 350 450 True 13 2 True Directories 1 2 True This is were temporary .m3u files are kept. temporary files 1 2 200 20 True True 1 2 1 2 200 20 True True False 1 2 2 3 True Directory for saving configuration settings and cache files. .config dir 2 3 True See freedesktop.org 0.10000000149011612 You can only influence this by setting XDG_CONFIG_HOME to a different location. 1 2 3 4 True HTTP proxy 5 6 True 0.10000000149011612 1 Start streamtuner2 with <b>http_proxy=</b> as environment variable. This will get picked up by Python and urllib. True 1 2 5 6 reuse temporary .m3u files True True False Streamtuner2 usually creates a .m3u file in the temporary directory when a station gets played. Reusing them is often faster than fetching the streaming data again from the directory server. True 1 2 7 8 25 25 2 True System settings are boring. System 2 False tab True True never True 15 queue none True 10 3 Every channel plugin can be enabled/disabled individualy, and sometimes brings its own set of settings. Channel Plugins 3 False tab 30 70 save 100 35 True True True 415 600 cancel 100 35 True True True 300 600 490 45 True <b><big><big><big><big><big>Configuration Settings</big></big></big></big></big></b> True True 30 15 5 station search center-on-parent dialog False center 0.95999999999999996 False True 2 True 20 True <b><big>search</big></b> True 0 True Which channels/directories to look through. 4 4 5 1 True all channels True True False True True 1 True True for 0 True True True True A single word to search for in all stations. True 1 True 2 2 True In which fields to look for the search term. 3 True True True True none False False 0 in title True True False True True 1 in description True True False True True 2 any fields True True False True 3 True True True none False False 4 3 True In which fields to look for the search term. 3 True True True True none False False 0 homepage url True True False True True 1 extra info True True False True 2 and genre True True False True 3 True True True none False False 4 4 True 5 1 True end cancel True True True False False 0 True True False False 1 google it True True True Instead of searching in the station list, just look up the above search term on google. half False False 2 query srv True False True Instead of doing a cache search, go through the search functions on the directory service homepages. (UNIMPLEMENTED) half False False 3 cache _search True False True True True Start searching for above search term in the currently loaded station lists. Doesn't find *new* information, just looks through the known data. True False False 4 False False end 0 inspect/edit stream data center-on-parent False 0.94999999999999996 True 15 10 2 5 5 True Radio station name. 0.89999997615814209 title 1 2 True True 1 2 1 2 True True 1 2 2 3 True True 1 2 3 4 True True 1 2 4 5 True True 1 2 5 6 True True 1 2 6 7 True Either the last playing song, or a general description of the station. 0.89999997615814209 playing/desc 2 3 True 0.89999997615814209 homepage 3 4 True 0.89999997615814209 genre 4 5 True PLS or M3U link. 0.89999997615814209 stream url 5 6 True Homepage icon for station. Points to a local cache file. 0.89999997615814209 favicon 6 7 40 True cancel 100 25 True True True 65 10 ok 100 25 True True True Save changes. 200 10 1 2 9 10 True 0.69999998807907104 <b>channel</b> True True 0.08999999612569809 <b>information</b> True 1 2 True True 1 2 8 9 True You can add extra information here, if you want. Useful for searching later. But take care that it gets reset on channel reloading. extra info 8 9 True Audio file format MIME type. format 7 8 True True 1 2 7 8 5 normal False True 2 True 3 3 True True Fri,Sat 20:00-21:00 1 2 1 2 1 True end cancel True True True False False 0 ok True True True False False 1 False end 0 streamtuner2/channels/__init__.py010064400017500001750000000001701143302377600167540ustar00takakitakaki# # encoding: UTF-8 # api: python # type: R # from channels._generic import * from channels._generic import __print__ streamtuner2/channels/_generic.py010064400017500001750000000462031144024072700167720ustar00takakitakaki# # 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.erphesfurt.de/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[ 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 if (self.shown != None): # if category tree is empty, initialize it if not self.categories: self.parent.thread(self.reload_categories) # load current category self.load(self.current) # put selection/cursor on last position try: self.gtk_list.get_selection().select_path(self.shown) except: pass # this method will only be invoked once self.shown = None # 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__ streamtuner2/channels/basicch.py010064400017500001750000000176511142510226700166170ustar00takakitakaki # 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) streamtuner2/channels/google.py010064400017500001750000000201431142510112400164540ustar00takakitakaki# # 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 streamtuner2/channels/internet_radio_org_uk.py010064400017500001750000000105711142765357000216030ustar00takakitakaki# # api: streamtuner2 # title: internet-radio.org.uk # description: io channel # version: 0.1 # # # Might become new main plugins # # # 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 streamtuner2/channels/jamendo.py010064400017500001750000000100201142510127500166150ustar00takakitakaki # 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" streamtuner2/channels/links.py010064400017500001750000000037531142315665100163450ustar00takakitakaki# # 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 streamtuner2/channels/live365.py010064400017500001750000000214331142510127500164070ustar00takakitakaki # 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) streamtuner2/channels/modarchive.py010064400017500001750000000057641142062527200173470ustar00takakitakaki # 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 streamtuner2/channels/musicgoal.py010064400017500001750000000071201142756207200172020ustar00takakitakaki# # 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] streamtuner2/channels/myoggradio.py010064400017500001750000000124301143056463700173630ustar00takakitakaki# # 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 streamtuner2/channels/punkcast.py010064400017500001750000000036151142062523000170410ustar00takakitakaki # 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"]) streamtuner2/channels/shoutcast.py010064400017500001750000000270161144024056200172320ustar00takakitakaki# # 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 streamtuner2/channels/timer.py010064400017500001750000000131521142300313500163230ustar00takakitakaki# # 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 streamtuner2/channels/tv.py010064400017500001750000000067431142510127500156520ustar00takakitakaki# # 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] streamtuner2/channels/xiph.py010064400017500001750000000213731142531340400161640ustar00takakitakaki# # 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 ], ] streamtuner2/channels/basicch.png010064400017500001750000000010261141521640200167340ustar00takakitakakiPNG  IHDRasBIT|dIDAT8Kha<;f"Bhq7Dt׍+ZPw .P\ԦP)d:y[ ==>~RJ&Kk VJuW -0/ræAnj`ЁGw O),6 =Di@ b0 WѵOjɍ )> QMzI^ʽ؝m~}\FLk#i eՊ5(!5 ^> @ّ8Hr2omI>c&m:TKTC~\i3 t-,r-{F?…_+XvE2(7OC"8WYt<i7Q]+{WvgReQV)fKy1{8Y_߽̕LJIENDB`streamtuner2/channels/google.png010064400017500001750000000020531141410230600166110ustar00takakitakakiPNG  IHDRabKGDC pHYs  ~ vpAg\ƭTIDAT8ˍkh[uu4i\״uD¨U&u:٬Ȝʘ? |M [6ikuX6ͺf1ImWqͽ'p8CZ?_hUldPfΤVzMd?)îv_6w./TL) VtU'|j6 spt5[OUtO?;139InȲfc;2+G*'8:kY3 _Hf;''C'ӱ+ܝwx":H/*7 7.6蚢+CF/޼Ey֩~)1te|4otԚ;#{z^X ơkcbG&,F-DX"1DQ\tT*.;mC%B$Q&j8ɥrGs ps >Wg^,щPO)Z"VR.g 0 L11L̮gψ|vl\ѱ۝@#h{8&l2}x:axg޼` ]\ԆZB5233 T 2zA|XNm(h9x~OomiȻ]%F#(ab,R|~[U.Hmet5.vy%j^͉xdl: P+0S0's"ۭh|6!8+ Rz6gU#o|x,<!>{,oFNTl\=ryhg폶J4.;{%tEXtdate:create2010-07-04T14:17:57+02:00/;%tEXtdate:modify2004-10-15T23:58:57+02:00MIENDB`streamtuner2/channels/internet_radio_org_uk.png010064400017500001750000000007571142037212700217310ustar00takakitakakiPNG  IHDRasBIT|dIDAT8=OAghD? ĂFJ0E[Q++~4|.&DeBR,u 7Ls=sgRDDJaVg0MAR;X9A[%;@Jg sWxhSR-xHFy⡽)4U\q9LH+*q&ɧj6x {VFP+k fno}YLɵ5V3χ*R3 'iy M)u) ZĶ6Xx?rr0y4}:Pc/ jN 2+b86*g,Um{w [MQIi]r/GIENDB`streamtuner2/channels/jamendo.png010064400017500001750000000015141141535412000167570ustar00takakitakakiPNG  IHDRasBIT|dIDAT8eKh\eϙ43Lg db .,RŅB^*"(,J0B7N[h[PEB]`Ḓ3gΙ\L-]{c p B\V3q)W˜V ɮu@ʝ=LOc+'JESE}bxNᓃ XdD+-$~Q oH c<کbg[iKM^F~/ 8DnĦmY._-)ko 9&&zKC I`^.Eˇ 9>v%^QUU./jT]S$fbxMF *`'oyN:Mz3GLZ+AVNZqUm"E%$3bn<-u͏).RwC,!/&*A'KS\GW$D;*_"2MWoW[BP.&$4<5oO;4 _~>vxWGw˽LDA?i*+QB˃F.G[{l(݀O|tr݊0}pfoAy٨- ȄL$+gşt_@=s5ѽ ڠ#QLh'q$FĄ\ϭ?D7~U^m vpAg\ƭ9IDAT8eKL\e߽3 R( %hR%1LhĺU ;҅&uQ@ h 66Z 0> ׼.w gsIkmA57'$|VWI)R:XK8@fs?ӿxRoPA~~)%@!Ha`]{^5Mٗ\q+ H 8'j|iDyua'b8vrᩮAh:vt3<<{+Ⓟ߯?>h+UEL=ۆ^Q i%JW͝/\2#BޚZHr_l}>sĆ0'Џ=cd6Q|ćGC.0[M#N:S[Kf܅mn[s;$F -:eĜ'1<^[X >-x3nc(2[10ϻ.ݤDI$ ֛(>2x+ze JԂBvfU/b.,`-1997zu5Ʌ0G0NRf8L{'|/}; `Aݩ5hDE ґ8Z5E;DƯ95QL,FfB?aë $@b 9ڱKn]T./]_ zSD6[RY(/aC??>=wxCf<$7cl´o-Aő!50@>_&Em_}d9YTu%tEXtdate:create2010-07-04T14:17:34+02:00J[౴&qiqwm RCc˘ 9n^sD2s| |6={-0BYBTLS1qta/=Rq:њ??xXc-ϥv}zRJ"fR/VT}Zk:~6m:9\7_s5FG:i7TRC d\0H 6kש&Z!69n1ٟ\A5}KY膴_Eepe=t>o31NFh'krU&!PҼb \TeHt;8E#N?B6@\N h1_6g|73MaY%tEXtdate:create2010-07-24T01:12:57+02:00&%tEXtdate:modify2010-07-24T01:12:57+02:00~atEXtSoftwarewww.inkscape.org<IENDB`streamtuner2/channels/punkcast.png010064400017500001750000000015651141522405200172000ustar00takakitakakiPNG  IHDRasBIT|d,IDAT8Ioe2fq'A+"@ġ?$\*~7rHRAEJIiqlyX˯oxMw͘0ea%KLs\<>q(0ZtZ-xVD8^]jGzDbE~;K&1>KI)0sv_5QiaT{۞5W;r|Ry|? 2 vpAg\ƭIDATc~Q7i0L*p#"|TD0sq01x"""$Ց,ͼj6Vl0'>}pGuX0LI@@BE&obsGy ca2z0A"Z:((?F5hp%tEXtdate:create2010-07-04T14:14:58+02:00%tEXtdate:modify2010-07-04T14:14:58+02:00\mIENDB`streamtuner2/channels/tv.png010064400017500001750000000016561142504316000160020ustar00takakitakakiPNG  IHDRabKGDC pHYsHHFk> vpAg\ƭIDAT8˥\U߹f b!J-DK'Xh#6%`# F0$FWFgf;w=c`yy>yݵ|'6dKD1F|BZzM9+c _ŵ-. Go_y#gm)+KY ,cvv6xEIɕӣg_V6 Bs{V0=^0PP9Mr>rSOnLý劰90V~ei(+K^TXjtelMh3>)R].K)?r1<:qhR[o*%K6ˍ?1Fbx ,&9hbGh RY=yE%B!XgX@ Xj{icr ! 6Tˊv҇| ]6QM҃A'z:VmZ5d@4}ҍ͎ g[m D[OdNWt2O^=Wӫ:$T-D%X4amNqᒍS}ux| '=NܔZjk 0_fz6 GU׍O/}~|#$7yK>1.$N v S/'~۽x >roXp?;mwe@m%tEXtdate:create2010-07-31T17:51:03+02:00(%tEXtdate:modify2010-07-31T17:51:03+02:00YTitEXtSoftwareAdobe ImageReadyqe<IENDB`streamtuner2/channels/xiph.png010064400017500001750000000013421141407610200163110ustar00takakitakakiPNG  IHDRabKGD pHYsHHFk> vpAg\ƭ IDAT8˥S]HQ~ffdMK" ?M-QEEHە"ە7! ͘`[݄i6l~l˵zXK=p.yy|C+;+PhfYi|OD<5'#0@Lۻ|p^5lC7oaAOGr c5#L=Gd1|XWRڳf`C6h E(F[+d8FU} `n7&p’3{.v2Hc%65)K{e.?z ~x j~q.0zۃ._|+u1I3V`pğ>Jv׳_NJMtԣ:ieֲ}ffd9M8c f^/0~ƅJB%ցmH5` Ւ>s0感 !UDhg~B{ڙY$yS9G|P\̔snYD>H xxxW+O+͛F+W_m>RRb>""|bL{{֭l%-vI=!E&@Hg8Co9S~3=峴4 j.'u6'E qK/9z2J"ec?] ]Jt2TTG|~`F@{O#м󑈬3޳GU/^,Z/UIXd} Ugkϧ'Hrc"?FpNP_N;S::Rն4)[ڻǣHOw"e.RVMC-˒u)[ZG>gj68 Hf/") Df  ,` gYO& s-W\a^:KUC C倶9,bo+x&L0*"Ǯ:]:`rB n}dZ \'a7FׂSen, U wSquڒ%֓zcLUOdlP)[XZZ 7;l1rr甛okO7nM$ЌIJDd0?7=^ӃG$_}scuE~[_'ik&t:;Ӳ|q["b[UONĿ}nAc&JVt/3IBS-1 zGvu$q®LQM5]Zҩ$8%C"wHZ?t9I QHL) 1P  G_WSp{6'R,^4'R6M-s|=nAoԅ寁_k_e v[@M矘jYs[REs|jOzNNR}j還swiU-s)[\ԥ|TH6)77|~!-#s; p@͆CNݪ#U/nmWjP m T-r$<8_gaJvLaf#Dk܍VWp(+";T5 H 3W{ ܷHo.ݮcbF0oosu(D[iⴜSz/~ ,^l- ήTKkI#"l9&'}jm{6hګ:qIvz1JrmV-$ez[Ò>ԣo/%תuoD~#* Ueh(d ~:|ldMr&6Gc_DێzEgL/b$ڳO[{8 w,?)DѾ#մYqMij9O,Uk2h /jmH$›US>,399m|U~+Jnl\3BR@Lؘh7c}g[YՎ8sNqsq弴KLɧK}Le)ud>lE妯IKM~Y\Ak8E#3XKO`ne(Fd٧8ieɝ'eԻj:Gm3U-VZ^t_L~fOInN=pIqA?ՏyKrpRpքybI/gWy\o̤(c+G2f ^79sY sKZNdPf&L n\O90fcYX{UM.YSOז)_EA<;k-xpLNy b+62{b%on 9Ռ{&b\ w`z#ABV|3-{gމ9qP9"e5yyiy}҈ۍCKdV ۫rj!3ppo`Ne0j?{p=g˿2P&=NZJ3f h2|-K<4x;ncs X{)wzSy%n9 "-Ԯ'r;Ez6Z?cm8!|cj 6-_AtG~d5`s L|!Gor6V53kBgymUToG @h ^eVɚ+q8 >CI;t w9X5'3:="q6l 2?sDJvo~:@O$M6HV bxd`K4džA#neg||U=֓8e+[~4Su׷àQR^Put:bp2>3 2>*[V1#3 vS6B.+H@}F {ƠጆM 9+ _ݡ5,2;F;Bf.mͥW!2!%kmVw7ƞ<.|IY7,/;b,_ ̀lqC]RŸ_Yg܋j/K82̘%N '}T\F Y:R o؜]zL3#q4bA>)=!yb- @ւË.N]mI8w:qmg,z"RGKK ;wmaH?Uվ<,}7ƙNdڔI-Q 9oR|FtmIW94cpI !([2}Y+C=4XO @̖3?c_Գa7ne(aDQ uԇ'sT"QS ,H,v\6}Q>߱O/A'tG2M,PgW%+Ӥ%氮*̭67-"  w6߅N݀꺿/ڂH b~d^^l`8Ԍ^И~˂,szm`&a~vX7 ?aSk\^{n۾ݭٱC<^[_Y?f EZ&?.pn[)>D` _M>^~:}`qCt[U•l%/`diN2y4ͱs =XUgyM=w?nkcJdsj*>Mua/fA ^l?FjL:ǜkjKn#뚚y~^:{ v6npw:l}dn\`V!W޳ =0DFyS5MmMAqIN鐞嵧4+kagẄ́m@ebZoV=]!7{ y$nq˩qF.RbicĦ)N]Ȧb8qGBRjC[T4iۤaHP_(4aXeHl7x[)&i8%7Uy1 ?;5ww.K\71#Ҙ9$t__rBwg!q๽64xS+>zxuE x ajaYCS2 @ARMz'`Ħ.v"w q 3 EWϭm#N~Kηս}1D|~"n>i/s߂|Vuu!;91z^M3D,tW+ƺ؋hNҜ@v#GEOA7HFrBUۢk^s7 /췟ulq|϶[|$w#b)b5X_yڲlYpĽUUmy2ҥ]1q q%J27бM.6^^="z~ںϷ{szuiH="D,b>TWw%OHT~]csViqA|U}ce#7oσɤL; Wt1` WUq)" **yQyC5}6UPi E_o^5H؋x0˲Vu;M"'rXoXE̹+BQ9jcЅ R2iN_< CǓOGj4y8%겏>pӧ"r#y>Hnu'J6@e"~KǶ|,P7Ȯ ";Zv7SE8 /'U?j%bIuͼKAĠXղG~k zr1%?A^z~b?֭zKU;ŽK%P#v .x/[Z?_DiY>l\ɹsc^c+_Hx`z1V3"םzc~)D`ѧFSԛ )zد_uLF&֟=ޜ so!M Q>r9e@n)}*o/×@sʎ"۸9[B٢{"!e\H9XKzۖoNe٭"Ru)˗۲Esuy/?#A o8_ջKD*TgڴQ=~=`jb>p%,Ehڈ-- moܼmj-|Ȍ;VU{)jNrOzV7jڴ>"eU/܆:C(T$ tYׄnNΝC79O-,틬m;?"SUvUA◮J7DaE%O?rk`z*rƄX|ܓ@?"Ч fsѫCJc* "YpaoS{{zx}FoĶ%k\^:պzdKDv挟Wn p}w^Ӏ YMt.9v##y;V.# Y]-q'~;@h||m|rl@xLtU?}?){&m~6*CNU|PY7d&Ͻ"fݥ9e4ʴ!Y=ߥe?k]zp,nZzr'6  a3229m$49\r?hSNo oIP.9QD/, ܇:; k7 J,gT};uλ.xR}nL/`45?N/QE(%#m9eۏoLE"b) zNg9t#J){oTIpǒkһO3>ݩ;z?&tɞ' ,ъ9Er&%X$ū]n'T#iW )z-S@0E/o}PkjdY@أWzW}u;C=1ҲYoסT~nNnW>)S#"LmmpXT+A"MDjd;p&~*4ڜڙz5Vsa&$PG*WnZN4l^ I_9l(]"{:,rןEd:%4n3jqHE_nȆ;Q oLW2/~̫Ҝ# C*CvW6Xq_dCȑĄncN<~%K'`[?̺\[k> vTVX[bdmڳ~Lc k?H(}oi ˨. `%y;ppnNxs'H#e 0 Mi\Az28#"ƅ-j6hS+#G 6Iz5uU "+ ~4 4l_U߉Kmj]_;22Xj.Ǡ ! )uA>}dN<1n!tm;ѳ>i/_ZAsQhq7~|;t֥"RAiHݠ?& G>JEf~̅K"׾Syȯ4H:2gPOCDH)pR=TYq{ɚS\-.q7e)с7{#b9Ǻ0Sx{H1/饗ۖ.W;9kٳYcǦ*/Ky+unU/<[4ȍj9eK79N_.?"P1\`WcZ+ScA9D-Gҟc(Z#rݓq3J* I_L$#e(/QP t0YU&!oUbRVr?GGCX N)l""m ~r>. %U!S?kB$w'|1rf9BH\bekŤB>Ic7#"hHYݜdvcz7|{ Eq4T %Ļr?`mU;D5U5S8ɽLKc#pvi03]U]CD`0 7qj`1OfŘI[iz̿^꒕"Mo"4[grTX+E?4u1pO1g=‚iu@'Gab#_<˭LvY$'/e'٦֍2!\bG0PRnLw#"r#FrIC(6ϭ!䌯-Į&t.أA-EJLQ|40T T5Ukp= ;{|U&9"Z5CS{cNb.; $y?P=ʞ| S3Vx]m?zM=Pj.Ѣe/4"/Tu%ϋ4+pWA3ǿ \F.qrUk4V᪼se|>b?A .uHͼ59D@mki,טکւ f?^:ݶxW#ŋrm"NmZl+|E4c+ ~6 e,״5q8 )qF_I v~D-bIw P 5A  ~@}eEq_ރ+B)J~I=/Oe*OS؁5;!$R mꝞ}m&RuŪgc-1Htϖ?d2;M.UL9C䨐wk.>Š !wF.Y„c2fI5ĪĴU6txڙ5;C~t{T{x}P y^-(_ɪK<V ϼ|ܷ'; Py;=.4HT{*%4Ww3DzD{ܯ}ܯ:s( `0ҭSu1)~E\5ZeIђ7f'}[ͧ3+fu^!4  !;+rbbi2M 7ORA3t%>dא>\v| sb6jfz&*ƏjE}xDY*^x$Ֆ[ROaU'ǔZއ>4#8IU;:_|l`wCmCW L}s|wFLG]DS;hQmjX|rMU@8Yh?lo;ID]%xի*t""?O[TZ]\{+ ^ a~A13#w sp -,CK *QsqCa=1+,aYwHٯfӂ:jU~m<bO9:@PJL8w*)<%W0)Jvj-,gٸȰ%ңrGuj&gb/s/RERtK_mK1ƋGR M Aw>GN~O.w4PoOcb_G߾sy&,TE(uyb_>٧nd6ƤML`98 `T®Bp>^W phO}6~WC)q&<$k&>$/I|Ejߪ4rr?qUn2){GSv6OeV̬@|]%orZ0RyoIA)lV\#,}u!A΅Q)B;V )[ݘKE*pB5M?jFaE8+m'۽[&oCHcNל︍TiLhlXA"A#2I?Y n_qX7fL$t#$x(QRvK.PYb2ߡ,7RupԚ2]. ``_sN~/3n̴L#g8 l>MaQIHBA7ՊؙN)%i&WeJ =KA,&R4 { kIC5bN{P#w?g1zQ^7~8PBȍJ6TC#iuCy@3aFǐַZ2pOlksoy\2H~=Te1KI8沄M"rW#UQz:z0yx "C sP )h0,D g))l.S&kx,o SkbO5 _'*XW+I٭i333t"eprФKZ5Gޜx7 K~mgfy.2(Ȗt  "ZTLNo' r0&| p?޶kd?1IM4]v8616MN읗Q?~Eu7AGr& }pj;nL.^1zQnWeX$t _9J᪏ؽ p?޶GtCcbkfͯWvd_h [H:F ZSP]E~EA7TyEV/׹F8lOr^ h]O 7K`~M}T-}KDS)pP { ) +Q ]S"5 > s\jہV`NrEAQfT\'_8X-Du7YfXj]WP$hVBr]UN~O\9*tFi IDAT@uͼEXT1%~>=mO@'ݝz]?vͧ;NM:fى*w?ٳo?m9 CD"r q"HDq" }DՌT^}z47?Jڝ6±ЦNi#QVZCFPPF2rŬQB4wc3ځ`սzXzm"2{fU< Tvֹ߭K^. # B]1zU3Հ-bI]@fX{NvyG{?DҋWy##yO 0a6晓 xl]7^3g_eeoX19F2i?5fDJw0B-s(utBFKL>V d}Ҙ$|Y:՚5#6\ > g4l٠ u)45V:z'S sK+XB>UU0"9.MlE64rzC4C^OH (.ahmCP`*Ğ9щKDNΕcb_뺻ܵ:Xdwؓ#i5̲*nabEh,ߚ}ֆVv4z"E"!U}z,XA@ lw*UYdАG/k! V]Yc)%Kk*>[>r`ҰhtU? \ٮ][ \D0IqR^K)Z?n$W=U~df`5.axK VjfL@sxڏWem\VuxZ {L-c 3bݓ{a(`,bVWڸI~vTkݥ)Y8>*_v+*´WsUN IIJ}{F 'b]0*b^zLFBF_8>'SX*-a"̄ǽOmw^ߜ?ώ+4/,~U{~e-)gcP7\t;UDq5fjueBbNo9S>`N>[r5;<^m%ӹ׬ىcWqMi%m;d9-JǒVz¼1&(QΖɝ%j>A/ILC#R!ϾۺpM]FPKV*hWT`N9j *'$ h.(-tFc%xvǀeDagC^D: f\ m鹱mb'eU*?z](Ģ]c)Zۊ2rݝV_y.QF1b|syBUJGȽ>aךWˬZy%;wOѹ|C*2ovNF<NJj[zF7:[v'nTsMA( _T(#dpD~qi`5:wj@8He4:w zrG#F$y=6oTpD[A.7Q橴uI.T$슡%6R)OY$> GyPV3ҘhPQ#C?@e`\tpJuɊ:-TdA~jϧ|٫` ̛ ^5bFrUG"Uh%z V}ww k1yjj:`˫~M`+T6{e]`hNٱx5Eo\4LlIR`Evk܉ tcLWi< wToZU+g!\آ/ lcAUF鹟+*u#GtƦ0we~Erq]A$J^x:@{o6T _ V-;{^eP`H #UpxZ|F& @*oW *}S90R!i؊cj.1x=pʙXDDyjABļqϬ?ƶJ$<6$M +xp.4M}Pefa]^}+_YfLdz 8=;Dm o/oU1Fڭ fM"jMXA.fںӞ̬qcRE]U+1KD?YX$~AQDשsz7f¤VVZeD_5NCټ],f Ui{X.rljZMىgTnf*f*1&PWTtKʹqoN[`?k#gPt``]Bb,JAVw#^Ml&; )rĀ#OķZH uPo("~E͂o׶X*=(CQ&M{^^uXr"Q*"j^Ad֘N[ĺ@@9eY#s;CTgEXQmuyk ;\Tτiӹ8IR'~j'}#:[75yOp2sdlNodoǓ$^fgb͑0IzqBVd`u%  47KSSI4l+nO.u?f'SR ֍y{9-T aQ -dzi?ki^X;7y߷m2HAỊiWkХx$ma;,gti?|Pӽhӂmei&¨\inhZ%=]e-22r@%] 4QW6x;b @] 𺍾C3g;;_IHҳMچ?RU|F;k}UĜytUSwO 3QSo;:\]Qg a&IJ\ b ]H0*y#b]Y[?);Ic4M˫aMj)p.xH*KU.wo0L[1߬l~'MF]Ɂ;y58{onƱ};},PUUl38xp 8hzw=̓0eȖPCD}2|*n{0tr!r1b3:.x{\E LeنhؤaK_qpUVN4CsHiS( %e0.6wdӖ(o-֎ҹv成MSRZlj |&C)R xHQ&/{yFN!Yr-Qjze Vvk"H II-P/Ƿ>I4ۨ9eko\Rgm4[Wu"BS451b*5 FfL*cdaR@^7ƹ!`ZAzΡ`c:ύP].M߂>|i5}m4c kQ])Sru \ TV*=m],ֵM樹/;s'gxrazӏKcPu[0E4A,]*{կ¶\|&_oR׻h}v,W4I7'}[ jzuE ZF[+! P-&O&Fddd}<`f#Z ^K.;0v;ƿdйA*IZԉ2 e(QGlz] AD3~ʐhʹ/j =ԩkV9mʷ""(1hom TLixSJJ}Srk#$x>o{gҹI/G(EO,r z%Mg~ܬe| FɄ PUP]3f z|S[0|Teba{?Emܿ2l*kW6'=%co7X>r~2Ȣrc PA<%}:P䮀k$YDl &{/y \-z@[c-04C逶 ` MM,.b*r,e+1c` Qasf~kݠ;6v7sȝ$E* 9iTF"-/dRieom8Xb"ts{_I7J"UsnRLjM ҆FRy b7ҷQu~[6r?gOywPUij;vtd~CtYӱٵϑvPW׎jHb5D} (`Ac'Np/8+6ۺ} PG O& um8a.7l0M0+xFb\z-jdį^l__y0t+*P_"(޲VV}VOrܣ/k^oF|`^O!-i/yFNrˌ mA`H^MyU"'\]8ck#إ2ي`\-J#S r B'ۮ<>& )^riZL^2|k>nf3U|,bzn|l3O[LǮ.~Fq5|x|^tՉm̙2j5:QL]K.xX>Qo|u>kw<. 2.+rFsʖgZd]6%$i|nP"$lWkz9:$?"uvmIIS* Q2lЙ7xEPq0/4*)5lZՊy*'Zu$*x*)HꖠDJT~ɟa\V\uP՜xPn•i*jq7GȂkY'NXlZ(* 0=Зnf;ܸQcgij(>n5S̑o_զsTh.D;P:h9IkӘ}xO>'o'ewbKlw%B>ܱk Dz|F8 LC1rZpǢDnzFݬɽvødƯ3pgq`'W9"jRY h_PźZyـmSf\#1cz8/ n nN3>im^揚=dфpDAfX-p)!9]Nw܉~h,y_\6K "r/E\a[}GARZ|Shu*n̉| xb"UGZ]GZ]O]&23G43Nqdx1yk*/X5j:7SF)$ԢnMs %T@ dxHS9J|~B+gmdf[mz6Otgykm3Cz1v]# X9r_W" y 4`*D;!U=NU236PzXq3qXs8;0 ȶ5%ג8>%g ^]~ϝoxYb :B\OJ*yvzU/mϰ+0`7yOA&v,#Zd)%L IDATTe@6z> v;(RCQ%#m# Z#(^pSv҇]}dm\"O)雈 NЖR"ԧ&8]YsHsu%&WZTS&c`>#>(ԩfЭ$0ZX\ g[:Htm@N]R(yJ~lncͮkW|'4\ (JSc;SOL5_.(9[c@uI- x =e|S˨UЊsӪXcx Xm!nlؖO@t+VTI/SUƮIJ)eᔓA<"Ƃ[_͋>xFqqSkPxjShb\Z7FpP09j|UӳO&d@ @$1샊ޟu"rwA돚ǘ6-݀@ɚwJ7uT0~%•5 kW8tprݐ@VJ)tim=9Vyp bslEps&0&trgB24g[-?O-sDrO./ {xw}7}/5ޚrDC@g8ăJ.Pdπ% 3Qauw{cN O`Ra{ԽzO.aD핹Nl>Y|CNѮmPjnc{1r#%q߭Ҫ]3Cۤgjg?1NL,.%9A:JDcEJYN HV/=yLnj2o!"9- (zuɈWS*ZJE Tr]!{||?Hƙ]!z`NǪQvraV3^ADZ…ͩ# |?" 等@b^!VU+7Q x'xvRۆyw5׋a_GĿ_ b΋ z<ذbljvX 3Q\ޏݯg2 "A4xfNbx}CpCɼalUEweAy&e*8(T*u[4MQF/AZt|KrP ;4cl6q.xU&˗T  !rX('+b3=$WTu\mH!IA?mgϲqh* %G%g *^<ńB&kt7{VOf[;nP 2fW,hx:!^rי?0gXJ\@Teg S$0 Fj?n}xaN K qd/.:g+' >,*w8S?bbJݍ5뿈SpԿݽ磥{ݡV|؀{oQP7'a³`R<}c (FJ 3Z5><U Խ=UowlR1dHY "sH!ѓrHc} Ձ)-:XLOsm;6DPy#eWB,SA>JDk}2,CsQ82Q؄_H5BXy;HPW\{Nl" S6nYxz;'tPT)yG}8KD} +mHM;?.kpHp;L]5y<PCϲW]w?}.[(]G@S-P: #(zn(_b"ԛy՛uLT;w|fǘ3יDer#?.dp.~nkLodp<2 K_?ӏpLfH:h`dLS:MPZx-))ܗ\Vax_b+ ُ01`Hcvs;d8jmа}i CY1 N3(m !dDۑk`d- W,F?50D 5@#H^Xpl9uHknkD.hI!0c*0m08Z>0(wu'GDqXNJ5alSv?TKp l1ם~K`r[, \gL^39Sw͹Z aTN'(+gZ *CIF?yiOmTT.[9XIV_\]7/L.ˉ)0\Gk)Yo]wE'vhwùzZt0 IXp`L2 'B\A ? lYLoú=D@ %=HH\HTfK `sPSnUz5\sb9Q`\bSKc`޳+6"=E.0@ -18f 0y*r 9ً3'V381]=&L_+ ;xuKT'L183Sr'h_>񇍅OS80/ZE,j` Gm hfG2S 0J^DdQSKj}:80[ = )˾Sa8~]ufmA>oQFwjy sLPZgڎXC k}F|. "Ӗzڷ5zz~Ɔk,1(KoP$}eMY<F)ٻ"R 5[~ ogH).;wvEMA}Zd$pk8pL $[,)[z߄[H!wDOZ>"o;5ςR\u< npu/S}gy3~Z;uJ;g`::R7}%n×^K?_3Yax:U3O4 %tYYƧPFڛ5wuv1&^)Ms@R{Rr翨,8M w8aȬgkǑ x`R#JrE[OOȅ"2+C%ˤ p&~ xrJsb8R ",=;A@"nbA$' `6s݊\{|CWpܹ7Z#]ocPv|Wb/ MQ16G4aǀtGm:y/4=mI?xNYmF?\+,sȱ"eeޓ"wZ&ll%P`)$Xj4-ܖB*=ߙ oz!Wf 80K<-*vf;i5CBϲ4:yH>v Rc'd,- ejJ.@.P |x!2PNF@񵕥$ u)=h:҅%wfL&PpYIYiH_NGl[<@WE3y={VeSЪxrSOow㲷G'piDCВ  ۴[tFltEmjq/r5=>DfPt{)AcE"k8Cb7DַY46m4[ukWKEjL%v vWR$=f(VdK'&u]$C:n𒳴`W>ʤZőמ'>ʺD"Z<*+3R@!|`@%Ƽ!F޴"Cd}TRzt0.bt?@GN@]G_P\%xq"0 's@FܔLN $W~F.[(8"^;MX>Kt0]TZ؅^ TRGu߿e(X.$zs^U>\l#-Q `Qð;RQWin#vA[=,l(BubžuuP{NK?FTaQCZ>ĞUd}^ȰFos>YgMQ{1]z: ܩ+YLOt5]s؟'a5ϝ{R,7*ftޅ~=YN֫R0 e& w$4=$W҂=1{L-"Y{@D5 e2Wiv׈XNrbgNcʢ#k}VRoA\8b}sƑϚ$#ZF2Kٗ=Ah!"7N\3gٝ<WJHWy.JBϊF_ `^t{(y:`cHDh\7 Нg٭ ,Vg ո~nZeG=D@vc+dwq1=B$q]"Hʘ]T4}c(dICU@^7mlYaC 2ҫBfDps]`0N&X;F2'0B2ble٢ibN3Z' PL>n@$L +΍dp\@YQd j1)@ٲz @1ٱĵ"3 ge1hZX-?Sq(%7j}o=#Y Sꎪ <7V,n 5D$ ~׶w{>x5Eԇt%gL(e8̡ d"~^Oǥـ^ -r5Ydލ, `"t#-I@Efi\ۮ7Kd?c,WL,p@l=J&kf#Ui-D;٪W UЯR 4:&yg2DX"C%bMTTClm(hh{x]ESLjPv5oh`HfFrww'=Wms2U@ yzM6ӓXm-[x b͑62:p6<2ui_Ki> l_fP>"u΅S7~7␂iz0^IsZݵ>@;$tA*q85GL-76=%!wg"S=8MK5 R/,)L^8Hp~FUN&v,_HmWt7!vJIޠ>uTo(=.a+gGYARCY }dv'7p d Q >>w_<$YWvɭJQl?bj!c})BٻZ ?3JbE1cA_x>pKñqY;Ң~=pBP]z'-Y]Mh2Y`f~EC_Oz]Bŧw hG1Q8 Zw~$,v)(N"qIsY bi ?&6H}$Ơ1J(LPZ6!TTȏ zfxdWO>QSEã`B3:"Uke{R!/ (bTaEn@|^\2MshS NI}iKٕ E?=(F1O+>LRCJτQr(F#L|B#hoCmĠP*kIENDB`streamtuner2/logo.svg010064400017500001750000006741511141435606600145510ustar00takakitakaki image/svg+xml 2 streamtuner streamtuner2/streamtuner2.desktop010064400017500001750000000003751141436135200170770ustar00takakitakaki[Desktop Entry] Encoding=UTF-8 Value=1.0 Type=Application Exec=streamtuner2 Icon=streamtuner2.png Name=streamtuner2 Name[en]=streamtuner2 GenericName=Internet radio browser Comment=Listen to shoutcast/xiph/live365 music stations Categories=AudioVideostreamtuner2/README010064400017500001750000000300661144024171000137230ustar00takakitakaki streamtuner2 ============ ST2 is a internet radio browser. It queries online directories like shoutcast and xiph.org for music stations. It mimics the original streamtuner application to some extend. It's however writting in Python instead of C now. how to run ---------- You can just execute the main binary "st2.py". All the other files need to be copied into /usr/share/streamtuner2/ however. If it doesn't work, make sure you have Python and gtk/pygtk installed: sudo apt-get install python python-gtk2 python-glade2 python-xdg The *.glade file represents the GUI. So if you have the glade-3 application installed, you can inspect and enhance the interface. Give it a try, report back. development state ----------------- There was a lengthy development pause, actual maintenance time of this application is probably 2-3 months now. However, it is mostly feature-complete meanwhile. The internal application structures are somewhat settled. Some modules are still pretty rough (json format, http functions), and especially the action module for playing and recording isn't very autonomic (though has some heuristic and uses stream format hints). The directory modules mostly work. If they don't, it's just a matter of adapting the regular expressions. Shoutcast and Live365 lately changed the HTML output, so broke. Therefore PyQuery parsing methods were implemented, which extract stream info from HTML soup using CSS/jQuery selectors. And it should be pretty easy to add new ones. There will also somewhen be a feature to add simple station 'scripts'; in testing. comparison to streamtuner1 -------------------------- Streamtuner1 has been written around 2002-2004. At this time it was totally unfeasible to write any responsive application in a scripting language. Therefore it was implemented in C, which made it speedy. Using C however has some drawbacks. The codebase is more elaborate, and it often takes more time to adapt things. Personally I had some occasional crashes because of corrupt data from some of the directory services. Because that was more difficult to fix in C code, this rewrite started. It's purely for practical purposes, not because there was anything wrong with streamtuner1. streamtuner2 being implemented directly in Python (the C version had a Python plugin), cuts down the code size considerably. It's much easier to add new plugins. (Even though it doesn't look that way yet.) For older machines or netbooks, the C streamtuner1 might overall remain the better choice, btw. Running inside the Python VM makes this variant more stable, but also way more memory hungry. (This has been reduced with some de-optimizations already.) advertisement ------------- If you are looking for a lightweight alternative, Tunapie is still in development, and it has a working Shoutcast+Xiph reader. http://tunapie.sourceforge.net/ Streamtuner2 CLI can also be used as proxy server for streamtuner1. There is a wrapper available. But there was nobody yet to be found who wanted set it up globally. (Else streamtuner1 would just require a patch in /etc/hosts to work again.) It's available as st1proxy.tgz and cli-mode-only.tgz from http://milki.erphesfurt.de/streamtuner2/ext/ Contact me for setup help. Requires a webserver with unrestrained vhost support. license ------- Public Domain. (no rules: unrestricted copying, modification, distribution, etc.) history ------- 2.0.8 - configuration files use prettified json - fixed double quotation for %pls players and /local/file handling - (unused) channel .shutdown function introduced (late saving) - external plugin: basic file browser, no ID3 editing yet - allow interpolating of %genre and other fields, streamripper wrapper - fixed pyQuery parsing for latest shoutcast change (strangely the regular expressions were still working) 2.0.7 - json cache files are now stored .gz compressed - xiph channel .format() call fixed into .mime_fmt() - simplified __init__ and attributes of main window object - .play() is now a per-channel function - global_key now accepts multiple keys, updates gtk view - new musicgoal plugin with radios and podcasts - silenced channel initialization errors - double clicking tabs is functioning now (-> channel service homepage) - shoutcast finally became a real channel plugin - processing.py pseudo module removed 2.0.6 - mirrored Station>Extensions menu into stream context menu - creation of .nobackup files in cache/ and icons/ directories - global_key plugin allows radio switching via keyboard shortcut - compound channel plugin is new, mixes different source channels - new external plugin: podspider - more documentation restructuring - feature plugins` options are now listed in configuration dialog - current_channel_gtk() - added basic package dependencies for .deb archives, packaged-in lxml/ removed (lacked etree.so anyway) - TV plugin for shoutcast video listings - simpler overriding of stream column titles is now possible - cleaner .src.tgz package, contrib/ files have been externalized - minor fix for quicksearch function 2.0.5 - display logic now can extract homepage URLs from station titles - automated google search for missing station homepages - kept .m3u files are reused for playing (faster) - registration code for (stations) extension submenu - timer plugin for programming broadcast play/recordings, uses kronos - heuristic update of bookmarks when reloading station lists - general thread() wrapper function implemented, for worker pool - simple mygtk wrapper for adding menu entries - MyOggRadio plugin is now complete, can upload individual favourites - links plugin, which lists other radio directories in bookmarks tab - CLI mode implemented - two PHP wrapper scripts to generate YP.Shoutcast for Streamtuner1 - GUI startup progress window added - one GtkWarning message fixed - the Shoutcast channel was plugin-ed out, but remains in the UI file - multiple additions to and restructuring of the help files, manpage 2.0.4 - PyQuery parsing for InternetRadio.org.uk channel, and adapted PQ usage for shoutcast - utility function http.fix_url extended, common string parsing functions strip_tags, mime_fmt are now in channels.GenericChannel - http module reworked, visual feedback now for GET and AJAX methods, and CookieJar was enabled - channel/tab order can now be configured (instead of tab dragging) - fixed PyQuery wrapper module, packaged lxml modules in (evades extra support for Windows port, native modules will be used on Linux) - more Gtk.Widget mini help popups in the dialog windows 2.0.3 - new channel plugin: MyOggRadio (an open source directory) - also Internet-Radio.org.uk channel, but only regex parsing for now - the quick search box is now in the toolbar, while an all-scanning search feature has been implemented in the former dialog - Shoutcast.com broke regex parsing, the homepage links are gone - Category updates are now performed in a thread too - interna: GenericChannel.display() is now .prepare() - live365 category parsing fixed - Live365 and Xiph are no longer built-in tabs, can be fully disabled - fixed disabling search, config, streamedit windows (gtk close event) - and a few help files were added 2.0.2 - more checks for initializing channel plugins - gtkrc theming support extended: apply and combobox in config dialog - PyQuery as new alternative parsing mechanism, as option for shoutcast - category tree gets loaded on first display of empty channels - windows port tested, new external project: python+gtk installer bundle - removal of .pyc bytecode files from generic .deb and .rpm packages - distribution includes gtkrc theme "MountainDew" - removed most debug print statements, introduced a config option for it 2.0.1 - new channel plugin: jamendo (just a simple browser for now) - new channel plugin: basicch (all new, because old scraper nonfunc) - new channel plugin: punkcast (just a very basic listing) - fixed shoutcast channel parsing - new elaborate http.ajax method using braindamaged urllib2 - extremely cool plugin configuration scheme implented w/ GUI controls - plugins (except code or glade built-in) now deactivatable individually - preliminary support for application themes - support for per-channel .play() method - reenabled audioformat= in play calls - channeltab doubleclick doesn't work despite hours of fruitless trying - add "format" to stream edit dialog - new helper methods: mygtk.bg(), config.get() 2.0.0 - search function implemented, highlights results in current category list - right click context menu added - station data inspection/editor added - auto_save_appstate implemented - station delete implemented - clean up of internal application interfaces: new self.channel() instead of self.c[self.cc] kludges all around (all traces rm), and new self.row(), and some auxiliary windows now have handling code in separate classes - now real favicons for directory providers are displayed - removed directory service homepage button (still in menu), donated icon to stations instead; double-click on channel tab still resultless - load_favicon hook (for currently playing station) added - added channel switching to menu, and submenu view merged into edit - fixed initialisation of open channel tab (previously only default shoutcast was populated by .first_show method) - made a new logo for 2.0.0 release - fixed shoutcast category loading 1.9.9-2 - bookmark handling fixed - pson/json decoding still flaky 1.9.9 - fixed record action - shoutcast parser redone twice - rebuilding of TreeView list more robust noew, always in gobject.idle() - favicon downloading methods implemented, display enabled per default - live365 is buggy, but usable - bookmarks still broken 1.9.8 - save-as dialog implemented (export to .m3u, .pls or .xspf) - download progress bar added - last selected category and stream entry is remembered in all channel tabs now (though the implementation is spread between a separate but unused state.json and mygtk.app_state() which stores notext indexes) - menu edit/copy implemented: saves current stream URL into clipboard - fixed XDG_CONFIG_HOME use - code parts have been extracted into separate modules - the Google stations plugin has been ported from streamtuner1 - packaging of .deb and .rpm archives 1.9.7 - configuration window added - threading support enabled (uses python 2.6 processing if avail) - more generic window/state saving - update_categories() added in menu - "generic" class for channels has been separated from shoutcast - new channel module "live365" (without login support) - bookmarks module has comments now - new defaults for audio/ogg and other media types - pseudo-json is now a fallback if python 2.6 module isn't there - preparations for saner url extraction in action. class - better doc for mygtk.columns() - (temp.) faster initial startup by not .load()ing default category 1.9.6 - added xiph.org example implementation, incomplete - bookmark module basics done - rowmap defined manually again, instead of in mygtk. 1.9.5 - basic menu added, toolbar style switching - glade XML file is searched in binary dir - static classes move to bottom - forgotten/deleted streams feature added - display() filter 1.9.4 - category change - app_state - mygtk.column_view() and .column_data() have been merged into more general mygtk.columns() - more elaborate, only depends on datamap, instead of individual sublists, pixbuf support added - shoutcast.rowtypes cellmap titles colsizes cellrendr colattrs gone 1.9.3 - thread for http GET (doesn't work) - app_restore added - action.record, browse, homepage stream / channel, .reload streams - .status() shortuct - dict2list removed (now in .columns_view) - treeviewcolumns sizes - simplified form of datamap, less dicts, more lists, reshuffled - http. wrapper class - action. with actual os.system() call 1.9.2 - action.play(), .m3u - pseudojson instead of pickle in Config class - more doc on top - icons in column_view() for category trees - fix for [format] regex in shoutcast - mime defaults for action. module in conf . .pls and .m3u methods in action. 1.9.1 - first real shoutcast server scans - ?? - .. - cache stream lists + category names 1.9.0 - first GUI implementation with standard glade - at least shoutcast category names were read streamtuner2/help/action_homepage.page010064400017500001750000000016221143127572500177640ustar00takakitakaki Start a web browser for a station. Radio homepages

    Most radio stations are listed with a homepage URL. Use the house symbol in the toolbar or right click homepage to open it in a web browser.

    Some homepage links are guessed, because for example Shoutcast doesn't list them anymore. And if you play a station without homepage URL, it automatically gets googled (but will not be displayed until you reselect the category.)

    Channel service

    You can also open channel homepages. Either from the Channel menu, or via double clicking the channel tab.

    streamtuner2/help/action_playing.page010064400017500001750000000022501142324024600176270ustar00takakitakaki Double click a station to start it in your audio player. Playing

    Simply double click a station to start it with your audio player.

    In streamtuner2 you can configure different audio players for different audio formats. In the 'Apps' section of the settings dialog, there is one application associated with each audio file (MIME) type. Per default this is audacious, but you can certainly use any other application.

    Note however, that some audio players will start twice and won't allow easy station switching. In these circumstance it might be sensible to write a wrapper script, or configure special commandline arguments to your favoured audio player.

    It's also possible to save a station entry as .m3u or .pls file, and load this manually in your player. You might even want to collect such .m3u files for archival / backup purposes.

    streamtuner2/help/action_recording.page010064400017500001750000000024061142324034300201410ustar00takakitakaki Save radio songs as MP3 files via streamripper. Recording

    Most stations that stream MP3 or OGG music can be recorded. This is accomplished by the commandline tool "streamripper". If you select a station a press the [record] button, a console window should appear. Within that streamripper outputs its current activity.

    Per default recorded streams are written into the current directory. Often this is your home directory. And streamripper automatically creates a directory with the recorded radio station title as name. Within that directory the individual music titles are split into separate .mp3 files.

    You can influence all these behaviours with -arguments to the streamripper program. Please refer to the manpage of streamripper. The options are too various to list here. You can set default arguments (e.g. another default save directory) in the 'Apps' section of the streamtuner2 configuration dialog.

    streamtuner2/help/action_saving.page010064400017500001750000000013531142324514000174540ustar00takakitakaki Export a station entry as .m3u/.pls file. Saving

    You can export the currently selected stream using Station Save. A file dialog opens, where you can adapt the title. The extension of the filename decides on the saved link format. Per default a .m3u file is created, because most audio players understand this format.

    But you can also save in .pls or .xspf or .asx or .smil format. Note that the lower right dropdown has no effect, you must edit the filename field.

    streamtuner2/help/channel_bookmarks.page010064400017500001750000000027371143061456300203260ustar00takakitakaki Collect favourites via bookmark function, entry editing and deleting. Bookmarks

    It's easy to lose the overview when you browse through the various channel tabs, genres and internet radio stations. Therefore streamtuner2 allows to create bookmarks. This way you can collect favoured streams in a single place.

    Just right click a station you want to bookmark, and choose bookmark in the context menu. Alternatively you can use the Streams entry in the application menu. Once bookmarked you can see the station entry in the (!) "bookmarks" tab, under "favourite". That's where they all go.

    Editing

    Entries can be removed from the bookmark list again. Use the edit menu for that.

    Bookmarked stations are shown with a star in all other channel tabs. If you delete the entry there, it won't remove it from the real bookmarks list.

    Subcategories

    The bookmarks tab can display other categories besides "favourite". For example the search feature creates a "search", while the "timer" and "links" plugins also display lists here.

    streamtuner2/help/channel_internetradioorguk.page010064400017500001750000000013021143061304500222320ustar00takakitakaki Large radio directory from the UK. Internet-Radio.org.uk

    I-R.org.uk is a good alternative to Shoutcast. It also lacks channel homepage in most cases, but is a likewise encyclopedical directory. Genres are similiar to Shoutcast.

    Note that this plugin uses its own setting for how many links to retrieve. Instead of stream numbers, it only counts pages. This is a lazyness related bug.

    streamtuner2/help/channel_jamendo.page010064400017500001750000000013601142324305400177350ustar00takakitakaki Creative Commons music and artist hub. Jamendo

    Jamendo is a Creative Comments licensing oriented music distributor. It hosts audio files for individual musicians and artists. Tracks and albums can thus be downloaded free of charge. Yeah, imagine that.

    The streamtuner2 plugin for Jamendo is pretty limited at the moment. It just provides a quick overview over top artists and most listened albums from each genre. To browse the whole collections, better go to the Jamendo homepage or use Rhythmbox.

    streamtuner2/help/channel_myoggradio.page010064400017500001750000000031341142324520200204570ustar00takakitakaki Open source stream directory. MyOggRadio

    MyOggRadio is an open source internet radio directory project. Since it provides its station list as JSON it is very well supported.

    Because the directory is currently still rather small, streamtuner2 provides the option to share radion station links. Use the Station Extensions Share... menu entry to upload the currently selected radio (from your favourite bookmarks).

    The personal section is empty, unless you specify an user account in the settings and actually bookmarks stations on the MyOggRadio web site. Shared entries are not automatically in the MOR favorite list.

    Channel options. <code>Login settings</code>

    If you want to upload station infos to MyOggRadio, you need an account there. Registration is free and doesn't require personal information nor email address. Specify username and password separated with a : colon in this field.

    <code>stream URL format</code>

    When uploading stations, the streaming URL can be converted into RAW format. You can however leave it as .PLS link file.

    streamtuner2/help/channel_shoutcast.page010064400017500001750000000024631142324305400203420ustar00takakitakaki One of the bigger radio station lists. Shoutcast

    SHOUTcast is the name of a MP3 streaming server software. It automatically collects all station lists on shoutcast.com.

    Station entries usually provide current playing information.

    Stream links are plain PLS files.

    Genres are subcategorized, so the main groups in the category list must often be expanded to see the interesting entries.

    There are a few plugin options for this channel. <code>PyQuery parsing</code>

    Uses an XML parser to extract station data from within HTML <tags> of the shoutcast.com site. This is slower, but often more reliable than regular expressions, which look for plain text markers.

    <code>debug output</code>

    Prints verbose messages to the console. This option is used while developing extensions for Streamtuner2.

    streamtuner2/help/channel_xiph.page010064400017500001750000000024501142510240500172650ustar00takakitakaki ICEcast radio directory. Xiph.org

    Xiph.org is a non-profit organization, which develops and promotes the OGG audio format. It also hosts a list of ICEcast streaming stations (ICEcast is the non-commercial pendant to the SHOUTcast server.)

    This channel is especially easy to read by Streamtuner2, because the source data is already provided as <XML> file. However, it lacks some essential informaitons like station homepages and listener numbers.

    Xiph also uses the .xspf format, instead of .pls stream links

    Channel options. <code>Filter by minimum bitrate</code>

    The bitrate of an audio stream determines the music quality. Many Xiph streams have simple and low quality microphone sources. To filter these out, and only leave high quality music stations, you can therefore set this option. OGG starts to sound good with 128 kbit/s (whereas MP3 often needs 192 at least).

    streamtuner2/help/channels.page010064400017500001750000000044661143127673400164470ustar00takakitakaki Switching through the channel tabs, different radio directoriy services. Channels

    The tabs in the main window represent the music directory channels. Each lists categories and streams from a specific radio directory.

    Tabs

    If you first select a channel tab, the categories should appear or be loading. Otherwise:

    Select a channel tab

    Choose Channel Reload categories...

    Pick a category/genre from the overview, left.

    Individual channels provide different music/radio information. The table views are however identical in each tab. You could modify the datamap[] in the plugin files, if you want to adapt this.

    Available channels
    Channel service homepage

    You can dobule click the channel tab to view the website of a directory services. Alternatively there is an entry in the Channel menu.

    Channel menu options Homepage of directory service

    Opens a webbrowser with the current channels website.

    Reload

    Updates the current category and displays fresh station lists.

    Update favicons...

    Starts assembling favicons for the current list of stations in the background. To actually display the freshly loaded favicons, reselect the current category.

    Reload category tree

    Updates the genre list in the left category pane. For most channels the category list is already complete, but it might change over time. So this option often only is used for initializing streamtuner2 channels when the list is empty.

    The channel menu also contains a list of available channels. Select an entry to switch into that tab.

    streamtuner2/help/cli.page010064400017500001750000000041311142324100000153630ustar00takakitakaki Console interface, exporting data. CLI (command line interface)

    Streamtuner2 is a graphical tool. But it also has a limited commandline interface, which can be used to query station information. This is useful for interfacing with other applications.

    Open a terminal and call streamtuner2 help to get an overview over available commands.

    Examples <code>streamtuner2 play "station"</code>

    This command looks through all channel plugins, and finds a station containing the mentioned title. In the shell you must put the station title in quotes, if it contains e.g. spaces. You can optionally specify a channelname before the station title.

    <code>streamtuner2 url [channel] "station"</code>

    Just looks up the streaming URL for a given station. If no channel name is given, ST2 searches through all available channel plugins again.

    <code>streamtuner2 stream shoutcast "station"</code>

    Prints available station data as JSON

    <code>streamtuner2 category internet_radio_org_uk "Pop"</code>

    Loads fresh data from the specified channel service, and prints out JSON data for the whole category. Note that the category name must have the excact case.

    <code>streamtuner2 categories channel</code>

    Fetches the current list of available categories/genres for a channel service.

    Specifying a channel name is often optional. If you add one, it should be all-lowercase. You can also give a list, like "shoutcast,xiph" which would be searched then.

    streamtuner2/help/config_apps.page010064400017500001750000000045531143061562200171310ustar00takakitakaki Common applications to use as players. Audio players

    On BSD/Linux systems there are a plethora of audio players. In streamtuner2 you can configure most of them as target application. Mostly it makes sense to use a single application for all audio formats. But at least the */* media type should be handled by a generic player, like vlc.

    Audaciousaudacious %m3uaudio
    XMMS2xmms2 %m3uaudio
    Amarokamarok -l %plsaudio
    Exaileexaile %m3uaudio
    Amarokamarok -l %plsaudio
    mplayermplayer %srvconsole
    VLCvlc %uvideo/*
    Totemtotem %uvideo/*
    Media Playermplayer2.exe %plsWin32

    Some audio players open a second instance when you actually want to switch radios. In this case it's a common workaround to write pkill vlc ; vlc %u instead. This ends the previous player and starts it anew.

    Some applications, like Rhythmbox or Banshee, are pure GUI applications and cannot be invoked with a play URL by other apps. This makes them unsuitable for use with streamtuner2.

    URL placeholders

    Any listed application can be invoked with a different kind of URL or filename.be invoked with a play URL by other apps. Which to use often depends on the application.

    PlaceholderAlternativesURL/Filename type
    %m3u%f %g %mProvides a local .m3u file for the streaming station
    %pls%url %u %rEither a remote .pls resource, or a local .pls file (if converted)
    %srv%d %sDirect link to first streaming address, e.g. http://72.5.9.33:7500

    You sould preferrably use the long forms. Most audio players like %m3u most, while streamripper needs %srv for recording.

    streamtuner2/help/configuration.page010064400017500001750000000112161142324336000175000ustar00takakitakaki F12 brings up the options window with the Player, Display, System and Plugin sections. Settings dialog

    There are many options in streamtuner2. You can find the settings dialog in the edit menu, preferences (last entry).

    It is separated into four main areas. Player

    Lists audio formats and the applications which shall be used for playing.

    Display

    Influences the display of all stream/station lists.

    System

    Filesystem and environment settings. Boring.

    Channel Plugins

    Every channel tab can have specific options. These are configured here. Also you can disable channels you don't need.

    Player application settings

    MIME types are elaborate identifiers for file types. audio/mp3 for example represents MP3 files, and audio/ogg means just OGG.

    The text entry fields can hold the application name of an audio player. Often the application name is just a lower case version of the program title, but you might have to look it up.

    Behind the application program name is a placeholder. If the audio player is invoked, this placeholder gets replaces with an URL (a http://..-Link) of the selected radio stream.

    It's introduced by percent sign, and followed by letters. %m3u for example generates a .m3u file. Most audio players understand that. Otherwise try %pls, which might even be faster because streamtuner2 doesn't have to download and preprocess it. %srv instead gives a direct stream link.

    The entry for */* is a catch-all. If the audio format of a radio station isn't know, this application gets called.

    Recording

    The last entry in the 'Apps' section specifies streamripper. It is used for recording stations. You might want to add some commandline -arguments here.

    Display/GUI options

    Most options here a self-explanatory. The options for the favicons define if station entries should show little icons. Not all stations have one, so you might as well turn this off to conserve a little memory.

    The number of stations setting is not honored by all channel plugins. Often it's not possible to load more or fewer station entries. Some plugins have own settings (in the 'Plugins' section) even. For the major plugins this however limits how much scroll text appears in the stream lists.

    "Retain deleted stations" keeps old entries, when you reload a category/genre. Shoutcast often forgets stations or throws them out. If you keep this option enabled, these entries are kept in streamtuner2. Browse down in the stations list to still see them.

    It's possible to select a Gtk+ theme. But not all themes work with all Gtk display engines, and not all themes work with streamtuner2. You just have to try it.

    Remembering window states makes streamtuner2 not forget which channel and category was last selected. You can however disable this option, and instead manually save the window states/layout in the edit menu, if you want.

    System info

    There are just a few options here, and some are hard-wired. Usually you don't want to change them.

    Setting another temporary directory might be useful, if you want to keep the temporary .m3u cache files. They are created whenever you hit play. For archival or speed-up porposes you might want to keep them elsewhere. They don't take a lot of space.

    Plugin and Channel settings

    Each channel plugin can bring its own list of configuration settings. These are collected here.

    The heading for each plugin is actually a button, which allows disabling the plugin. Alas the state cannot be easily discerned with all themes.

    If you want to find out more about the short option descriptions (most settings are checkboxes), please have a look into the channels directory /usr/share/streamtuner2/channels/ and corresponding *.py files. These are readable, and sometimes contain more information.

    streamtuner2/help/extending.page010064400017500001750000000030041142324112700166110ustar00takakitakaki Writing your own channel plugins. Extension Howto

    Streamtuner2 is written in Python, a rather easy programming language. And it's also rather simple to write a new channel plugin.

    The basic layout of every channel plugin is as follows:

    from channels import * class myplugin (ChannelPlugin): title = "MyNewChannel" module = "myplugin" homepage = "http://www.mymusicstation.com/" categories = [] def update_categories(self): self.categories = [] def update_streams(self, cat, force=0): entries = [] # ... # get it from somewhere # ... return entries

    There are some self-explanatory description fields, and two important methods. Sometimes you don't need categories even. The update_streams() function often downloads a website, parses it with regular expressions or PyQuery / DOM methods, and packs into into a result list.

    Here entries is a list of dictionaries, with standardized entry names like "title" and "playing" for the description, and "homepage" for a browsable link, and most importantly "url" for the actual streaming link. Often you want to add a "genre" and "format" and "bitrate" info. But this depends on your plugins data source, really.

    streamtuner2/help/global_key.page010064400017500001750000000031341143062160200167360ustar00takakitakaki Global keyboard shortcut for radio switching. Global_key

    Using the global_key extension you can define a keyboard shortcut for switching within a list of favourite radio stations. Per default it uses your bookmarks list, but it can be configured to alternate in any other channel.

    This is useful if you don't want to keep the streamtuner2 window in the foreground all the time, but still want to switch radios easily.

    Go into settings F12 for plugins to define a keyboard shortcut:

    keyspec:corresponds to:
    F9Responds to only F9. (too generic)
    <Meta>Roften the 'Windows'-key and R
    <Ctrl>NControl and N
    <Shift>F1Shift and F1
    <Alt>SPACEUse Alt and Space. (too generic)
    XF86ForwardUses a "forward" function key. (default)

    To define another channel as source, enter its module name in the according field. Also add a category separated by : colon here.

    If the configured keyboard shortcut conflicts with another application, it won't work. And if you choose it too generic, you won't be able to type longer documents. Also found out: key names are case sensitive.

    streamtuner2/help/glossary.page010064400017500001750000000100111143061400600164610ustar00takakitakaki Technical and streamtuner2 specific terminology and jargon. Glossary Channel

    Each tab in the main window is a "channel". It represents one music directory service.

    Stream

    "stream" is a technical term which means continuosly flowing data. MP3 radio music for example is streamed, because it's not just a time-limited audio file, but unending (unless you stop the player or paying your ISP).

    In streamtuner2 we also use the terms "stream" and radio "station" interchangably.

    Genre

    Music genres are represented as "categories" in the left pane. Every channel groups its music stations into some structure.

    URL

    URL stands for "Uniform Resource Locator" and simply means hyperlinks and web addresses like http://www.example.org/. There is also the hipster term "URI", which is technically more general (but superseeded by "IRI" and "IRL" anyway). In streamtuner2 the audio streaming link often is an URL, as is the radio station homepage of course.

    Radio

    Plays music. Sometimes interrupted by advertisements.

    Favicons

    Favicons are small symbols for websites. Every website has one. Or should have. As it makes it easier to associate content with homepage addresses. (ST2 downloads favicons either per menu command or automatically for the current station once you hit play.)

    Cache

    Radio lists are kept in "cache" files for efficiency reasons. To not redownload stream information on every category or channel flip, streamtuner2 saves this data. This avoids time consuming server requests.

    Python

    Python is a programming language. It provides extensive constructs and many functions, yet is easy to learn. See python.org and Google.

    MP3

    MP3 (MPEG Layer 3) is an audio file format, part of the wider MPEG (Motion Picture Expert Group) video format. It's the most widespread format in use today, however doesn't provide the highest audio quality..

    OGG Vorbis

    OGG is a multimedia file format. Vorbis is an audio compression format. OGG Vorbis was developed as alternative to MP3. It's often of higher quality at lower file sizes, and isn't encumbered by US software patents.

    MIME

    For classification of web and email content, two-factor descriptions like "audio/ogg" are advised. These are called Multipurpose Internet Mail Extension types, and are used on the web in lieu of file extensions (which URL resources don't have). Note that ST2 uses the MP3 type wrong; it's officially audio/mpeg, and not audio/mp3 as shown in the settings window.

    Bitrate

    Audio streams are compressed with exactness loss. This can be heard at lower "bitrates". For MP3 files any music with less than 100 kbit/s starts to hiss, while OGG Vorbis still sounds okay at a datarate of for example 64 kbit/s. So while bitrate basically means file size per duration, it's commonly used as quality indicator.

    Filetypes

    Besides audio formats MP3 and OGG, there are also station/streaming link files. These are often downloaded from the directory servers, before your music player gets activated.

    streamtuner2/help/glossary_json.page010064400017500001750000000013251142324116400175250ustar00takakitakaki JavaScript Object Notation is a common data exchange format. JSON files

    JSON is a data representation format derived from Javascript (the browser embedded programming language for websites). Streamtuner2 uses it to store it's configuration and radio station data files.

    The MIME type of these files is application/json and they often look like:

    [ { "title": "Station title..", "url": "http://1.2.3.4:8000/", "homepage": "http://radio.org/", }, { "title": "second" } ]

    streamtuner2/help/glossary_m3u.page010064400017500001750000000007641142324125300172650ustar00takakitakaki MP3-URL playlist file. .M3U files

    M3U files also contain playlists, like .pls files. But they are often used by locally installed audio players.

    The MIME type of these files is audio/x-mpegurl and they often look like:

    #M3U http://123.45.67.189:8222/ http://123.45.67.222:7555/

    streamtuner2/help/glossary_pls.page010064400017500001750000000011261142324137400173540ustar00takakitakaki Playlist file format. .PLS files

    Playlist files often have the extension .pls

    It's the primary station stream link format of SHOUTcast.

    The MIME type of these files is audio/x-scpls and they often look like:

    [playlist] numberofentries=1 File1=http://123.45.67.189:8222 Title1=(#1 - 555/2000) radio station Xyz - top 100 - all the hitzez Length1=-1

    streamtuner2/help/guiseq010075500017500001750000000002621142324452600152220ustar00takakitakaki#!/usr/bin/perl -p s{()?(\w.+( > \w.+)+)()?}{$2}im; s{(\w.+?) > (\w.+?)}{$1 $2}img; s{(\w.+?) > (\w.+?)}{$1 $2}img; streamtuner2/help/img004075500017500001750000000000001143721627600145055ustar00takakitakakistreamtuner2/help/img/categories.png010064400017500001750000000161701142314056400174100ustar00takakitakakiPNG  IHDRokqLsRGBbKGD pHYs  tIME98m tEXtCommentCreated with GIMPWIDATxw|TUǿw&3 %z/BA*  gEwm}}E]_kݵDE@ҥH̽$2!9̭=wJU˂sI )=6Ez?[@EVc ETxns~K"(q/8 NWFTEq~Tҭ*"6E"\mP6ٸ~P[Wv9 Q:`%W\ٍe z9Uam,R"O~F~6"HH)[Ɇ`w&s("N^ZĩBEu2!$X#'rc*+4NY":Pm8*oktP,rY6ctJt*J( $ZmKI@{1e(#ZO]GDJ$&£, +y';kO$vRH G >G(&/o*5(+%-vˮlFٚcUr0 vњL:8iIXAW.@ gg$("5FDvZv$)up%zq%BCO[CH| %6-e9ͺgS]n$,jC0D[rH A ͸~D;_0C+u %Xώ# T%aX^v *a !:TR[D`,vJU:ZE!%W.qey)UG/?wE'G;[9p"݋9zp$;ȊfHLţhgW&bj0a[xs:sx:tfMl %,4mc9&OD#b6g|[5 o&~OҨ.?=/|SJ,e^1;SBvjAko:?PJ|N]z~'8imArQڌLh3DMoV߇P $хB:[Ԋ2VPS2mXa!'xM*,gCd$"&97 #Sy09Շg--|~c:~SGjհLtlSJ&.ř9PxEOf,o#߷%${`kkX8RQVBaiN?ɣ} !!ֺ#Y-L`ԺyI{ܥ?@2T!4cCIa!7}KWb 3cdoMNn5o$@h$u;~[a~OKrQ|pLf3zC jgu""1C(.%&ip7A `9$w<孪vZHN14j X8*hӁ<( ,<, BaQulQ^V,aeжm[ZliF dݤVR5$`Yq9pTUkX$DD&-- =-[޽{w^N>M>};}! H"N–BhxLGҥT؋=y9oQ8q"_~SѼysl66gժUVcE?I,NVAӖ)$]YC@z(c8q"/6ޥט`2[BE)4gͭ-C8KjI>ZIn*Ç_Ett]]C&̻}s.Wᤡ R'S @hGQ1b8z.u4Q TTM Hva6kz1)S".IHOxw*}0GY g _Iٵsj^_K 7cm L7޿/Œ?~iLq  (u ξfYlMjy1\ ~T2BֵQ8.Ra\E{>34D/̡ 06x/>|Sse[ Y q[| ڻUaʝs0QM\T&#BwSGo`"qULͪ޾%?J2$GN%OL~7>G07{d6NKQNO%oa}O'C @eNR`|Xz?wzai ֣xd,z&QvKf?,O&tcp9#[\V\nre,dwh}eyzc'x5zL/C&JmՌ(9PBG :K; Wm cĘ|~S/GWQjJj]ig3hڟ|6_ANBLLQu'F8`~u ݚGڑU[F%_ǝ? ;)-`zø˼d73C}7)C7z;׿TΏt`YUo[w(U kHUEː"$!uU8N?78|cG`Hf~F.#KSU(zGN>7הPhThd9_f2لS\.1ᭃp˜oU5!QU'*IV"KK1: "?@ q=ٚJ3\> m=+ n\IHef3fsrPIp_qxvw>˔;d9\:|YfHd#冧y2=-'<I8#MX-ROx~^1e\aQ5ws,yOϫخx'SQWDK!Zv#yZFcWD,G{&5JNn'Ɵg5eWWW"ut/ݴ'<˹yƣ=%K,' !@ƌNw@nj8^u`F#G:rعzY:,~k f2ݴ35EGdɲ؅\5tvb'Y?o9.k:'gLTbd+e\e:2}OO;rgҼ8x24O;M!q{cL?ge-\NA.LvgIbK#_~ǾNf"{f4~y)[װt.=cB2k&s8ϏWºwƒ)Sɸj I[ײ;V%% Qǔ~`shd=-tΊv཯Gbxqf O N,;fϰGܽg2Ww~@ë<4_,;]^sE v<.tG;0iazp_Ƴxb;=MΓno 0bZ}n5o&edRmNLC4d6Ϧo,Ƣ5 zj$K,S+ image/svg+xml streamtuner2 Station Edit Channel Help bookmarks Shoutcast Xiph Jamendo CategoryGenregenregenregenregenre Radio station 128 http://www.radiStation title ... 128 http://123.fm/Stream title .... 128 http://www............................... streamtuner2/help/img/logo.png010064400017500001750000000035321142323743400162240ustar00takakitakakiPNG  IHDRw=bKGD pHYs;; vpAgxL^IDATH]kW}g޹ϲ݅vi. .MIBD-h`%iCblƘX?ըH^HH%MV@K)weٝaw\ޙy/3蓜O<\ѣr˥KܑCvjJ,lܨ3"^9i+뀗i`9Ç!Duugi.%zTU1U(PxA>-1k`_>@tLy3Y)l=N#&mzxy7_8To-z{u_I3d=|9`X4bѝ;&u_V? __m/z(ʛ7bj q;1M\D%d ΩSA;W: x3||8 0'hе{U>-W G=-F w^ N[@5 ||o2)?|k=nEϷmKuXXm?g0ߒ9_bͻD%!(x 8 oqx8zb=xP쵯oO)@@w5uu8y Y \fM E21%A0+;v׉Iwpp -R@Vf%u렴A<0fkw;SߴM6+@ DͮZ,+fϜO4>ʕ^k^. !Nl 3iӈp}C@-7xo ڗ@O[Z17]$g-a .A[F"&Xh|1MFOi+١\m^eUfqP;O"d&="K_Om'5&|>@Y Z1鷻%U Ow~!i DHTB4hjuL4fE쨒ZͅJn46$ק#MN,(T, M;&lyl0ؽ5~~-ĝŀf/}ek jnX*] 3zgYǴˑJ'dy(D`<f3+r>)Xcbeg%57rE[On|SְCMI3ydШɗ6WźϬn ,)(ki O4m! h"`_-PW䆧{Z8ppc,̺wM܄[>y㒊8d{$5 +&bbH2kM=냩cmCW\R"; $kj 8ʼnsfmlݗJS.L?ۖy| I+ە21cCmt,M`[J"+`1_n뮾9S-~ȷ[MK%tEXtdate:create2010-07-26T09:53:32+02:00O%tEXtdate:modify2010-07-26T09:53:32+02:00h[VtEXtSoftwarewww.inkscape.org<IENDB`streamtuner2/help/img/channels.png010064400017500001750000000252411142313701500170520ustar00takakitakakiPNG  IHDR4%UssRGBbKGD pHYs  tIME+ۇtEXtCommentCreated with GIMPW IDATxw|TUN$B zޥRDQW쫮m?˺]tm6`Ei " +H-&${df2${)ss`*+<ր.袋.(:袋.u~{R]QJiqkR9"AlB["bu@tE]tB˯ Ofڡ(Y͡RUO^^kD.袋Nht9U)?Fێ&:tP΢(6!8svuPtE]M]M%eyh H.@}m 8hrEH$":C8sPӰT=EjJ Bb2[QsDJ4MnEJb0}DSU& RjZacO]tL))E4,aUv>=.`0a *$ ыaifSau fO]tLf+u5U]GuZRTOΜ{Niڳ%,AQק.:gP5>CQ5;:m1'NӞO]tL2ZS@sڛ~-gKmm-cƌat0[K'O=Qq#4R<'=^-W(@pJfeߟc0$,2BJC \ٵHE<!%Xda۾#YOFe:!}'aB`@:*bh c.!ߤރY\8q,)%i56Kg.?IΝIMM%;;ݻcZ,Al$K&ßdb| UviNSd=&3RӐBh0"0iGb.l &bZ4TӤNjZMEwB?B\b_d`:j'AggGMqq1$)):P[[+m7u j2a|:JpmHU[y\޼+iU~O\/%;>E_b *bָ+@a^\*/LNhpXnq)zy F;ڊ:{<5B,9DB|ydqO\a͸1>ܾa1_}G),g%TBHDitM5)6MSQ5 KS?)QoZ: z!B<+p͗ =Ke2f3˖-crArMhd^=>=n1 *fǾ*Nk"wSng`t:ퟋ>S'۶mc놅\1$OL iC-=DΟ:N1&bz16yQ5"eolYw^Թ˻aqtS7 y{f$@(f,3@a_(u’z3㡇:Crd5 Mq0E *ٵ^l;lø;do܎\ l}t<ʩ'/:g j~΂OrߚטɆ|z9\Ԏ06|Xһ{w N8 ~pݟAhVn㶤8Lxh:OBmX KS0Ũ/q7~!+|>>t9:'0cP"_YGO{M_gzqh>\۬kԆWfk{F~G% at;FD(6uLCr&b_.i{8NM}"fb<;`Ħ"CgRD #`WK^[vspuq6u @c-' Hma[p`c3mv Shb Rx`9j*4(qMX~g>cjWTfM/2w>m` L1ax`'ka !~jgI3+DC-{7ѧsj7h=7gy3FЮ} Ι_Ϯ[l{t+i.'k?m2;9g ||=?)^XH6˧߇KȺS31C Cacn Lz,mヵ9YgtqWb v~:*IAfJZ/|ރHҕ~W/0͚Wmt'eȥ_=o M38P5̪ ؄FUHiiu]̶\%wҎȶft5z.M%h}$.$Gēѥ?D+~Hb4 SK{'C<&b=l)e(/.^Ozqh6yIj}Ǯ1]oBnO$dSX[GK\~ꧣ K^rj!* 3ra'x~ΧbVQ/2lXg}gW?5#P?!7Hv|_jemSNlsacߑdl%;x}VNgNg H1q & [_VL躟k̎TNlulJ " BSo^ZՆ:`Di90p 5a} h %Z6s_W_~ QcM)_{ܙ89HA IJj"cQDXPܭDBE.TIW_OE,XuH@USVK`LGog0 `g?//=1]MQ|z䪫 pkXiz1PZա2r x^qCw\2f c岻kOGTXs" tCóm żt}7?pa5\n$iIW6CVyߋtPt`?y2:8[j169XWJ#7t#>/^N=O6TCj'1ieH%CHԐ„5*^|.RL!ZDjY~lCv.}R|W[MV1X4M)D\l4 q* $0ZFɇpI 5Пb{ cl<1LܟP>bG8Xe$,>Y}(V=J~(hy3+^>nٵtw?Ry_ܼ-CG04I4p5\ϤI=hcamè ulUA˺[Y0o }/宋;:r8((zj!c37 I Xv)IӍH$vch Fkj3e@E{# (UIjHLȀjegvoRa:&~XYZ?bbb>F25k $b04uʔ:E.Z%[fn$4g2 $G6V4'W?,>~/\ӎ1[XH3G{%_篓4Vk˴&|:gb[ f?Ue„ma3/7MDZk7V0gT4xi1kqUs}+tdCKA߸j<(y@;y \~rHaP07I5yVkͮ_y۹[K<>(m;JgM۶˅XԈy1d&}y=L\tiRw8o0Sh7?Z3:}|w"bl%0.w=X=m݌Qh5oO ů\IExKW'~m#a&kH?u/z5!.`p–L͍w+ &yVUJZ篾WY[ւNhm{DߩyR*M+mǘ;Y $65ԘbHƘxJl V Yvlh |\Lqv*+t`1HԆ*DY.hpD`:S;naܚڱ#9DGEKݑv`46?ߘؓQG\) 刧Y4+s&٫tX##ZW5> u5mf 1!zvkێ>[ѣK:6n P;ݎ 5* q0՜(3&kjE5]]F*8@-R4 91pt 'O/IG.]Ig2 (z ȣJzFuD:y !52G |γׄĊ/2n ⣌~ɌӆmX WCN5'cn P +yAu(D[q\R>o 2Ny`Uli}c*{ 9m= aIa ^zcmX#/!:rv<[kLF(@ ėzTzȫOL}r֨o},ƾڷe|VL[a8tDQ,kC&'9^md+˹jUhn:9g{*aN2"JeF:?8ov5@QR!MMT~p?v۪uy^϶$7q9}6:zDda <)˜¤;`O _'6}l%22sdg%Y7DMFOEC~ j4fBYmH!0OIRFӇ]̄'  ㇰX)0i{ۖ0nMLArR3YIdfRQYHJr<)lgcqfp0ǐY?9Dts!.nؾ$ڸkh(/Z3cm9&k!ʽ7Nv -='3,IG6V.VLIP TA(kb~L,˖ƛ ̹v4=ަW{=vJrb8f!Ѫaن>tg4.c7X`-cD <>)rs |<>aDpэ++ELnF.O=y|ioZFwƟy1Jƨ,bQ=q34?Lkk6Rgw֐Y` /e+4(9'3I":=9 *`#9K~4qX8 ~+>܎ۆǺ|C$śdSɌl#jRŊ6)ڱMunh2a!G{n o O7GAjhD{|5Yn1)<4͒U:goq1Qy^^qH4$릇bWL ;cROFwxq/ hudեG^ ol ڦ~kS!?5> ɘ؍QYc0kZ_|!@p}jD~HlC1ގqL{d: Gm%huQ֥qAfN,OW0nML=I](ktYf\2g}Dn jyܗ!bYf uь܆ua'NDN#XK޾-|8c`prL3Z54@>`9 :P=7^0I B撷Y_ij $ L5y~0uCBj[s( >ʥ,\Fi1>Ɛؗˆ` i%ON1s,z;VFoKl;N>"pG"-lXw-7̢]ZY\:3&(\|I -7"FJ0 IDATxG]֕}wW0>V~6-aE~ӏveRRr`ˣ3$!tjlǐȄYZ'g A\~ ʒg۱2F|!W\H_r8s?r$o7}4cqbAÜ>;Q×߳_Q%,v]qeHѲr~\}/Ze-_MD&O/N3f m:fk,xsȣliEXcZ سd{n~J%ȔN*'OK ʯ{P݈ q+bꖜ'(A{f1ˑ_rز}g6;۫?/FBT|nϯ51A_~pBL8v/Ӿ-_6hFue7="bM9]=2y;A`0{jJЩK7Fv$,<=DUC[V+rn"V L?,W5O~Dg?oEbQb_@jq]A8n Q&sPn⸁o;Tie|;}6.Hc @aQ)Fؘϫ7PTTJԤ_fsCϰVYl2XLt'C#}aڱmtN_U-cy:FgI㗘xcUp|MR{',t3U#y*FpGSQakk11q`}B,TSs%Lj TU nE1"&ڀf0Po';2S!p~HsPql?[ͤG4[& 1[&ڦ&Rj. dXjZ/&zhB{n{.(O^KW0KW|AA$^#47 Pj66O&9DڸcZ#}GH^{\4>[7s7(h4Qم+Z:xޞw1+ 陖c,|3v;:c<yp1t桸99ft9b2Y=d2u5e:Pcس+zӐ[,FB ]蘞QxI'u%IDq%4R.:8E,!9]@׆nպ1!4uhl H}M9 t ι}4PNQק.:ghJu4~Kjm9'tE]>ꢋN*uE]ʲr {<_eXKA)yjC4x-t,2[@Ѧو xѣh>FM(/hc,>6(Ďq\+.|x@3BJ !5g;?-\FBGG51vY&^zc“ AQ\Ty\[jj#p B6ˆ6yS7k#R4~KmyG_hR5k+ Jt ,?Nw!cxwC}&}sqhn#4;fD$ ~EsS`+^K&[Ė e?q=ͦK~:,e0rCJ/HYkNd4[ A(-/- >;gl|@Lq<6h~և4{Ќh$Y[1{!{oB$/^D\3|lO~fFE9oh w iį 2#˷dyQ$};mw^jlcvٯ36qSɺlx3gٮ+9-J6zh3J_Aܳ&]^)4 Ϛ= xϛf/e¿?I?8 C079˖b_;D+LDd/[ɕ~!{^:p>9W ߧ7~-(ƮIENDB`streamtuner2/help/img/streams.png010064400017500001750000001100211142314047400167270ustar00takakitakakiPNG  IHDR`sRGBbKGD pHYs  tIME9otEXtCommentCreated with GIMPW IDATxw53#,˒sT 9I$AD?As5g+YT$HD%GXr̦=3=aEA}UfT 9U#N\ؿ}&L0a„ &;3gф &L0a_ށ\sM0a„ &LR99Hvyp,}?UE p/BH;t{- {|IHNy<*(_߾x՗cSZ]R)=sSٌ~BSPҔ<.J׀O/<[yF:+|AP B'D'>=rq &=yR\+OKX\,zL1I, |4 O={뽔鿓VM+tTbGlyD]]WlwD$ǜ2*. Wi|'>sX.C^UBs%THA\ {Hv. n3)"B8M(ޤNy횏UAW( BJ4g'h>+,JAqa,*RwO\ƧaH&){"=n8Gw 40ު;C 'O$w( \o:0`'KW7ӓC_MĄ6W2>/ "Ȋ͗jJ&[(Co.qt%%o&D(w~|hz}KOcNF'mZjHDK$@Sm4 nr}mIۅJ^|D̡r:c H? 9h$b"9bqIM+n-}wٍ漏tM]4zfo[:-D9֤A >cבPAp߀ I߀ߠF!$R+ ҅˦Jp3THl ])?r^tN$^3:=Q}+rSL[_2R!PzMwUOW&(aޭ) JNJ% A]8miT痍rUGt+\ ?bɀA+I>Q0X(*H/BV2D kBTFS|+[«JTTe%Ƶ8/[EE1L~G@8cgU#<7H? 1~d^N?n«kdWK mZ46*C`Bڜ45c dW.3ec?oY7Q6!=W(.AֳNO}joΠ>u5]\K]!UU5[ DG1\ ΫJOYָ^u<@s!K,, uZ<.(R:HOb_m6VQr/9 {p?5aB^9He3/>E o,Jo`kn\T\^%>Kwdg̥{:"ßNz>9r/S  ;yGuS-Rp^KAԅ+{~ ΫZPe J}QI 6]ң,ܫ^ GIONQ:dy4$Rn5K8v]͔%4]M/^[JʗDWꂑ[Br$ b, (qKKHY9z 9'$HcP9E#lDG/ZVDB J+C ;4:,]i\JZo ~4"viH^dI0A@ GJJpW}ށc;4C 5i1'%>v.< QHv-mYlV[=lLڕ44'9^68IկJHOhOztz꺋3ݯZ+|j]!BڼbpK݅ ! .Yx[a/C}sQҳZVTc@ P]/[d1 ϪjzLu[͇ C]n{n9?=7l+=ݩ~yAzW\]Vc Bq^9_Ҩ1X#Bb[~ݱʮ-x,ɉe?a~G̒AFjmD[ϕD"|UW4%.C,GKPdž?K efw}؀&_zE$^f[!nrz@'y.D#}`8zDi( ٪m3ނ ηoEe ^~u^ pηrj>]BRz=@(uU^ǜtZs?$&=y*?i:Ƕ7]F3?~\|'O^+Xx=7 * qtμn;⮲ RxgVRW }G%1M=^YD2Ado8O6o8B!& !vN"X*@ wqO5گ/-+uEEyDM? QFC~蓁F{ƅ_5ʷ *×/xG d wkD0^~BwFFFc ߝ EhVŹc`!0,`Y_[ FKҗ>U&)ُ*=ȭ$+Zr,|n<7x$}+>UFmx gEW8MUUC'GAVc,T);~CB8 RAG*,re|.dȲzm`2@Iid2ߛ=Ւoӛ'}xV0_"HJU^xc+ʲJ0q"HbR*;fg^ IS-22}xl&"P&=^>/)z}0yl5ғq3G/ӻ X~(_GPxJW6}: |>(yBȾ~!=ᬨ XYY9xjj+=E6S8m̕]:ѾN<6W`ygkd.ȎIX eɲD+0nCs{o%G73ur0bL&˩iN~3EL@*VT4K_hx]vc|CO/!V&?=? ~ w?֝CnXz%qNϰw}.sh55Zo}D7v)$\*(ͤaz9 15TvYֿ9Gi=nF6tȦW>`WymHA4qp+oʌ:P6=fpe˂xp"_=Wq_X?kl0,FMD@[oeDV=&^$e:ئjD諶V[;bEŅ\J]؊4evYoa'^Я uQ^x)=תp`:f'Y{|IDt.oR 8"ų˸>5Yt|pT3GwMUQ/B`UEFFPU$U*Ja527gtnl _cj&̪*PTcUud8ITUTE [ez֖\ܛ@2R!c >fKHѤP| 8[?NwuJ:_$}?Vّ*ĵcfyDUT~D[BoeB+dG"2|s;تCP$(a;v+TPEFFPUTHs&<'<ҽ^n"*&V#bO> ~euL+N_>C;9Cio11ܗFjCpUbR:Y`c"U|E ci{7wFɰ$Lݎ"20k.gc6j^F!]id |:P叝9KLPOOuI[R͡J9TqE7 22`"K*RZHh։:{`/pK,LwY8BE1eC$D$ E~VFMK0ojP%:[Qc|GI;HbGѰ+ftn_֕}.M#㧩wCӉV/9w=+ٹ|ï$8|X{ʙ, KN)8cyCY:{~ޫ⻱}~7^FT$*GssL3ykhrWwPsqq|/UE v UE#,>w?[EҭUJ"ÅW il)N"/+/PFT{*|5{~ul?r}R~a_V;RG1l?CHLGҝOQ%TTE9c+Ge(CNvDbCei|EnD\˩t ӯ`@0NO[ȭމF ˯h jGX0vC42ksW;8ɒϾeg<<qEYlNSQ;v8-6vN:.U4Ջ3bnG;k09;. <[2E9OFPcH2;V*J!C?zO*dW_0il֑Ї_E|ݨ:#Fqvss 4ycW$1q/i|ShpPQ{\ 1\C:<[f0}`eo燣~G3EPCGj= *m[P+Ri_Y1m^tSXljDMHC5 _\1Z>7%y~[2 \کV%ZÒ뭤6i& OnHV?M$MEi('w9#5&&V+{6gÇ#K1cR5O091Djߐ?!Ȩ 2e]W\/{8Ig:/箎8a%ѶavU{Be#5 . w菔wJ$K$V,Y9Afle*DƐV0O'$i<񳮞հ;ꄣXM/yW$&Iq=D~P해H B[Tövd*6MZN`<ÔV-1Ul؟Gbu(8!KG, ձͯP&v[- W=ɢe=">LaI !U?x DŇt n6Rdsx@NiGox>S2?R:ޑ\;c= %=i`/^byx~gϦ@Rp%b&@E 6:k /B1[Haɗ{5+{7Y+Ҭl?1#YInD:gLcgvȯ[8G,,='?Y5qu;4i&Y]g_-NUyVڅjX£ёB"\Ît{8}j3~|V˦@qpMl?I*QXHIީ\E$dHqk\,H<+wqXE?Ə+v b mL !̻gBnqhAԞ%Pl?,iXBk X"vx0_&5%-+pd2IU X/߲&7yI_Lz0?Oe6.0f.(3+*X‰*[eVUUqGS;ojY 'iݼMi\Q'" IDATRl?p(k=\~ZB bHQ>ZR+L \!GqTZn΀5tR\R>(4HkYhNulێPK{gp4¨ߡͳ?jU]5_|gl""2sCQ׌V0MJ5{ShNZ0`d$X$lLJ< n>0*fn{+=Krsz_Ϸ} DҸ"S4)Hiua4Hªm~$e+7+rr7.I(YiO[d:N3EX*ӢE#Of81UqIosy7ىt/6.t9s"H8Rm1,OwAr o>綎M[Wj,TmUoT!W^4XBd:t^ېCG\|9s L0a2~o7p]r}HeLv+L:~|Q +>á.aD. mO!9$Bs{o &L,3;^//HDD5mhYwՇV7:O"r'QpywQfs L0aQ[MET8C%}|dg5$TjІkU%\jS12_tzͧC~aR+$U$iĿЙc`„ ӗ0 I)J16[x\1 /1a"a۶i%-<2h廽ۻ(&LĄ_H2|ffپ&-:}]iۻa &L_b?S/$M>B?So+Uk6 >I˿cZ &L0a„ Ԧ,5 Μ8BFPzp]4j_=0a%&L3uB#,Rӈ!;i=&L0a„ &.NR `(.7GÄ &L0aKjWL0a„ &LP`3ڕ2e YYԬU pe-8p v1Ʉ &L0a$(CRV->*$zgөǟ| &L#!ϲYlK5-+8TM0\ +{e듉HmoCq Lz}2Gf5`0 &LC!bƊeq&.R+p[ִ~>ϰgZӾ`tNÀi߮&ˏg:cƎaɎ;s+G*x~orE{fPDǮ]5jcn)G{ѽ]kڷ΍OLc{8'=?^ڹ{C;p<$ nnKWS?h\!%iwhLѡsٲ2wWG; bKrk'S^cRFWNִow%w. K;Cl[ҷDZ@Fɩinz|F=O]I$A-;ovyAZ藅YƱ#jc׈R$y<%~m$Ѓz Y;[aƖ(NxOlVT fOxG{sc?ھzKbb|J~]`+`pAAr'Y}%TqN(d~&>M-A%xUh@r};?]ݑIH/̦o}$ E?_c u:56&3_mIE2v;2݇ar$V[4ɵnT Tlז6l/@¢+Ѵ߃LWB?43^׋}m>*ӳ#K4۵[xsI-[lnpEf s gN[2}\׳-۵c^Cr9}иiiM7lMv] N*!3z<9v_R[e4LlT4qkP#-d!>ҫ=MƔQ fTSF-jGJ~E6sOFqI KDw#)[86 }@ q03?MOqJ-)*Z▽܋JзW(2r5ۍd0F?fQ)Nϧw'ͣ؟W(/!Rѳj>$Ѵ~{`ҚSv-KWZŁUkpI;F#dffTUEUUΜɠfzԎYj9xCvȧtr4~ˆF';z|MԹ&+W/፾|G.t=t'S2qz֭M63SnSMblp>2KVj3bU,[:'W"5 BѡUl̪L>IBW zKXG|zYj,Ls0%cWlsu)f=qz vNR.R.eq Ӗl`lVC]S|h53S֧1³ΐnNߑ Ax/>,Y6[[;LϊO}|rK?Kү8Rtq!P{*oy#}Q_׿`\]}ckaT;q(\윳ayG~A?w!XCOt6ƸGQ3|pispꌛǺ ܩwGװ*9|TGX-%7e5Qu9rlژM.^ЯR^ %mKCe1sHq%OZ$h6*)~`&/̈jgG$I=%JTLcJ.Yj5YJb,Rj(bqmPD4bQ^~Dql-Z-%9aWk1tP=v;^c3MP(R$d({y{TO>%(կQ%znkT"rwR˦rrs<𹍛]dָb\Ҭe+Kiq4i r)}b]ߢ*8rlc#xjL~ˠ1|>=xeMEPiWPP%jXJ+NhP@+:Ŏo_W'0<ۦ\k)İ9*T6-I(wsC$h!|^8Ax  cT!'~8籩:EDt\QJː8}\% $G$\=Gd/> m;>fr;/ϵU2urODfc3ծB&R׃8]$;ƒ/mS 2Yx^\Uy757f{PֳZYi7_~ ؏d!R-Ϯo\=Uc؂]cx C<<TcT~` f-c[z&g r9o%2c5{ѩZXpJ9k 5|a[x`ae})zUs4X'Y9w%4odsaU.^hC v'G U5Q w9Nנ}ld3ѹ/c)8?M!To=}zZvphlDjkFN|>xKd*~M؈MDr pd;4w -#y!u^.Lz}35ª[ud3zT F|Ъ@y0`jҘ<$&L0anI!Op^]ƤPF+v>M^sLF.'\.Rc`„ ӗ0 dL0a„ &Lք &L0a„ Ԛ0a„ &L0aZ&L0a„ &5a„ &L0a$&L0a„ &Lք &L0a„ Ԛ0a„ &L0I &L0a„ &5a„ &L0aLjenkך?ſ٨ִo7/.(0ckڷİ Q$ nnKWS*oѕ7Nv}GnYv1ۗ]ɝ Oyܧp jךaKAhv7;/Wfb,Y*@QSWR7I}h N}i6&JM"Z*IQȜ?XL|.F:Cb7KqQKx'X#㒨SlxNz{Q/ᔥ9#Oh+yfԍ R|`?6ΰ}sAe_*E{F?;azUoIK oNJlQ$iː_66f>3[Ӿ]>GCM&rhP5بh*֠FZ DYǧWj?#u%6,iߍ?3G ~|ՔQogZ$ƑҸcGѮ=O2^׋Zq+酻P1;&o 0okN`g~۟ZRT0F?fQw)Nϧw'ͣ29$]*zv_ޜ$OpLXZsjێpEdS0vGzʇOj!r߳vO^E|,$6gXbC`G(M[nA!D1;lCsXrKƔ#=`=˦#jɋKk֛>Xš?;H]:]W߻<>`x4~#v׸+53ֳ q6OńۖFlAi܅Q\f?WoEU×A(KYLOb)|d!o_W̯1e,z<_ˏWCѡUl̪L>s*3;ח ֳn<n}K&LSqfٽځ{pf=/qx8Q}c;7u*weOs˓K8"<= ?+1a yYZw匿 I煩Y{ݝoHXX HM1-HspꌛǺYX]ênJnxe'[r,9@lW/!x$YGQI`3yaF7߇T+x%2H +zK^0EdI_.['*56?h!|^8Ax  cT |y^/mp~LJ*d52(e Oxv;meSpz‡w6|3I|SݫS!$Z",*Fd(E#Q\.oL[ΎHYKQdPGT"P:!#xjL4Z˕8c{3̛}.e*tm|kT7} #xgHS8s1cg\Fo5U3k\ x i֞y ;Ϩ?;Xذ#*v ڻĞ5ް5BرW]ʩPQUHfZ>{yۼ3{&z"d1=Tu>mI X`S—s+Gңv }{}biG~0IQ;VY7ׇ\3d~Wj1S>%*U]bؙ /x^p {Vb5$Er=15t^CX?2#n-Z--ԯM9xh'а}kT)" Z4ل\VZ oIT2#/A_:÷Rsڬ'b``ȲYt[LH6cy3~N0(I'-hpՋ U:*;S;.K!/[@^ǍrEyGhu^eiÊe~gё?: ]~/RQӈpݳG0l2mT&Kwp-ZTɏtʢ%Xz=sQRx~+ճ6/e@cpi)sOdF{LޱXWF-1 G2tD?ҧ@r0~J?LZv$h`0J8McfjCџ_{XbdV/\ʢw`-yuUO?ǵ>GE/'FU "=WE-l~VN챕9|jf LݐWP+4o{cMFJ4VXr߮G2 Hr2=Sg/D2;\2S{~8"%xٕXqJ@φ* ғd"d茽d gy}FgΜy0*]?ҭ>Hrڬyy5;>3c̭) 3oOGvOM j k}dž><6n.9"E`/uuErf!r]7k͉8L&uϢDBUduUs*)oKO.}sE^f U_%aX[i^24>XDž@  ~w|yr!!@ z4}Y$^%8!E _@ bHj@ "@ DR+@ V @ Z@ #~ԩS:u VT*իWT*̐H$j^zh^"0zh!D@ ""g?XbTFE{.SN%--  Ə?ȩS011X$@ 0Hll,Jڶm ׯc``;u֥VZY@ #Vj? 9v*U:Pn]WΩ'3f ͚5bŊ4ib@ J6]qtRbp͵=(ODH`x8W#p楺޽{Yv-۴AjՊ]=tvzs?Q2+ f\$"~hF=qs!d2lڹ:g.iWݺ!s)ش!C%ob64ԟWN G5+ h/~pcw޷v2Ћwۮ=θ0loYþ6vƣ|n({7fL`hԿkRO}6<5[=wOfLZmϠ#$ bן@ࠗwc9Qy<C5L3?Ւpa#4d.s 2J i&˹S!ǯģ" \=z.j4_AU UM%5#LP(.G}\P LR2>FI菁pSkkFVg <,ɦIi錛 3>M/GmfJrK9n]yR_\J-s)l317".C*$/! GF51>15}U"zJ@Q*򣦥= Z7>.5(9U}d*EnYj`W.Ul052ƬD9ٛoatuɇG@狻3m^۩._:jxyv֞(7Qֲ/}/)n~Xd3//y=cY9UrH"~e ~kþ8T]Fi;8v_pԨlF0zu E4w6qjh!>ўKNǗᨢC*^w}hZ>k^=S6θ5ޘ,↻D/ 㚻eΚ+/n[Zr-/9Kưs,6:͵ (s۴DcD@|frbKs$ռԖ .;])Y} I䲥\H,a%Րq3)@0nYҰAq%AG32u2&[59'G'pr+kqr- }4e3w12ph`læ0aʇl5M'N$h^Xޑi(wم*ƒ/x?0M YÐDiF}1 [Nv|(V] a]rKf9ʼn]+^1I#񴘱;guO>mN͜S>ՠJXpry~r΍!Ohb|: * *ȖGq9V&-{BIKRCzcP^_cw<<t6 O9<<Gm%4 ZƱi*"u(VtM["6)1a);j*Z?Doh,EfQu.?SYlvkf<6q6 :k߇{ۦ|K~IK" CL d!jR_))~cRk_fuk5/EZp Yןuh˥2&Cn֌SqܛX${ 5'sY{lةsn@wӐ0ʥGȯ\+d30q_$hO Ү熪:[*Ou歽e[9и]eBWRnQXaޖ[%f8yUBy%{i \DZ!mn{[)^DpiI\xFclP?Z4ÃNK?RӕF?͇svOL3mk\^oS/u썑"Ŵ:QU47g+9fR ^En>y;e5yLA(k EnL!0~؝s_tv}Fv^)q!!oc]g):7L%n0$U]m*/Nסwxze %Pѫ&Exn~ ru*rEB> k) 1NtԬap+7~K7c!ۜ9sa\zb˜G0:KNҍa~_iپ~+هn#&-gQ hK5 ^tрN&9WfVdaz߰FY8.ՌsuE}۸mј53γNr%.%rI?!}i=Jf6?RA?xR&agFcu6*1P [Y0cX9ub|+Tk ĎiY T4Ο=x,&g[e1ZDaHp+X`c`M _KXϭjKE\[>  ,D=I6vs T<=un,xZEi̔Jz:K;s Tޖ w_j7o%VCRd(cp#VB{ݛqjTy,n'q*P\ʞe ~ZL*EpssC.ӭ[7bQeڰ~ƍԫ[ $niw3*j8@J #'\WeS4M:U`߱&w;s-w_l5yngԠ$k\K6omi4jn7-OZf9?Fȭ)Wر cxCҪHרaQb;S:JY h>Qns׵ 69q81iUа&PŸF"ޙόk>%6%1iDɣe6¼Ar-dcJbS>WlWgӗl 1w0iz8fd=R6t?33Y\qju 5TYP\G㹾=ʫĦi(y d&Y~[nဥ~mzoZNBk#%M d%p~)yA%r diRIbCdlx"el-CMMcy[d7@ jWTlMwfof>qHԤOv/+I@MQtG0y_G{qsfBOzc Z8bpk=P)s9SQ+Ox7&O=DSs3:؎a$#>(Uhee*>#tg(5hUq\ۿ4jEɍxR^q:&8}!ZJ!{8^&V(gmIy_La$h`#_fb&X`JY6pA&X x]M\~ SjAYd8#>sǷY::}+j^pb)4ΓX,q.Em]&Y7BжU9_dn啜,{m&cE}d!U yDFzSrڹJGw֝~Ev ?.KlȬ"_E0{9s~q*)Fv6s5-YρVc4k ? Hezg%0wϫ3{&!V&T Qy>=7M71wB$1c  3sq-14~k}@^Nɫ;n>?hKC㛻ƌ  ;j{xb{a03y3߄7/L3"p8ṣDQ1nqV3v9+AfD _%S.^]ɊE?lޝ)=9h;ڛ#c[z˚cT.uؼ"E`/uuErf7Npl7k?=u6Ckw3-ҎnDV̞=[kdd1cիW hk׮֨QCRFh ,hK֖-[V+j<)\ lSns%?>~=4[OV\H|?/‚ 3f ?dɒܻwcBCC166"@V`ڏ/_.$B,)))p}j5JB["9'** ++w@ ѳQW"*1`ljb݅ &0a„\ϋN!,b@ #Z@ Hj@  @ "@ V @ I@ @ Z@ @$@  @ "[v㸐R+3nY~Gx#B7WO3/PZO/kXtï~mbQrZ6KHT̓c]θ~_t1!6ۘ 3]av̹\Z7c9 57ƅn.Ym,%X17V g NX :k1n.UiU2>st[. bp-eܓ?41.Սjj- 9~cGNJ1]5vGC.n9BI?¬Qx~g&K'aWN=F(xU4$}QnUFݾgڈZ$7X5b`Y‚r9&&Q.cDK 9(1aSO4I >]sEcfq!x>ÜѺ]@gX'Hdc1DfqfYxe;dlܚu*.i6B&Nnmiy+8P [ *Rs4 qCUi?%n%䱲W*b9}JE+}$_3abZ?D?Q狇 MgoCus|-%C8"VOC\]篧Iм>O6MNkOg\]073g,N^ʮ) +*:-}n^fKKןwb4s4r՝9zOښAxxwovfn??3nQe 9י>brw\iqhу˸ńwиAOsX,ƺ 䢻t_xyLP(.GA_LmͤD{+ \5˯.tP}T]A]…Ґc`kW|`ו&L;b SuKG_IJ,ge323H+fN?_]hn})r3y+hHWk WPUjBUSI=7}$etu2*cQi׏JR䖵8 {rӨ=§#6ftoJ_ Z (_p3JCV~Դ#5A&ا^bP6Ϸrq8Xu,Z{귋nCOߜFY˾:7 N ȘXDokWDe\DZOR0~ o۬1=Y~ K^ϘwfKJIom7~h:mGUnq3њ-FϾO8b:CKORkd먡o'8q$AZG}]r{nđ={yY;O[Yʲ"bK]1+'"nQze ߯7<=5,rNuhN{N]?)8xZ板'lo]rv-45ڔa%Րq3)@0nYҰAq%AG32uRsgȤ P' hb|: * *ȖGq9V&-{BIKIY!ڔ.?դp^E¥=l'qC1a);jrjLSD}OyeZ+=P=csO/l[fgf3l@2(6FnƲzS+ѶzFD}*Xrj^l^qUdQVH&XAƃ%,x)heRIWx"=MmSgVDC=P\T)׃[h %%ި-Dק-5eV]l:{R}_X:LEm~ ^O1ե.Iw`|+Tk ĎiYį<]}KmR"žmܶḧgK\|Kdw5YMFVo1 "]w3Oܖ|22wfof,}_I<$rM<K26ؖ˦ҦۺTvߒ&d.JP|bogkZM}?9~ϩZIt1{md?66)aL՗/tIo?wlA [^|ԉWٹ`'/Q굸DZ[hk{IMP>vnb ,EY-n$пHTjccm4#P9ѡVK/hQ"W^ϼS;`c ЩMKZs)U"Mjc) {(?ʮs3x=i{c1IAyŭWUer\[<%WhߪB.nQe%(xt` _6w"Kpm1gn֞:j^v|BƃP.>jIX;ؾ#;e7v\~ݿ EOtd:Vǘ[Wq'5,2Ұb#*lc4>k\3 D&% RZM e&5qr#:Wq?3Ncuܯ/\ :/95?ۏI2U䖕lНO֠Uqm&"B,UnB#9KRV#k"q\:^E=N'}|z74)֍_Hi>qeJ;N@bLic)^jhE,If%rLmJac"T3^\O&jweh9fqwf9[F4HYR * c8`)7B$.gOa8H1.9(Ohe|ĩէ9,>;\td4ݠưwlWWǒE9g/D2;\2S{~8Wp_g%Ff¥,zW?BGԙ_}d*jq̻t1-ȰkҕZMvM9Ue%*boa2tt$7"Ð*]41fgHfXQۓ .xvyͧ8Oc-;l3BPP$݆\U-x#x9!?r/|$fNxWr6<3&ynhx܏ӯ92;o+\ 2k'X$*z"r[}5;>3cU5e|aRÙ3g=}&Jק叁tPIT/M)+2^ YWW$رm^t 3mм:*ZFУz#pDW]u+yx؜4lQg,J-4YEF^Z5¹6˺7XeFqPet5/iث/ԣ U]{d Y5gS FW/|_S,},6i[5/ N ,icra _Ām vn/5#;5l΀ٝh *(Jl@ }{cER+ F<{w4; #LY6?@ Fi?HjJ qB@&_2йϯ_  Qbx )@ (V @ Z@ @$@ Hj@ "@ DR+@ V @ I@ @$@ Hj?m!:ݧ8.AŊ;ጛk{Q=>pՓF,KuѨ{z+æ2smRZ6KHT̓cM_2$;פ]M;WgܚԿA>m{۠#$?~V'g:ԳI6:;iFYga+[_A5ʴ&~=@'Ϻ"ng>trUfY'wK.JvuƭΥ|xγ+ko]qskBBx)g鴉DsyоߏlqO# g[όoV&f70}' ;eεt@O›G=2M)9-aDBhe dr<3x-~ڼF=a Ɨ`㢯(-/./źR~slĨctp{KAjIѷ<銃 ۏ(g k.hz!W!C$/%vY,OмM@%’?6屟5$eï߹Qʸ\=̾hR b"r*4֘nC{I:?ߟΰYlmR s>b,eZNAF׃Āރک,&%lhf_LSCsi4m< eɸ Z3`?d9uabV1FHdggX6Clʭ ]ǮB&lÍ-d֖6|\3 MLePw %:;GS`1DnZSVB+Hd2>Dq9*,ښG (˂"|puIJ&Z9+$;=e(K.4xDEmβ m* 2Cαm3^+0uPWtyA,ˢsMe܃6qsu@D)r@ Y##c gV$ çBn)2&W[ڕ-QO R_\J-sL۸Y|puƻ$ߏ%r|㌛kczrg*a]EF)4MļC1|֤C^?4;=qsu`VLϣ,]M_%V:.5Fi3sLЦԖLx즼ĿoXPh۔v#T{[ Ft\uHxbrw\iq;c`r_~8"VOC\]篧Ieݯt?}ݨ`a""q:c;SGj׫4g du Q*6cVзHՠ^sԌN~VDQxr9|qwuƣxOIM^]Ǣ'x~0ͩlKs?}ڹ6GeqO9Cx3f.Uo19/kY1ڰo>UgѲuƫĻW'f55*[}*ȟpq:m:NWu~^qSg8v0aSr#z}1 MT 0W^   /·p?Ysk[S<OؼwVV!KnɌ=8#1r7t?煼hScǞ"T1B N\CC:/rQQv\;8Qsath:,KXeK=ʇ߮D]qu_#_q(lg1CH(Mjc&f֬T&.nL! %A4q';D&wm=|~ vhQw[4wr,-JFڵ\{2YyYV1)=`mxf-E7X~PCu]5f@p_,|s=ڊr!XPͶBm$ex>b6wc2]>cTnN%d9 &vRWp6ӉhYoM,nV!xVϙh.Nl̝&vz0#=|{~C[3b1|Cmv@߈_.`粌ɸ7 yQJ*O cͧRIt~nQ+d[853 2NIYSp~~f+DŽN(%J%fr5`|+Po9NOg[575^D|ekؚB.NmG-QAUq;J<&b -VUDDHk ï#co2K]E$RO֦*7\hJX+ GCJ a:=dVՉIQw@ wV߃d6NO;կ(!5_qn QjoAobGNOsto bȭk^>!q/K8aj*4woͬH%WR7V$&xV{0{N4oqaԶ;C==_̩IENDB`streamtuner2/help/img/inkscape_pasted_image_20100831_171132.png010064400017500001750000001157101143721564400233650ustar00takakitakakiPNG  IHDR%qsBIT|d IDATxy$}{z}뼙7+faHP(R9:֡(99Q#'GqŎՉI։-mBE"EZHj#Db#0Yf[U{V %D ] Aٕ@~7А ,}] Ȳbhh&̘6dxn{f'kȆ֮X1RFž;GDӆeP0B` cYY_xb>%{VΒ542E!뼶+Uˍiv`"AHV܀ ,>ʙo~sS},,wmJxg({Oa:lCR3psb_1$!h"͂¾C ou+|3ϟXv~q@j^Tɠߕ[v5.6$F ` : K]6:|9}<9aZW9&yTA FD# 71tɮFEqFM-;p>KGmg/VA.c}0+^GĠ`` BdjD!UhU#mML͠sx3½|of O? O~ Z !b~E|'9qC._܈nw)HA0.Zc-:ΞOgvY\-ٷ8;[)$404^:Ÿqc#@$WPEc1a\2oԒ`[W v1z9dXw o_`_?}=ǖ*|ϰ<0@Ðgï/GLX# w]yd诇Ll>RU@c "/:o sKPЏBf8'?[8~q: Sl YXfchN:cϜi4214¨WuLٕ[vI"8cC"b1Ɓ !~Je&`o̱wswӿ2x _4Bde"Xn֟dwh4gV!@-dcP56mbQbA!ٵ#\~=? %|^G9n9}Q͈@8G804]F)>d?yx=`؜Aot]^RI*}DU@ J9"}qi j2j°1K+(|;>‘ ̀Rd O=HAU1?Wq`15k~?|W2E3^1`d该Lks y2If`'`E"[bY+͏OwG|C}DžPkQb!OQ2ػљqcx+mm(9E/lٗ6 D[CT5vfzQ kR Bn2,8IH:K\ Bp; 9N~g\>s'gتx9nM*(EͮJb١)if5Ciwf9zpFYy\mFsg#UEL]B KhU)&W@ 5*+Hi7P ;<4[w|?0Op.W`Zh䰼ێgi~l% T,"A J.lA~U2ۋotM*?D}F@CL#Gw˯|x/%O<$>BhblD4Zb̈j (iF!YjlAoD(l`H5x#06."C5bI- /@vMW!`# h6G厓Kɲ!@f TZJv0_3xFqs ,>t }}}(fQmRG#AՂf _`(X1P)hx5 X=CܞOwaf$U$u],JZMf_UHM)xi`x7MG H$'b0umoSSu3*;*lKG0C@42xnY[܇n7 9-ǟhKcE*b#Y*O($ihūGQ$D :/HI\F B JD!El ccdQV.^oo_" )/iR/-.OvMW!A[lZso~(+;&-\gӴ͊ #  u]1C$! %bbB@՝EPbM-J y.,ksyd d.>"{?.?Gb7%"&A~mˮ|N-S}Ryy g9t?rN6dvp,uR,vZRXk}2T U\Z("h1F=8ͷ&^:} qP6[u7z V`S"666kthH5ib[M JC|#fɅ팕A/G(c]*$?ݢg'|\^ߤ_dĐ&骈4-Z͌vBXcEYUmQc L=}zM{iJ 6խ>TdyVFVhO7?/C=$ l ~CSQQ!!5țD2 S{BűDAICkWBKۇCíʾ}KD 2WyJuO+W]Myf5Lހv . ʼ ?Oa?v7V=&5:4DB(cd&Y|U,)JOQT* 24a%i c #<ǛF^ek啜W,Uq'zt#Ԁ^c17{L$VRV K[nz[$`7xQEae1x+Da&/qvcf k( I=C>&hI59Ǟ*\,?d2QA,ibFDl@:TwU㼸sYjbaY,)WCOʨQ%Z&Zy9s.ӑdxG,Zonúw2pv=lRn 8muS!R1tM&7>| `#qH@ 6ki2\¹K)߻2BgMRY8~ٌ"\j }` [8~{()溨FB,Z5X+$8De4>P%eYR@q9%[:CU8EjR:\piɕyPJ^v҄"`onʷ8#jQI0uˢBlR<3rCf$-YKvL7O5:2mJ n\{zr0ԭ:?}P K *M IP]SOSRUQC8"TUE<>D|cqd.^?cc+C6Vh:w9H\lzzM1 5oYLMIIJA՛puV+^pA%֬ZIDe$#Fv)ַad?Ei宎'< k:MH͙\VrVو?F-0wޙ]_ط =e4h?>,{3GݼDU1*:yk c@*lT7Awɛ09DMA 'BTOl l‡h4#w~boqZٮRkA0b T%%*]0_@Qɲ643T>~5dvoOFg%Z6c 5kp#D e*ĺ62c`MF7U𞪪PҢU/*%l!B1<19ĕC|frDռ4iG'uS*K_Cn<:0e;zzF@-C19BY!@R/1v r9@Sr*,EriKoSvol ^ENsіjI4RJ)!"D5x'ޫf:~RSdke\FAfnn=v4[t(W6%h X_qP*D;џ['T.6z$0iP/g x"> ?oiy6盅omcEޞek >*t O<@L[5bT 1hɄQU^%-<O@cm LObFc/(yev&⼧`rՀQÞ=ˆMޟ{_}gaqWhg`iρ׶IUEQSn%~] kx?}5RJLt IDATpvM-ǖhε)̫A!i@[8 PO#Giq盎mߺ m7i,D, 1QLCFW&WjKjUӋD&kDXS"LK"AeAg]&2;9ZH۾%aWM (.U*rl[Vh@1qnL9 _oC3<⇘rD7kF ?֢=͛#Wg +Y/YZlЪs-(ng! N=4(gI+9B*3+;-Gӱ0ү\gڴne|'7TKq^}'a` g&EЊ>}'(D)H';< NJ4Թ4ca?8 t&)wBszfFYZИ!ٝ|3w0wpld"2S7yL<+nn-M%@30 i2 x/\fq 'XZO›+9'CeRd45ڪĘ%[b:6k+4Աcߞ:?̐(TAj`6jgd"R.٪8rS3 mv!~ vFz;wH>f5)iŧ/9Ɂ.-i 3?``\&ow9ql/`2"ScF2,6@)1+4Ii߼2,^Qm6X`C g8 "M;[8 2Bl5'Ǐ %8uM <R*nT "@UҒhӨo;eqoQ=˗wK4;_zh5WhM 9KN;&{k&yƸ`LLSesxQ=y<ֽC'k/q.`Dd="՜( l.k:l" T$i*& /LBٴD50ؔt(ݍ5{@4jj?4:0Ai1Th4 ycgvp6V~?Kކ屑h55`h#YH@Yq7<'HRS(SfR^5fi9@h?̅QV#he׻k0QK}-)>Hֆ3x?ӄ~V#&8!:(p!!JD,;*e9{Q=MvQ- D< X)R,.-Z# Wl8ndFE݈e!䦃*}3Ns5VBŪƟ}<oݘR1 * `@rSoၾSXת𱦾{MU٧LVC.ninWe|yyIz u\UEc"1rzz&^ƼvӺ1E =!%{y~^be`4dC'o!aZ;{p|> SZSA361И;cRҝ8~|/煲ca!F_؊^yVb+J6i<6ZnzU4UhueM@>c)+e8 gsc]gnG5o2T;D8JTN6k5"=a]ߖG:>R"D]e"eY4.b3S/:32.N)aY晋ogz0XiAZ-0-('8C+k`zo>/Sbڃ ~R*S_mn&BoT4q caK94Ħ.2֙G "dn7WWl)/SGRe_ ZBRU(KOUk bX'dYkSQG}ɉSa *Lޗqx1qXH^}\%'*QdkjēL#J9q 'tV Op;} b%2#iBnx_WFwI ^63 ƵR#B'.0)`y EܐV;1u2 er0W_76:sH1NYJ|0@젱v"HZQ Mi1&#x;z[`((%|:Ͼ|7̽FK,la.+͛Oך Pwa8K//О=mf=j֧1 뚄oS$d"Nx%Xc-M=*fBrx6}w9N7]^uԾu2U`4,(KF*AC m44H-sJ4RX%7uMRJUrcAҼ-z/_<›Q{(FЊD5~~7@pxD-Vњ 6SVGYUط#0a"W)4 4KwRP/P"h]ɕ$0T*+{ !z)86oЂzuwxHǓ<_c^wV\#ZUUB9h4WsgWx^$mH5-Ķx>8 Fу$-a'vآ-ϧ|/?r6B6 4U ZZdol+kC+00ry{fʩ[TdUGjK$d H !ViRءkJ]dǫ]U`LIMϞ.؜>~tExǓ4`vB( 4 ^HUecȲ~o/s6Vf f ܇Ȭ$[DN<ы~2*zO.u9qk&y'~=pD~1x错B__ΧnA,LBUUn4RVɁ{_7lZCYF.lsX+eF#%fRR.ϛ"N EQa2CLg!#xY1a@߿_W0F9|)&NdX?VHf :K  +})~8V 9M@qȸ^m;W'R#8NBfÕ@zw#;&~.29+jЕϝ"LZM]vvRxYGD /B&ϡ[p8$F8+/5W5mfmE7 JhglZJuFaέ{WΠ:0nL7@֫E&'hW"kybsfΑE$XDSGQϧH͡MljqI,n,&2.۩ tlկc^)@_eTI@5כ0^ipյW,KFt+(FFY Z:}=KYnllpŗ8w"[C441̠1'9Yr9P9x9 NPٽ<=JqA/ƖKb ߄;f R @I3N@T/ 72Y&H :MT֊si++5ԓJJEU OEje_qpΏO&t;7Ο3&h'Wo8 Mc+r]=k5i43] WXIQ+l6/8.5nC6.o} {߁'| J+cS:%\ bʝ:5H:˖c;C,sؿ\G}X͘f2UY_azYSqMy4+<4+`'ײv &L<5\ ;m5T3;9@" G%"sEu@L2$k%,8#\sϼD5j"hlUrAgv~A+tJ ,}OQ.JCiu;)gNjm;v[AR^&BԌXJS+֣A2<2tfj' ?ڷ݉p|r+`Vz+Ȭ!؄ִqڼh|2׃Du=֛&O RX!ELfŢX\֤QD{Ux xD} <\GTU{].o{K彿vtRqzrf$( eȲ@##^F 0et&<6%` X!.M鞎tXiZNF0sW?9Uuv['I:bT]bn9giå tW:\ة!o%ωO`䯧 2[ hQYQK7iV)cӆ{Eo"rDP1n}+t)g*Xlq]Pڭ&bfo"4>σg#8NߵA_iq/y!ljcA>gm6uEM4}$w#Gu Mx!+DJ=/{3ц>ӄU'SIw>{%I.EO\C]JզΑQt:%kjΰןf}޹~<)XKkNQU{%#|KЎKMXHS)c#eu EK;>@5\6COgصoAZ8C B,[k(!6HG*D]A:xtڋ<wl NfJh˰^!P&1ʽrk߬|ៗe1#FJ9y^B$T`78΄kZ^B9W:;UΝbk}D.Zΐ |+Vlaq_|KBS\u5n8ŋ 1 HnO'~ҹ0hũg^jVTBR "}sӪ_Ǹ/_irrQcql2P e<{mnq; *x;ɻ #Xޡ_x2`6yf)iA ,f j<^b,&ݯ%bR`LeVghuuSgna?q 1Ȱzn̽>Kŀ< o~Z-8w.]Mx)"Dy9j?zSY+gic{[MAN@ &jBD:'82qHXA‡>kw!Z5 }~o =X>ctdNQK #&0== o]uܦ}+B CBRVZkR!iP2G!"q")d x8sb\ij4р_2m2ꝓvKʛxV ,[,?4i3=Y>:s ;/k8ƽӆEph?>sH )|HX|9ba,sxe)LC3(JuDK|}=r#tdk.v-!d{cJ㤛РD{pإHtA\b>ޠ\1AM*P>L{ˉ\ x,kDij': R %8)8\ڒ$9pI,|`ldT^o? Í{-$/PRs,-)FuEZj,9OuaO6*i4\@"dvB< l-dV6F>>q,)B$#A" N Gv:oD*9ⵯ~ $ZCccP樌ab;#PUcTXT^Fl44ǎ7ǬMn8lZXN:AIy+"Lq!NyE vkCmU\,Ғn~yASؠm2X򯎠5tZ5Eh-0Tra<:om/?83phd]/>CKbKMAp$q.8Oo_^0áWGC52h$<,I^)Ed r!PV% /}9?g1n0\HJJcb3=\;S^l)MC]D>ko7!Ƙ}!H\וRAx,*TA S<5 Ryx@9;[#Dws}~בqXT%BRX o|-p-.z옧73/QIBxI]"앵 }LSXiC_M+RVG C UK4 )tŗW آ +mq=8|&Z Nݷ2)%H~Zj*{L_i&8hl3 &>&A4Ql~ZawXP*WHƣn_FO IDAT'7/I)F#yC}'N.J %[ChC.7jxC&?d>@JzF_?OrviN?^/ޡ0|ӆ>k +fjߡiqp_Ll0_jl+pyv댁6-?^JٿPïE Pt;%D:aҭ-vޮմk4oy.yr7euObl^;lzB-a8zO=m^B h:Fk( ܿ?WuUïx +Dw`(RvHr .ڠT"val·y恓n6CV+r^n<?}?˧},: D\{s|B:$i^p:6GkF#`\biv /<AAS\ebϡ(#FOJE9ƺ^+&ϧbn9e:l(3,ZW2ȒGӆA1&$FʪHT\},ZR7x8_R3ue$dX`gkx+ n#ov_MҾX+^ S$X*TaSC)؛]F@2Bx< ECEv/P⬤,[#.nr6G8j/3$ۡP ISI]Io'p`ZlPB@f(G xNQ-iZe k{)u eۜOQ݊Zn|#ņ.)}rWX,f*nJ6 _08PJaj |[D@Odhak~^|+`i)A27ų4S C`&\ /]1SL`gjAA͡zE}(i_,(ME4Ud2<9ag׶Ӝ95m͡dhh^q0iQ육_gT>K[:ZI҆0nȥ?m.[@Kg75JliK=E,m?5қR)^(/JE4iAZ. (-nnFpV,.d7Z`JrF%vٱK4y(v4?\2r>fJhVTe,B8`05VC,X81{ҋI%"l2wxqͯޅpX"`0.FQA9C[#F)M16A Gg ei !_jH#L'£bq`):y~'c6AM2.)7x؉1 ()0|KO| %#7?]qe@z\ "S-xHL.Sy\GmHt%J.ԓ w=w% 0:&]O /`8(A:":yAܖ1x %tZ"l bWEAc\0K=TuƗ]=X75(u:!L|ST2pClx^m"B. Xw(So2)BJ~UHȀUwBP2TȲ6iF`Q͸(JÊt8'Bg=b@rO械$"IxBZ<6=-Jdl".lnl3`<ZU!Jd"r$ V$V*C]_B ,]>'1~@+T| ?۩%.m¯,-_b\m'~Gz ݋i(NP-Y l2ZIgco2qr^e.H'; Fa ^NoF$[|K'=:ؐ4qN`E+!d>$6*ԓ9bBqEqiY#cja O6ߜ6()AR)  qhXh4"|$D"#+ U]b$yT8YcǛ(}z*_u;Qդy8RZe87D$rP[R |e)\ 0k-:)P> U%P!2! KB,+*h8f49i2 5k-GL, S2SjT*0u$o# ˷t+9 %YhIpL,5BbH <+O}BQ yGd}e{s$ۀ+;A~~K$I1Jͭʩ|"? ^egSRSk;l=?q[/XF,m[ahrl]1Dya1d7&L< j350AQdY/':6Bmc GxTJMQ D':* $QV)I%)Af*FK4XU#"Mwqg=mmZ4XXM6`~ϊ#*ڤ%mx;~oXD0ox`a?Y[Kc$i㤘Gz o6X©cj׃:ݠbLN^SP;H'x9%u6'^ǕG89qvNG9w$IBQTH% A2pؽ X CxZKmBMxW :D%i 95>,|0bc=6Zx<'* NKCaLMU jL@DzG/c~o -JYi:*q+%O}2+kC4x^s0=y+*\Z:{ށ׿1,u|?gz<$:7y@Hr!LGpL[94?)"ʱ< ʘr!Z#G҄)is5 |d,FS9bEh]P fh\Lqj'^p:]37%I0r1Vazqgc"'ź^$84xZJT1΃T"lRx,:O8p,c{< $}#w5߿BȀDG_,;ɤ2].y&) ]$o|oL` ̷[f WIH=M[;(xeA/zYvq 9vF87 CE$ |3 67[4͝ZIqDkc*Tal5X UU njOg*xR1T'1Nߝyqd0x'㦡6)WC^GmR?HA&|"9 N3P/*zYudXћo/#bMs/G{n[30L8$uUĝ'&[a<W ~[(m/k}/~Qӟ5P#nFyCuWsg#iH Džf: 4K/PCPB1wєĽ&{9)8֥;cbLd fDL4QLWΉ |>]F'@pz:9,X? ڇq2h=﯒g^~n!軸TUx"-1󺻿G%˘;wBh9:YA惘^o|FI¸ӉxIoZm~78G>LO݉c|d2\ /Ū'<ʴP#oM8p>o@ yq^r?C5<8.rqc҂$٭~!l('L#{G:)$} >w¢d#kJD†oVxdTgS2cB瘄A*t0\,9$i۟A/ ^Q d7ģz) hR!Y x[߁^B޾}~ܻ;ڡ{v6 I=f둝2+]:X1B  r=& #tTh1K7JI[<55BH "6wq#D&`xE'*R@uܷ&ݰ^|ΨSd-*aZ, mx(=Cb.ͬXT pY&8Zg.Ƨ7{M* RMTr\S'-xUcyZAy|Ajtަ_[*c2Tu6w\wD*DTH6IS_5ALM. p߽MV<IZMoXˮ~KPT%9R8C7oe{ެG~$L>mUƀzV=Цr %w-ב]XAtF

    JWjΈ+ǖT 4pĚpq Ia❥ƤI qO\T㒥#e͡ `gkL70ah`0BS[Wef;"o= c"9JCUU!jz^!\"C HJ/^i'Yly,u"0q׆T%)Oؓ6]A]H!J"c_5OFL_e'=뺜&s/GbJ%w@36EFY?ׂtDQ Eڲ̓Ky"2\HR" *8HN0!` .ί >uiU%hΟ*JXu `7Psxwa!vы7X8+O!*rZ #2EJI]ʢ ΅QS6C4>J:L+%:rֽ/>\ONcࡏexR)1!3?°_y[A )DPRv׭vԚk*^m7â} (8u&6B0eGՐp?gqRek6HYہ/|5ҭ@9[>ʏXn뚢5{2B֞6t&uQ:I(jax+^5ELkR8Kƺ~gXmSև[%'?és7 #GYCSyEmB>5YS ځKHy`CPps-Rmpf "E@BZ-S$wm}x\)1 ip H`38XxY -R$uz _!'-9\4\GF ɐbt.hpbBԌqJq^1*l08RP@Zix%s=C9ڥnycGT3VGu 3_V86,$+#5u|k35qY.mfi䙼d<.6h*t ^P0)CLbvD^_F'цPQ YDw2+$% 𲗽v~3\y[o1ߺz p!1*q{%eh[Ha΂N0/`ȕW^>ƨ]j|{};rAm j\)vG$y7"@JΈXxk#%HTj3IuIVXphBѣ cN%Yh4q&k[n'WY]\F IPD cxO߳zEQqՊ}=In(Kq݁C B\t7D/bm6ooM]NxG Dh8ф:EFeMڒH]ŋ{M ]Gȋ0 p1}8:*ɕ_ g1J܂= u!폒b-"Q|CR[{%H2+"p AR#Z\DOБOrQz[t;9P$Iq`ʍcKFe-lC*(tz)d $|>e-3C\Zs/q3D9+Ν_'Ofv6elRQ8b&W(2 ͌`4toSUf DUʢE7dE s Vq'O*3(eK̥3.iScmW!52*)I eajO^qO,-7bN0 {P\{Н@4L&{#j,qsH̵p`e J? z=ƸRXYD m6#΄OCXI*k[k ;[\w}-GWzd`y.V Lm^4BUZE)'`Dl5g "to~"^3OYN $CK̯<(s ҄x @;A"R\o䤼z&8Y~(BMS ޿m}] {rZ:FUzIc㭯 ((+y537] 2-#H.z͡i.e}@s vj&&|P->p߇+U樲PCE(#a>?zMu?r;/ǯK> s,[f~-ӞG%YP3 ޷@(dEeGQARZTւ=bm`U`$h/tn!Mͷ,*.7Cp-EDZ).rܼ9sɌwy]M$"|BScLԓ3%47!$JROsax%= 8L$þM:3'WxQFwfs~_Cί1݁<1(A 2| GPޕ֣R#SkO*H~z"o+J/r|6'nĸkٹOȓX ]G{~"^+?<(8CD]1FUH+@+uIa -Pډ牷_A o5;d@W-QGJM+'Fgiw0f;U͢ƁAA @Q902 E0 AQzy~\Zw04Ai=E.{8ˮ3ܩ殞ԒZI@ f 8^ ;‡`<?Cv!6` $2@sσzgc[-0!v窪=^7pV-C\\@P̷VR0~45qa5Kgd./$qZ_S^UP(D! Ba-#)6$F!QIQؿ6rMORkhKiNĒPeɼLF%Hʶseӆ͌L#bJ܀HiM$EtzWL|4ϱ67޴{r .hY>Of!+ Ene<ܥ򪫄$,CX*߽ pV%5؀ \)9 _NPK\.֤/!Ȁt`8({RkBT^ :d%\>MRF}z4KZ~8P@e*4,Enfq;WhWGz]|\7:(abzj#J>q72ڍ3/_Dܴ*CT4Yrd !(VDVqFFF=EY2Ȳ $%Q)"[e9۞װW$/pN&A/ ˫NrJ%6a2;UOF]/SLPAEb%"C]"2 \U%ְ-[ )Tա](Oa3@`Ex͛o/ g,F}}4,ROw)u>@jMǞW'~w1^q@_"z4JWsUFF a)@K$I6eY9e9RDcm] ?wE{FF 9Qvi'òmV62R\&"/%e^RzYAgz,R&kR|u*8lP 1P@ [{`Ԝ1AWsųMgDe%~dҥNq0jncayυ,_M<=Zf؟=Un[G ȍaPd$ILQg5]5l"wHlWkLƿ4^z.`:$ND)44 Kp+l56A?(ء`J8Kw¸VN`5d3]ǚ ƧC3ϪtU9Mh! FN-0P͞R)" $,~)jhkM,g&Ͼ%϶ \zzB/FD`=F^ $|n3:x0Vء@{P[K*ײvuNStq# gCkuռ<9'cE:w-x1jY3-CqMȺƻ)݌B=I y J#H}G4 (AIq&Gֱ~`PV9jb]xH̒*G%_)Vga!Tt"LVMB$gdtRӜl,.RrqՕ){e}]ODT% !KW0U弫e^II*P֒Ky6HEUI"ȉM7NJe`)pBxMo ]_J^Ƣ/_?MS0|$v3G\ܲ?4NDҋ3af⚻H~uS)L۷[NoXT8O%c+)p8vzKD:䨪,K='"'"} K^/_wS$d.5o|߼Qǔ 唶Z|]eTAW[QXlP”~i2R){CelZVZ,J ^+*P9e$"(y:9x뾀w?bQbJ_ KZ+NtY0õ/2qgJYKi}xx,w|0^8Sf3;T%v{0fm(4dD)v!N~W "H;_T $w߽b`R25cFN#Ⱥ UI5{2['u8#h&5R > Tm aDKFp29EҒdi R&/Z4 .ꧯGꋑ~5`?ogmƽ N WS:MiWx) 2>F0oX\KoЦd[ЮGD$L.ERɡ0V78PRPtT Pt 8,8>/رkNR /ɠ=љC(x 3t8*ck] xdfZ#* zW^({YU?C"IIJO qhX +d^P;;7>ϼqbܬ) ',UוDqj'zqlB9,j@+`z;G9kZ&41YD@eظr`^$FJA>iD4\VwKjhBCE%ip=G `~u~8ڙgtr"#UqkXBJ|XR#@hƠgBmo_F4H*U(1䈸V`84fvaB@X^` گ 4"罿0KAcUVBg5R?8 ȴjס5Bd>yNch%,vS3\qq&}u*ugM6m s8(X@XK3䔥a,8-Mw pB@b5v(DMLw=z[ +)(\U"C(k}e$ux۹;ZP@}S%9%DHV Ü%>zAPYQ_^[E-JI\z{ *ڋ%xF"\$&l?*NDwr #x$QM>R5Mg'2T^t{Ƿ/riZjFSKRÒ )"~8+ J(VtJiŠسglٚn(?X[Q/=Α" U^D!.}ITT6]⫤RfIMT]LX`ňIJY6>O&nG2pl1VS>@l?"Prj3G#mח\t~m/b,x#1Ʈ a&DB^#721'pS:чL)A;0Q?u il"0 F.G. bF4φ3R4Ƒ;(Hu%V#b. ]]Ű-p8z;٭ (\p@s?dOIDAT(JH z x~|cj䃌CsM| 2KaDR_]HBb}]N D O3"sfJZp!*J۪q%^KH%yo۵,̯!዇ٶ!~?2=vBH3aR0Ȁe$jqJ'>.HI[ b.[m/M5D!&<"ؼcN-Z ml‹G (1b]SM7ш ̃2T=TFJU--[ 1H,$X5k XcEɈ!Ox < ,@_ۍmSt:S0pH ͫa뙏Qw 4Q 9r8^+ES2>+%o" H%BQzV)X!3n[94]ӡdT$XpEp_+T@PcqvVgf1g\zq+_nK bG^,طw_?Xc5߾ی$/ZSY"d %j:EE1}WҌXwuXdDj*(AU<>$du!<7 v?y#/w%]؀* +W',ξJ+Nt/@hqQ"ƕEE(Gjdu5JB⠍[H\dK ͝J#]M\~z (wr-Gضm5i%Av2"&R"q" _ aXR1]3)c L$#xY"QoW|#|*>4i r;_s4bIQq lŬ (ȿ2B(=B*L)!|'A wfZˇ3|F|/qGV}R{u>eԉry'TGO!}m\JG`K"׎GQb7gqE(nтt5<\Kijg89 `pjl Yj&/_<1VXG|odPqxrZP h-䥼@U;K@%F΢ #~;qs1ݜMLԿG?tknQp7D Q> Wz?:NXfp(I(4|" -?Hz7ΪEۺ.ѣ3`߅(o|O)&CRht2$UGX}M5NepKM Ͻ<|W lWtG9o/J=|S?ˆi Ekd` u%8"* 'xkcmx˻aoDQG7vjbē-nE<T-2:iǀǍ;EA8<Á.R9)ےq/WO-NgjexF{t8ဨJN"+Ii3`}m/{u=xW>[psSL JzJ:dv@*-M,#1b/ }aKHtB/CgApUkԈe =V5(wPBКA4W^[n;B>؃W]1kV*/\yR;pDJg];Ex@'GSy[3^@g9ɈF[NXN>'$?xߜqZ':"ʥIzPj ieƒib(q"j]l0K_:X4D @ʼexM=˿9+f"DJ#nyGDKD >,f,.1rU:NEĆcUkt}]gb<<% t2?8wpd|f]:CJ/¬WpUޅov/ ELN-QԚ{'[tL592~KɎ];(ulܤyew[ƚ8a*]Qv]D=g~*hӕ%8|ΒX_V)|{AC]|Kh@YE*lQ,%u҂NPfKaNl/)<^ kqGAa'[z<*˨-/[1;@a:&J\̣W;&8T ޔ(܂ H2\2ڋsQf؅7=,v֐i6199oyGj`$#l7,EG!\\Mf(\}kk'6VGilHG}9a&ȑgx q(w=F7JV/{4;»WRc#)=Z8|w[$\JDI zmCĊ\XB? o#)ɤ/=`M>f+ ۣGaQïy̆} N^X]$Y3m[HCkMpGycSNC܀ G(EbL []0Qm'βke/'aQJt#,{YhqfxhgD!'i6I嵼ᵋesIMtYpI"][ܵ?b%џTC[F-&f-LA] طaڋmL!&k__aywyϜGos /KM_ϕgஙRpEe`0S89̭;_.<[r֟ mDPuvŅ&itV 9bQG1՟i?N)ô7?x&y| @q5Fe++ zEp)R@f`yQؑHa:Z3,vֱFu)N]A_Lss "> 0dF#8<7Iͅ 2ƚsY) D2ax#R[qeA1^a7BNs|=Clb!(B2g4,;O2V ts1Vb-Ph"cj_͟nGӭ8#)ጨք8 /86Bp+tJ?$XmN %xt KEa Qd fnf^ѽېq|0JWn}aPpv7Z\H_dzl_싨U\/. gz&_~<~ޭ$q ؂ee@VO:p͟AXX4" rzY:an< h(>yʗ]d];49ȧ>q?3< O}gVk5~;<+_׽&J)(omh5L$P"2=*<)_| h\t`x<5rUUZV D‰G%#rܮ|><7ݒ}%嘨O淽w;±#(K{c s%5&oO`71c^-^2U 9/8Uzb%џЁ"+ TK U1Gx$I|/_;?ȡiTc3`oy;'}:gI$u?ü-⋬)(Ȼ)I=Y'ev(ĿJ? dxV}[|n\:]xFd,D0rVh,/3d|o>x ͑4Ȭ˦9ޅ^Y{}<缾y+ #N# $I7wBA`|v?'V'V FC~|U4Pd!d 3ȱґXasKc@\ٖt3.S8!+Vv#@*CGX=z3|"m. i1*\JBա <ц$MJ?!n4* Pe` HTec'RC%ܮÉMX3gk]0sY~y7 C726`۳%Dez Q,uUu+I!@|%4J?!O`.uN/,N-NnJp>BK"mG)覇C{Vt{Vsn~>rO+v^1ozc7&kzp|F ߪ Nޢ=ԪdZ(1@P/i/l:IENDB`streamtuner2/help/img/inkscape_pasted_image_20100831_171614.png010064400017500001750000000645101143721627600233750ustar00takakitakakiPNG  IHDR%qsBIT|d IDATxiy{yVںWͥ)(KfL q|  3N0qb  $A&kHMJ*Q$J$%n"쥖{yἷi{Il{kuϑG׫aaQY&IH=ux$^ȼ! 7 k>58p *slu9s_C6n,l5&97{[ln8&iE8q!HB$j` ,(B5Ip$X9ed.vBg`-rbS.I$рQQPB鄙INh*s|DQ g` ƄQS)I@ h_wW,> \@/<+ꅚ@<  H/F-F iԁ 'F2;j9"{iOT"x#!-ghsf16mޞfRzhFҪbH"P ^] P0@k| v_^@) ;x$!8Dc*-gYZ`AjR  JJ')餎"ihgXКI( `;,{AH$jD TxejHnc#4^bLx̘5Yw{9z餍QK"E1 &S2mJiSI~?|oވ* JK!!WA$51?_zbn5,N.w.P֢uN{N.KաDJXޞauc3.A (xET0^HgH˄0H RIu(c jU .*T VcQИp}xא:ReE`6P$֬,n13T 4WH >AT'j< $$͙3Yp(cfr~pa1)Y+;SR7Tp`JK]ҸTK]^15BݥD:23jПen@o̸GEі!B]O;**iy%qV"/ xڨtF0fo"/T!Xk1j0LS:v_c$ B0%n&FDF%X\7#w e,Dz8*k ƛ{A 8c*X/$TMtmQRPRxaJb$x$'1׺vZpK]0\ݒpqPl'.!w)]0vq"Y&s /BuNh T-F"Ǚţ2dEPqF0C%.Tx V"ޕIQI\JҥIU"7%.tx3"XMH|¸ޭ9V/.*Hx!qVRXK c8h):P%ȴ+nh)sQvs.oUc m x@HK@حDŻT3;R#Մj230찲eek^lQ O@Xl8ViŋxSк35 93̰ T]-40Y6I,Q sYH:|r%.,`24&qsnuΑ96#J@L]A]cغ+Q ` F,䛖sޓBl[$>+1/Zd*y%.~itQA$i V KtXޞcy{^GhFq`0.8 猪 N%f*UBB !Y+ۯ4R\Ki8*W2)l/!f+};ZpHCZ S$>&Ս$0^dRԢ* GB/"QT c0.!Pkx Q& A(8 @g7FT RF#oQI4\&H~Cnk\6ܧɎ&b4XTgXR03ne%V,<oj|@FG]@PeE#1 )Bx!`SGؐT BT^M8e I[ܑ| &byv>@h1}9dcBj|ۘ'#C #4h6p~a<+KZf-#yG3fk&  *+X&zmyG(գXUG k1VU $yfvlI61I,&[ Z|RnjZD#j"Lnʵ:EjGE\p5铴[sy©ͬ_O$9lnmЙk]84egLyQBGBuzvx\/|딎I/~$mJooiž&21`:${{D_ã_QcdZbziqCSt5$I`MVcq w0#靬3 8ф^o7H2yd.usMuJJm'iD !bFv \q_8?rOf6'1^Uv809&I4H\qVCP'Y(JU2~˳tk8@QI8#р,I@|bVQa'T/"f jL3W\xmifsL_MPSECXB%RK1EmjK ;e!I)Ť{I'B{YEBѠCg(˜N0iD#E1%73^[姵|ع`(BP!AԐeĽ.B !S6D[{ղchWN|j/xxTanwl}x/st .g).oc_Dr㰿HԿ"Iyob1aRhVAjҜƍq_8[+C!EIw5TTqFA)Pǡ4nw<L~Ǐr`[z% 6 s}5 t|UڙZe7mՊS hS(^R w$PU3`[i^EAfm쨣z;i&(^N2ߙ ԷHDϝ C,*\ )= :%0 dB;2vq|䩍< 3.n7[=3Oތ=?_WſB9v(;˜@wikҔgkl1 n8aե(! qkKPl/a4D.!h!Mà@7tG-*ۡ:l76eI{`7!ӽh^RwޕŒ MDim$mtOp CO]Kov︇ji'$lh9ۃi4$.3+$cj6Gы5`ƝF3D/ULJCRSHA l[vw+FC@3>gح U bk6T |N˵+w~ae <#4'5'9q6[[[#i͌#FYŲtZlF$fTiLM=[)~.PըK]``Uy+` ,'X\\u`7!BDlTaZi(G% E-؞Uc_z~ߥT<%8{lD)P)̫( {("B97Ob /\4IqJZnH* A/UqZ~zb(L,JF%V zLȳ G~}gT%ڹCh=n)x|7 <( |o}c/aSayy&g:  سg@j1-.n6xcMLkNM/E8^<^_]겠Qb9% a%;@ Rozlk\ .Ϩ7FQLD 8F ?c>?"w!Ze,zY%I-?GQo˯"iƹՠPnQLt[9RyH][:m/jKňS l=p„@^*I%*%Y cqY͋L Beq*L0Ț.N#x=Pv ?GEwsOŹ&7M.dFV ԧ9u|{fc"шIYpf=U4ϟy+LSy=+] ! NٙY^ˤH<%(As"LE4P^ft*0ԻS^M2.oJfV\lǵ .w ȕ*@!77f`"ٽQ&GLT;p롿ò|ߥw<ԣ?7nj5e4S%^VM[<jRq3v $dI"?:sneRqQ& wa;5eM%jUqj/Q(k1 $LW8o.:aҫXwTw~CEHH5UA@5n[a`BN KG4Kc_˜aNfFqw2*6Y]7x^6^7xp|ykloocgKf:3}-lfH .‹ghd I0UM*0:p-vn\z4mK qGU,'"nOyM~Fr*'=1!bi2^.ѯb3 8x;=J}{U @$jĬTkJZ #ҪOPvh{i4W,.p&ɄM|M<7;i/i!V̱O&ʳqqU_7;F!gz~Z\.ѯr`b9TTb3.In3AiT=?boZ?&^1Be8{O{(,3x t0b '_?p0b&eo˯N$:tdK?y ã<K=r3/cxhδY\[ ʹ۬-^"sJ>֩k4#NPR}gğ&:ն[*'r| "Ǐemi*v~#x]xT] CXE(0嘮֒kf{̈4|EQ!\[!h*wc<Ơs>}'33\h4>GPh|uh4&&7ʊFoO[8q+u.78y/yvCPUqrɱ3Go fqJb3L3(GpaqasL[xL t7{D!uT &BY XC#XOx2W*GreYOt@+lmp,#m݌,|Q]1L"h4g2*/o~?~lk>p->7@7fP$vY]/Kpr5u>F]wZ&}f_~sqyv]T2&5S Ū٩^.ѯbDIkmD;bopĂ̸1k&p8l5"%FPY#ވa(m}z2 } eYJ1* ᘲ*9~EYx< {Y^^x?*F!=8s:xn/RGkm:vcǢg#\$Y6edG2dEUD"X{^'x/cW3-0.Zk1Le5D]lusj!@Côfn !MlcdaUV,!0Y VV˹)ʊS]< VW^ygCN?ɾ5:L&<#4[-ghw;yXC_,+4/si>FU|OB_|A9~kO]ð3X'F#lY*TIn^ds~–NjTK!jzHmɯ.ѯ ޭ\Q%H]Ř0f-qeŒ C<u&n!1_ HnCGB 9-Pab'ކpYNb&1T>ѿ&O~Il/ヌ5nVO_=~p뭷½{,MFy_uسB&.]Z Q1mgϲl]_r)<5D)v~jӠ WV9`o6@WYNfU**(M!oDtc$*tP1˚%#EGvBJA/$ r$̑&~+<lnF#'Opom{!%Oqq˭{WBI>!1Ib"kӏ1a sa0:o2iqDkیO^l iKtfڅ0ع ]MyQ+cJ$hM%P UOPTIE` O9GӠJq~۔ń$|ל8ɵ\u_Gw˷${4>OqaNt'N2cO_$&3GqM7 Y SV/nqes<[ s45v~%h8O׍؛ &ʬcҺw4 9*hotNCZn.c(u=1rSES(DCLc] ُac.}{QΓa?59r;nE#@za:n47t 9l$|/wE13$Fϲ}3sv~n >vg„Wf9_N$Pz&hv AA]P!ȝ?ƨ5NP-*JDǠUF2A cso!b,0SJUN7{qJP>GOgm}}.zzť9tÇ/}zN-L :/=*_6|]_a?ӬJzar:bYI^(ilC4fnGfoh$c!!/!aUx(ͷXW(e4ΫV%&LAzL 93 x,g0,<(Iڤ <7}4K:u- |#[YXП? _77e^'yidWI }Z[?y*!:bQ-,O2:k!m0+"cRĀ!ޗuM1Z`CB DueWVWzD]0xÂ0 6F4p|i\՞ASם{' l|o'sm7|?EeL&pofue/O_,? B5|GYUU|nd3~Qv~/@&HP/W,`W?<63mIK#4[0IS՚zn 1V_\82 PL@H(@a8PnRҩ*0>*q ʪO=6ʰrac{?'*'#|~p=wc!H>gko#\P^ħ`$UR@*v'ZM?r MPa,6{{w $!BÚ Cč#mѵ%&1OI.^B@QhP7m#2:0.9BFjXC<أO?iN8Ʊ9u ,ߤsx_K|^~&J,A2XEI^ D侠[' Y,x Ay6}S;KH:#! Z]Yǡ P"ZHc# GɄP1~ 68Bљ hLm6 1.}=U.6i=ÿgf\ 9x λoo=O nϖY<:`4 yRSJc&jZ[K]sɳP1JNT(\hunGz7Cv|W3TX aJNw(ަshaEs U j |Q IU1 d aI3K,?)q k<#xRz_؉.q۝דF|[? _9пJ)c5qM̦|}tJPE?\ o ~0S|gN mD.DENP`jPm-A]iRՍ7:)pZP8*GBGM?!$qau@Id{k*I:1wu7,G!*x _Ҿn)xoL{I)܀T 5!2WgJw<%_B'Ǣb0( _2G #$M˒(vI$MPNB|1TƵf@Zu<AQ#x cR H $^( $Å8ˬèB|M6:ʑKd&o*<ؓd.'\ ?$p qI!h8xmx~ 1(>8A4iE&xFy&~N7 ׃*f )zNnMtP4]J((ĕC#zQAD “ _uȴ%,M20E ֐*eI6ޠx*1aZ-wބꈧzMYpƋ#$I}wD""_+MJ(1/a@q!xCHxE:bo.6YdH׍"Ij)\¯33;IZvRw _!Fc.jn-FZZN6 &%RحWw  `C FLVuj_)d.Ëߣxy/sۢ*cPyO?W%֋c7]/{M.OKwo#3]}D !@o`T!`18iT}cuw 4~kr[eݺbzhz%Y"!@9`.a7^ $tC4FF81ؠKHq hU]<kdMNYuYUs}{: `K,vR_|ŽrY4##Rۊa\Y!:"!CBWZaCBR"؈+5,RkMC(CFܢdlj{r:?gX~7C\pmB[?cx{JL ֋lE$ b w >Qz) e_XeBѱ'%g#:>#b9Tϲ}⾟ٰ6$5?.:6ekCWB C"fX?}G^ qABjPRh8X/LXw39[?RI"l2y"}7{u-3g6?@x{N$˽0`Z)X,nhZQЦs=Y`.?lEOΓS6HtdSOZGTG7`\)*#V̌zٯx.eI :BNS'zo$W/]&{;̦ ) T=.?9oV7~Sa6 /Ϟllt'BrTm 4B d񙰌6':U=̅.V=l 1Hs4"+>=œ%ŵ^]> Iפɩ6r)7oNJX8Ho}\m Dio,/|.*GАlOMbФP<%'wu#d1RhY^rW7k?C* .R|sg~%krqKr4OԓyĀї9F 'y99twHQuRVS\Fo\šbb+y|/8떘AiӘ Iy11v } ';oX`l㭻wkCylb nI,r'z{7P֑ v\O.4N25a{ . OW>4U$ h{$ﰋv> ējQ0l2PT[ȫ%1O\ !s]{h̊6^. kD5L!0flL4M 6~e7Âni׊?Nɧ%6?@AM3f~ M#f\* گ'A]z{Ņ ? *Sd*b$4%]􂓎C“Ҝˌ@B%qAFxI^@eLޜ1uCK L.ӯ "US= z97y *W 'd~6< AKSh*ł{h4:B7Z;5Wg1fTL:? OC pD崖5}6}m͓O.6l}[[ђ9pKie"A(κLK"m)YGi&g0.rEn͸=ˆHbmb L|n ax 6i+I ix~QPQ&$;{G@Ռ0 /y'/wg-*@ףڕJ R(d嘗k6D M~c $RY2ÎE ^Qn[rPh$g-o;B,_iǗjI/hr怞˕L OEPj-0B|KY.m$(m ~]ROsrPxroǓkna@+rBR\>/Oa ܱKTC&2ȲmyҊo]Kv3a3@>9d=r*&J(xrUjqصkAyvyBFD (v? { HxPaDa#I~M/uv<1ZpVDJPC;nDQTsLZ.i|Gx\7m}Vw`!q.^Tq܂%Af JehSMmclFi-kI!\32bƓb\i$KjP3v_A|RM H O>zrd<9y̼&OεH8EX31/E<}x)@=-/:~ _4G@K?P>Ѧ ;m(J2`⊷\R\yq]:U: ^wz %Ik_NR_PSĕ;zt7mHRN{5Xt j3CT\֍#!9uH/? Rhh[L&7:mbz@̘5 wG: 'B:(&f`,v=b#<5&!t+?ah<;/Ϻf% jj7#Wn͜L f=^oaPdf\W=D=MH4"Y%C)o z-v⮷uś;ܺ˛[5n\D)[v CZklNt)<hכʖSw hp7'dmVenz8fϓSM]&wfcIq 0jF઴4x.׃ dq8h0GMVCQ 3U CrT] Du%c&e>cl[ B_E:~7F14f*F^qkD3jmw<҉&Oߛ'Ek/ϸdbK{h矂0d`>˖옩#pX?} |,xyVM[?̓ ϭKC 9f7+*52SuaY݃Wvx kxMt,'0<S9ܹ͜MUh9a4='/jY dY;ؐsr?ZSK!5o7|o\s\m{F<҉ OOZ=9hqbO\xҮx1UJr~;jxmO.lKX3{}Pܬ4֦O~JRz'Q<9{ѮGm IDAT,niΙDF56re^m=DRfÞ䵺!Xu. ϕFœkO ?l"|k&ͿOoRd3m1{ |\kߑ'OJrOINAtBH a> w𮢚m|gN Kw}ׯkQxQz球.} %&~4H.ꍲa=vQɍr6gz'Tq3)s`IGM^2-w[@uV<҉.$$Q?yrO^ɓ+d#ԟ/)ЖD"HizIG׭a͐cLKw}@<9 +<9M,u/Lɧve(U* 3S ^4cUG(H(#CG<Mi7= zѹMyřՒ>xrKu?;7Oh†!O6/׋|:ߙ'; 1}<ɬ 3^2ț~nbB߱'}T3W)A~ܼ< $+yts/D/L+1O.E媹ɗ'.%5POQ{w<\FbN(6}2K|?!~ȦJ$U4G'Hbm'^:絋-wwj/FJ*ia#& FgRN0w'ΓT!!Æ'ow^AwqsrwF c+2u,dE1W4Be 'řl -R<X~gߜdxPI[Aˍ+sn\jykZz=>3ߦiCG<ѵ.X) ]73UՌ'"dɍLo{KΓ@gO.ͽ,7W9WkY,W箃@$wQS[,6)4T}*XQ`5 %IVR7( !{c,I t O5[^ٯ8@=EMNxqc>t .|suKSgWݱ䧗.a4Vˉ]~UŎ9$}$Y#!+g_9؊c8!T/4zy+ޜQU[p'xKXGE>Z4.Y[BM}'bĮ㩶K1tKTGz+PC#\~y請5eW OtZPHXFmD]$̜Gtա%#!߉'?:W-;ʳkױ,'@<7q㈨,B_Gn5|Wm8׉bN76ܤZp@2%O1q}^ܬÓ[&LʵaD\B׹sv%)%ǍnSt'ޜ4F n]hquΫf٩| vDshS"j~O0? DD!vcE:P܍G[@|j![-aL<9F7ݒltuzҥ/z'+waN#4o|ηn.;(.]-A'y(M,e[s^zoFED%(#_j,7A ix˶d1*1;B< VPےjrcx4CY xQ|tôaN _70ºM  $X:#|7a%~h Y}H+pwɧz'#tT']Zɥk\6R_dkjq$wpRFy dVOVuQl7E;yx ^cc@?One?'/ q(zdǿ1SU#M*8p;.ܙsh*]l4`3ԇ?? erꨂBI0\e/E1'|wJE2eӉuX;;%L6bȴq ,1jk8hw]2t* 8>50%c 4T5@ \g,F,W@  \bZɾ4;^Y O[ME;NqO ;~a#y8bO2٩YxQ*W缲=rD,$n_tnJ9q= Ӓ" tK4`-c"zzXqƑdQX~?c<in98r'/e+'/Ϋ~Of5ma] `Jr Ev̩n#! u̓[W,cX'yXJlc<сX"1ݻ"PW*(+kcg+.'>iR9d6>Rh-5SѝXE}3Pxr+;^]ZjFoPѥBNe?y:Xto,82B̻T“zuέÖsɧ$OZy>P$>hu#hƎqXpDX.]fVthO/4OQ5o 93:ɥtU.6I\NX1HW0H?K“3D1rB<͓̊L'OBP|7[VM$Γ*OhΓ-O~^\'7@iTT*Iik\ FXi`Wr [Կv-<< *x,Ś9G_z Qq>~}] #۴;L0pzم.sw=O>Fۊ;-^꥖;; qBƗ(M5On6?q݁tluιD=3$O\еpa MfՒuA3 z@Xڜaba8"=OTrv4&oBGwk]u'􉐊VqÓk,\z~+2N0?mx`h*\huOH"T1  WWӉv~.'qGQ{LXBWyQ.r]v24/CT>53ƥX+tbb @c!ae'G[f'9݈.{ʶq͓&F m $G~@MP?B}?o'.Ϲ[1RI Tʴ2j:rlo#y[ḳ㝀قl )*q.{Zghq,gK֭Ea85k!mQ 8ָv$:O:ˎt,͊13lݛrfe5"i$u+tUGK-~r&zJ4lym8>G Uz6T|D*_RJՀcBL' xRtz4$wn5%N-(3{\". :)Mw[|>7!~wdֆ:'>G 8i\m&`j\ȘUL]兊wa@eēO D->rﵟ\Ӛ'l˓oŹO`;ɢe,P:)Q7h$c4Lxi+IgeQɾ -u:k~͓ׅ'.QJv][>~ 5oiS`nymgDH)Uhf c:AMUs~?yӒ iGNYG" M[W(iO⨚Ft*kz/ Hg%<n]O~<[|p=1UĊԡ&wBfW眺"͹RA8[ )Rhi_?$bvW3yBW=rbO.k IAx"TYímym8׉sFMUŪijca>h0U4zo$8"eVf4p#A1~rwkʜO^XrbXQg)Pw-Dg57/4ܸ2mDF8(LW*6Ĩ䕰x']В7WEH0ED}eY[( 2Qjq9\5Ow)Jot (.24S{=ħOR>$*T%ښCŃ ni gOvOFslNm e74>~4}@t|5n:9_ȟPìdR 4x,龙ݺb9,`J}|׶R]%EūZ^.k$4OnQ}lm8׉pҭh^*|ɏ?Ew;IwﭛaJ崵!7e:'IfLo\@ɟ7GrdL='6_|@j*ӽح&tO_\su*_Ep#o?zK,P; ,Sx_$T3j<4֊W5g<)ZJ}-H,SP*fe&MjGxrO>ᔙQcjMb`Q"#-ܺo lʟ78 -ABMbqqG(ƈfj{}QIڑGcjڸ#T4DɤGd# \kܮr Tf&5HR%%##\^A)>燙EIDATl[1ʮi6saӧU ry+\ŗ~o5XT 4f{ Lj7a2{8jZwC<\Z>HU: U6tW=X41F{ɷp#Ot;14bȪcE5UIN?㸋ƷxC@fWq߂n˵`b7%TqNUGHOFi60h`=~I8D^>~$s_O:ÿ|Gg]]Bpzl@ǐK^.>]LT2_1 }{cos_3oQW&6Q]Of5+TFhܶiБzտϛ+FEa0{G6>D l꺮PU2X!#yFj8/xyxfEg~ |g~6~k\|l(Uh# &4AJD bbCӣs[wx-n~>aCNڑ^J.߭C O>: ,RB]Grdu#<ؓ\>1`K &o?Yί ϔ㻷B m6U#3"X%o-n~kvnx&!e(Q^};>Diqn)%D:B 1T7BOys/Ǯ1X?ɟ癬W_^BZ xw3։ά{zGwx-n/Lg=:j&C8ѯ:%beL)i1~<yxx18Z"ʺr_Pz-YR7dmFw7u|)PCG~ş/v&ww~Ŀo_Y, d$L e,@ )Uϛ[xun.os_bbJ3ל%WTjV3ezMSUdLo^lc'>^B&=ӄ9SU2^;cQ]ү`8i/ ?T-:(= 5ԟ`E%CPVtIx ^:߿I#9X*G{40LD Px؏>Un=F/6m|&%3.5uprrDz.YW{D7oՋ|_''i*Q1n$E[/u2%7oO_[?wVGz&7nqd'.yFCA\Ɉ%b1RZBK)݉|B|'&Ns}.]:WoA]q|[wg>kgV(W`"&y)Et,<$b&D8|Niy4׾؄~o6PNr׃7`8<<6;;{ +e֯+?,߀//!1P/ pOY, ?'z{H%wSG%l6;wT x7!B,0)I=܁V$>>5ƳmW_ⅆbZwU$hѽ__K͗ /A9.؁'_3O~-ߢݫ7ykyAn (0S i0s%w@4h߿İmoCAo"L9VK.!t`q{g_y/,'? ##:6_!*+s{kxF؃4usmT]S|d|6CW>}2T6mDȴdijFΙ8ygXO,O'? M2gGwjF&/3DD"[$$FfR ly~:yE'/|3YW x b2E M-oտ y#q.wWɳDj]Xp4Q}߳أ~5)U2K1"V4zËL swryv9x x%oa?泏%%IENDB`streamtuner2/help/img/inkscape_pasted_image_20100831_165045.png010064400017500001750000001405611143721330500233650ustar00takakitakakiPNG  IHDR%qsBIT|d IDATxYWz_2}_A3eHqBEhIb(&ǤL!*B/t #t{&Ej轫k_9/Ο 0 8uTTvVVy{RnOxbʴKAd?tK@(ј`]!XSeu$>Ϙ1G\x9s > uhM(kzV Σy G]PC8ȲUZ-Aow9Utȟ|z^<|Z 8A0(F‡'}LxP@) xsj7Y~ 7@kZڑ P5J++^dJ4Mο 6 |Je4jN+mQ,/1۫Q6NKrp%N%Dir !m7ᕊg{iP@9. *Ä J2?T>"dBZpwz#g7ѫ pAt<JP5yqZ!!K^Ը@ȎvY=r&,%ƌPwg. 3) M(SI4Z48@,%0{{j*ޠAƫ@`"=(% {Z*ЭF]կp|f*GIèTQ VAwpx8h'i S؏J@{ &RO!7̢3UT Zn&/ HUD j H-.(Td 呅]w=G0% @D׈ :Ke%Á)B0R'b}gwR+geeuT bMeMZkrD7\_wvxv&'^?9;xHV#l:]*#&7I b481у140J/Fe BR "+ Nq [;ZV+t@RadgQ?,Wyf2<> . F7I8<Kq*^.{=GjP4A,F>f4eǡs5g<{#hg,k1uRTآxA+|%[]V{D" _eJ(OGZׯ`8B+cVؕC 0}|7!_//bnjn|fmƣ}¡:iNAp !<pY_u],++x g-rɷﴖNoQb?>?_ 7ai6ʐIZ(K.W2_:ʟh!z7F̞  Hupqr_yW^hM5#ׁvʑf6?G1٣+ iu5pQ@ce ؾ]|WN7ekwtMo8deiÖ/ILOpcZMi%mѶNx'FՔA.)P2وZ&|yBfvvݔZ Izƅ|Λ|Oyn.?,v86o3*Jih.BYCN9!$V< S :8 EHb3͗^9l6cZl i/-1 9~8rs/6{La:-ڠ4f:6Y'hR|2GH{~iF /}w=zcs}4.E ʳYO8_Wܻˡ=ZyCt66p*a4pŌ{jFp{G-e@[KJ тR%zQHVnq^C?] ,gF f3Y>k~g~7a)0-v:!ou xɉVH?Y?:E]3STZa$Dy0(6(t{kB|GW7c !6oBaF\BRUɈ_kpUr#%QƌTpM-2@8@) |L{J4@,i*!șo򄢘q H҆tk3'G%2nVc,j$FYcT'PSǺt:p+Cu($#gboTt#=EaǮfX‚e!(4׊ੵ}hMw8vk\5ٿbj.ZԡrPgx攇OxsZ>X"ԸыE+A\x\^r-zZ+fjSngoS%8`!NpL'J}X;A4f~4_srtsKT!w|}R!k~y„{1$'Uཻz ;M4yUcjP*]zpa* ߺ V]:vk t&QZI0[7FF챠T0BA,v+e.;TFV3F&wa ŐMFJRQX>jԪuRcEk:ue.pov ϸtL]755s3i :NFO% s]&FiRDnz0T R;sr˥9\xc YǐY= Kf D|Mb7mGUJ$a-YgLYj):JXG`1Z GB]Idj6) cc :M8I аtx H<`EcB\]BZ[|PȠ@KJi8:xf _SOhTmn"ъzwBٟ2(%JK)@8J10?}#G$Fd6P5}7)f$MxG?߇s'pjP5Kk:0]ES(by b?@"%e˧Ro#= fRdk1A{ Nk+7:R{tbTBmtdPXK6<} gn^(PNr ? + D*jWQhpUsl`tZvٟlKZ_%.M 7eV>M.k66(34:y66xbP@1")qqeV1 WPM?q1z@tJ:8֦Sv9w .]{yL: (`0"'HY"I\D,4Dc7ul<r1VypN;]Kg*ff3IJc<NFBC,yqݴ:/{eIԭ*CaQM^*&A^F=a)B $YڕQGw_q4b;ԥf`bds+tR (@1l0E fA,6_E䘈rZ/<2(bCJ(Jhv~׾ _x_ 5BQEf5E9Fs$ũ(76bӈ1f.V`4ӴFK@|]ӐjVIԼ=L5izFhF8t$ӈ'0%vOlil>Z5D4"ymR\T81[[\x5Nc.YjV,,(T7.gPJaE5b 2Em XUz.@ZZʼn4WIx(*4ո *4"'Il#(*noՁ*@|#WBU8 (rQO+cU54xW*HF&)c;qyNQV9[8HlUQbE(uZk%+FE|-V7SgE" wz^Kqx i>~@~yч|Q=0< r96ŊGh Nvyz.Oq7r6Lh&1!lFpq ;Os M_]KS<cizBFgBPP5;c42]hnəoǿ3`8Qaxo<[lbȌj,*;TUWUn߾ŧ.QUvw8qu]㽧 hA~K!|dS7_eveiK+<h4ڸG/[eyR8iO1;|@Ԃ"RL/A ^NJXP4E҉YZA 8 iBΌ37^w2g[]VVVhZ(tʰ?`4!!4H`3dA׏ܫY "c4>"t>_WYG:`u JUO8?(OgTz2{ʲ{=cL&8(W^yx̋/Hۥk^unݺEb}}k-"f}@}Y麮1`#ĶIsO]CO93e}WFBDt1zܽ H 5jѱ Š"Iy퉅\I4&+`}6$^U ghKnYms[_ͷ9wH|)ccutp8dSf(ȓ4ceոB,)74VbP{!JaeA1\Pqr"G `lF̨ӢZ.GO6.ʲ\,HlȡZs 7n?t1#G(;wpU.]Dzמndʜ10zZm^x6L vjAcc, nuNi̓Ns2irI1׮]#I._6zNuoMDL,X1[jϟcr\ U9egL%Cq/wx@ h6Ni&67CH'opP%)V9wγwr읯sFYJ`$(0ߧL&C9$:uZ7n9F_Q=!6^!WBdF9+Lw*a?䩿󷠝s0--3+*:IL$*!\zt뚽=n޼JZYrWyWxjmc׎p}8{"{E>O52dݜ&IL$ }QV+UE[nqU1={g2N֒9UU$bOSVVVpokkkt|/Ͻ hL_K HeN܌YGHYDGş;ǔw{iTVTTj tS+&*x>!?H˂Å6/g{X^%tw!B+ ݢEX+n 4 4;td(4;*pRQ]1O'P (flrޜ_"?EJs^fJF cBD1<$ !a6֚{J H9`vo6Ǐ{{3>FOk1$IPZ) UԚTΛ? |ͮ,#X55VdE&pwmxKO`,ʛXcG3R1B=~yW8_nS/8vV,f-&U7 WbbgJ $ eє {R)zc_ :qYNqD7PllJXkJA|Qܻw۷oS%Gܹs Q{[nĉf3n޼*O?4Z& NI߿5 !`TBTW9"r_z:/Zc8P6&_RW*Ģ()P3+\!֑vN$P[h Xug8{<xբOR趺l8-X]&{YA[<]F9: 3XBFeWNOwuaf$W^⋿%V8Za1MbS Q BG$f<]UׯC]$Ib8>|-WJQUv3gΰ믿NԩSdYp4N>u]?οЈL65USfm`Oqr'K9=qcyuq u~Ⱥ 7hAj &Ռ^LYv0XoӴ@$P8ZAb &)BX/_4>(¨@-TAA;iQLc{?STtۜݶ,--qIf2]Gڍ=yiLDH,L&s|7xd]a{ 70`b~9 s@˗93L:z64S؅@>Y[ii ["$)IlIǏ8u3oos[ǜp2tLi( tG*Bf&E讣s8SJ:A&h0B3OƌFcdi`fm~'/@wѬJ4F)V<?k:F4E)wekkhıc8}4N{quB|SZ\}'2ʲDD, !pQN>W\nnz[(<7b2a YEdUiHlsy?^n3R;ԪrIiQ?'vtEH. ĕ@*jđj<}<+\ OUwXie]6O` >8f)R;oō (2mIT QIjx+omBH Ep )xJ]\P6 Ih=}_3_IJ7:Ӳ46xO&8;ğ{$!1鶿o͛7Ht9<+++iJa8Cݦ鼋iR5!6Zk1vMQlmmBɓdYd2!MӇx ubρFheͣTM'kaewoZ- {?0߿]ѐB$j)4I0(;uVՈ6W[ιQ%ׁOn0ş'ͿKg˒:YazwNQU%@RJDP߻JIns1W[b ugc:1θgVCzF~z=@ނi2A49U5+Eo `N u=LٻW|UzUl]h=d=9vdi MI;; w&֨KG]I*K\]b 53 OjtnF_?1KCN ?e;GXjwb2ŋ& e]RG&7\(m/`a2sw.^U.\ 7LS1pMslnn}JtJeoo,Y[[nvZp8dmmU&ɻ@SJ‡:Q` ǎw MӨG؈zF#r$GG^">'AaBtGf|hOO̫s_`բqNJXހ=F<6KpB+1Q@2B$fOאv1ΣChu*Ď4-hcQ$LL {}C¨TL:]ڟ4?[u2ƙ6MrB]$5mI`Шe[e:6UUywpqy.]Ç2ӹ+^W\9ɓ'{if'zիWt]N829Dn۷occccI2Z7^"B+o-4gM&cn߹ͽ{ܽ{nŋ9ybSM挷y)nww5ˤ2l]8X[[c6q5_1'Np<>{8vL|"i’x銨>;o: }ܗ=ISb_?nw.7zc Ze]cmdVIe&ў놬%fm)Ă3-5cc?ynkˮK*S6% vK2hΧok1!S,fLjфr6#8J|G[=ZSZEDԑQta"q0u{#fYpdw0} } $Yj#}v<术sBp8doowRUGass'OFe&PB <9Flooo?yodّ}2novWo@}0 0!9hz,Aq 4MaKrP6-aPMY?;̐c XTwuU޼YH 8C*#*P9{{;;;ܿhqDynaaak-owaee5q\='gÐl1(B<Ο91#^MllA^" Vf 47rʂ:GY[NÐ+J4Vv.E)MkٌKq]}U.{KN˜+Mb2&f6)ýhJXmTѧnLPZ!ɕA( R}Q&<̲h`<`<2$:$A-k<}3)yA$72ʋւ' qXyjollr]Fө/hpZyܻwc 1p>\r:Ƙ:uZRqǟE w+#ئh FICyб _F?\< t> + "FЄCW3e5qm.W?`%3Y]X)LL#ŗ %%ң ăRHC`-y'mdAwgl"$I~=@{v2CxXZZb:V`3,˪]zΥK/Y$MSN>MѨ8wQ=Nu݅P*x )Brvy#{=3<:c-%ZeA eq>m1 ! Fj"^=|?Ԙ'f]m|wy^/I0hZ8F)E^r??egg^~efY&?,ȲstM*a)Et:ess[LL_y~g>E:!<,*- 9S3{oÇAŋ9s V6sϡ""ۣjXAaHǴmae8Zdkƭ[Ȳ+W/|f2jYUu$I|}eս}1A09::" CZ@Q8;w'ɉhb7[ҤPȃNG[& !DۀLn|2( DjZ! OXq=>Ox?6CÈ5UZ6 QmtrW·Օy8C'ئ<z8/6ȉU{Ԣ$> r^Ydyo1,/1N&)&(+JygG .xXtz(Hℍ n߾McaaK.q9e`I$U<5ͪV&_WzxNj/ȍ7h4qzfuPt:{-hc IDAT 9*>~*5 (/Y^^c>X4=rL&Vi^X@(c 2 Ql2%5>31"d*Ȥ*r21W,A`,,g&wCV 2«gIaLdܧMrRH< we5ؼ"򭶏.Q'鏆m6w9(Q6S/o?bgx:!GP,1I2 |f%KbIY4lb~EY ܿw>4Mp7nԩS':w'%N4M&MS*!Z:o7+x ʈ1={tݻwf?4@Qn.8uFSNN,// ]tp' X4nQ204&ъIj94H )|a^*d` <9+wԽ7_s:a%YЭWS(hHbhw R!OEi TeߪdK3pt=<;92cg1GGNb3hCy~Opn-%X$ 52W)ō9W+ M{La1=&);.DQ׹tAT%+'je2m> wuzKC> E B+gfϘ͆0kK=mVsCE$VO}|}N<;Nl6AT'”qd~!(l9_Y֕\{z'_AgL31dL挥G\kr|6~y EԚ]YzhL2<2 i3~[t>O1 \ntJU2cպPl[ =xuN4 -:vDj,JJiXfuAL=(!"-8Yzs!7h+,F>Rl%R?McAHEubb0; c",ciF<!,F%IA){J" 7#|Ҹ}CCV!d}?!O1NPYcN͎xzk,f)@@`}u21 B+ <)xÏȋeq&< m]8Uys"!)@HZO%]F11ľb9c?H)Rϣ?hT+-Fx8bۭFQTG##]t}'1Fb[u(*4cf<{h4L&曼kUm6On*cw9#rTgwq*-΃k͛|As=*hi\H8wq5''jaXE+ظet>;;;F# eY!-zʄ<XQf5< "%=[pMbaL40 5<^`cXbEI'xlQ!n 8#4ܢߓ(Vem겉cyJ ݁6o +%c ЗznҔn#=b:0'GwIY/-'wx.k0Bm0s.yI"4qMH8Ny&|ӟ'1FBŋ9]N=_7`}}%nܸp8du5QUcy.D6\YڃRjV;h4:AuaAI@h4 #YOɌӫ+iJ; m&L|YI˨ԓ>^.K~Dyy{`pE$?*F!"<.ĴE(UGar,+uy^ 9tv=G;.Ct#bz&L56 xhx,MȭE5;}y&˗0Wrs\q71ukk2L'5ON*4|oo۷os=ּVѺ\q}m;W.+=39O.)܁w, g220 r W>O{?ů?"b6#}'c4#C1TӬ؊Z2Lx/~\pdB$'DPlv'qU<鰵h4" C[ok1N~:_җhZaXdnlٳgVv]!i޽{z=VVV*>~~{L&lll@8Au3xD dYVQ]ݼ8׎3(#?: Zl\f]N>7`ʴZ,霵]Z1f|jWǹ|cf Ec OP1LʉmeNZAH!ItlFҨٙ&-.?waVX飅D P+fyBKi4cMy๽ŷ^W^峟,a2K).lee>tJ{,--U4yʪ+:CRhFܽ{EVVV*F7i7֭[cv/QU<&… lllRK.1L*..w>o>ի@rxG- LHb)}RY8b*A`biɮ!/|SܮVD[C΂@V ^|ȃ|ӟPN}>gΜrM L9[ VenCoEIM%]N`Q$ҢB6A%;D! d B)crÇZPk.G{v˗/t*a q\K(k.,n) >a!rF ð &>887.NE% l6 1;-WsRgΜ3 ޽R˗/O}58ar컣#6778} ZFxGE;\p˗/W΀ݔ<;#G"ګuOyRs=z̒+$2hvZMmEb~_rUIn6$2?/J]TM3t>jLAUHŷ<E ANQN-I  B)yѿy`Z. 1N~tG*{#W+,)e9V4u2"xcIoKu :u%9*d:nca4Ņ *n߹so~O/zŪjZd#^y_^!Rxumm]d2_:iO%|ߧvZ3ͨj$IBѣGU!=CgOx,-,]X.qzud6Ez^So5G`FRm69 #6U4\{ ?y4cV:bxXUZɌ%B$*iNgDi isTƐf)Y\Wwa9O@Jcr3clyՉ=~IJ{եUdȱE?b4"==j% $YP^GE"cF}L+7 ȥrEI 2c`B+-Z3l&҆<~ @ Lh%18j/Q-$q3EXleotȖY IDATH%2k)82cv翡 2H $MAi4zG^XPˏMV[jyoɰ2So4fը[nqw~,r݋O ϳv Yk3VWjܿw6ع/w类ǟ韰hz}K\vz=(ԩSUZJ4%I^6A{g+Wp1'6ɯUIn:ѣ k4LSqE'Bj-qV 0:Flom) f'٩%`8#Cd`=QG3:^ٱfTѡQh.7OXQVq oJ{(||CwHSy Zr(ꆲOD `ek-Oh I2)fD* Id6bQQiшbÌ'O3CB'#6 ~5`W~?HYrx8-EQ[?,Pgv]UvQ9~|RTo7(c4Ç<ӕ#8aů˃R j\r^|e1ix:<0z􇣂|B5~PD)PH҄n͛oE+yWX=Ji>:<'f}^f=ym)S[Hky2óB VZbB".C{5_^^급{^(t匰$R2 h!dS,gi4)Qax{;z\J- FSQWДبD 禭88y`޽:N*2Ο?BNz^]Qvߘ̷Z-Ο?Ϲs ޿,4^t-9žZUG|]<+y'%>^pEJ7$G{6H)[=@\A{n,vlSG2'o3Kcـ4j!Q$NI/N< r⥂$4[;{8\7ӯ,~#3"Z aJ`=Gt:Hfys9uT%o|Czi? KWf [v32]tY1˿r( FQ(T=Ngr9Ɖ`_ Wz٩;*% Ig55GGC29d4'1hh[N)I,%cQV#3I }N8K\W_L!Ψ{(?"Osl7h d f0so!,#gS|&?=E`Nk(r5v˩;>^M0͛^z_xdh2WZbҜ<*XX)f??:BӧO׾;o"}NsmU7u9r>F_~V) kuhP <`nak߫Oۙ2a/!D%9/qqאmЊn' 1ILFk,}Ow+JҊO1TRV GI}PtBcأgHh2<6F1ɰ8S$C{&K7?=hD <.YBbQ?r][vR7}w~8橧B`},2PE۬xV+4xO'L1z_ys3'(9EaR"ʍMRqs%#cΟ?OE 9ܹs]8{,.]" Czﳸd2Ak]iJ}!GGGe< (T <>yC&IBQҏ#F(5/ClC^AoJ,C@ꗽ!%VĪdWQ0Ӽ9H''pC?BԟIE&$ӲH169i'6dÄQh6&-kW=\ L 0B!f+wX^jRJUo}}7|v).۷osi($s#467^/=1,TXkJ0̧wxz43M&nIbBD"%PRh,UI@m677ۣlV>ܻw,˸x"/_rqgIT%4g؎⻵EXbwAk] dji6Uu9|PK'>7~Н䕵V#7FjBXsLlWBVmO?}I^*+tieU`Jx+>DhZ@d]-j{Ƭimlyf<,y_Ax#O?҆ٚ]@ H#22cO&aD &31:_ mc\Eh#vitA# #t5K9*N'^⣏>ÇwM?>>bAS *AҖky̩׫sQhk预AFcaIX+QDAD). $M, hN9[DQ^q֥WTc~rc N`0 2/h4x.W-!n׏p8*@(Q LJ206jІ@MF * sC*X 9ZH"Lbn&7$1*2R!0"crٙ988" zS_Q,ܜ?wj꿁ٖ՗J pRSV IRX,-.L,E* m606EQp8dww=9}Wܚ.RZ gffwޕ=eӚsR}U*J5ZI8ddvT6Ì@rztD&bsɉCh!haF\ b.R4w Dr{HNMc/__%*oi%Tc$TZ)YQ@r?Bf s;{E(Xm7f}:Kkx/_duuﳺyJ)/0>izxoA9f 〸QeMsNu@pr\Ta)k}D#e@9!<qD.!S :swok6y8ɓ'y8Rk~nh4~:Wz ۂۏw߶o.lS0aK$q,!B|x:7%ZB>=CpTwFs7DFH,Vֳ*H2$c4-fk !w_@9`( rJqk k2aHVƎЁ0M'ܿﳲIN5Vb6͹&ܤl=y5(3yIʼ0Q 4q  ZK;BFiBlPoIь&cII$xk&"etz!.EĥuH1ByI5IԚP 0" "PYSQİ!%ݨ Ġds1 p o__kk!IB#$&0ʀMޠ_|B"Bq $Rvdg D@ XR۹nU{ӧO{> (nKCJ)j:9{یCXXYfȈ~svt+jۇ<ԩ y:@U`-kRX@S8FQ5j%1F`^27DqUׯ_7Y,// -)%wzGy_f'|y77/ tG9??@w]. o фRdJN9.!r.Rcd ÄN2\ (.aG3t0"ea~0bdȜ{2A$\J*qJf"$0Jqu..>bo + F dYeJ"$ T9|$P16zE@g s+vvՃNơ0𐓓scD)%|'h4"Yy#4'WMkkk<owi/VuOm{]R 6u???{QEfڠd@9CcnL`:zF+N ,AUA6eK2G߾ԝ`NKMX(_BX6̦9 f9Bm'B=XƠLF왮~O%Nw;Q)%nׯ_:<͛^̉5yQӴLҴ8ƞ'eyy|cZmS$$$"@+Qx]e!dN&5RP<` &*"UB:!GGsܾ-MS6+'}6h fYXX<~cZvwuqxKO?a:nlM\.SUȌF?"*EV@"1?_dWǬ%""elk+]c5{(sIyƯyDѫ.- E4 "AF&#wDlAr1?Zv;aȤUlg$I|3>KKKܻwϟtfd t:mߌsHZGӮ>4l IDATCwXZZ" :.2ʴ4*PXDJN.s4Be)Rr.G@(([y* Wz8WRnIMstt<qmwl 3WLit4M9==fឳZ"EVȺZX Vڪw\OGu] ݰ0,\F7ad{/UaՊK X_{֞[$m[K$FtwJj5)AFr:A chߝe阻yoo5^?iM~DV`0nSTfDNtO? *;45+[MvkUk`LrΉ!ĀVF"2+\A3h/r\VJdb2HiSW.>2(יL&~^J{NS9vMl_>zi(|/B(;D!qL]YA?y7xӼMuNTbƤʥᆻ_{֝E*9^|(H^~0\*#@Zûcr|)KlL)T 49 8sw(2DQ^ܼyE\\MUVIK^Ǐspp<ַw.J,IuN\)h4 g"N9QSgTsCܖ"#sI)%*1n44XbӞwexҥZ*SjA]~i89flt^~dC޽{t9z=s`ޞ.tBP.i6diJ(`.yWMut1[gg{"RPww d-rʧnb4y gyɄ;wxt0 OfZ>~l1fi~J9ͩ3d&P:k9FYdTkf Lg3\{%q561/_&mW͘]+F#/'xsI'謩wzЅi2h4$߶ӓi5YZ;#20VBFjz=.]gifث/)tTi (?s)Q喆CBPzۦ3gذ'(i +|HDSa=>gyLbtjsi1RdHr,3P 1}21tyӝv.(ﳺJa{{J"^ǏK({>j֨IT d8,G$BEpڧi4TuN$Bd0\^paY sebW**&PllowNQk9>O>egg??lGgaaiu}nʇwevBsp{jV_>xfV􂏟o$Y^IZ4KRjB*( Za4nFCYXy⏟]6$;c02/,l#> c]j [dj D1Z|zٙ'S͝paj|a0Lx {{{,..ok׮xHf)Hn҂"8☝Cj2yǤ?`դ~N\"M5H5:g-FD71s=VoG5CF YU(baa0  ^if:-dY&O>f|@bvv!ApImw9wP.њp2ϮʮQd*T133G ?8ে|:yaue- B4VUL4h](MKs*=(z_]KRܬx`л\H-ӊcVmPH"A˛c:9-!d23r|3e )7/\\\p}n߾b1fJU[ iS?Cdjޢn0&$ 2hdEk9]#yҭ,|7iTBaL6M 0Zrzz1zݟ=6-vY[[˗hm7oruj58"Eѕn\\\xPt6xYt6's/hdPVT:A%+jMml?`]5 Z "4|v+"hiNAh¹˸夾fKI EVu{W> ewuh>Ob)=CRa*&8<O|փr7ɂ*'#d3q;:VF#`lVUJ)nܸ{XXX3'wu_+i'C]Ul3LYۀW}?33C?lG|~6fAðN/(HsTA(B8P#,٢euf6-p./U!n@f` ^Z U͹4i -TQL@j h""&~9)XlT 4)He(ul{T $IF$ѣG~VռQEjLDhCC`6h&0a0X]F~~1ii.αr> i,P*61eDFRrrPHiET<.y}joΝ;~ZN`WƸk`﬎(H:wmF`9]U.Rv;[[[{u3VEZz]ӵWGl6"b/jK(2](-H <3Bv\KvS KEU)Nt}E8æ9d/N|a20$C5¸8ȳr PzBz~YjU;ܪP\6fv5\6mˉ0 FFNJ)I){=)%N𐳳31,,,F֚ Ǝ(E%"0 BE6I+!oGtJXs}oޢژA e(!M$ 2RU02DHӌ /=ؖ'Kgfn6$IH˥ؓɤ0y2 QRGYb\w(=NI [;TJeJ]KzrJ9Ctc_[˫Ke^3 cKGxcɑ:/ւ`M6 wB\'^e7"]!*v7YHκEٿ `lq!G1A"F*0#II'C[Z,A2V\Tf2"+)=Ɛ FBZI.gE3rpkJo8"2lnnrnܸAZͥ<P.'8F(йfLn>hIRB) G矡8,s|t KK+AFhd`0YHKn NNNަcaeeK0Z׍!G?{#Ӛ.+r4V˓dl# ReF :un + n^IFf9q0Άk1VAO_ OҘ0*[ @-\ZbnT>e!h(DأGɹTb_b&X?]Aϝ !ekFZ)Dh M5Yo r{N 6LMXi-pܾhATƛP0R r3d!z7|ᇴmznu]RDףn-AM$M1ĥBJĺ^bnܸ,ф3ꍆRV,;O8ҧǥR(ɓ'377f·)&밻bEK>~fKTbT$MpR *a\k*PXTj TmlQUP'Cf\qTe?/z-vUNdd`IGu>?n7\/j9ߎw]_{^K0z kѩ) hL83טoUAo` UsKml*QN@W@V o3(C|[:o3\ kVeff 454ϟ{{JFJ 6ikBM&A4Fm>|^Zh\!8T9N1 <'2@t\ c~Bϟ?kofii77oxpʴ4fݑP\3J)!<?۷onógBeVqowLv{a) ؎P@gJJ).)mLR Iz>xo :4Tg*MJ5nӱd=ؕU 1wuot M&EX`af2z✇mh+ ANsa ݥw֗ 0o3332t:<|:q{敛)O'Lj5JNs2 Ðnˋ/GϽ{(JWqvvQ_.v!cO777Y[[cݻǍ7DEZ-4c 6ADZMՄa3Mܺujj" 0L#;^4`4TJeʥ27E+eGXygtGd*T+n4i636e<1T995DȢp=Q&dY" 0?ZCg\_<U/ֲN--@b wj5OR4a]3id_Nfmk:Vh4bdV'+3"r89??ӧ9ܿׯ{u'!UߔܦpnLlmmKwyQ*y2%yTchrcʥxρ]Bm;yKcI)d&%9ܝƠO{}\n ZX,2xqGQG-..^mOkM&p8R\AxkTݻܽ{+8dYFZ%+e "np8dooOhiF$$%<(AQQHQsuFH 99GGl£9S!)KsJ3ócS拱⢶DZns$ZP!V~B;5žtFrߍ K$z)tIǔAwܻ{dA_9慁 ; |jc;rsi1k=:=v:Qk:K}:NM4>>-T*>2^e%c}1|377`\ O&O< XXX@)ūW0p5?tMC[*vd2h۞I6- ])rC c T5t"=c8?}M4`[58$dFAȒ1$2:7:vf^aa A!Xe` !bNԚVP7$ 'OG(%0XA4nXa  |X'pvvOa,2r_E\*c&8{>Vum G5 <ŋT*VWWٳg~uo-ܝ6q,}7ct#IY?~7nG?p6z1O3H3j͢K1Ʉ$KJPo(l,Bwv998VrmJ^_<+&wuQrv-tog4杻:V)Et5\:䡝 ?|^bEW@'> 'p䧅_%׮]c41}Orn9;;J>U IDAT;6668??Ν;ށ~ܤ`iD>&gwNY]LFmMrDT#5ޤOE QF*b+ &O*Y8 {?hB0aqoSov֙.3",Ʌׅ\(0A"ɉH'cpset)eȋDbA/MNchA9fa4yS;uhAsttC_)J)8??Vj|5{oeOۣhP8==enn45LW,.+q/^R>۷oRyOnt2UFr dIzJ\"Bi9q;7=u($2{<jo쉯!b>o~ƳA~y-JE&2Fka9LX)'pr!PA;-hJyA+^,LiE!q 3-_C QM5]UgoFyLՎ黫]7}Z5fjo. X__gcc;wp޽+DZGSfqH4qFvy/^`0p}4$nS. ~>o[ny9!ߧT*y]ף\.S.L&Po;گ'T$ !|*U |A[.n)SdoӁ8-< whp||sss,--S x$U^PJgsssk}}=h6 vvv޽{ܽ{Uk !x/ Vwo}}W^qvv"=bffƫ &aN *T1<+oѧvXF@% OMn8ڴ9SMRYE"ÖsʌuAҁ#BfU7:-7P2Oq/_ V/k85D"Cgʑ`.NM(B`R *o+rp~4ڵk}֚|;ܼytνVT&j5!q!'''xF_~[ny}~C ZBT$QQ!޲2"RɢsPOWt+c^{ƹ2ZNU~AN_?I7:UtͣP"FSG73K!u*6DHP_t^RķmVWWv~F;]MJ1^YgϞqrrBÇeٕm.J)svvp8dqqJq9o޼a}}}*FqLd*RQ%JE B0R3&!%!*Y{Ͽcz2L3&) ȌLf9]?th(4#RR c o,HA:{K ~@xYӡ^{po⪻p&IwH9;;c~~?%OqoDZxE(mV/_ٳg CfggX\\+bF2cхD12RLnA6\v)?% o*/*O^cc1 ʠ¢}EQy ]V)ɂ!BFFw +\S MsTQ4z(TP sPͲC)JܸqMp(Xh9~!w*W>ެ1v0 ɲfgg|^h>Uv8u1kabd ·2L@*5HIP@ Dqpt_^pIWI) zu"aQV]*NLLG*wN 2^M,#MS&?OQl2C 8affy?_vri^凃# I4MZΝ;t:R\x˗/qCz@TwA\ɵHɉD fۏ Ve"jC^:fbh#E*,@aŒAL;^q#Sh0"C+Q~YghYxBXґ& iWH3f_Vew(WU'Ðmvww-7(#l<X\\,kv/qxxXܪ17O^yÜGjuT9JU7JTes}ыQY"ġh Mzg\3ݚ P15s} hw*UT*1?woo=ǬG}D*RV^{83*g8=zDe,,,pfffh4FnTk}W^K=AdzD Y7oV9^^.>o+'T C֨uE-``|I~9)$=s7I!pN2 kCr QJKW eP,^UgYuWg%KKK|&*ߧqu2g~#hv|2~E3J)t 7 K=ǰZA"iJjb%y?vz|p/7X50ЊRvm1x\ --Fj(x J7Nъ Bc)!lD% BhZcKCbzTѯkVkϟp } R%r$% ?<W+[V>Z`0`ssE,..c:DWΎ}AJYH!Zݧ|#{eY㾈xb`T%)"puQ,#Y[`E!]\i`9n+,PlFpP̐if )-w}>= ]vvvt:\x[[[Pht]rvê:I~Z[AUz,..x$JyYq%#TKۏO+,湕n'hz9p-跅uP{n"~TH?[{T;}speBX(6B'E@~8`vr .kqnqo.=Vov4Mi\t uI2pպ?Ir=Z[<4M&&&#}wekk8Y\\w)mu?n>U5#.g:/m%;.V_,IgiEi!BM~A_Gk#P=np*pj  V˥Тsj%ғ$)̼m :N `X__޽{F#vVlUKRt(0`Px|:_r2hBZAjR"!Ňj2X%ΛkBטxKA.V;+Ue'˜'4Ll>?+>z! ‘:B%K i^T^t{ooݲI^͕ FkbAB-PeH:rmSE!PΒ9IZc]7o? 9TlX%!$5RECF7E{Dž dmmL=;w+Ò/~zz%h4kUz;C{Ev$ '8fZ2 I{︹1yCLOF @L_$Ҏk"0Үt$"_;j2~+ \ F*9DdZ :5E .V9;Uk,a찻tI\Uu< #YYYamm\B9:Uv}%#kL W ]P5ll|0`+nPy)vey4j0FhCh-Қr[8VST_OةwtOwD't饟O;tp9!皼X_a`aZ(𛰮,^Unkqo'um_GQp8DkZM>V?rثZkvvvX[[cgg{Wd9U>vՅ@ AtV( "$Ɉ ܻ&Ń_ !{èMĈN #e#RKFA,Sޥ:(`it$:j^_^# ۙpn֋w,dǭ}CS;{y$asscR,X]]H)ywrJu@ tb[, ( '?7x6ӫ$Q—tVk+d04maR z/xgiXw2#F{/PZFd8rppP T]^gϞhk׮a9O=֛EIXb"QÂ~Kf'g9`ΑEmz'u+QF,"La҄(0nMYFTE34X9k/#|c]:p`c4RY " 5!HDYkBi^ɲ<ϙ˗mj[[[ &''˨~X=yQ\ա}lpM"06;e%]o/|yM~ و/ҍgɨրR`6 ,R"@BKVJ[D\?k;soe2E(Gż Re#"24sPI3%$VBpDn<$M7<`=n| ~[^^G.{p͉¹,Kйp `/nl.p+oEY0P`dA(K.8RFc rdr)iT5kw?6#з'g :B I?ya&:]lȕ9$=7i:qka_7tsssiv+++%9to5x9%0]Y]7r]:"+|Kn 6ksLDJ9 *}Fbu[i.[2:Mp6fN;$_;n bh$ior3.,ǥv ÜF=DTjjT6oF#͑$ ny,; S}]7Q =X_~ t/u;~.2rp h9u+ӸW.8ΐq-!u{5J;H11=8rR=oZ$\Dgpjc$Y‘P۾~^+boo|1TٮͲGZ YC`4i t",J; %)R-L0X䉥י愔$?Kma*GEH6,&#UPFT Wk 9V,|Feǽ+z^Bzq'/?"[޶ֲ_ͧwѺȍ#`-xr;9B3" GJ0ʒ4wIDAT ʉ)ـ@KBP VQȪ l@ $ 0'ݙ}9: "І\gha-"4x?~ɍ}#Ó\x?Bshtl$Iy@h ÐVV[`2ưӧOQ˻]g욀xH##Aa \u<#30K k/ (:!(̾ )OBIENDB`streamtuner2/help/index.page010064400017500001750000000022451143061272300157430ustar00takakitakaki Mario Salzer Documentation overview <media type="image" mime="image/png" src="img/logo.png">♪</media> Streamtuner2

    Streamtuner2 shows internet radio stations for easy browsing, playing, recording.

    Browsing channels and radio stations
    Channel tabs
    Functions
    Configuration
    Advanced topics
    streamtuner2/help/introduction.page010064400017500001750000000063301142361123300173510ustar00takakitakaki Basic usage instructions. Selecting a channel, category, and playing a radio. Introduction

    Streamtuner2 is a simple browser for internet radios. It aquires its radio lists from various directory services. These are represented as "channel" tabs in the main window. Below are the category/genre lists (left) and the stations for a genre (right).

    How to select and play a radio.

    Select a channel tab, like Shoutcast.

    Click one of the genres in the left pane. For example Classic.

    Now the right pane loads a radio list.

    Double click one of the radio streams or use the play toolbar button.

    Note that some categories can and should be exanded. In a few plugins (modarchive), the expandable genre/category brings up no station list on its own, while in Shoutcast and most others it's a valid genre in itself.

    Some radio stations cannot be played, because they just have a homepage. (That's the case for all listings in the Google Stations channel.)

    What else can you do?

    Double clicking an entry row in the radio list starts your audio player. But there are other functions available. Either go to the Station menu, or access the context menu with a right click.

    PlayStarts the radio in a configured audio player
    RecordOpens a terminal window and streamripper, which cuts the radio broadcast into individual mp3 songs.
    BookmarksCopy radio entry over into the bookmarks channel.
    HomepageMost radio stations have a homepage. Open this in a web browser.
    SavingA radio entry can be exported as .m3u or .pls file.
    ExtensionsSome plugins add other features in this submenu.
    EditingThis command is in the Edit and context menu, allows to inspect and modify radio descriptions.
    SearchingYou can get a radio list according to search criteria.

    Play, Record and Homepage also have buttons in the toolbar.

    Radio lists get stale

    After some time, stream informations become obsolete. Therefore you should regularily refresh the lists. The Reload button in the toolbar (or F5) is your friend. You can also update the category lists with Channel Reload Category Tree and load favicons using Channel Update favicons...

    You should only select radio stations and genres that you like.

    streamtuner2/help/reloading.page010064400017500001750000000014241142324146100165750ustar00takakitakaki Updating station lists. Reloading

    Station information can get stale. Especially Shoutcast invalidates old information frequently. Therefore you have to [reload] the lists. There is an action button below the menu bar for this. It retrieves the current data from the directory service.

    Favicons aren't loaded automatically. There is a menu entry in Channels for that, which works in the background and doesn't display the new icons automatically either. Simply reselect the category/genre in the left pane. Or again, use the station reload button.

    streamtuner2/help/search.page010064400017500001750000000044111142324532500161000ustar00takakitakaki Quicksearch field and Ctrl+F compound search window. Searching

    There are two search functions. The quick search field is in the toolbar, and allows to highlight search terms in the current station list. The cache search is available through the Edit Search menu instead and provides more details.

    Dialog options

    You can get to the search dialog via Edit Find or Ctrl+F. Centrally to this dialog is the text field, where you can specify the phrase to scan for.

    Above you can check which channel plugins to inspect for the search term. Using this allows to limit the search to specific radio station directories, but usually you want to search them all.

    Below the search phrase text box, you can specifiy which station fields to look into. Often you just want to search the titles of radio stations. But you can also have the search occour in the description/playing fields. Alternatively you could just search the homepage links.

    Search methods

    Lastly, there are three search methods. You mostly want to use the cache search, which just scans through the station lists streamtuner2 has downloaded. Since you are mostly looking for something you had already seen, this will give you the desired results.

    The server search would try to do a live search on the directory servers, providing you with the most recent data. However, it's not implemented for all channel plugins, and therefore brings limited output.

    Use the button google it as last resort, if streamtuner2 didn't find anything.

    Quick search

    Just enter text into the quick search box. Streamtuner2 will instantly highlight any matches in the current stations view. If you switch tabs, just click the glass icon to reapply the highlighting.

    streamtuner2/help/streams.page010064400017500001750000000026551143061437700163260ustar00takakitakaki Radio streams lists, station information columns, entry actions. Streams / Stations

    Radio stations are listed in the right pane. Usually they have a title and a description. The description is often the last played song. (This isn't updated automatically to conserve bandwidth and because live information is seldomly available.)

    Station list

    You can double click a station line to get it to play. Alternatively there are the play and record buttons in the menubar. You can also invoke a stations homepage, if it has one.

    Actions
    Context menu

    Additionally most actions are available in a context menu. Right click a station entry to display it.

    Reshuffling of station entries in the list is possible by dragging them. But this is only a visual effect and will confuse the internal ordering of entries. Don't do it.

    You can always click the current category in the left list, to have the current station list redisplayed. Which is useful after updating favicons, or accidental dragging of entries.

    streamtuner2/help/streamtuner2.1010064400017500001750000000033201142315410000164760ustar00takakitakaki.\" this is one of the nanoweb man pages .\" (many thanks to the manpage howto!) .\" .TH streamtuner2 "July 2010" "BSD/Linux" "User Manuals" .SH NAME streamtuner2 \- Browser for internet radio stations .SH SYNOPSIS .B streamtuner2 .I command [ .BI channel ,... ] [ .IB title ] .SH DESCRIPTION Streamtuner2 is a graphical application for browsing through internet radio station directories, like .BR Shoutcast.com " and " Xiph.org " or " Internet-Radio.org.uk . It is written in Python and easy to extend. And besides the grapical interface, has a commandline interface. .SH OPTIONS .B Display data from cache .TP .BI help Prints out a summary of available commands. .TP .BI stream " channel title" Searches for a station with the given title. Either looks in a single channel, or scans all plugins. .TP .BI url " channel title" Prints out only the streaming URL. .TP .BI play " " [ channel ] " title" Invokes the configured audio player. .PP .B Load data from directory service .TP .BI categories " channelname" Returns a nested JSON list of all categories/genres. .TP .BI category " ""channelname"" ""Category""" Prints out a JSON list of the genre. Each entry constains title, url and other meta information. Note that the category must have the exact case. .SH EXAMPLES .TP .BI streamtuner2 " stream" " shoutcast,xiph" " ""Top 100""" Searches for the term "Top 100" in the shoutcast and xiph channels, and returns all info about the first match as JSON output. .TP .BI streamtuner2 " play frequence3" Looks for the first occourence, and starts the audio player for FREQUENCE3. .SH FILES .IR /home/ $USER /.config/streamtuner2/settings.json .SH "SEE ALSO" .BR streamripper (1) .BR audacious (1) .BR json (5) .BR m3u (5) .BR pls (5) streamtuner2/help/technical.page010064400017500001750000000055501143061270500165700ustar00takakitakaki Filenames, Directories, Dependencies Technical information
    Dependencies

    Python 2.5

    PyGtk

    Gtk+ 2.12

    Soft dependencies

    Python-LXML

    Python-PyQuery

    Audacious

    Configuration files /home/$USER/.config/streamtuner2/

    Corresponds to the XDG_CONFIG_HOME setting. All ST2 configuration settings are contained within here and are in JSON format.

    ~/.config/streamtuner2/settings.json

    General runtime options, plugin settings, and configured audio players.

    ~/.config/streamtuner2/window.json

    Saved window sizes, list widths.

    ~/.config/streamtuner2/state.json

    Last category in each channel tab.

    ~/.config/streamtuner2/bookmarks.json

    Is a separate cahce file for your bookmarked/favourite radio stations.

    ~/.config/streamtuner2/cache/***.json

    JSON files for stream lists in each channel.

    ~/.config/streamtuner2/icons/*.png

    Holds downloaded favicons for station homepages.

    Installation spread /usr/bin/streamtuner2

    Is the main binary.

    /usr/share/streamtuner2/

    Contains the individual ST2 python modules, and plugins in channels/. Also packages in pyquery/, but which is only used if the according modules aren't installed by the distribution.

    /usr/share/doc/streamtuner2/

    Contains the README, and Mallard/gnome-help/yelp files under help/.

    Public Domain

    There is no licensing requirement with this application. All code can be copied, modified and distributed unrestrictively.

    streamtuner2/help/timer.page010064400017500001750000000042551142324703300157570ustar00takakitakaki Programming recurring play and recording events. Timer

    You can programm play/recording events with the timer plugin. Simply select a station and choose Station Extensions Add timer.... A small popup will ask for a data/time string. If you press OK the station and the programmed time will be stored in the bookmarks channel in the "timer" category.

    Note that streamtuner2 must be running for the programmed timer events to work. (In a future version there might be the option to have it handled by the system cron daemon.)

    Time specification strings

    The time and date specificators follow a simple scheme. It's always one or more day names followed by a clock range, and finally the action.

    For example "Mon,Tue,Wed" will make the event occour on the first three days of each week, while just "Fri" would limit it to Fridays.

    A clock range of "18:00-20:00" would start the event at 18 o'clock and last it two hours. Note that for "play" events, the end time is irrelevant, since streamtuner2 can't kill your audio player anyway.

    The end time is only important, if you replace "play" with the word "record" in the timer event string. This runs streamripper instead and limits the recording time.

    Editing events

    You can remove entries from the "timer" list again. Use the normal Edit Delete for that. It's also possible to modify the date+time strings by editing the stream info and the specification in the "playing" field.

    However, such changes don't take effect until you restart streamtuner2. The timer events are only scheduled when adding a new event, or on starting streamtuner2.

    streamtuner2/contrib/config004077500017500001750000000000001142355115500157015ustar00takakitakakistreamtuner2/contrib/directory012077700017500001750000000000001142340252500165102.ustar00takakitakakistreamtuner2/contrib/index.php012077700017500001750000000000001142340250200214132ypshoutcast.phpustar00takakitakakistreamtuner2/contrib/sbin012077700017500001750000000000001142337720100154412.ustar00takakitakakistreamtuner2/contrib/shoutcast-playlist.pls010064400017500001750000000021361142340241000210710ustar00takakitakakistreamtuner2/contrib/streamripper_addgenre010075500017500001750000000014071144024173600210040ustar00takakitakaki#!/bin/sh # # This is a helper script for adding genre ID3 tags for recorded # radio stations. You have to change your player/recording settings # to: # streamripper_addgenre %srv %genre # # for it to work. Install this script in $HOME/bin for example. # Don't forget to set the target DIR= parameter in here. # It needs the "id3" commandline tool installed, but you can # easily adapt it to "id3tag" or "mp3tag" or other utilities. # DIR=/home/$USER/Music/ URL="$1" GENRE="$2" #-- time stamp touch /tmp/riptime #-- start recording xterm -e streamripper "$URL" -d "$DIR" #-- after terminal closes or streamripper ^C cancelled # search for new files in target directory, and tag them find "$DIR" -anewer /tmp/riptime -type f \ -exec id3 -g "$GENRE" '{}' ';' streamtuner2/contrib/tunein-station.pls012077700017500001750000000000001142355102000245712shoutcast-playlist.plsustar00takakitakakistreamtuner2/contrib/ypshoutcast.php010064400017500001750000000207231142355056600176160ustar00takakitakaki

    yp.shoutcast.com emulation

    oh nooes, it haz no colors

    Top 40 (test)
    
    # Add this to your /etc/hosts file:
       yp.shoutcast.com  old.shoutcast.com
    
    # And patch your streamtuner1 shoutcast.so plugin.
    #   hexedit $(locate shoutcast.so)
    # Change both occourences of "www.shoutcast.com" into "old.shoutcast.com".
    
    
    $row) { # be lazy $max = 2000; extract($row); #-- convert to old urls, else streamtuner1 won't see them preg_match("/(\d+)/", $url2=$url, $id); $id = $id[1]; $url = "/sbin/shoutcast-playlist.pls?rn=$id&file=filename.pls"; #-- remove invalid homepage URLs if (strpos($homepage, "shoutcast")) { $homepage = "http://www.google.com/search?q=$title"; } # invalid html of yp.shoutcast.com is mimiced here print <<< __END__ $i  
      [$genre] CLUSTER $title
    Now Playing: $playing
      $listeners/$max   $bitrate   __END__; /* print <<< __END__
    [$genre] $title
    Now Playing: $playing $listeners/$max $bitrate
    __END__; */ } } #-- just dump genre list always if (1) { //isset($_REQUEST["genre"])) { # haha, it's just a fixed list $categories = json_decode('["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"]]'); # fake YP html print "
    \n"; } ?> Page 1 of 1 streamtuner2/PKG-INFO010064400017500001750000000007031142703657100141470ustar00takakitakakiMetadata-Version: 1.0 Name: streamtuner2 Version: 2.0.7 Summary: Streamtuner2 is an internet radio browser Home-page: http://sourceforge.net/projects/streamtuner2/ Author: Mario Salzer Author-email: xmilky+st2@gmail.... License: Public Domain Description: Streamtuner2 lists radio directory services like Shoutcast, Xiph, Live365, MyOggRadio, Jamendo. It allows listening via any audio player, and recording of streams via streamripper. Platform: ALL streamtuner2/version010075500017500001750000000000711141417777000144660ustar00takakitakakiperl -ne ' if (/version: ([-_\d\w.]+)/) { print $1 }' $@ streamtuner2/_pack010075500017500001750000000017161144024142400140500ustar00takakitakaki#"bsd" "osx" # "rpm -a noarch" #-- meta data VERSION=$(./version st2.py) perl -n -i -e "if (/^[%]version/m) { print \"%version $VERSION\n\" } else { print }" _package.epm perl -n -i -e 'if (/^(\s+a.set_version)[\d.(\")]+/m) { print "$1(\"'$VERSION'\")\n" } else { print }' st2.py #-- linux for pkg in "rpm" "deb DEP=deb" "slackware" "portable -s streamtuner2.png" do echo \#\#\#$pkg\#\#\# sudo="" if [ "$pkg" == "deb" ]; then sudo=fakeroot ; fi $sudo epm -v -n -a all -f $pkg streamtuner2 _package.epm done #-- win32 for pkg in "win32" do epm-win32sfx -v streamtuner2 _package.epm done #-- src.tgz cd .. pax -wzf streamtuner2-$VERSION.src.tgz \ streamtuner2/*.py streamtuner2/*.glade streamtuner2/channels/*.{py,png} \ streamtuner2/*.png streamtuner2/*.svg streamtuner2/*.desktop \ streamtuner2/README streamtuner2/help/* streamtuner2/contrib/* \ streamtuner2/PKG-INFO streamtuner2/version streamtuner2/_pack streamtuner2/*.epm # streamtuner2/scripts streamtuner2/_package.epm010064400017500001750000000305631146775757600153410ustar00takakitakaki%product streamtuner2 - internet radio browser %version 2.0.8 %vendor Mario Salzer %license %copyright Placed into the Public Domain, 2009/2010 %readme README %description %description Browser for Internet Radio Stations %description %description streamtuner2 is a browser for radio station directories. %description It can fetch lists from SHOUTcast, Xiph.org, Live365, %description Jamendo, DMOZ, basic.ch, Punkcast. And it lists stream %description entries by category or genre. It reuses existing audio %description players, and recording is delegated to streamripper. %description %description It mimics the original streamtuner 0.99.99, but is easier %description to extend because it's written entirely in Python. It's %description already in a stable and useable form. %description %description There is no license to accept. Streamtuner2 is open source %description and released into the Public Domain. %system all #-- base f 644 root root /usr/share/doc/streamtuner2/README ./README d 755 root root /usr/share/doc/streamtuner2/contrib - f 644 root root /usr/share/doc/streamtuner2/contrib/streamripper_addgenre ./contrib/streamripper_addgenre f 755 root root /usr/bin/streamtuner2 ./st2.py f 644 root root /usr/share/applications/streamtuner2.desktop ./streamtuner2.desktop d 755 root root /usr/share/streamtuner2 - f 644 root root /usr/share/streamtuner2/streamtuner2.png ./streamtuner2.png f 644 root root /usr/share/pixmaps/streamtuner2.png ./logo.png f 644 root root /usr/share/streamtuner2/st2.glade ./st2.glade f 644 root root /usr/share/streamtuner2/pson.py ./pson.py #f 644 root root /usr/share/streamtuner2/processing.py ./processing.py f 644 root root /usr/share/streamtuner2/action.py ./action.py f 644 root root /usr/share/streamtuner2/config.py ./config.py f 644 root root /usr/share/streamtuner2/http.py ./http.py f 644 root root /usr/share/streamtuner2/cli.py ./cli.py f 644 root root /usr/share/streamtuner2/mygtk.py ./mygtk.py f 644 root root /usr/share/streamtuner2/favicon.py ./favicon.py f 644 root root /usr/share/streamtuner2/kronos.py ./kronos.py f 644 root root /usr/share/streamtuner2/pq.py ./pq.py #-- channels d 755 root root /usr/share/streamtuner2/channels - f 644 root root /usr/share/streamtuner2/channels/__init__.py ./channels/__init__.py f 644 root root /usr/share/streamtuner2/channels/_generic.py ./channels/_generic.py f 644 root root /usr/share/streamtuner2/channels/shoutcast.py ./channels/shoutcast.py f 644 root root /usr/share/streamtuner2/channels/shoutcast.png ./channels/shoutcast.png f 644 root root /usr/share/streamtuner2/channels/xiph.py ./channels/xiph.py f 644 root root /usr/share/streamtuner2/channels/xiph.png ./channels/xiph.png f 644 root root /usr/share/streamtuner2/channels/live365.py ./channels/live365.py f 644 root root /usr/share/streamtuner2/channels/live365.png ./channels/live365.png f 644 root root /usr/share/streamtuner2/channels/google.py ./channels/google.py f 644 root root /usr/share/streamtuner2/channels/google.png ./channels/google.png f 644 root root /usr/share/streamtuner2/channels/punkcast.py ./channels/punkcast.py f 644 root root /usr/share/streamtuner2/channels/punkcast.png ./channels/punkcast.png f 644 root root /usr/share/streamtuner2/channels/basicch.py ./channels/basicch.py f 644 root root /usr/share/streamtuner2/channels/basicch.png ./channels/basicch.png f 644 root root /usr/share/streamtuner2/channels/jamendo.py ./channels/jamendo.py f 644 root root /usr/share/streamtuner2/channels/jamendo.png ./channels/jamendo.png f 644 root root /usr/share/streamtuner2/channels/myoggradio.py ./channels/myoggradio.py f 644 root root /usr/share/streamtuner2/channels/myoggradio.png ./channels/myoggradio.png f 644 root root /usr/share/streamtuner2/channels/internet_radio_org_uk.py ./channels/internet_radio_org_uk.py f 644 root root /usr/share/streamtuner2/channels/internet_radio_org_uk.png ./channels/internet_radio_org_uk.png f 644 root root /usr/share/streamtuner2/channels/timer.py ./channels/timer.py f 644 root root /usr/share/streamtuner2/channels/links.py ./channels/links.py f 644 root root /usr/share/streamtuner2/channels/global_key.py ./channels/global_key.py f 644 root root /usr/share/streamtuner2/channels/tv.py ./channels/tv.py f 644 root root /usr/share/streamtuner2/channels/tv.png ./channels/tv.png f 644 root root /usr/share/streamtuner2/channels/musicgoal.py ./channels/musicgoal.py f 644 root root /usr/share/streamtuner2/channels/musicgoal.png ./channels/musicgoal.png #-- scripts #d 755 root root /usr/share/streamtuner2/scripts - #f 644 root root /usr/share/streamtuner2/scripts/radiotop40_de.py ./scripts/radiotop40_de.py #-- themes f 644 root root /usr/share/streamtuner2/themes/MountainDew/gtk-2.0/gtkrc ./themes/MountainDew/gtk-2.0/gtkrc #-- help files f 644 root root /usr/share/man/man1/streamtuner2.1 ./help/streamtuner2.1 d 755 root root /usr/share/doc/streamtuner2/help - f 644 root root /usr/share/doc/streamtuner2/help/action_homepage.page ./help/action_homepage.page f 644 root root /usr/share/doc/streamtuner2/help/action_playing.page ./help/action_playing.page f 644 root root /usr/share/doc/streamtuner2/help/action_recording.page ./help/action_recording.page f 644 root root /usr/share/doc/streamtuner2/help/action_saving.page ./help/action_saving.page f 644 root root /usr/share/doc/streamtuner2/help/channel_bookmarks.page ./help/channel_bookmarks.page f 644 root root /usr/share/doc/streamtuner2/help/channel_internetradioorguk.page ./help/channel_internetradioorguk.page f 644 root root /usr/share/doc/streamtuner2/help/channel_jamendo.page ./help/channel_jamendo.page f 644 root root /usr/share/doc/streamtuner2/help/channel_myoggradio.page ./help/channel_myoggradio.page f 644 root root /usr/share/doc/streamtuner2/help/channel_shoutcast.page ./help/channel_shoutcast.page f 644 root root /usr/share/doc/streamtuner2/help/channel_xiph.page ./help/channel_xiph.page f 644 root root /usr/share/doc/streamtuner2/help/channels.page ./help/channels.page f 644 root root /usr/share/doc/streamtuner2/help/cli.page ./help/cli.page f 644 root root /usr/share/doc/streamtuner2/help/config_apps.page ./help/config_apps.page f 644 root root /usr/share/doc/streamtuner2/help/configuration.page ./help/configuration.page f 644 root root /usr/share/doc/streamtuner2/help/extending.page ./help/extending.page f 644 root root /usr/share/doc/streamtuner2/help/global_key.page ./help/global_key.page f 644 root root /usr/share/doc/streamtuner2/help/glossary.page ./help/glossary.page f 644 root root /usr/share/doc/streamtuner2/help/glossary_json.page ./help/glossary_json.page f 644 root root /usr/share/doc/streamtuner2/help/glossary_m3u.page ./help/glossary_m3u.page f 644 root root /usr/share/doc/streamtuner2/help/glossary_pls.page ./help/glossary_pls.page f 644 root root /usr/share/doc/streamtuner2/help/guiseq ./help/guiseq f 644 root root /usr/share/doc/streamtuner2/help/index.page ./help/index.page f 644 root root /usr/share/doc/streamtuner2/help/introduction.page ./help/introduction.page f 644 root root /usr/share/doc/streamtuner2/help/reloading.page ./help/reloading.page f 644 root root /usr/share/doc/streamtuner2/help/search.page ./help/search.page f 644 root root /usr/share/doc/streamtuner2/help/streams.page ./help/streams.page f 644 root root /usr/share/doc/streamtuner2/help/technical.page ./help/technical.page f 644 root root /usr/share/doc/streamtuner2/help/timer.page ./help/timer.page d 755 root root /usr/share/doc/streamtuner2/help/img - f 644 root root /usr/share/doc/streamtuner2/help/img/categories.png ./help/img/categories.png f 644 root root /usr/share/doc/streamtuner2/help/img/channels.png ./help/img/channels.png f 644 root root /usr/share/doc/streamtuner2/help/img/logo.png ./help/img/logo.png f 644 root root /usr/share/doc/streamtuner2/help/img/mainwindow2.svg ./help/img/mainwindow2.svg f 644 root root /usr/share/doc/streamtuner2/help/img/streams.png ./help/img/streams.png #--pyquery d 755 root root /usr/share/streamtuner2/pyquery - f 644 root root /usr/share/streamtuner2/pyquery/LICENSE.txt ./pyquery/LICENSE.txt f 644 root root /usr/share/streamtuner2/pyquery/README.txt ./pyquery/README.txt f 644 root root /usr/share/streamtuner2/pyquery/__init__.py ./pyquery/__init__.py f 644 root root /usr/share/streamtuner2/pyquery/__init__.pyc ./pyquery/__init__.pyc f 644 root root /usr/share/streamtuner2/pyquery/ajax.py ./pyquery/ajax.py f 644 root root /usr/share/streamtuner2/pyquery/cssselectpatch.py ./pyquery/cssselectpatch.py f 644 root root /usr/share/streamtuner2/pyquery/cssselectpatch.pyc ./pyquery/cssselectpatch.pyc f 644 root root /usr/share/streamtuner2/pyquery/pyquery.py ./pyquery/pyquery.py f 644 root root /usr/share/streamtuner2/pyquery/pyquery.pyc ./pyquery/pyquery.pyc f 644 root root /usr/share/streamtuner2/pyquery/rules.py ./pyquery/rules.py f 644 root root /usr/share/streamtuner2/pyquery/test.html ./pyquery/test.html f 644 root root /usr/share/streamtuner2/pyquery/test.py ./pyquery/test.py f 644 root root /usr/share/streamtuner2/pyquery/tests.txt ./pyquery/tests.txt #-- lxml #d 755 root root /usr/share/streamtuner2/lxml - #d 755 root root /usr/share/streamtuner2/lxml/html - #f 644 root root /usr/share/streamtuner2/lxml/ElementInclude.py /usr/share/pyshared/lxml/ElementInclude.py #f 644 root root /usr/share/streamtuner2/lxml/__init__.py /usr/share/pyshared/lxml/__init__.py #f 644 root root /usr/share/streamtuner2/lxml/_elementpath.py /usr/share/pyshared/lxml/_elementpath.py #f 644 root root /usr/share/streamtuner2/lxml/builder.py /usr/share/pyshared/lxml/builder.py #f 644 root root /usr/share/streamtuner2/lxml/cssselect.py /usr/share/pyshared/lxml/cssselect.py #f 644 root root /usr/share/streamtuner2/lxml/doctestcompare.py /usr/share/pyshared/lxml/doctestcompare.py #f 644 root root /usr/share/streamtuner2/lxml/pyclasslookup.py /usr/share/pyshared/lxml/pyclasslookup.py #f 644 root root /usr/share/streamtuner2/lxml/sax.py /usr/share/pyshared/lxml/sax.py #f 644 root root /usr/share/streamtuner2/lxml/usedoctest.py /usr/share/pyshared/lxml/usedoctest.py #f 644 root root /usr/share/streamtuner2/lxml/html/ElementSoup.py /usr/share/pyshared/lxml/html/ElementSoup.py #f 644 root root /usr/share/streamtuner2/lxml/html/__init__.py /usr/share/pyshared/lxml/html/__init__.py #f 644 root root /usr/share/streamtuner2/lxml/html/_dictmixin.py /usr/share/pyshared/lxml/html/_dictmixin.py #f 644 root root /usr/share/streamtuner2/lxml/html/_diffcommand.py /usr/share/pyshared/lxml/html/_diffcommand.py #f 644 root root /usr/share/streamtuner2/lxml/html/_html5builder.py /usr/share/pyshared/lxml/html/_html5builder.py #f 644 root root /usr/share/streamtuner2/lxml/html/_setmixin.py /usr/share/pyshared/lxml/html/_setmixin.py #f 644 root root /usr/share/streamtuner2/lxml/html/builder.py /usr/share/pyshared/lxml/html/builder.py #f 644 root root /usr/share/streamtuner2/lxml/html/clean.py /usr/share/pyshared/lxml/html/clean.py #f 644 root root /usr/share/streamtuner2/lxml/html/defs.py /usr/share/pyshared/lxml/html/defs.py #f 644 root root /usr/share/streamtuner2/lxml/html/diff.py /usr/share/pyshared/lxml/html/diff.py #f 644 root root /usr/share/streamtuner2/lxml/html/formfill.py /usr/share/pyshared/lxml/html/formfill.py #f 644 root root /usr/share/streamtuner2/lxml/html/html5parser.py /usr/share/pyshared/lxml/html/html5parser.py #f 644 root root /usr/share/streamtuner2/lxml/html/soupparser.py /usr/share/pyshared/lxml/html/soupparser.py #f 644 root root /usr/share/streamtuner2/lxml/html/usedoctest.py /usr/share/pyshared/lxml/html/usedoctest.py #-- windows %system win32 %description Windows version gets no love, nor support. %description It requires manual installation of Python 2.6 and Gtk+ and Pygtk.org libraries first. f 644 root root /usr/share/applications/streamtuner2.lnk ./streamtuner2.lnk %shortcut $desktop$\streamtuner2.lnk|/usr/share/applications/streamtuner2.lnk %homepage http://streamtuner2.sourceforge.net/ #-- distribution specific dependency rules %system all %requires python %format deb %requires python-lxml %requires python-imaging %requires python-pyquery %requires python-keybinder %requires python-gtk2 %requires python-glade2 # %requires python-httplib2 # %requires python-json # %requires python-xdg # %requires python-xdgapp %format rpm %requires pygtk # %requires pyxdg # RPM package names are weirder, and there's no comprehensive list of them (Suse and Fedora depart anyway)