Index: action.py ================================================================== --- action.py +++ action.py @@ -18,11 +18,11 @@ # 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 # Coupling to main window @@ -30,40 +30,57 @@ 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", + "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 +} # 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", + srv = "%srv | %d | %s", ) + +# Playlist format content probing (assert type) +playlist_content_map = { + "pls": r""" (?i)\[playlist\].*numberofentries""", + "xspf": r""" <\?xml .* ]*> .* """, + "wpl": r""" <\?wpl \s+ version="1\.0" \s* \?>""", + "jspf": r""" \{ \s* "playlist": \s* \{ """, + "json": r""" "url": \s* "\w+:// """, + "href": r""" .* """, +} # Exec wrapper # @@ -102,28 +119,32 @@ 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 t in listfmt_t.values(): + for short,mime in listfmt_t.items(): if mime == t: return short return t # "pls" # 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] @@ -145,29 +166,102 @@ # 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)) + urls = convert_playlist(url, listfmt(source), listfmt(dest)) # insert quoted URL/filepath - return re.sub(rx, cmd, quote(url), 2, re.X) + return re.sub(rx, cmd, quote(urls), 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): + urls = [] + + print(dbg.PROC, "convert_playlist(", url, source, dest, ")") # Leave alone - if source == dest or source in ("srv", "href"): - return url + is_url = re.search("\w+://", url) + if source == dest or source in ("srv", "href") or not is_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)[0] + + # Probe MIME type and content per regex + probe = None + for probe,rx in playlist_content_map.items(): + if re.search(rx, cnt, re.X|re.S): + break # with `probe` set + + # Check ambiguity (except pseudo extension) + if len(set([source, mime, probe])) > 1: + print(dbg.ERR, "Possible playlist format mismatch:", (source, mime, probe, ext)) + + # Extract URLs from content + for fmt,extractor in [ ("pls",extract_playlist.pls), ("asx",extract_playlist.asx), ("raw",extract_playlist.raw) ]: + if not urls and fmt in (source, mime, probe, ext): + urls = extractor(cnt) + + # Return asis for srv targets + if dest in ("srv", "href", "any"): + return urls + print urls + + # Otherwise convert to local file + fn = tmp_fn(cnt) + save(urls[0], fn, dest) + return [fn] + + + +# Tries to fetch a resource, aborts on ICY responses. +# +def http_probe_get(url): + + # possible streaming request + r = session.get(url, stream=True) + if not len(r.headers): + return ("srv", r) + + # extract payload + mime = r.headers.get("content-type", "any") + if mediafmt_t.get(mime): + mime = mediafmt_t.get(mime) + content = "".join(r.iter_lines()) + return (mime, content) + + + +# Extract URLs from playlist formats: +# +class extract_playlist(object): + + @staticmethod + def pls(text): + return re.findall("\s*File\d*\s*=\s*(\w+://[^\s]+)", text, re.I) - # Else - return url + @staticmethod + def asx(text): + return re.findall(" 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/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 = "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 = ""