Index: action.py
==================================================================
--- action.py
+++ action.py
@@ -2,11 +2,11 @@
 # encoding: UTF-8
 # api: streamtuner2
 # type: functions
 # title: play/record actions
 # description: Starts audio applications, guesses MIME types for URLs
-# version: 0.8
+# version: 0.9
 #
 # 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
@@ -18,59 +18,81 @@
 # given resource (works for m3u/pls/xspf/asx/...).
 
 
 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",
+    "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",
+    "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",
+    srv = "(%srv | %d | %s) \\b",
 )
+
+# Playlist format content probing (assert type)
+playlist_content_map = [
+   ("pls",  r""" (?i)\[playlist\].*numberofentries """),
+   ("xspf", r""" <\?xml .* <playlist .* http://xspf\.org/ns/0/ """),
+   ("m3u",  r""" ^ \s* #(EXT)?M3U """),
+   ("asx" , r""" <ASX\b """),
+   ("smil", r""" <smil[^>]*> .* <seq> """),
+   ("html", r""" <(audio|video)\b[^>]+\bsrc\s*=\s*["']?https?:// """),
+   ("wpl",  r""" <\?wpl \s+ version="1\.0" \s* \?> """),
+   ("b4s",  r""" <WinampXML> """),   # http://gonze.com/playlists/playlist-format-survey.html
+   ("jspf", r""" ^ \s* \{ \s* "playlist": \s* \{ """),
+   ("json", r""" "url": \s* "\w+:// """),
+   ("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 +108,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, listformat, 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)
     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,134 +161,252 @@
         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)
+# Substitute .pls URL with local .m3u, or direct srv addresses, or leaves URL asis.
+#  · Takes a single input `url`.
+#  · But returns a list of [urls] after playlist extraction.
+#  · If repackaging as .m3u/.pls/.xspf, returns the local [fn].
+#
+def convert_playlist(url, source, dest, local_file=True, title=""):
+    urls = []
+    debug(dbg.PROC, "convert_playlist(", url, source, dest, ")")
+
+    # Leave alone if format matches, or if "srv" URL class, or if it's a local path already
+    if source == dest or source in ("srv", "href") or not re.search("\w+://", url):
+        return [url]
+    
+    # Retrieve from URL
+    (mime, cnt) = http_probe_get(url)
+    
+    # Leave streaming server as is
+    if mime == "srv":
+        cnt = ""
+        return [url]
+
+    # Test URL path "extension" for ".pls" / ".m3u" etc.
+    ext = re.findall("\.(\w)$", url)
+    ext = ext[0] if ext else None
+
+    # Probe MIME type and content per regex
+    probe = None
+    for probe,rx in playlist_content_map:
+        if re.search(rx, cnt, re.X|re.S):
+            probe = listfmt(probe)
+            break # with `probe` set
+
+    # Check ambiguity (except pseudo extension)
+    if len(set([source, mime, probe])) > 1:
+        debug(dbg.ERR, "Possible playlist format mismatch:", (source, mime, probe, ext))
+
+    # Extract URLs from content
+    for fmt in [ "pls", "asx", "raw" ]:
+        if not urls and fmt in (source, mime, probe, ext):
+            urls = extract_playlist(source).format(fmt)
+            debug(dbg.DATA, "conversion from:", source, " to dest:", 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 = tmp_fn(cnt)
+        save_playlist(source="srv", multiply=True).export(urls=urls, fn=fn, 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")
+    # Map MIME to abbr type (pls, m3u, xspf)
+    if listfmt_t.get(mime):
+        mime = listfmt_t.get(mime)
+    # Raw content (mp3, flv)
+    elif mimefmt_t.get(mime):
+        mime = mimefmt_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
+    def format(self, fmt):
+        cnv = getattr(self, fmt)
+        return cnv()
+
+    # PLS
+    def pls(self):
+        return re.findall("\s*File\d*\s*=\s*(\w+://[^\s]+)", self.src, re.I)
+
+    # ASX
+    def asx(self):
+        return re.findall("<Ref\s+href=\"(http://.+?)\"", self.src)
+
+    # Regexp out any URL
+    def raw(self):
+        debug(dbg.WARN, "Raw playlist extraction")
+        return re.findall("([\w+]+://[^\s\"\'\>\#]+)", self.src)
+
+
+# Save rows in one of the export formats.
+# Takes a few combinations of parameters (either rows[], or urls[]+title),
+# because it's used by playlist_convert() as well as the station saving.
+#
+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, None, dest)
+
+
+    # Export a playlist
+    def store(self, rows=None, fn=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
+        txt = converter(rows)
+        
+        # save directly?
+        if fn:
+            with open(fn, "wb") as f:
+                f.write(txt)
+        else:
+            return txt
+
 
     # M3U
-    if "m3u" in format:
-        txt = "#M3U\n"
-        for url in stream_urls:
-            txt += http_fix_url(url) + "\n"
+    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
-    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"
+    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)
+
+
+#-- all others need rework --
 
     # XSPF
-    elif "xspf" in format:
+    def xspf(self, rows):
         txt = '<?xml version="1.0" encoding="UTF-8"?>' + "\n"
         txt += '<?http header="Content-Type: application/xspf+xml" ?>' + "\n"
         txt += '<playlist version="1" xmlns="http://xspf.org/ns/0/">' + "\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 += "  <trackList>\n"
-        for u in stream_urls:
+        for row in rows:
+            for attr,tag in [("title","title"), ("homepage","info"), ("playing","annotation"), ("description","annotation")]:
+                if rows.get(attr):
+                    txt += "  <"+tag+">" + xmlentities(row[attr]) + "</"+tag+">\n"
+            u = row.get("url")
             txt += '	<track><location>' + xmlentities(u) + '</location></track>' + "\n"
         txt += "  </trackList>\n</playlist>\n"
 
     # JSPF
-    elif "jspf" in format:
+    def jspf(self, rows):
         pass
-
-    # JSON
-    elif "json" in format:
-        row["stream_urls"] = stream_urls
-        txt = str(row)   # pseudo-json (python format)
-    
     # ASX
-    elif "asx" in format:
-        txt = "<ASX version=\"3.0\">\n"			\
+    def asx(self, rows):
+        for row in rows:
+          txt = "<ASX version=\"3.0\">\n"			\
             + " <Title>" + xmlentities(row["title"]) + "</Title>\n"	\
             + " <Entry>\n"				\
             + "  <Title>" + xmlentities(row["title"]) + "</Title>\n"	\
             + "  <MoreInfo href=\"" + row["homepage"] + "\"/>\n"	\
             + "  <Ref href=\"" + stream_urls[0] + "\"/>\n"		\
             + " </Entry>\n</ASX>\n"
+        return txt
 
     # SMIL
-    elif "smil" in format:
-            txt = "<smil>\n<head>\n  <meta name=\"title\" content=\"" + xmlentities(row["title"]) + "\"/>\n</head>\n"	\
-                + "<body>\n  <seq>\n    <audio src=\"" + stream_urls[0] + "\"/>\n  </seq>\n</body>\n</smil>\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("<Ref\s+href=\"(http://.+?)\"", http_get(pls))
-    elif (re.search("\.m3u|\.ram|\.smil", pls)):	#audio/x-mpegurl
-        return re.findall("(http://[^\s]+)", http_get(pls), re.I)
-    else:  # just assume it was a direct mpeg/ogg streamserver link
-        return [ (pls if pls.startswith("/") else http_fix_url(pls)) ]
-    pass
+    def smil(self, rows):
+        return "<smil>\n<head>\n  <meta name=\"title\" content=\"" + xmlentities(row["title"]) + "\"/>\n</head>\n"	\
+             + "<body>\n  <seq>\n    <audio src=\"" + stream_urls[0] + "\"/>\n  </seq>\n</body>\n</smil>\n"
+
 
 
 # generate filename for temporary .m3u, if possible with unique id
 def tmp_fn(pls):
     # use shoutcast unique stream id if available
@@ -276,50 +416,7 @@
         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]
