Index: action.py
==================================================================
--- action.py
+++ action.py
@@ -1,76 +1,118 @@
-
# encoding: UTF-8
# api: streamtuner2
# type: functions
+# cagtegory: io
# title: play/record actions
# description: Starts audio applications, guesses MIME types for URLs
-# version: 0.8
+# version: 0.9
+# priority: core
#
# Multimedia interface for starting audio players, recording app,
# or web browser (listed as "url/http" association in players).
-#
-# 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/...).
+# It maps audio MIME types, and extracts/converts playlist types
+# (PLS, M3U, XSPF, SMIL, JSPF, ASX, raw urls).
+#
+# Each channel plugin has a .listtype which defines the linked
+# audio playlist format. It's "pls", seldomly "m3u", or "xspf".
+# Some channels list raw "srv" addresses, while Youtube "href"
+# entries to Flash videos.
+#
+# As fallback the playlist URL is retrieved and its MIME type
+# checked, and its content regexped to guess the link format.
+# Lastly a playlist type suitable for audio players recreated.
+# Which is somewhat of a security feature, playlists get cleaned
+# up this way. The conversion is not strictly necessary for all
+# players, as basic PLS is supported by most.
+#
+# And finally this module is also used by exporting and (perhaps
+# in the future) playlist importing features.
import re
import os
-from ahttp import fix_url as http_fix_url, get as http_get
+from ahttp import fix_url as http_fix_url, session
from config import conf, __print__ as debug, dbg
import platform
+import copy
+import json
# Coupling to main window
#
main = None
# Streamlink/listformat mapping
-#
-lt = dict(
- pls = "audio/x-scpls",
- m3u = "audio/x-mpegurl",
- asx = "video/x-ms-asf",
- xspf = "application/xspf+xml",
- href = "url/http",
- srv = "url/direct",
- ram = "audio/x-pn-realaudio",
- smil = "application/smil",
- script = "text/x-urn-streamtuner2-script", # unused
-)
-
+listfmt_t = {
+ "audio/x-scpls": "pls",
+ "audio/x-mpegurl": "m3u",
+ "audio/mpegurl": "m3u",
+ "application/vnd.apple.mpegurl": "m3u",
+ "video/x-ms-asf": "asx",
+ "application/xspf+xml": "xspf",
+ "*/*": "href", # "href" for unknown responses
+ "url/direct": "srv",
+ "url/youtube": "href",
+ "url/http": "href",
+ "audio/x-pn-realaudio": "ram",
+ "application/smil": "smil",
+ "application/vnd.ms-wpl":"smil",
+ "audio/x-ms-wax": "asx",
+ "video/x-ms-asf": "asx",
+ "x-urn/st2-script": "script", # unused
+ "application/x-shockwave-flash": "href", # fallback
+}
# Audio type MIME map
-#
-mf = dict(
- mp3 = "audio/mpeg",
- ogg = "audio/ogg",
- aac = "audio/aac",
- midi = "audio/midi",
- mod = "audio/mod",
-)
+mediafmt_t = {
+ "audio/mpeg": "mp3",
+ "audio/ogg": "ogg",
+ "audio/aac" : "aac",
+ "audio/aacp" : "aac",
+ "audio/midi": "midi",
+ "audio/mod": "mod",
+ "audio/it+zip": "mod",
+ "audio/s3+zip": "mod",
+ "audio/xm+zip": "mod",
+}
# Player command placeholders for playlist formats
placeholder_map = dict(
- pls = "%url | %pls | %u | %l | %r",
- m3u = "%m3u | %f | %g | %m",
- pls = "%srv | %d | %s",
+ pls = "(%url | %pls | %u | %l | %r) \\b",
+ m3u = "(%m3u | %f | %g | %m) \\b",
+ xspf= "(%xspf | %xpsf | %x) \\b",
+ jspf= "(%jspf | %j) \\b",
+ asx = "(%asx) \\b",
+ smil= "(%smil) \\b",
+ srv = "(%srv | %d | %s) \\b",
)
+
+# Playlist format content probing (assert type)
+playlist_content_map = [
+ ("pls", r""" (?i)\[playlist\].*numberofentries """),
+ ("xspf", r""" <\?xml .* ]*> .* """),
+ ("html", r""" <(audio|video)\b[^>]+\bsrc\s*=\s*["']?https?:// """),
+ ("wpl", r""" <\?wpl \s+ version="1\.0" \s* \?> """),
+ ("b4s", r""" """), # http://gonze.com/playlists/playlist-format-survey.html
+ ("jspf", r""" ^ \s* \{ \s* "playlist": \s* \{ """),
+ ("asf", r""" ^ \[Reference\] .*? ^Ref\d+= """),
+ ("json", r""" "url": \s* "\w+:// """),
+ ("gvp", r""" ^gvp_version:1\.\d+$ """),
+ ("href", r""" .* """),
+]
# Exec wrapper
#
def run(cmd):
- if cmd: debug(dbg.PROC, "Exec:", cmd)
+ debug(dbg.PROC, "Exec:", cmd)
try: os.system("start \"%s\"" % cmd if conf.windows else cmd + " &")
except: debug(dbg.ERR, "Command not found:", cmd)
# Start web browser
@@ -86,44 +128,44 @@
run("yelp /usr/share/doc/streamtuner2/help/")
# Calls player for stream url and format
#
-def play(url, audioformat="audio/mpeg", listformat="href"):
+def play(url, audioformat="audio/mpeg", source="pls", row={}):
cmd = mime_app(audioformat, conf.play)
- cmd = interpol(cmd, url, listformat)
+ cmd = interpol(cmd, url, source, row)
run(cmd)
# Call streamripper
#
-def record(url, audioformat="audio/mpeg", listformat="href", append="", row={}):
+def record(url, audioformat="audio/mpeg", source="href", row={}):
cmd = mime_app(audioformat, conf.record)
- cmd = interpol(cmd, url, listformat, row)
+ cmd = interpol(cmd, url, source, row)
run(cmd)
# OS shell command escaping
#
-def quote(s):
- return "%r" % str(s)
+def quote(ins):
+ if type(ins) is str:
+ return "%r" % str(ins)
+ else:
+ return " ".join(["%r" % str(s) for s in ins])
# Convert e.g. "text/x-scpls" MIME types to just "pls" monikers
#
def listfmt(t = "pls"):
- if t in lf.values():
- for short,mime in lf.items():
- if mime == t:
- return short
- return t # "pls"
+ return listfmt_t.get(t, t) # e.g. "pls" or still "text/x-unknown"
# Convert MIME type into list of ["audio/xyz", "audio/*", "*/*"]
# for comparison against configured record/play association.
def mime_app(fmt, cmd_list):
- for match in [ fmt, fmt[:fmt.find("/")] + "/*", "*/*" ]:
+ major = fmt[:fmt.find("/")]
+ for match in [ fmt, major + "/*", "*/*" ]:
if cmd_list.get(match):
return cmd_list[match]
@@ -139,187 +181,285 @@
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"
+ cmd = cmd + " %pls"
+ # "pls" as default requires no conversion for most channels, and seems broadly supported by players
# standard placeholders
for dest, rx in placeholder_map.items():
if re.search(rx, cmd, re.X):
# from .pls to .m3u
- url = convert_playlist(url, listfmt(source), listfmt(dest))
+ fn_or_urls = convert_playlist(url, listfmt(source), listfmt(dest), local_file=True, title=row.get("title", ""))
# insert quoted URL/filepath
- return re.sub(rx, cmd, quote(url), 2, re.X)
+ return re.sub(rx, quote(fn_or_urls), cmd, 2, re.X)
return "false"
-# Substitute .pls URL with local .m3u,
-# or direct srv address, or leave as-is.
-#
-def convert_playlist(url, source, dest):
-
- # Leave alone
- if source == dest or source in ("srv", "href"):
- return url
-
- # Else
- return url
-
-
-
-# Save row(s) in one of the export formats,
-# depending on file extension:
-#
-# · m3u
-# · pls
-# · xspf
-# · asx
-# · json
-# · smil
-#
-def save(row, fn, listformat="audio/x-scpls"):
-
- # output format
- format = re.findall("\.(m3u|pls|xspf|jspf|json|asx|smil)", fn)
-
- # modify stream url
- stream_urls = extract_urls(row["url"], listformat)
-
- # M3U
- if "m3u" in format:
- txt = "#M3U\n"
- for url in stream_urls:
- txt += http_fix_url(url) + "\n"
-
- # PLS
- elif "pls" in format:
- 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"
-
- # XSPF
- elif "xspf" in format:
- 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]) + ""+tag+">\n"
- txt += " \n"
- for u in stream_urls:
- txt += ' ' + "\n"
- txt += " \n\n"
-
- # JSPF
- elif "jspf" in format:
- pass
-
- # JSON
- elif "json" in format:
- row["stream_urls"] = stream_urls
- txt = str(row) # pseudo-json (python format)
-
- # ASX
- elif "asx" in format:
- txt = "\n" \
- + " " + xmlentities(row["title"]) + "\n" \
- + " \n" \
- + " " + xmlentities(row["title"]) + "\n" \
- + " \n" \
- + " \n" \
- + " \n\n"
-
- # SMIL
- elif "smil" in format:
- txt = "\n\n \n\n" \
- + "\n \n \n \n\n\n"
-
- # unknown
- else:
- return
-
- # write
- if txt:
- f = open(fn, "wb")
- f.write(txt)
- f.close()
- pass
-
-
-
-
-# retrieve real stream urls from .pls or .m3u links
-def extract_urls(pls, listformat="__not_used_yet__"):
- # extract stream address from .pls URL
- if (re.search("\.pls", pls)): #audio/x-scpls
- return pls(pls)
- elif (re.search("\.asx", pls)): #video/x-ms-asf
- return re.findall(" 1:
+ debug(dbg.ERR, "Possible playlist format mismatch:", (source, mime, probe, ext))
+
+ # Extract URLs from content
+ for fmt in ["pls", "xspf", "asx", "smil", "jspf", "m3u", "json", "asf", "raw"]:
+ if not urls and fmt in (source, mime, probe, ext, "raw"):
+ urls = extract_playlist(cnt).format(fmt)
+ debug(dbg.DATA, "conversion from:", source, " with extractor:", fmt, "got URLs=", urls)
+
+ # Return original, or asis for srv targets
+ if not urls:
+ return [url]
+ elif dest in ("srv", "href"):
+ return urls
+ debug( urls )
+
+ # Otherwise convert to local file
+ if local_file:
+ fn, is_unique = tmp_fn(cnt, dest)
+ with open(fn, "wb") as f:
+ debug(dbg.DATA, "exporting with format:", dest, " into filename:", fn)
+ f.write( save_playlist(source="srv", multiply=True).export(urls=urls, dest=dest, title=title) )
+ return [fn]
+ else:
+ return urls
+
+
+
+# Tries to fetch a resource, aborts on ICY responses.
+#
+def http_probe_get(url):
+
+ # HTTP request, abort if streaming server hit (no HTTP/ header, but ICY/ response)
+ try:
+ r = session.get(url, stream=True, timeout=5.0)
+ if not len(r.headers):
+ return ("srv", r)
+ except:
+ return ("srv", None)
+
+ # Extract payload
+ mime = r.headers.get("content-type", "href")
+ mime = mime.split(";")[0].strip()
+ # Map MIME to abbr type (pls, m3u, xspf)
+ if listfmt_t.get(mime):
+ mime = listfmt_t.get(mime)
+ # Raw content (mp3, flv)
+ elif mediafmt_t.get(mime):
+ debug(dbg.ERR, "Got media MIME type for expected playlist", mime, " on url=", url)
+ mime = mediafmt_t.get(mime)
+ return (mime, url)
+ # Rejoin body
+ content = "\n".join(r.iter_lines())
+ return (mime, content)
+
+
+
+# Extract URLs from playlist formats:
+#
+class extract_playlist(object):
+
+ # Content of playlist file
+ src = ""
+ def __init__(self, text):
+ self.src = text
+
+ # Extract only URLs from given source type
+ def format(self, fmt):
+ debug(dbg.DATA, "input regex:", fmt, len(self.src))
+ return re.findall(self.extr_urls[fmt], self.src, re.X);
+
+ # Only look out for URLs, not local file paths
+ extr_urls = {
+ "pls": r"(?im) ^ \s*File\d* \s*=\s* (\w+://[^\s]+) ",
+ "m3u": r" (?m) ^( \w+:// [^#\n]+ )",
+ "xspf": r" (?x) (\w+://[^<>\s]+) ",
+ "asx": r" (?x) ]+\b href \s*=\s* [\'\"] (\w+://[^\s\"\']+) [\'\"] ",
+ "smil": r" (?x) <(?:audio|video|media)\b [^>]+ \b src \s*=\s* [^\"\']? \s* (\w+://[^\"\'\s]+) ",
+ "jspf": r" (?x) \"location\" \s*:\s* \"(\w+://[^\"\s]+)\" ",
+ "json": r" (?x) \"url\" \s*:\s* \"(\w+://[^\"\s]+)\" ",
+ "asf": r" (?m) ^ \s*Ref\d+ = (\w+://[^\s]+) ",
+ "raw": r" (?i) ( [\w+]+:// [^\s\"\'\>\#]+ ) ",
+ }
+
+
+# Save rows in one of the export formats.
+#
+# The export() version uses urls[]+title= as input, converts it into a
+# list of rows{} beforehand.
+#
+# While store() requires rows{} to begin with, to perform a full
+# conversion. Can save directly to a file name.
+#
+class save_playlist(object):
+
+ # if converting
+ source = "pls"
+ # expand multiple server URLs into duplicate entries in target playlist
+ multiply = True
+ # constructor
+ def __init__(self, source, multiply):
+ self.source = source
+ self.multiply = multiply
+
+
+ # Used by playlist_convert(), to transform a list of extracted URLs
+ # into a local .pls/.m3u collection again. Therefore injects the
+ # `title` back into each of the URL rows.
+ def export(self, urls=None, title=None, dest="pls"):
+ rows = [ { "url": url, "title": title } for url in urls ]
+ return self.store(rows, dest)
+
+ # Export a playlist from rows{}
+ def store(self, rows=None, dest="pls"):
+
+ # can be just a single entry
+ rows = copy.deepcopy(rows)
+ if type(rows) is dict:
+ rows = [row]
+
+ # Expand contained stream urls
+ if not self.source in ("srv", "raw", "asis"):
+ new_rows = []
+ for i,row in enumerate(rows):
+ # Preferrably convert to direct server addresses
+ for url in convert_playlist(row["url"], self.source, "srv", local_file=False):
+ row["url"] = url
+ new_rows.append(row)
+ # Or just allow one stream per station in a playlist entry
+ if not self.multiply:
+ break
+ rows = new_rows
+
+ debug(dbg.DATA, "conversion to:", dest, " from:", self.source, "with rows=", rows)
+
+ # call conversion schemes
+ converter = getattr(self, dest) or self.pls
+ return converter(rows)
+
+ # save directly
+ def file(self, rows, dest, fn):
+ with open(fn, "wb") as f:
+ f.write(self.store(rows, dest))
+
+
+
+ # M3U
+ def m3u(self, rows):
+ txt = "#EXTM3U\n"
+ for r in rows:
+ txt += "#EXTINF:-1,%s\n" % r["title"]
+ txt += "%s\n" % http_fix_url(r["url"])
+ return txt
+
+ # PLS
+ def pls(self, rows):
+ txt = "[playlist]\n" + "numberofentries=%s\n" % len(rows)
+ for i,r in enumerate(rows):
+ txt += "File%s=%s\nTitle%s=%s\nLength%s=%s\n" % (i, r["url"], i, r["title"], i, -1)
+ txt += "Version=2\n"
+ return txt
+
+ # JSON (native lists of streamtuner2)
+ def json(self, rows):
+ return json.dumps(rows, indent=4)
+
+
+ # XSPF
+ def xspf(self, rows):
+ return """\n""" \
+ + """\n""" \
+ + """\n\t\n""" \
+ + "".join("""\t\t\n""" % self.xspf_row(row, self.xspf_map) for row in rows) \
+ + """\t\n\n"""
+ # individual tracks
+ def xspf_row(self, row, map):
+ return "".join("""\t\t\t<%s>%s%s>\n""" % (tag, xmlentities(row[attr]), tag) for attr,tag in map.items() if row.get(attr))
+ # dict to xml tags
+ xspf_map = dict(title="title", url="location", homepage="info", playing="annotation", description="info")
+
+
+ # JSPF
+ def jspf(self, rows):
+ tracks = []
+ for row in rows:
+ tracks.append( { tag: row[attr] for attr,tag in self.xspf_map.items() if row.get(attr) } )
+ return json.dumps({ "playlist": { "track": tracks } }, indent=4)
+
+
+ # ASX
+ def asx(self, rows):
+ txt = """\n"""
+ for row in rows:
+ txt += """\t\n\t\t%s\n\t\t\n\t\n""" % (xmlentities(row["title"]), xmlentities(row["url"]))
+ txt += """\n"""
+ return txt
+
+
+ # SMIL
+ def smil(self, rows):
+ txt = """\n\n\t\n\n\n\t\n""" % (rows[0]["title"])
+ for row in rows:
+ if row.get("url"):
+ txt += """\t\t\n""" % row["url"]
+ txt += """\t\n\n\n"""
+ return txt
+
+
+
+# Stub import, only if needed
+def xmlentities(s):
+ global xmlentities
+ from xml.sax.saxutils import escape as xmlentities
+ return xmlentities(s)
+
+
+
+# generate filename for temporary .m3u, if possible with unique id
+def tmp_fn(pls, ext="m3u"):
# use shoutcast unique stream id if available
stream_id = re.search("http://.+?/.*?(\d+)", pls, re.M)
stream_id = stream_id and stream_id.group(1) or "XXXXXX"
try:
channelname = main.current_channel
except:
channelname = "unknown"
- return (str(conf.tmp) + os.sep + "streamtuner2."+channelname+"."+stream_id+".m3u", len(stream_id) > 3 and stream_id != "XXXXXX")
-
-# check if there are any urls in a given file
-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
-def m3u(pls):
-
- # temp filename
- (tmp_fn, unique) = tmp_fn(pls)
- # does it already exist?
- if tmp_fn and unique and conf.reuse_m3u and has_urls(tmp_fn):
- return tmp_fn
-
- # download PLS
- debug( dbg.DATA, "pls=",pls )
- url_list = extract_urls(pls)
- debug( dbg.DATA, "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:
- debug( dbg.ERR, "error, there were no URLs in ", pls )
- raise "Empty PLS"
-
-# Download a .pls resource and extract urls
-def extract_from_pls(url):
- text = http_get(url)
- debug(dbg.DATA, "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)
-def srv(url):
- return extract_urls(url)[0]
+ return (str(conf.tmp) + os.sep + "streamtuner2."+channelname+"."+stream_id+"."+ext, len(stream_id) > 3 and stream_id != "XXXXXX")
+
+
Index: channels/__init__.py
==================================================================
--- channels/__init__.py
+++ channels/__init__.py
@@ -58,11 +58,11 @@
# desc
meta = { "config": [] }
homepage = "http://fossil.include-once.org/streamtuner2/"
base_url = ""
- listformat = "audio/x-scpls"
+ listformat = "pls"
audioformat = "audio/mpeg" # fallback value
config = []
has_search = False
# categories
Index: channels/bookmarks.py
==================================================================
--- channels/bookmarks.py
+++ channels/bookmarks.py
@@ -38,11 +38,11 @@
# desc
module = "bookmarks"
title = "bookmarks"
base_url = "file:.config/streamtuner2/bookmarks.json"
- listformat = "*/*"
+ listformat = "any"
# content
categories = ["favourite", ] # timer, links, search, and links show up as needed
current = "favourite"
default = "favourite"
Index: channels/exportcat.py
==================================================================
--- channels/exportcat.py
+++ channels/exportcat.py
@@ -22,29 +22,33 @@
from config import *
from channels import *
import ahttp
from uikit import uikit
+import action
+import re
+
# provides another export window, and custom file generation - does not use action.save()
class exportcat():
module = ""
meta = plugin_meta()
- # register
+ # Register callback
def __init__(self, parent):
conf.add_plugin_defaults(self.meta, self.module)
if parent:
self.parent = parent
uikit.add_menu([parent.extensions, parent.extensions_context], "Export all stations", self.savewindow)
- # set new browser string in requests session
+ # Fetch streams from category, show "Save as" dialog, then convert URLs and export as playlist file
def savewindow(self, *w):
cn = self.parent.channel()
+ source = cn.listformat
streams = cn.streams[cn.current]
fn = uikit.save_file("Export category", None, "%s.%s.%s" % (cn.module, cn.current, conf.export_format))
__print__(dbg.PROC, "Exporting category to", fn)
if fn:
dest = re.findall("\.(m3u|pls|xspf|jspf|json|smil|wpl)8?$", fn)[0]
- action.save_playlist(source="asis", multiply=False).save(rows=streams, fn=fn, dest=dest)
+ action.save_playlist(source="asis", multiply=False).file(rows=streams, fn=fn, dest=dest)
pass
Index: channels/icast.py
==================================================================
--- channels/icast.py
+++ channels/icast.py
@@ -42,11 +42,11 @@
class icast (ChannelPlugin):
# description
homepage = "http://www.icast.io/"
has_search = True
- listformat = "audio/x-scpls"
+ listformat = "pls"
titles = dict(listeners=False, bitrate=False, playing=False)
categories = []
base = "http://api.icast.io/1/"
Index: channels/internet_radio.py
==================================================================
--- channels/internet_radio.py
+++ channels/internet_radio.py
@@ -41,11 +41,11 @@
# description
title = "InternetRadio"
module = "internet_radio"
homepage = "http://www.internet-radio.org.uk/"
- listformat = "audio/x-scpls"
+ listformat = "pls"
# category map
categories = []
current = ""
default = ""
Index: channels/itunes.py
==================================================================
--- channels/itunes.py
+++ channels/itunes.py
@@ -44,11 +44,11 @@
title = "iTunes RS"
module = "itunes"
#module = "rs_playlist"
homepage = "http://www.itunes.com?"
has_search = False
- listformat = "audio/x-scpls"
+ listformat = "pls"
titles = dict(listeners=False, bitrate=False, playing=False)
categories = [
"Adult Contemporary",
"Alternative Rock",
Index: channels/jamendo.py
==================================================================
--- channels/jamendo.py
+++ channels/jamendo.py
@@ -60,11 +60,11 @@
homepage = "http://www.jamendo.com/"
version = 0.3
has_search = True
base = "http://www.jamendo.com/en/"
- listformat = "url/http"
+ listformat = "srv"
api_base = "http://api.jamendo.com/v3.0/"
cid = "49daa4f5"
categories = []
Index: channels/live365.py
==================================================================
--- channels/live365.py
+++ channels/live365.py
@@ -57,11 +57,11 @@
module = "live365"
title = "Live365"
homepage = "http://www.live365.com/"
base_url = "http://www.live365.com/"
has_search = True
- listformat = "url/http"
+ listformat = "pls"
mediatype = "audio/mpeg"
has_search = False
# content
categories = ['Alternative', 'Blues', 'Classical', 'Country', 'Easy Listening', 'Electronic/Dance', 'Folk', 'Freeform', 'Hip-Hop/Rap', 'Inspirational', 'International', 'Jazz', 'Latin', 'Metal', 'New Age', 'Oldies', 'Pop', 'R&B/Urban', 'Reggae', 'Rock', 'Seasonal/Holiday', 'Soundtracks', 'Talk']
Index: channels/modarchive.py
==================================================================
--- channels/modarchive.py
+++ channels/modarchive.py
@@ -43,10 +43,11 @@
# description
title = "modarchive"
module = "modarchive"
homepage = "http://www.modarchive.org/"
base = "http://modarchive.org/"
+ listformat = "href"
titles = dict(genre="Genre", title="Song", playing="File", listeners="Rating", bitrate=0)
# keeps category titles->urls
catmap = {"Chiptune": "54", "Electronic - Ambient": "2", "Electronic - Other": "100", "Rock (general)": "13", "Trance - Hard": "64", "Swing": "75", "Rock - Soft": "15", "R & B": "26", "Big Band": "74", "Ska": "24", "Electronic - Rave": "65", "Electronic - Progressive": "11", "Piano": "59", "Comedy": "45", "Christmas": "72", "Chillout": "106", "Reggae": "27", "Electronic - Industrial": "34", "Grunge": "103", "Medieval": "28", "Demo Style": "55", "Orchestral": "50", "Soundtrack": "43", "Electronic - Jungle": "60", "Fusion": "102", "Electronic - IDM": "99", "Ballad": "56", "Country": "18", "World": "42", "Jazz - Modern": "31", "Video Game": "8", "Funk": "32", "Electronic - Drum & Bass": "6", "Alternative": "48", "Electronic - Minimal": "101", "Electronic - Gabber": "40", "Vocal Montage": "76", "Metal (general)": "36", "Electronic - Breakbeat": "9", "Soul": "25", "Electronic (general)": "1", "Punk": "35", "Pop - Synth": "61", "Electronic - Dance": "3", "Pop (general)": "12", "Trance - Progressive": "85", "Trance (general)": "71", "Disco": "58", "Electronic - House": "10", "Experimental": "46", "Trance - Goa": "66", "Rock - Hard": "14", "Trance - Dream": "67", "Spiritual": "47", "Metal - Extreme": "37", "Jazz (general)": "29", "Trance - Tribal": "70", "Classical": "20", "Hip-Hop": "22", "Bluegrass": "105", "Halloween": "82", "Jazz - Acid": "30", "Easy Listening": "107", "New Age": "44", "Fantasy": "52", "Blues": "19", "Other": "41", "Trance - Acid": "63", "Gothic": "38", "Electronic - Hardcore": "39", "One Hour Compo": "53", "Pop - Soft": "62", "Electronic - Techno": "7", "Religious": "49", "Folk": "21"}
categories = []
Index: channels/myoggradio.py
==================================================================
--- channels/myoggradio.py
+++ channels/myoggradio.py
@@ -45,11 +45,11 @@
# settings
title ="MOR"
#module = "myoggradio"
api = "http://www.myoggradio.org/"
- listformat = "url/direct"
+ listformat = "mixed(pls/m3u/srv)"
# hide unused columns
titles = dict(playing=False, listeners=False, bitrate=False)
# category map
@@ -101,10 +101,11 @@
# 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
+ entries[i]["format"] = "audio/mpeg"
# send back
return entries
Index: channels/punkcast.py
==================================================================
--- channels/punkcast.py
+++ channels/punkcast.py
@@ -88,11 +88,11 @@
# look up ANY audio url
for uu in rx_sound.findall(html):
__print__( dbg.DATA, uu )
(url, fmt) = uu
- action.play(url, self.mime_fmt(fmt), "url/direct")
+ action.play(url, self.mime_fmt(fmt), "srv")
return
# or just open webpage
action.browser(row["homepage"])
Index: channels/radiobrowser.py
==================================================================
--- channels/radiobrowser.py
+++ channels/radiobrowser.py
@@ -59,11 +59,11 @@
class radiobrowser (ChannelPlugin):
# description
homepage = "http://www.radio-browser.info/"
has_search = True
- listformat = "audio/x-scpls"
+ listformat = "pls"
titles = dict(listeners="Votes+", bitrate="Votes-", playing="Country")
categories = []
pricat = ("topvote", "topclick")
catmap = { "tags": "bytag", "countries": "bycountry", "languages": "bylanguage" }
Index: channels/shoutcast.py
==================================================================
--- channels/shoutcast.py
+++ channels/shoutcast.py
@@ -52,11 +52,11 @@
# desc
module = "shoutcast"
title = "SHOUTcast"
base_url = "http://shoutcast.com/"
- listformat = "audio/x-scpls"
+ listformat = "pls"
# categories
categories = []
catmap = {"Choral": 35, "Winter": 275, "JROCK": 306, "Motown": 237, "Political": 290, "Tango": 192, "Ska": 22, "Comedy": 283, "Decades": 212, "European": 143, "Reggaeton": 189, "Islamic": 307, "Freestyle": 114, "French": 145, "Western": 53, "Dancepunk": 6, "News": 287, "Xtreme": 23, "Bollywood": 138, "Celtic": 141, "Kids": 278, "Filipino": 144, "Hanukkah": 270, "Greek": 146, "Punk": 21, "Spiritual": 211, "Industrial": 14, "Baroque": 33, "Talk": 282, "JPOP": 227, "Scanner": 291, "Mediterranean": 154, "Swing": 174, "Themes": 89, "IDM": 75, "40s": 214, "Funk": 236, "Rap": 110, "House": 74, "Educational": 285, "Caribbean": 140, "Misc": 295, "30s": 213, "Anniversary": 266, "Sports": 293, "International": 134, "Tribute": 107, "Piano": 41, "Romantic": 42, "90s": 219, "Latin": 177, "Grunge": 10, "Dubstep": 312, "Government": 286, "Country": 44, "Salsa": 191, "Hardcore": 11, "Afrikaans": 309, "Downtempo": 69, "Merengue": 187, "Psychedelic": 260, "Female": 95, "Bop": 167, "Tribal": 80, "Metal": 195, "70s": 217, "Tejano": 193, "Exotica": 55, "Anime": 277, "BlogTalk": 296, "African": 135, "Patriotic": 101, "Blues": 24, "Turntablism": 119, "Chinese": 142, "Garage": 72, "Dance": 66, "Valentine": 273, "Barbershop": 222, "Alternative": 1, "Technology": 294, "Folk": 82, "Klezmer": 152, "Samba": 315, "Turkish": 305, "Trance": 79, "Dub": 245, "Rock": 250, "Polka": 59, "Modern": 39, "Lounge": 57, "Indian": 149, "Hindi": 148, "Brazilian": 139, "Eclectic": 93, "Korean": 153, "Creole": 316, "Dancehall": 244, "Surf": 264, "Reggae": 242, "Goth": 9, "Oldies": 226, "Zouk": 162, "Environmental": 207, "Techno": 78, "Adult": 90, "Rockabilly": 262, "Wedding": 274, "Russian": 157, "Sexy": 104, "Chill": 92, "Opera": 40, "Emo": 8, "Experimental": 94, "Showtunes": 280, "Breakbeat": 65, "Jungle": 76, "Soundtracks": 276, "LoFi": 15, "Metalcore": 202, "Bachata": 178, "Kwanzaa": 272, "Banda": 179, "Americana": 46, "Classical": 32, "German": 302, "Tamil": 160, "Bluegrass": 47, "Halloween": 269, "College": 300, "Ambient": 63, "Birthday": 267, "Meditation": 210, "Electronic": 61, "50s": 215, "Chamber": 34, "Heartache": 96, "Britpop": 3, "Soca": 158, "Grindcore": 199, "Reality": 103, "00s": 303, "Symphony": 43, "Pop": 220, "Ranchera": 188, "Electro": 71, "Christmas": 268, "Christian": 123, "Progressive": 77, "Jazz": 163, "Trippy": 108, "Instrumental": 97, "Tropicalia": 194, "Fusion": 170, "Healing": 209, "Glam": 255, "80s": 218, "KPOP": 308, "Worldbeat": 161, "Mixtapes": 117, "60s": 216, "Mariachi": 186, "Soul": 240, "Cumbia": 181, "Inspirational": 122, "Impressionist": 38, "Gospel": 129, "Disco": 68, "Arabic": 136, "Idols": 225, "Ragga": 247, "Demo": 67, "LGBT": 98, "Honeymoon": 271, "Japanese": 150, "Community": 284, "Weather": 317, "Asian": 137, "Hebrew": 151, "Flamenco": 314, "Shuffle": 105}
current = ""
Index: channels/surfmusik.py
==================================================================
--- channels/surfmusik.py
+++ channels/surfmusik.py
@@ -44,11 +44,11 @@
# description
title = "SurfMusik"
module = "surfmusik"
homepage = "http://www.surfmusik.de/"
- listformat = "audio/x-scpls"
+ listformat = "m3u"
lang = "DE" # last configured categories
base = {
"DE": ("http://www.surfmusik.de/", "genre/", "land/"),
"EN": ("http://www.surfmusic.de/", "format/", "country/"),
Index: channels/timer.py
==================================================================
--- channels/timer.py
+++ channels/timer.py
@@ -95,11 +95,11 @@
self.parent.timer_dialog.hide()
row = self.parent.row()
row = copy.copy(row)
# add data
- row["listformat"] = "url/direct" #self.parent.channel().listformat
+ row["listformat"] = "href" #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
@@ -170,11 +170,11 @@
# action wrapper
def play(self, row, *args, **kwargs):
action.play(
url = row["url"],
audioformat = row.get("format","audio/mpeg"),
- listformat = row.get("listformat","url/direct"),
+ listformat = row.get("listformat","href"),
)
# action wrapper
def record(self, row, *args, **kwargs):
#print("TIMED RECORD")
@@ -188,13 +188,13 @@
# start recording
action.record(
url = row["url"],
audioformat = row.get("format","audio/mpeg"),
- listformat = row.get("listformat","url/direct"),
+ listformat = row.get("listformat","href"),
append = append,
)
def test(self, row, *args, **kwargs):
print("TEST KRONOS", row)
Index: channels/tunein.py
==================================================================
--- channels/tunein.py
+++ channels/tunein.py
@@ -38,11 +38,11 @@
# description
title = "TuneIn"
module = "tunein"
homepage = "http://tunein.com/"
has_search = False
- listformat = "audio/x-scpls"
+ listformat = "pls"
titles = dict(listeners=False)
base = "http://opml.radiotime.com/"
categories = ["local", "60's", "70's", "80's", "90's", "Adult Contemporary", "Alternative Rock", "Ambient", "Bluegrass", "Blues", "Bollywood", "Children's Music", "Christmas", "Classic Hits", "Classic Rock", "Classical", "College Radio", "Country", "Decades", "Disco", "Easy Listening", "Eclectic", "Electronic", "Folk", "Hip Hop", "Indie", "Internet Only", "Jazz", "Live Music", "Oldies", "Polka", "Reggae", "Reggaeton", "Religious", "Rock", "Salsa", "Soul and R&B", "Spanish Music", "Specialty", "Tango", "Top 40/Pop", "World"]
catmap = {"60's": "g407", "Live Music": "g2778", "Children's Music": "c530749", "Polka": "g84", "Tango": "g3149", "Top 40/Pop": "c57943", "90's": "g2677", "Eclectic": "g78", "Decades": "c481372", "Christmas": "g375", "Reggae": "g85", "Reggaeton": "g2771", "Oldies": "c57947", "Jazz": "c57944", "Specialty": "c418831", "Hip Hop": "c57942", "College Radio": "c100000047", "Salsa": "g124", "Bollywood": "g2762", "70's": "g92", "Country": "c57940", "Classic Hits": "g2755", "Internet Only": "c417833", "Disco": "g385", "Rock": "c57951", "Soul and R&B": "c1367173", "Blues": "g106", "Classic Rock": "g54", "Alternative Rock": "c57936", "Adult Contemporary": "c57935", "Classical": "c57939", "World": "c57954", "Indie": "g2748", "Religious": "c57950", "Bluegrass": "g63", "Spanish Music": "c57945", "Easy Listening": "c10635888", "Ambient": "g2804", "80's": "g42", "Electronic": "c57941", "Folk": "g79"}
Index: channels/xiph.py
==================================================================
--- channels/xiph.py
+++ channels/xiph.py
@@ -60,11 +60,11 @@
module = "xiph"
title = "Xiph.org"
homepage = "http://dir.xiph.org/"
#xml_url = "http://dir.xiph.org/yp.xml"
json_url = "http://api.include-once.org/xiph/cache.php"
- listformat = "url/http"
+ listformat = "srv"
has_search = True
# content
categories = [ "pop", "top40" ]
current = ""
Index: config.py
==================================================================
--- config.py
+++ config.py
@@ -269,11 +269,12 @@
# Use config:-style definitions for argv extraction,
# such as: { arg: -D, name: debug, type: bool }
def init_args(self, ap):
for opt in plugin_meta(frame=0).get("config"):
- if [kwargs for kwargs in [self.argparse_map(opt)]]:
+ kwargs = self.argparse_map(opt)
+ if kwargs:
#print kwargs
ap.add_argument(*kwargs.pop("args"), **kwargs)
return ap.parse_args()
Index: help/config_apps.page
==================================================================
--- help/config_apps.page
+++ help/config_apps.page
@@ -14,19 +14,18 @@
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.
-
Audacious
audacious %m3u
audio
+
Audacious
audacious
audio
XMMS2
xmms2 %m3u
audio
Amarok
amarok -l %pls
audio
-
Exaile
exaile %m3u
audio
-
Amarok
amarok -l %pls
audio
+
Exaile
exaile
audio
mplayer
mplayer %srv
console
VLC
vlc %u
video
Totem
totem %u
video
-
Media Player
mplayer2.exe %pls
Win32
+
Media Player
mplayer2.exe %asx
Win32
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,
which ends the previous player process and starts it anew.
@@ -45,16 +44,25 @@
specific playlist file types or URLs. You can control this by adding
a placeholder after the configured application name:
Placeholder
Alternatives
URL/Filename type
+
%pls
%url %u %r
Either a remote .pls resource (fastest), or a local .pls file (if converted)
%m3u
%f %g %m
Provides a local .m3u file for the streaming station
-
%pls
%url %u %r
Either a remote .pls resource, or a local .pls file (if converted)
%srv
%d %s
Direct link to first streaming address, e.g. http://72.5.9.33:7500
+
%xspf
%x
Xiph.org shareable playlist format (for modern players)
+
%jspf
%j
JSON playlist format (widely unsupported)
+
%asx
Some obscure Windows playlist format (don't use that)
You sould preferrably use the long forms. Most audio players like
- %m3u most, while streamripper needs %srv for recording.
+
Preferrably use the long %abbr names for configuration.
+
+
Most audio players like pls, yet sometimes the
+ older m3u format more. Streamripper requires %srv for recording.
+
+
Leave it to the default %pls to avoid Streamtuner2 doing unneeded
+ extra conversions (just delays playback).
Index: help/configuration.page
==================================================================
--- help/configuration.page
+++ help/configuration.page
@@ -58,14 +58,14 @@
likely to work. If it turns red / into a stop symbol, then the entered name is likely incorrect.
After the application name, you can use a placeholder like "%pls" (default),
- or "%m3u" and "%src". See placeholders.
+ or "%m3u" and "%srv". See placeholders.
Catch-all entries like */* or a generic audio/* entry allow to configure a default player.
- While video/youtube is specific to the Youtube channel. And url/http a psdeudo MIME type
+ While video/youtube is specific to the Youtube channel. And url/http a pseudo MIME type
to configure a web browser (for station homepages).
You can remove default entries by clearing both the Format field and its associated Application.
Add completely new associations through the emtpy line. (Reopen the dialog to add another one.)
Index: st2.py
==================================================================
--- st2.py
+++ st2.py
@@ -340,11 +340,11 @@
# Save stream to file (.m3u)
def save_as(self, widget):
row = self.row()
default_fn = row["title"] + ".m3u"
- fn = uikit.save_file("Save Stream", None, default_fn, [(".m3u","*m3u"),(".pls","*pls"),(".xspf","*xspf"),(".smil","*smil"),(".asx","*asx"),("all files","*")])
+ fn = uikit.save_file("Save Stream", None, default_fn, [(".m3u","*m3u"),(".pls","*pls"),(".xspf","*xspf"),(".jspf","*jspf"),(".smil","*smil"),(".asx","*asx"),("all files","*")])
if fn:
source = row.get("listformat", self.channel().listformat)
dest = (re.findall("\.(m3u|pls|xspf|jspf|json|smil|asx|wpl)8?$", fn) or ["pls"])[0]
action.save_playlist(source=source, multiply=True).save(rows=[row], fn=fn, dest=dest)
pass
Index: uikit.py
==================================================================
--- uikit.py
+++ uikit.py
@@ -361,22 +361,30 @@
#-- 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
+ def save_file(title="Save As", parent=None, fn="", formats=[("*.pls", "*.pls"), ("*.xspf", "*.xpsf"), ("*.m3u", "*.m3u"), ("*.jspf", "*.jspf"), ("*.asx", "*.asx"), ("*.json", "*.json"), ("*.smil", "*.smil"), ("*.wpl", "*.wpl"), ("*","*")]):
+
+ # With overwrite confirmation
+ c = gtk.FileChooserDialog(title, parent, action=gtk.FILE_CHOOSER_ACTION_SAVE,
+ buttons=(gtk.STOCK_CANCEL, gtk.RESPONSE_CANCEL, gtk.STOCK_SAVE, gtk.RESPONSE_OK))
+ c.set_do_overwrite_confirmation(True)
+
+ # 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
+
+ # Filter handlers don't work either.
+
+ # Display and wait
if c.run():
fn = c.get_filename() # return filaname
c.destroy()
return fn
@@ -446,24 +454,30 @@
where.add(m)
# gtk.messagebox
@staticmethod
- def msg(text, style=gtk.MESSAGE_INFO, buttons=gtk.BUTTONS_CLOSE):
+ def msg(text, style=gtk.MESSAGE_INFO, buttons=gtk.BUTTONS_CLOSE, yes=None):
m = gtk.MessageDialog(None, 0, style, buttons, message_format=text)
m.show()
- m.connect("response", lambda *w: m.destroy())
+ if yes:
+ response = m.run()
+ m.destroy()
+ return response in (gtk.RESPONSE_OK, gtk.RESPONSE_ACCEPT, gtk.RESPONSE_YES)
+ else:
+ m.connect("response", lambda *w: m.destroy())
+ pass
# manual signal binding with a dict of { (widget, signal): callback }
@staticmethod
def add_signals(builder, map):
for (widget,signal),func in map.items():
builder.get_widget(widget).connect(signal, func)
- # Pixbug loader (from inline string, as in `logo.png`)
+ # Pixbug loader (from inline string, as in `logo.png`, automatic base64 decoding, and gunzipping of raw data)
@staticmethod
def pixbuf(buf, fmt="png", decode=True, gzip=False):
if not buf or len(buf) < 16:
return None
if fmt and ver==3: