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

⌈⌋ ⎇ branch:  streamtuner2


Check-in [b784d408c1]

Overview
Comment:Still some parameter renaming in action module to do. Optional support for row={} parameter in play/record calls, in case .pls/.m3u needs to be constructed (to retain title=). Adapt action playlist exporting to wrapper object, which preconverts plain URL lists or [rows] list, can itself call convert_playlist(), and optionalized file writing. Rewrite main save() and exportcat.save() to utilize new save_playlist(). Implement overwrite confirmation for Save-as dialog.
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | action-mapfmts
Files: files | file ages | folders
SHA1: b784d408c106623b035e7104492edb62e3e071cd
User & Date: mario on 2015-04-09 14:50:54
Other Links: branch diff | manifest | tags
Context
2015-04-09
21:57
Figured out how to use standard confirm-overwrite dialog (buttons were defined, but no actions associated). Removed custom msg box. check-in: 5539fcccc2 user: mario tags: action-mapfmts
14:50
Still some parameter renaming in action module to do. Optional support for row={} parameter in play/record calls, in case .pls/.m3u needs to be constructed (to retain title=). Adapt action playlist exporting to wrapper object, which preconverts plain URL lists or [rows] list, can itself call convert_playlist(), and optionalized file writing. Rewrite main save() and exportcat.save() to utilize new save_playlist(). Implement overwrite confirmation for Save-as dialog. check-in: b784d408c1 user: mario tags: action-mapfmts
02:51
Use ordered list for playlist content probing. Fix listfmt() mime to abbr conversion. Allow non-http URLs for raw() extraction. check-in: babd818a96 user: mario tags: action-mapfmts
Changes

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

1
2
3
4
5
6
7

8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25


26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

41
42
43
44
45
46
47
1
2
3
4
5
6

7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

42
43
44
45
46
47
48
49






-
+


















+
+














-
+








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


import re
import os
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
listfmt_t = {
    "audio/x-scpls":        "pls",
    "audio/x-mpegurl":      "m3u",
    "video/x-ms-asf":       "asx",
    "application/xspf+xml": "xspf",
    "*/*":                  "href",
    "*/*":                  "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
104
105
106
107
108
109
110
111

112
113

114
115
116
117
118
119

120
121
122
123
124
125
126
106
107
108
109
110
111
112

113
114

115
116
117
118
119
120

121
122
123
124
125
126
127
128







-
+

-
+





-
+







#
def help(*args):
    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
#
157
158
159
160
161
162
163
164


165
166
167
168
169
170

171
172

173
174
175
176
177
178




179
180

181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198

199
200
201
202
203
204
205
206
207
208
209
210
211
212
213

214
215
216


217
218
219
220
221

222
223
224
225

226

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
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
302
303










304
305
306

307
308
309


310
311
312
313




314
315
316
317
318
319

320
321
322
323
324
325
326
327

328
329


330
331
332
333
334
335

336
337
338
339
340



341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
159
160
161
162
163
164
165

166
167
168
169
170
171
172

173
174

175
176
177
178
179


180
181
182
183
184

185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202

203
204
205
206

207
208
209
210
211
212
213
214
215
216

217
218


219
220
221
222
223
224

225
226
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
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
302
303
304
305
306
307
308
309
310
311
312
313
314










315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336

337
338





339
340
341
342
343
344
345
346
347
348
349
350
351




352
353
354
355
356
357
358
359



360
361
362





363
364
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







-
+
+





-
+

-
+




-
-
+
+
+
+

-
+

















-
+



-










-
+

-
-
+
+




-
+




+
-
+
-
-
+
+
+
+







-
-
-
-
+
+
+
+
+
+
+

-
-
+
+
+


+



+









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

-
-
-
+
+
+

-
-
+
+

-
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

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

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


-
-
-
-
+
+
+
+
+
+


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


-
+



+
+
-
-
-
-
+
+
+
+
-




-
+

-
-
-
-
-
-

+
-
-
+
+






+


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







    # inject other meta fields
    if row:
        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
            urls = 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, quote(urls), cmd, 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.
# 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):
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 ""
    ext = ext[0] if ext else None

    # Probe MIME type and content per regex
    probe = None
    print cnt
    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,extractor in [ ("pls",extract_playlist.pls), ("asx",extract_playlist.asx), ("raw",extract_playlist.raw) ]:
    for fmt in [ "pls", "asx", "raw" ]:
        if not urls and fmt in (source, mime, probe, ext):
            urls = extractor(cnt)
            debug(fmt, extractor, urls)
            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", "any"):
    elif dest in ("srv", "href"):
        return urls
    debug( urls )

    # Otherwise convert to local file
    if local_file:
    fn = tmp_fn(cnt)
        fn = tmp_fn(cnt)
    save(urls[0], fn, dest)
    return [fn]
        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):

    # possible streaming request
    r = session.get(url, stream=True)
    if not len(r.headers):
        return ("srv", r)
    # 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", "any")
    # 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()
    @staticmethod
    def pls(text):
        return re.findall("\s*File\d*\s*=\s*(\w+://[^\s]+)", text, re.I)

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

    @staticmethod
    def asx(text):
        return re.findall("<Ref\s+href=\"(http://.+?)\"", text)
    # ASX
    def asx(self):
        return re.findall("<Ref\s+href=\"(http://.+?)\"", self.src)

    @staticmethod
    def raw(text):
    # Regexp out any URL
    def raw(self):
        debug(dbg.WARN, "Raw playlist extraction")
        return re.findall("([\w+]+://[^\s\"\'\>\#]+)", content)
        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)

