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

βŒˆβŒ‹ βŽ‡ branch:  streamtuner2


Check-in [749715cb39]

Overview
Comment:Fix .desktop file exporting. Add mime_guess() for streaming url. Move insert_rows() implementation out of DND module.
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA1: 749715cb398b898b57332b26f29f5be0630eaf79
User & Date: mario on 2015-04-22 20:52:07
Other Links: manifest | tags
Context
2015-04-22
21:16
Add some notes about DND to the manual. check-in: faacd9c284 user: mario tags: trunk
20:52
Fix .desktop file exporting. Add mime_guess() for streaming url. Move insert_rows() implementation out of DND module. check-in: 749715cb39 user: mario tags: trunk
20:50
Move DND insert_rows() into GenericChannel. Add load(y=) parameter to scroll back to previous position after insert_rows(). Reenable select_path("0") after reloading category list. (Fixes initial startup.) check-in: a5893e591c user: mario tags: trunk
Changes

Modified action.py from [29efeecded] to [b6bfb9c13d].

317
318
319
320
321
322
323
324

325
326
327
328
329
330
331
317
318
319
320
321
322
323

324
325
326
327
328
329
330
331







-
+







        if fn and self.probe_ext(fn):
            self.fn = fn
            self.src = open(fn, "rt").read()


    # Test URL/path "extension" for ".pls" / ".m3u" etc.
    def probe_ext(self, url):
        e = re.findall("\.(pls|m3u|xspf|jspf|asx|wpl|wsf|smil|html|url|json)$", url)
        e = re.findall("\.(pls|m3u|xspf|jspf|asx|wpl|wsf|smil|html|url|json|desktop)$", url)
        if e: return e[0]
        else: pass


    # Probe MIME type and content per regex
    def probe_fmt(self):
        for probe,rx in playlist_content_map:
426
427
428
429
430
431
432
433

434
435
436
437
438
439
440
441
442
443
444
445
446
447

448
449
450
451
452
453
454
426
427
428
429
430
431
432

433
434
435
436
437
438
439
440
441
442
443
444
445
446

447
448
449
450
451
452
453
454







-
+













-
+







        "asx": dict(
            split = r" (?x) <entry[^>]*> ",
            url   = r" (?x) <ref \b[^>]+\b href \s*=\s* [\'\"] (\w+://[^\s\"\']+) [\'\"] ",
            title = r"(?x) <title> ([^<>]+) ",
            unesc = "xml",
        ),
        "smil": dict(
            url   = r" (?x) <(?:audio|video|media)\b [^>]+ \b src \s*=\s* [^\"\']? \s* (\w+://[^\"\'\s]+) ",
            url   = r" (?x) <(?:audio|video|media)\b [^>]+ \b src \s*=\s* [^\"\']? \s* (\w+://[^\"\'\s\>]+) ",
            unesc = "xml",
        ),
        "jspf": dict(
            split = r"(?s) \"track\":\s*\{ >",
            url   = r"(?s) \"location\" \s*:\s* \"(\w+://[^\"\s]+)\" ",
            unesc = "json",
        ),
        "jamj": dict(
            url   = r" (?x) \"audio\" \s*:\s* \"(\w+:\\?/\\?/[^\"\s]+)\" ",
            title = r" (?x) \"name\" \s*:\s* \"([^\"]+)\" ",
            unesc = "json",
        ),
        "json": dict(
            url   = r" (?x) \"url\" \s*:\s* \"(\w+://[^\"\s]+)\" ",
            url   = r" (?x) \"url\" \s*:\s* \"(\w+:\\?/\\?/[^\"\s]+)\" ",
            title = r" (?x) \"title\" \s*:\s* \"([^\"]+)\" ",
            unesc = "json",
        ),
        "asf": dict(
            url   = r" (?m) ^ \s*Ref\d+ = (\w+://[^\s]+) ",
            unesc = "xml",
        ),
469
470
471
472
473
474
475
476

477
478
479
480
481


482
483
484
485











486
487
488
489
490
491
492
469
470
471
472
473
474
475

476
477
478
479


480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503







-
+



-
-
+
+




+
+
+
+
+
+
+
+
+
+
+







    }


    # Add placeholder fields to extracted row
    def mkrow(self, row, title=None):
        url = row.get("url", "")
        comb = {
            "title": title or re.sub("\.\w+$", "", os.path.basename(self.fn)),
            "title": row.get("title") or re.sub("\.\w+$", "", os.path.basename(self.fn)),
            "playing": "",
            "url": None,
            "homepage": "",
            "listformat": self.probe_ext(url) or "href",
            "format": ",".join(re.findall("ogg|mpeg|mp\d+", url)),
            "listformat": self.probe_ext(url) or "href", # or srv?
            "format": self.mime_guess(url),
            "genre": "copy",
        }
        comb.update(row)
        return comb

    # Probe url "extensions" for common media types
    # (only care about the common audio formats, don't need an exact match or pre-probing in practice)
    def mime_guess(self, url):
        audio = re.findall("(ogg|opus|spx|aacp|aac|mpeg|mp3|m4a|mp2|flac|midi|mod|kar|aiff|wma|ram|wav)", url)
        if audio:
            return "audio/{}".format(*audio)
        video = re.findall("(mp4|flv|avi|mp2|theora|3gp|nsv|fli|ogv|webm|mng|mxu|wmv|mpv|mkv)", url)
        if audio:
            return "video/{}".format(*audio)
        return "x-audio-video/unknown"



# Save rows in one of the export formats.
#
# The export() version uses urls[]+row/title= as input, converts it into
# a list of rows{} beforehand.
610
611
612
613
614
615
616




617
618
619
620
621
622
623
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638