+    
 

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,41 +22,34 @@
 
 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, "stationlist." + conf.export_format, formats=[("*.xspf", "*.xpsf"),("*.m3u", "*.m3u")])
-        with open(fn, "w") as f:
-            __print__(dbg.PROC, "Exporting category to", fn)
-            f.write(self.pls(streams))
-        return
-
-    # plain PLS file
-    def pls(self, streams):
-        txt = "[playlist]\n"
-        txt += "numberofentries=%s\n\n" % len(streams)
-        for i,row in enumerate(streams):
-            i = str(i)
-            txt += "File"+i + "=" + row["url"] + "\n"
-            txt += "Title"+i + "=" + row["title"] + "\n"
-            txt += "Length"+i + "=-1\n\n"
-        txt += "Version=2\n"
-        return txt
-           
+        fn = uikit.save_file("Export category", None, "stationlist." + 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).store(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 &amp; 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 &amp; 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 = "srv"
     
     # hide unused columns
     titles = dict(playing=False, listeners=False, bitrate=False)
     
     # category map

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 = "pls"
 
     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: st2.py
==================================================================
--- st2.py
+++ st2.py
@@ -355,11 +355,13 @@
     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","*")])
         if fn:
-            action.save(row, fn)
+            source = row.get("listformat", self.channel().listformat)
+            dest = re.findall("\.(m3u|pls|xspf|jspf|json|smil|wpl)8?$", fn)[0]
+            action.save_playlist(source=source, multiply=True).store(rows=[row], fn=fn, dest=dest)
         pass
 
     # Save current stream URL into clipboard
     def menu_copy(self, w):
         gtk.clipboard_get().set_text(self.selected("url"))

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: