#
# encoding: UTF-8
# api: streamtuner2
# type: base
# category: ui
# title: Channel plugins
# description: Base implementation for channels and feature plugins
# version: 1.1
# license: public domain
# author: mario
# url: http://fossil.include-once.org/streamtuner2/
# pack:
# bookmarks.py configwin.py global_key.py history.py
# icast.py internet_radio.py itunes.py jamendo.py links.py live365.py
# modarchive.py myoggradio.py punkcast.py radiotray.py search.py
# shoutcast.py streamedit.py surfmusik.py timer.py tunein.py xiph.py
# youtube.py *.png
# config: -
# priority: core
#
#
# Just exports GenericChannel and ChannelPlugin.
# 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.
#
# Makes module scanning available. Checks for conf.share, so
# should pick up /usr/share/streamtuner2/channels/*.py plugins
# as well as local ./channels/*.* - Needs rework for in-zip
# searching.
#
import gtk
from uikit import uikit
from config import *
import ahttp as http
import action
import favicon
import os.path
import xml.sax.saxutils
import re
import copy
import inspect
import pkgutil
# Only export plugin classes
__all__ = [
"GenericChannel", "ChannelPlugin", "module_list"
]
# Search through ./channels/ and get module basenames.
# Also order them by conf.channel_order
#
def module_list():
# Should list plugins within zips as well as local paths
ls = pkgutil.iter_modules([conf.share+"/channels", "channels"])
ls = [name for loader,name,ispkg in ls]
# 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)]
return ls
# generic channel module ---------------------------------------
class GenericChannel(object):
# desc
meta = { "config": [] }
homepage = "http://fossil.include-once.org/streamtuner2/"
base_url = ""
listformat = "audio/x-scpls"
audioformat = "audio/mpeg" # fallback value
config = []
has_search = False
# categories
categories = ["empty", ]
catmap = {}
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", {}], ],
["Now Playing",185, ["playing", str, "text", {"strikethrough":11}], ], #{"width":20}
["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
self.module = self.__class__.__name__
self.meta = plugin_meta(None, inspect.getcomments(inspect.getmodule(self)))
self.config = self.meta.get("config", [])
self.title = self.meta.get("title", self.module)
# only if streamtuner2 is run in graphical mode
if (parent):
self.cache()
self.gui(parent)
pass
# These are all implemented in main (where they don't belong!)
def stations(self):
return self.streams.get(self.current, [])
def rowno(self):
pass
def row(self):
pass
# read previous channel/stream data, if there is any
def cache(self):
# stream list
cache = conf.load("cache/" + self.module)
if (cache):
self.streams = cache
# categories
cache = conf.load("cache/categories_" + self.module)
if (cache):
self.categories = cache
# catmap (optional)
cache = conf.load("cache/catmap_" + self.module)
if (cache):
self.catmap = 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()
#uikit.tree(self.gtk_cat, self.categories, title="Category", icon=gtk.STOCK_OPEN);
# update column names
for field,title in list(self.titles.items()):
self.update_datamap(field, title=title)
# prepare stream list
if (not self.rowmap):
for row in self.datamap:
for x in range(2, len(row)):
self.rowmap.append(row[x][0])
# load default category
if (self.current):
self.load(self.current)
else:
uikit.columns(self.gtk_list, self.datamap, [])
# add to main menu
uikit.add_menu(parent.channelmenuitems, self.meta["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 category in self.streams):
__print__(dbg.PROC, "load", "update_streams")
self.parent.status("Updating streams...")
self.parent.status(-0.1)
new_streams = self.update_streams(category)
if new_streams:
# check and modify entry;
# assert that title and url are present
modified = []
for row in new_streams:
if len(set(["", None]) & set([row.get("title"), row.get("url")])):
continue
try:
modified.append( self.postprocess(row) )
except Exception as e:
__print__(e, dbg.ERR, row)
new_streams = modified
# 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__(dbg.ERR, "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] = \
uikit.do(lambda:uikit.columns(self.gtk_list, self.datamap, self.prepare(self.streams[category])))
# set pointer
self.current = category
self.parent.status("")
self.parent.status(1.0)
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):
uikit.do(lambda: uikit.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 ("url" in row and (row.get("url") not in new)):
row["deleted"] = 1
diff.append(row)
return diff
# prepare data for display
#
# - favourite icon
# - or deleted icon
#
def prepare(self, streams):
#__print__(dbg.PROC, "prepare", 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 "bookmarks" in self.parent.channels 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:
# entry provides its own image
if "img" in row:
favicon_url = row["img"]
streams[i]["favicon"] = favicon.localcopy(favicon_url)
# get actual homepage favicon.png
elif "homepage" in row:
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
#
# - drop shoutcast homepage links
# - or find homepage name in title
#
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)
# update streams pane if currently selected (used by bookmarks.links channel)
def reload_if_current(self, category):
if self.current == category:
self.reload()
# display .current category, once notebook/channel tab is first opened
def first_show(self):
__print__(dbg.PROC, "first_show ", self.module, self.shown)
if (self.shown != 55555):
# if category tree is empty, initialize it
if not self.categories:
__print__(dbg.PROC, "first_show: reload_categories");
#self.parent.thread(self.reload_categories)
self.reload_categories()
self.display_categories()
self.current = self.categories.keys()[0]
__print__(dbg.STAT, self.current)
self.load(self.current)
# load current category
else:
__print__(dbg.STAT, "first_show: load current category");
self.load(self.current)
# put selection/cursor on last position
try:
__print__(dbg.STAT, "first_show: select last known category treelist position")
self.gtk_list.get_selection().select_path(self.shown)
except:
pass
# this method will only be invoked once
self.shown = 55555
# update categories, save, and display
def reload_categories(self):
# get data and save
self.update_categories()
if self.categories:
conf.save("cache/categories_"+self.module, self.categories)
if self.catmap:
conf.save("cache/catmap_" + self.module, self.catmap);
# display outside of this non-main thread
uikit.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
uikit.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 <tags> 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/mp3":"audio/mpeg", # Note the real mime type is /mpeg, but /mp3 is more understandable in the GUI
"ogg":"ogg", "ogm":"ogg", "xiph":"ogg", "vorbis":"ogg", "vnd.xiph.vorbis":"ogg",
"mp3":"mpeg", "mp":"mpeg", "mp2":"mpeg", "mpc":"mpeg", "mps":"mpeg",
"aac+":"aac", "aacp":"aac",
"realaudio":"x-pn-realaudio", "real":"x-pn-realaudio", "ra":"x-pn-realaudio", "ram":"x-pn-realaudio", "rm":"x-pn-realaudio",
# yes, we do video
"flv":"video/flv", "mp4":"video/mp4",
}
map.update(action.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)
# Extracts integer from string
def to_int(self, s):
i = re.findall("\d+", s) or [0]
return int(i[0])
# channel plugin without glade-pre-defined notebook tab
#
class ChannelPlugin(GenericChannel):
module = "abstract"
def gui(self, parent):
if parent:
module = self.__class__.__name__
# 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
pixbuf = None
if "png" in self.meta:
pixbuf = uikit.pixbuf(self.meta["png"])
else:
png = pkgutil.get_data("config", "channels/" + self.module + ".png")
pixbuf = uikit.pixbuf(png)
if pixbuf:
icon = gtk.image_new_from_pixbuf(pixbuf)
else:
icon = gtk.image_new_from_stock(gtk.STOCK_DIRECTORY, size=1)
label = gtk.HBox()
label.pack_start(icon, expand=False, fill=True)
label.pack_start(gtk.Label(self.meta.get("title", self.module)), 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)
plain_label = gtk.Label(self.module)
# to widgets
self.gtk_cat = tv1
parent.widgets[module + "_cat"] = tv1
self.gtk_list = tv2
parent.widgets[module + "_list"] = tv2
ev_label.show_all()
vbox.show_all()
parent.widgets["v_" + module] = vbox
parent.widgets["c_" + module] = ev_label
tv2.connect('button-press-event', parent.station_context_menu)
# try to initialize superclass now, before adding to channel tabs
GenericChannel.gui(self, parent)
# add notebook tab
tab = parent.notebook_channels.insert_page_menu(child=vbox, tab_label=ev_label, menu_label=plain_label)
# double-click catch
# add module to list
#parent.channels[module] = None
#parent.channel_names.append(module)
""" -> already taken care of in main.load_plugins() """