Internet radio browser GUI for music/video streams from various directory services.

⌈⌋ ⎇ branch:  streamtuner2


Check-in [1937c5766b]

Overview
Comment:Fixed ASX and SMIL playlist exporting, allowed new placeholders %xspf, %jspf, %asx, %smil for application configuration. Documented in help/ pages.
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | action-mapfmts
Files: files | file ages | folders
SHA1: 1937c5766b8ddd88c304baac7cc206eeaff59ce9
User & Date: mario on 2015-04-10 02:36:21
Other Links: branch diff | manifest | tags
Context
2015-04-10
10:45
Update comment on rewritten action module. Add alternative MIME types for m3u and asx, spport asf detection and extraction. Fix listformat→source arg. Move save() and filename handling out of save_playlist. Fix mediafmt_t lookup and print warning when there's an audio-response on playlist fetching (and it does happen). Change myoggradio plugin "format" population, and set listformat to "mixed(..)" for automatic probing. check-in: 223368ebbf user: mario tags: action-mapfmts
02:36
Fixed ASX and SMIL playlist exporting, allowed new placeholders %xspf, %jspf, %asx, %smil for application configuration. Documented in help/ pages. check-in: 1937c5766b user: mario tags: action-mapfmts
2015-04-09
21:58
Python3 doesn't like `if [x = ...]` inline assignment trickery (kwargs out of scope). check-in: 82cf514e49 user: mario tags: action-mapfmts
Changes

Modified action.py from [941a6a0502] to [f7b4eccd84].

63
64
65
66
67
68
69




70
71
72
73
74
75
76
77
78

79
80
81
82
83
84
85
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81

82
83
84
85
86
87
88
89







+
+
+
+








-
+







    "audio/xm+zip": "mod",
}