+
+
+
+







        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<{} src="{}"/>\n""".format(row.get("format", "audio").split("/")[0], row["url"])
        txt += """\t</seq>\n</body>\n</smil>\n"""
        return txt

    # .DESKTOP links
    def desktop(self, rows):
        row = rows[0]
        return "[Desktop Entry]\nVersion=1.0\nIcon=media-playback-start\nType=Link\nName={title}\nComment={playing}\nURL={url}\n".format(**row)



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

Modified channels/dnd.py from [08769a579d] to [badcdb5ec3].

1
2
3
4
5
6
7
8
9

10
11
12
13
14
15
16
1
2
3
4
5
6
7
8

9
10
11
12
13
14
15
16








-
+







# encoding: UTF-8
# api: streamtuner2
# title: Drag and Drop (experimental)
# description: Copy streams/stations from and to other applications.
# depends: uikit
# version: 0.5
# type: interface
# config:
#   { name: dnd_format, type: select, value: xspf, select: "pls|m3u|xspf|jspf|asx|smil", description: "Default temporary file format for copying a station." }
#   { name: dnd_format, type: select, value: xspf, select: "pls|m3u|xspf|jspf|asx|smil|desktop", description: "Default temporary file format for copying a station." }
# category: ui
# priority: default
# support: experimental
#
# Implements Gtk/X11 drag and drop support for station lists.
# Should allow to export either just stream URLs, or complete
# PLS, XSPF collections.
70
71
72
73
74
75
76

77
78
79
80
81
82
83
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84







+







      ("audio/x-mpegurl", 0, 20),
      ("application/x-scpls", 0, 21),
      ("application/xspf+xml", 0, 22),
      ("application/smil", 0, 23),
      ("text/html", 0, 23),
      ("text/richtext", 0, 23),
      ("application/jspf+json", 0, 25),
      ("application/x-desktop", 0, 26),
      # direct srv urls
      ("text/url", 0, 15),  #@TODO: support in action.save_/convert_
      ("message/external-body", 0, 15),
      ("url/direct", 0, 15),
      # filename, file:// IRL
      ("FILE_NAME", 0, 3),
      ("text/uri-list", 0, 4),
91
92
93
94
95
96
97

98
99
100
101
102
103
104
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106







+







    # Map target/`info` integers to action. module identifiers
    cnv_types = {
       20: "m3u",
       21: "pls",
       22: "xspf",
       23: "smil",
       25: "jspf",
       26: "desktop",
       15: "srv",
        4: "temp",
        5: "srv",
       51: "json",
    }


192
193
194
195
196
197
198
199

200
201
202
203
204
205
206
194
195
196
197
198
199
200

201
202
203
204
205
206
207
208







-
+








        # Keep in type request buffer
        self.buf[info] = buf
        return buf



    # -- DESTINATION, when playlist/file gets dragged in from other app --
    # -- DESTINATION, when playlist/file gets dragged into ST2 from other app --

    # Just a notification for incoming drop
    def drop(self, widget, context, x, y, time):
        log.DND("dest←in: drop-probing, possible targets:", context.targets)
        # find a matching target
        accept = [type[0] for type in self.drag_types if type[0] in context.targets]
        context.drop_reply(len(accept) > 0, time)
227
228
229
230
231
232
233
234
235
236
237
238

239
240
241
242

243
244
245
246
247
248

249
250
251
252
253
254
255
256
257
258
259
260
261
262
263

264
265
266
267
268
269






270
271
272
273
274
229
230
231
232
233
234
235

236
237
238

239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261





262






263
264
265
266
267
268
269
270
271
272
273







-



-
+




+






+










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





        return True

    # Received files or payload has to be converted, copied into streams
    def import_row(self, info, urls, data, y=5000):
        # Internal target dicts
        cn = self.parent.channel()
        rows = []
        print info
                
        # Direct/internal row import
        if data and info >= 51:
            log.DND("Received row, append, reload")
            log.DND("Received row in internal format, append+reload")
            rows += [ json.loads(data) ]

        # Convertible formats as direct payload
        elif data and info >= 5:
            log.DND("Converting direct payload playlist")
            cnv = action.extract_playlist(data)
            add = cnv.rows(self.cnv_types[info] if info>=20 else cnv.probe_fmt() or "raw")
            rows += [ cnv.mkrow(row) for row in add ]

        # Extract from playlist files, either passed as text/uri-list or single FILE_NAME
        elif urls:
            log.DND("Importing from playlist file")
            for fn in urls or [data]:
                if not re.match("^(scp|file)://(localhost)?/|/", fn):
                    continue
                fn = compat2and3.urldecode(re.sub("^\w+://[^/]*", "", fn))
                cnv = action.extract_playlist(fn=fn)
                if cnv.src:
                    rows += [ cnv.mkrow(row) for row in cnv.rows() ]
        
        # Insert and update view
        if rows:
            # Inserting at correct row requires deducing index from dnd `y` position
            streams = cn.streams[cn.current]
            i_pos = (cn.gtk_list.get_path_at_pos(10, y) or [[len(streams) + 1]])[0][0]
            for row in rows:
                streams.insert(i_pos - 1, row)
            cn.insert_rows(rows, y)
                i_pos = i_pos + 1
            # Now appending to the liststore directly would be even nicer
            uikit.do(lambda *x: cn.load(cn.current))#, cn.gtk_list.scroll_to_point(0, y))
            if cn.module == "bookmarks":
                cn.save()
            #self.parent.streamedit()
            # if cn.module == "bookmarks":
            cn.save()
            # Show streamedit window if title is empty
            if not len(rows[0].get("title", "")):
                self.parent.configwin.load_config(rows[0], "streamedit_")
                self.parent.win_streamedit.show()
        else:
            self.parent.status("Unsupported station format. Not imported.")