# Save row(s) in one of the export formats,
# depending on file extension:
#
#  · m3u
#  · pls
#  · xspf
#  · asx
#  · json
#  · smil
#

    # 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

def save(row, fn, listformat="audio/x-scpls"):
        debug(dbg.DATA, "conversion to:", dest, " from:", self.source, "with rows=", rows)

    # output format
    format = re.findall("\.(m3u|pls|xspf|jspf|json|asx|smil)", fn)

    # modify stream url
    stream_urls = extract_urls(row["url"], listformat)
        # 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):
    def pls(self, rows):
        txt = "[playlist]\n" + "numberofentries=%s\n" % len(rows)
        for i,r in enumerate(rows):
            i = str(i + 1)
            txt += "File"+i + "=" + u + "\n"
            txt += "Title"+i + "=" + row["title"] + "\n"
            txt += "Length"+i + "=-1\n"
        txt += "Version=2\n"
            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"
        txt += "  <trackList>\n"
        for row in rows:
        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 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")
        for u in stream_urls:
            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
    def asx(self, rows):
    elif "asx" in format:
        txt = "<ASX version=\"3.0\">\n"			\
        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"
    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"

    # unknown
    else:
        return

    # write
    if txt:
        with open(fn, "wb") as f:
            f.write(txt)
    pass



# 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 channels/exportcat.py from [878cc6dbbd] to [b38cb32770].

20
21
22
23
24
25
26



27
28
29
30
31
32
33
34

35
36
37
38
39
40
41

42
43

44
45

46
47

48
49
50


51
52
53

54
55
56

57
58
59
60
61
62

20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

37
38
39
40
41
42
43

44
45
46
47
48

49


50



51
52



53



54






55







+
+
+







-
+






-
+


+

-
+
-
-
+
-
-
-
+
+
-
-
-
+
-
-
-
+
-
-
-
-
-
-
+
# a bit more deterministically.


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")])
        fn = uikit.save_file("Export category", None, "stationlist." + conf.export_format)
        with open(fn, "w") as f:
            __print__(dbg.PROC, "Exporting category to", fn)
        __print__(dbg.PROC, "Exporting category to", fn)
            f.write(self.pls(streams))
        return

        if fn:
            dest = re.findall("\.(m3u|pls|xspf|jspf|json|smil|wpl)8?$", fn)[0]
    # plain PLS file
    def pls(self, streams):
        txt = "[playlist]\n"
            action.save_playlist(source="asis", multiply=False).store(rows=streams, fn=fn, dest=dest)
        txt += "numberofentries=%s\n\n" % len(streams)
        for i,row in enumerate(streams):
            i = str(i)
        pass            
            txt += "File"+i + "=" + row["url"] + "\n"
            txt += "Title"+i + "=" + row["title"] + "\n"
            txt += "Length"+i + "=-1\n\n"
        txt += "Version=2\n"
        return txt
           

Modified st2.py from [eacd2e641f] to [ad8ec4ecd9].

353
354
355
356
357
358
359


360

361
362
363
364
365
366
367
353
354
355
356
357
358
359
360
361

362
363
364
365
366
367
368
369







+
+
-
+








    # 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","*")])
        if fn:
            source = row.get("listformat", self.channel().listformat)
            dest = re.findall("\.(m3u|pls|xspf|jspf|json|smil|wpl)8?$", fn)[0]
            action.save(row, fn)
            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"))

    # Remove a stream entry

Modified uikit.py from [0158bdc6ec] to [5abf53dbd8].

359
360
361
362
363
364
365
366

367
368
369
370
371
372
373
374
375
376
377
378
379
380



381





382
383
384
385
386
387
388
359
360
361
362
363
364
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







-
+














+
+
+
-
+
+
+
+
+







        pass



    #-- Save-As dialog
    #
    @staticmethod
    def save_file(title="Save As", parent=None, fn="", formats=[("*","*")]):
    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"), ("*","*")]):
        c = gtk.FileChooserDialog(title, parent, action=gtk.FILE_CHOOSER_ACTION_SAVE, buttons=(gtk.STOCK_CANCEL, 0, gtk.STOCK_SAVE, 1))
        # 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
        if c.run():
            fn = c.get_filename()  # return filaname
        c.destroy()
        # check if selected file exists, ask for confirmation
        if os.path.exists(fn):
            if uikit.msg("Overwrite existing file '%s' ?" % fn, gtk.MESSAGE_WARNING, gtk.BUTTONS_OK_CANCEL, yes=1):
        return fn
                return fn
            else:
                return None
        else:
            return fn
    
    
    
    # pass updates from another thread, ensures that it is called just once
    @staticmethod
    def do(lambda_func):
        gobject.idle_add(lambda: lambda_func() and False)
444
445
446
447
448
449
450
451

452
453





454


455
456
457
458
459
460
461
451
452
453
454
455
456
457

458
459
460
461
462
463
464
465

466
467
468
469
470
471
472
473
474







-
+


+
+
+
+
+
-
+
+







                where.insert(m, insert)
            else:
                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()
        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())
            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)