# Player command placeholders for playlist formats
placeholder_map = dict(
    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 .* <playlist .* http://xspf\.org/ns/0/ """),
   ("m3u",  r""" ^ \s* #(EXT)?M3U """),
   ("asx" , r""" <ASX\b """),
   ("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""" .* """),
182
183
184
185
186
187
188
189
190


191
192
193
194
195
196
197
186
187
188
189
190
191
192


193
194
195
196
197
198
199
200
201







-
-
+
+







#  · 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):
    # Leave alone if format matches, or if "srv" URL class, or if not http (local path, mms:/rtsp:)
    if source == dest or source in ("srv", "href") or not re.match("(https?|spdy)://", url):
        return [url]
    
    # Retrieve from URL
    (mime, cnt) = http_probe_get(url)
    
    # Leave streaming server as is
    if mime == "srv":
210
211
212
213
214
215
216
217
218


219
220
221
222
223
224
225
214
215
216
217
218
219
220


221
222
223
224
225
226
227
228
229







-
-
+
+







            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):
    for fmt in [ "pls", "xspf", "asx", "smil", "jspf", "m3u", "json", "raw" ]:
        if not urls and fmt in (source, mime, probe, ext, "raw"):
            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"):
267
268
269
270
271
272
273


274
275
276


277
278
279
280
281




282
283
284
285







286
287
288
289
290
291
292
293
294
295
296
271
272
273
274
275
276
277
278
279
280


281
282
283




284
285
286
287




288
289
290
291
292
293
294




295
296
297
298
299
300
301







+
+

-
-
+
+

-
-
-
-
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
-
-
-
-







#
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):
        cnv = getattr(self, fmt)
        return cnv()
        debug(dbg.DATA, fmt)
        return re.findall(self.extr_urls[fmt], self.src, re.X);

    # PLS
    def pls(self):
        return re.findall("\s*File\d*\s*=\s*(\w+://[^\s]+)", self.src, re.I)

    # Only look out for URLs, not local file paths
    extr_urls = {
       "pls":  r" (?i) ^ \s*File\d* \s*=\s* (\w+://[^\s]+) ",
       "m3u":  r" (?m) ^( \w+:// [^#\n]+ )",
    # ASX
    def asx(self):
        return re.findall("<Ref\s+href=\"(http://.+?)\"", self.src)

       "xspf": r" (?x) <location> (\w+://[^<>\s]+) </location> ",
       "asx":  r" (?x) <ref \b[^>]+\b href \s*=\s* [\'\"] (\w+://[^\s\"\']+) [\'\"] ",
       "smil": r" (?x) <(?:audio|video)\b [^>]+ \b src \s*=\s* [^\"\']? \s* (\w+://[^\"\'\s]+) ",
       "jspf": r" (?x) \"location\" \s*:\s* \"(\w+://[^\"\s]+)\" ",
       "json": r" (?x) \"url\" \s*:\s* \"(\w+://[^\"\s]+)\" ",
       "raw":  r" (?i) ( [\w+]+:// [^\s\"\'\>\#]+ ) ",
    }
    # 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):
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380






381
382
383
384





385
386
387
388
389




390


391
392

393
394
395
396


397
398
399
400
401

402
403
404






405
406








407
408
409
410
411
412
413
370
371
372
373
374
375
376


377
378





379
380
381
382
383
384




385
386
387
388
389


390
391
392
393
394
395
396

397
398
399
400
401
402



403
404




405
406
407
408
409
410
411
412
413
414
415


416
417
418
419
420
421
422
423
424
425
426
427
428
429
430







-
-


-
-
-
-
-
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
-
-



+
+
+
+
-
+
+


+

-
-
-
+
+
-
-
-
-

+



+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+







        return txt

    # JSON (native lists of streamtuner2)
    def json(self, rows):
        return json.dumps(rows, indent=4)


#-- all others need rework --

    # XSPF
    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"
        txt += "  <trackList>\n"
        for row in rows:
        return """<?xml version="1.0" encoding="UTF-8"?>\n"""					\
            + """<?http header="Content-Type: application/xspf+xml" ?>\n"""			\
            + """<playlist version="1" xmlns="http://xspf.org/ns/0/">\n\t<trackList>\n"""	\
            + "".join("""\t\t<track>\n%s\t\t</track>\n""" % self.xspf_row(row, self.xspf_map) for row in rows)	\
            + """\t</trackList>\n</playlist>\n"""
    # individual tracks
            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")
    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")

            txt += '	<track><location>' + xmlentities(u) + '</location></track>' + "\n"
        txt += "  </trackList>\n</playlist>\n"

    # 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)
        pass


    # ASX
    def asx(self, rows):
        txt = """<asx version="3.0">\n"""
        for row in rows:
          txt = "<ASX version=\"3.0\">\n"			\
            + " <Title>" + xmlentities(row["title"]) + "</Title>\n"	\
            + " <Entry>\n"				\
            txt += """\t<entry>\n\t\t<title>%s</title>\n\t\t<ref href="%s"/>\n\t</entry>\n""" % (xmlentities(row["title"]), xmlentities(row["url"]))
        txt += """</asx>\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
    def smil(self, rows):
        txt = """<smil>\n<head>\n\t<meta name="title" content="%s""/>\n</head>\n<body>\n\t<seq>\n""" % (rows[0]["title"])
        for row in rows:
            if row.get("url"):
                txt += """\t\t<audio src="%s"/>\n""" % row["url"]
        txt += """\t</seq>\n</body>\n</smil>\n"""
        return txt
        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"



# 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):
    # use shoutcast unique stream id if available
    stream_id = re.search("http://.+?/.*?(\d+)", pls, re.M)

Modified help/config_apps.page from [eda5c4f097] to [d8a5ff9529].

12
13
14
15
16
17
18
19

20
21
22

23
24
25
26
27

28
29
30
31
32
33
34
12
13
14
15
16
17
18

19
20
21

22

23
24
25

26
27
28
29
30
31
32
33







-
+


-
+
-



-
+








	<p>On BSD/Linux systems there are a plethora of audio players. In streamtuner2 you can
	<link xref="configuration">configure</link> 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.</p>

	<table shade="rows" rules="rows cols">
		<tr><td><p><app>Audacious</app></p></td>     <td><p><cmd>audacious %m3u</cmd></p></td>  <td><p>audio</p></td></tr>
		<tr><td><p><app>Audacious</app></p></td>     <td><p><cmd>audacious</cmd></p></td>  <td><p>audio</p></td></tr>
		<tr><td><p><app>XMMS2</app></p></td>         <td><p><cmd>xmms2 %m3u</cmd></p></td>      <td><p>audio</p></td></tr>
		<tr><td><p><app>Amarok</app></p></td>        <td><p><cmd>amarok -l %pls</cmd></p></td>  <td><p>audio</p></td></tr>
		<tr><td><p><app>Exaile</app></p></td>        <td><p><cmd>exaile %m3u</cmd></p></td>     <td><p>audio</p></td></tr>
		<tr><td><p><app>Exaile</app></p></td>        <td><p><cmd>exaile</cmd></p></td>     <td><p>audio</p></td></tr>
		<tr><td><p><app>Amarok</app></p></td>        <td><p><cmd>amarok -l %pls</cmd></p></td>  <td><p>audio</p></td></tr>
		<tr><td><p><app>mplayer</app></p></td>       <td><p><cmd>mplayer %srv</cmd></p></td>    <td><p>console</p></td></tr>
		<tr><td><p><app>VLC</app></p></td>           <td><p><cmd>vlc %u</cmd></p></td>          <td><p>video</p></td></tr>
		<tr><td><p><app>Totem</app></p></td>         <td><p><cmd>totem %u</cmd></p></td>        <td><p>video</p></td></tr>
		<tr><td><p><app>Media Player</app></p></td>  <td><p><cmd>mplayer2.exe %pls</cmd></p></td>  <td><p>Win32</p></td></tr>
		<tr><td><p><app>Media Player</app></p></td>  <td><p><cmd>mplayer2.exe %asx</cmd></p></td>  <td><p>Win32</p></td></tr>
	</table>

	<p>Some audio players open a second instance when you actually want to switch radios.
	In this case it's a common workaround to write <code>pkill vlc ; vlc %u</code> instead,
	which ends the previous player process and starts it anew.
        For VLC there's however also the <code>--one-instance</code> option, which sometimes
        works better. (And sometimes not.)</p>
43
44
45
46
47
48
49

50
51
52




53
54
55
56







57
58
59
60
42
43
44
45
46
47
48
49
50

51
52
53
54
55
56
57


58
59
60
61
62
63
64
65
66
67
68







+

-

+
+
+
+


-
-
+
+
+
+
+
+
+




	<p>Any listed application can be invoked with a different kind of
	URL or filename. Most are rather flexible, but some depend on
	specific playlist file types or URLs. You can control this by adding
	a placeholder after the configured application name:</p>

	<table shade="rows" rules="rows cols">
	<thead>	<tr><td><p>Placeholder</p></td><td><p>Alternatives</p></td><td><p>URL/Filename type</p></td></tr> </thead>
		<tr><td><p>%pls</p></td><td><p>%url %u %r</p></td><td><p>Either a remote .pls resource (fastest), or a local .pls file (if converted)</p></td></tr>
		<tr><td><p>%m3u</p></td><td><p>%f %g %m</p></td><td><p>Provides a local .m3u file for the streaming station</p></td></tr>
		<tr><td><p>%pls</p></td><td><p>%url %u %r</p></td><td><p>Either a remote .pls resource, or a local .pls file (if converted)</p></td></tr>
		<tr><td><p>%srv</p></td><td><p>%d %s</p></td><td><p>Direct link to first streaming address, e.g. http://72.5.9.33:7500</p></td></tr>
		<tr><td><p>%xspf</p></td><td><p>%x</p></td><td><p>Xiph.org shareable playlist format (for modern players)</p></td></tr>
		<tr><td><p>%jspf</p></td><td><p>%j</p></td><td><p>JSON playlist format (widely unsupported)</p></td></tr>
		<tr><td><p>%asx</p></td><td><p></p></td><td><p>Some obscure Windows playlist format (don't use that)</p></td></tr>
		<tr><td><p>%smil</p></td><td><p></p></td><td><p>Standardized multimedia sequencing lists (which nobody uses either)</p></td></tr>
	</table>

	<p>You sould preferrably use the long forms. Most audio players like
	%m3u most, while streamripper needs %srv for recording.</p>
        <p>Preferrably use the long %abbr names for configuration.</p>
        
	<note style="info"><p>Most audio players like pls, yet sometimes the
	older m3u format more.  Streamripper requires %srv for recording.</p>
	
	<p>Leave it to the default %pls to avoid Streamtuner2 doing unneeded
	extra conversions (just delays playback).</p> </note>

	</section>

</page>

Modified help/configuration.page from [6c83ddf5a5] to [cec2e8132d].

56
57
58
59
60
61
62
63

64
65
66

67
68
69
70
71
72
73
56
57
58
59
60
61
62

63
64
65

66
67
68
69
70
71
72
73







-
+


-
+







    <p>Application names are most always lowercase binary names. Double click an entry to edit it.
    After editing the icon next to the application name will be updated. If it stays green, it's
    likely to work. If it turns red / into a stop symbol, then the entered name is likely incorrect.</p>

   <p><media type="image" src="img/configapps.png" mime="image/png" /></p>

    <p>After the application name, you can use a placeholder like "<var>%pls</var>" (default),
    or "<var>%m3u</var>" and "<var>%src</var>". See <link xref="config_apps#placeholders">placeholders</link>.</p>
    or "<var>%m3u</var>" and "<var>%srv</var>". See <link xref="config_apps#placeholders">placeholders</link>.</p>

    <p>Catch-all entries like <var>*/*</var> or a generic <var>audio/*</var> entry allow to configure a default player.
    While <var>video/youtube</var> is specific to the Youtube channel. And <var>url/http</var> a psdeudo MIME type
    While <var>video/youtube</var> is specific to the Youtube channel. And <var>url/http</var> a pseudo MIME type
    to configure a web browser (for station homepages).</p>
    
    <p>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.)</p>

</section>
<section id="recording">

Modified st2.py from [ad8ec4ecd9] to [707c0d6df4].

351
352
353
354
355
356
357
358

359
360
361

362
363
364
365
366
367
368
351
352
353
354
355
356
357

358
359
360

361
362
363
364
365
366
367
368







-
+


-
+







        entries = self.channel().stations()
        favicon.download_all(entries)

    # 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|wpl)8?$", fn)[0]
            dest = (re.findall("\.(m3u|pls|xspf|jspf|json|smil|asx|wpl)8?$", fn) or ["pls"])[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"))