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

⌈⌋ branch:  streamtuner2


Check-in [2335ea7a46]

Overview
Comment:Move mime_fmt() into regular function. Fix live365 ahttp feedback= bug. Regroup functions and update a few comments in channels/__init__
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA1: 2335ea7a4687038ce7421643783956dda56c263c
User & Date: mario on 2015-05-13 00:00:39
Other Links: manifest | tags
Context
2015-05-13
19:00
Make favicon redisplay work after google_find_homepage() and existing icons in cache. Shorter timeout/display for status label for google search. check-in: 56776a4e90 user: mario tags: trunk
00:00
Move mime_fmt() into regular function. Fix live365 ahttp feedback= bug. Regroup functions and update a few comments in channels/__init__ check-in: 2335ea7a46 user: mario tags: trunk
2015-05-12
22:18
Add default filters only once in GenericChannel.__init__ Allow preprocess_filter callbacks access to current channel object. (Used by filter_bitrate to recognize .audioformat if row[format] is absent.) check-in: ae2f48310a user: mario tags: trunk
Changes

Modified Makefile from [014fb4ee72] to [5237489135].

80
81
82
83
84
85
86
87
88
	$(INST)		*.desktop	-t /usr/share/applications/
	$(INST)		icon.png	/usr/share/pixmaps/streamtuner2.png
	$(INST)		help/str*2.1	-t /usr/share/man/man1/

# start locally
st2: run
run:
	python -B -3 ./st2.py -D








|

80
81
82
83
84
85
86
87
88
	$(INST)		*.desktop	-t /usr/share/applications/
	$(INST)		icon.png	/usr/share/pixmaps/streamtuner2.png
	$(INST)		help/str*2.1	-t /usr/share/man/man1/

# start locally
st2: run
run:
	python -B ./st2.py -D

Modified channels/__init__.py from [51e13176b4] to [5f327fb17e].

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
63
64
65
66
67
68
69


70
71
72
73
74
75
76
import os.path
import xml.sax.saxutils
import re
import copy
import inspect


# Only export plugin classes
__all__ = [
    "GenericChannel", "ChannelPlugin", "use_rx",
    "entity_decode", "strip_tags", "nl", "unhtml", "to_int"
]
__path__.insert(0, conf.dir + "/plugins")



# generic channel module                            ---------------------------------------
class GenericChannel(object):

    # control attributes
    meta = { "config": [] }
    base_url = ""
    listformat = "pls"
    audioformat = "audio/mpeg" # fallback value
    has_search = False

    # categories
    categories = []
    catmap = {}
    shown = None      # last selected entry in stream list, also indicator if notebook tab has been selected once / stream list of current category been displayed yet

    # gui + data
    streams = {}      # Station list dict, associates each genre to a list of stream rows


    gtk_list = None   # Gtk widget for station treeview
    gtk_cat = None    # Gtk widget for category columns
    ls = None         # ListStore for station treeview
    rowmap = None     # Preserve streams-datamap
    pix_entry = None  # ListStore entry that contains favicon

    # mapping of stream{} data into gtk treeview/treestore representation







|

|






|









|
|
|
|

|

>
>







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
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import os.path
import xml.sax.saxutils
import re
import copy
import inspect


# Only export plugin classes and a few utility functions
__all__ = [
    "GenericChannel", "ChannelPlugin", "use_rx", "mime_fmt",
    "entity_decode", "strip_tags", "nl", "unhtml", "to_int"
]
__path__.insert(0, conf.dir + "/plugins")



# Generic channel module
class GenericChannel(object):

    # control attributes
    meta = { "config": [] }
    base_url = ""
    listformat = "pls"
    audioformat = "audio/mpeg" # fallback value
    has_search = False

    # Categories
    categories = []   # Category names or subcategory groups in [] lists
    catmap = {}       # Map category names to channel/service-internal ids
    shown = None      # Just a state flag for .first_show() now

    # Stream list
    streams = {}      # Station list dict, associates each genre to a list of stream rows
    
    # Gtk widgets
    gtk_list = None   # Gtk widget for station treeview
    gtk_cat = None    # Gtk widget for category columns
    ls = None         # ListStore for station treeview
    rowmap = None     # Preserve streams-datamap
    pix_entry = None  # ListStore entry that contains favicon

    # mapping of stream{} data into gtk treeview/treestore representation
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104



105

106
107
108
109
110
111
112
113
       [False,		20,	["format",	str,	None,	{}],	],
       [False,		0,	["favourite",	bool,	None,	{}],	],
       [False,		0,	["deleted",	bool,	None,	{}],	],
       [False,		0,	["search_col",	str,	None,	{}],	],
       [False,		0,	["search_set",	bool,	None,	{}],	],
    ]
    rowmap = []   # [state,genre,title,...] field enumeration still needed separately
    titles = {}   # for easier adapting of column titles in datamap

    # for empty grouping / categories
    placeholder = [dict(genre="./.", title="Subcategory placeholder", playing="./.", url="none:", listeners=0, bitrate=0, homepage="", state="gtkfolder")]
    empty_stub = [dict(genre="./.", title="No categories found (HTTP error)", playing="Try Channel→Reload Categories later..", url="none:", listeners=0, bitrate=0, homepage="", state="gtk-stop")]
    nothing_found = [dict(genre="./.", title="No contents found on directory server", playing="Notice", listeners=0, bitrate=0, state="gtk-info")]
    
    # regex            
    rx_www_url = re.compile("""(www(\.\w+[\w-]+){2,}|(\w+[\w-]+[ ]?\.)+(com|FM|net|org|de|PL|fr|uk))""", re.I)






    #-- keep track of currently selected genre/category
    __current = None
    @property
    def current(self):
        return self.__current
    @current.setter
    def current(self, newcat):
        log.PROC("{}.current:={} ← from {}".format(self.module, newcat, [inspect.stack()[x][3] for x in range(1,4)]))







|

|




|


>
>
>

>
|







90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
       [False,		20,	["format",	str,	None,	{}],	],
       [False,		0,	["favourite",	bool,	None,	{}],	],
       [False,		0,	["deleted",	bool,	None,	{}],	],
       [False,		0,	["search_col",	str,	None,	{}],	],
       [False,		0,	["search_set",	bool,	None,	{}],	],
    ]
    rowmap = []   # [state,genre,title,...] field enumeration still needed separately
    titles = {}   # For easier adapting of column titles in datamap

    # For empty grouping / categories
    placeholder = [dict(genre="./.", title="Subcategory placeholder", playing="./.", url="none:", listeners=0, bitrate=0, homepage="", state="gtkfolder")]
    empty_stub = [dict(genre="./.", title="No categories found (HTTP error)", playing="Try Channel→Reload Categories later..", url="none:", listeners=0, bitrate=0, homepage="", state="gtk-stop")]
    nothing_found = [dict(genre="./.", title="No contents found on directory server", playing="Notice", listeners=0, bitrate=0, state="gtk-info")]
    
    # Title to homepage regex
    rx_www_url = re.compile("""(www(\.\w+[\w-]+){2,}|(\w+[\w-]+[ ]?\.)+(com|FM|net|org|de|PL|fr|uk))""", re.I)

    # Hooks for station list updating 
    prepare_filters = []      # run prior columns() display
    postprocess_filters = []  # called after update_streams()


    # Keep track of currently selected genre/category
    __current = None
    @property
    def current(self):
        return self.__current
    @current.setter
    def current(self, newcat):
        log.PROC("{}.current:={} ← from {}".format(self.module, newcat, [inspect.stack()[x][3] for x in range(1,4)]))
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
            self.update_datamap(field, title=title)
        
        # Initialize stations TreeView
        self.columns([])
        
        # add to main menu
        uikit.add_menu([parent.channelmenuitems], self.meta["title"], lambda w: parent.channel_switch_by_name(self.module) or 1)


    # Just wraps uikit.columns() to retain liststore, rowmap and pix_entry
    def columns(self, entries=None):
        self.ls, self.rowmap, self.pix_entry = uikit.columns(self.gtk_list, self.datamap, entries, show_favicons=conf.show_favicons)


    # Statusbar stub (defers to parent/main window, if in GUI mode)
    def status(self, *args, **kw):
        if self.parent: self.parent.status(*args, **kw)
        else: log.INFO("status():", *v)


        
    #--------------------- streams/model data accesss ---------------------------


    # traverse category TreeModel to set current, expand parent nodes
    def select_current(self, name):
        log.UI("reselect .current category in treelist:", name)
        model = self.gtk_cat.get_model()
           # [Gtk3] Warning: g_object_ref_sink: assertion 'object->ref_count >= 1' failed
           # ERROR:../../gi/pygobject.c:688:pygobject_register_wrapper: assertion failed: (gself->obj->ref_count >= 1)
        iter = model.get_iter_first()
        self.iter_cats(name, model, iter)

    # iterate over children to find current category        
    def iter_cats(self, name, model, iter):
        while iter:
            val = model.get_value(iter, 0)
            if val == name:
                #log.UI("FOUND CATEGORY", name, "→select")
                self.gtk_cat.get_selection().select_iter(iter)
                self.gtk_cat.set_cursor(model.get_path(iter))
                self.gtk_cat.scroll_to_cell(model.get_path(iter), None)
                return True
            if model.iter_has_child(iter):
                found = self.iter_cats(name, model, model.iter_children(iter))
                if found:
                    self.gtk_cat.expand_row(model.get_path(iter), 0)
                    return True
            iter = model.iter_next(iter)
        
    # selected category
    def currentcat(self):
        (model, iter) = self.gtk_cat.get_selection().get_selected()
        if (type(iter) == gtk.TreeIter):
            self.current = model.get_value(iter, 0)
        return self.current
        
    # Get list of stations in current category







>




>










>
|








|
















|







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
            self.update_datamap(field, title=title)
        
        # Initialize stations TreeView
        self.columns([])
        
        # add to main menu
        uikit.add_menu([parent.channelmenuitems], self.meta["title"], lambda w: parent.channel_switch_by_name(self.module) or 1)


    # Just wraps uikit.columns() to retain liststore, rowmap and pix_entry
    def columns(self, entries=None):
        self.ls, self.rowmap, self.pix_entry = uikit.columns(self.gtk_list, self.datamap, entries, show_favicons=conf.show_favicons)


    # Statusbar stub (defers to parent/main window, if in GUI mode)
    def status(self, *args, **kw):
        if self.parent: self.parent.status(*args, **kw)
        else: log.INFO("status():", *v)


        
    #--------------------- streams/model data accesss ---------------------------


    # Traverse category TreeModel to set current, expand parent nodes
    def select_current(self, name):
        log.UI("reselect .current category in treelist:", name)
        model = self.gtk_cat.get_model()
           # [Gtk3] Warning: g_object_ref_sink: assertion 'object->ref_count >= 1' failed
           # ERROR:../../gi/pygobject.c:688:pygobject_register_wrapper: assertion failed: (gself->obj->ref_count >= 1)
        iter = model.get_iter_first()
        self.iter_cats(name, model, iter)

    # Iterate over children to find current category
    def iter_cats(self, name, model, iter):
        while iter:
            val = model.get_value(iter, 0)
            if val == name:
                #log.UI("FOUND CATEGORY", name, "→select")
                self.gtk_cat.get_selection().select_iter(iter)
                self.gtk_cat.set_cursor(model.get_path(iter))
                self.gtk_cat.scroll_to_cell(model.get_path(iter), None)
                return True
            if model.iter_has_child(iter):
                found = self.iter_cats(name, model, model.iter_children(iter))
                if found:
                    self.gtk_cat.expand_row(model.get_path(iter), 0)
                    return True
            iter = model.iter_next(iter)
        
    # Selected category (current state from Gtk TreeModel)
    def currentcat(self):
        (model, iter) = self.gtk_cat.get_selection().get_selected()
        if (type(iter) == gtk.TreeIter):
            self.current = model.get_value(iter, 0)
        return self.current
        
    # Get list of stations in current category
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
        except Exception as e:
            log.ERR_UIKIT("Couldn't set row_icon()", e)

    

    #------------------------ base implementations -----------------------------


    # read previous channel/stream data, if there is any
    def cache(self):
        # stream list
        cache = conf.load("cache/" + self.module)
        if (cache):
            self.streams = cache
        # categories
        cache = conf.load("cache/categories_" + self.module)
        if (cache):
            self.categories = cache
        # catmap (optional)
        cache = conf.load("cache/catmap_" + self.module)
        if (cache):
            self.catmap = cache
        pass




        

    # make private copy of .datamap and modify field (title= only ATM)
    def update_datamap(self, search="name", title=None):
        if self.datamap == GenericChannel.datamap:
            self.datamap = copy.deepcopy(self.datamap)
        for i,row in enumerate(self.datamap):
            if row[2][0] == search:
                row[0] = title














    # Called on switching genre/category.
    # Either fetches new stream data, or displays list from cache.
    def load(self, category, force=False, y=None):

        # called to early
        if not category:
            log.ERR("load(None)")
            return







>
|















>
>
>

>
|








>
>
>
>
>
>
>
>
>
>
>
>
|







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
        except Exception as e:
            log.ERR_UIKIT("Couldn't set row_icon()", e)

    

    #------------------------ base implementations -----------------------------


    # Read previous channel/stream data, if there's any
    def cache(self):
        # stream list
        cache = conf.load("cache/" + self.module)
        if (cache):
            self.streams = cache
        # categories
        cache = conf.load("cache/categories_" + self.module)
        if (cache):
            self.categories = cache
        # catmap (optional)
        cache = conf.load("cache/catmap_" + self.module)
        if (cache):
            self.catmap = cache
        pass

    # Store current streams data
    def save(self):
        conf.save("cache/" + self.module, self.streams, gz=1)
        
        
    # Create private copy of .datamap and modify entries (title= rewrites)
    def update_datamap(self, search="name", title=None):
        if self.datamap == GenericChannel.datamap:
            self.datamap = copy.deepcopy(self.datamap)
        for i,row in enumerate(self.datamap):
            if row[2][0] == search:
                row[0] = title


    # Reload current station list
    def reload(self):
        self.load(self.current, force=1)
    def switch(self):
        self.load(self.current, force=0)
    
    # Update streams pane if currently selected (used by bookmarks.links channel)
    def reload_if_current(self, category):
        if self.current == category:
            self.reload()


    # Called on switching genre/category, or loading a genre for the first time.
    # Either fetches new stream data, or displays list from cache.
    def load(self, category, force=False, y=None):

        # called to early
        if not category:
            log.ERR("load(None)")
            return
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
                uikit.do(self.gtk_list.scroll_to_point, 0, y + 1)   # scroll to previous position, +1 px, because
                # somehow Gtk.TreeView else stumbles over itself when scrolling to the same position the 2nd time

        # unset statusbar
        self.status()

        
    # store current streams data
    def save(self):
        conf.save("cache/" + self.module, self.streams, gz=1)


    # called occasionally while retrieving and parsing
    def update_streams_partially_done(self, entries):
        if gtk_ver == 3 and not conf.nothreads:
            pass
        else:  # kills Gtk3 too easily
            uikit.do(self.columns, entries)

        
    # finds differences in new/old streamlist, marks deleted with flag
    def deleted_streams(self, new, old):
        diff = []
        new = [row.get("url","http://example.com/") for row in new]
        for row in old:
            if ("url" in row and (row.get("url") not in new)):
                row["deleted"] = 1
                diff.append(row)
        return diff

    
    # Prepare stream list for display
    prepare_filters = []
    def prepare(self, streams):
        for f in self.prepare_filters:
            map(f, streams)
        return streams

    # state icon: bookmark star, or deleted mark
    def prepare_filter_icons(self, row):
        if conf.show_bookmarks:# and "bookmarks" in self.parent.channels:
            row["favourite"] = self.parent.bookmarks.is_in(row.get("url", "file:///tmp/none"))
        if not row.get("state"):
            if row.get("favourite"):
                row["state"] = gtk.STOCK_ABOUT
            if row.get("deleted"):
                row["state"] = gtk.STOCK_DELETE


    # Stream list preparations - invoked directly after reload(),
    # callbacks can remove entries, or just update fields.
    postprocess_filters = []
    def postprocess(self, streams):
        for f in self.postprocess_filters:
            streams = [row for row in streams if f(row, self)]
        return streams

    # Filter entries without title or url
    def postprocess_filter_required_fields(self, row, channel):







<
<
<
|
<
<





<
<
<
<
<
<
<
<
<
<
<
<
<
|
<
















|

<







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
                uikit.do(self.gtk_list.scroll_to_point, 0, y + 1)   # scroll to previous position, +1 px, because
                # somehow Gtk.TreeView else stumbles over itself when scrolling to the same position the 2nd time

        # unset statusbar
        self.status()

        



    # Called occasionally (by some plugins) while updating station list


    def update_streams_partially_done(self, entries):
        if gtk_ver == 3 and not conf.nothreads:
            pass
        else:  # kills Gtk3 too easily
            uikit.do(self.columns, entries)













    # Prepare stream list for display (called immediataly before .columns() refreshing)

    def prepare(self, streams):
        for f in self.prepare_filters:
            map(f, streams)
        return streams

    # state icon: bookmark star, or deleted mark
    def prepare_filter_icons(self, row):
        if conf.show_bookmarks:# and "bookmarks" in self.parent.channels:
            row["favourite"] = self.parent.bookmarks.is_in(row.get("url", "file:///tmp/none"))
        if not row.get("state"):
            if row.get("favourite"):
                row["state"] = gtk.STOCK_ABOUT
            if row.get("deleted"):
                row["state"] = gtk.STOCK_DELETE


    # Stream list cleanup - invoked directly after reload(),
    # callbacks can remove entries, or just update fields.

    def postprocess(self, streams):
        for f in self.postprocess_filters:
            streams = [row for row in streams if f(row, self)]
        return streams

    # Filter entries without title or url
    def postprocess_filter_required_fields(self, row, channel):
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
                url = url.group(0).lower().replace(" ", "")
                url = (url if url.find("www.") == 0 else "www."+url)
                row["homepage"] = ahttp.fix_url(url)
                print row
        return True

        

    # reload current stream from web directory
    def reload(self):

        self.load(self.current, force=1)

    def switch(self):

        self.load(self.current, force=0)
    
    # update streams pane if currently selected (used by bookmarks.links channel)
    def reload_if_current(self, category):
        if self.current == category:
            self.reload()
    
        
    # display .current category, once notebook/channel tab is first opened
    def first_show(self):

        # Already processed
        if (self.shown == 55555):
            return
        log.PROC(self.module, "→ first_show()", ", current=", self.current, ", categories=", len(self.categories))
    







|
|
|
>
|
>
|
>
|

<
<
<
<


|







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
431
432
                url = url.group(0).lower().replace(" ", "")
                url = (url if url.find("www.") == 0 else "www."+url)
                row["homepage"] = ahttp.fix_url(url)
                print row
        return True

        
    # Finds differences in new/old streamlist, marks deleted with flag
    def deleted_streams(self, new, old):
        diff = []
        new = [row.get("url","http://example.com/") for row in new]
        for row in old:
            if ("url" in row and (row.get("url") not in new)):
                row["deleted"] = 1
                diff.append(row)
        return diff
    




    
        
    # Display .current category, once notebook/channel tab is first opened
    def first_show(self):

        # Already processed
        if (self.shown == 55555):
            return
        log.PROC(self.module, "→ first_show()", ", current=", self.current, ", categories=", len(self.categories))
    
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
            return d
        elif isinstance(d, (dict)):
            return self.str_from_struct(d.keys()) or self.str_from_struct(d.values())
        elif isinstance(d, (list, tuple)):
            return d[0] if len(d) else None


    # update categories, save, and display                
    def reload_categories(self):
    
        # get data and save
        self.update_categories()
        if self.categories:
            conf.save("cache/categories_"+self.module, self.categories)
        if self.catmap:
            conf.save("cache/catmap_" + self.module, self.catmap);

        # display outside of this non-main thread
        uikit.do(self.display_categories)


    # insert content into gtk category list
    def display_categories(self):
        log.UI("{}.display_categories(), uikit.tree(#{}), expand_all(#<20), select_current(={})".format(self.module, len(self.categories), self.current))
    
        # rebuild gtk.TreeView
        uikit.tree(self.gtk_cat, self.categories, title="Category", icon=gtk.STOCK_OPEN)

        # if it's a short list of categories, there's probably subfolders







|













|







463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
            return d
        elif isinstance(d, (dict)):
            return self.str_from_struct(d.keys()) or self.str_from_struct(d.values())
        elif isinstance(d, (list, tuple)):
            return d[0] if len(d) else None


    # Update categories, save, and display                
    def reload_categories(self):
    
        # get data and save
        self.update_categories()
        if self.categories:
            conf.save("cache/categories_"+self.module, self.categories)
        if self.catmap:
            conf.save("cache/catmap_" + self.module, self.catmap);

        # display outside of this non-main thread
        uikit.do(self.display_categories)


    # Refresh category treeview
    def display_categories(self):
        log.UI("{}.display_categories(), uikit.tree(#{}), expand_all(#<20), select_current(={})".format(self.module, len(self.categories), self.current))
    
        # rebuild gtk.TreeView
        uikit.tree(self.gtk_cat, self.categories, title="Category", icon=gtk.STOCK_OPEN)

        # if it's a short list of categories, there's probably subfolders
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
            audioformat = row.get("format", self.audioformat)
            listformat = row.get("listformat", self.listformat)
            action.record(row, audioformat, listformat)
        return row



    #--------------------------- utility functions -----------------------

    

        
    # convert audio format nick/shortnames to mime types, e.g. "OGG" to "audio/ogg"
    def mime_fmt(self, s):
        # clean string
        s = s.lower().strip()
        # rename
        map = {
            "audio/mp3":"audio/mpeg",  # Note the real mime type is /mpeg, but /mp3 is more understandable in the GUI
            "ogg":"ogg", "ogm":"ogg", "xiph":"ogg", "vorbis":"ogg", "vnd.xiph.vorbis":"ogg",
            "mp3":"mpeg", "mp":"mpeg", "mp2":"mpeg", "mpc":"mpeg", "mps":"mpeg",
            "aac+":"aac", "aacp":"aac",
            "realaudio":"x-pn-realaudio", "real":"x-pn-realaudio", "ra":"x-pn-realaudio", "ram":"x-pn-realaudio", "rm":"x-pn-realaudio",
            # yes, we do video
            "flv":"video/flv", "mp4":"video/mp4",
        }
        #map.update(action.listfmt_t)   # list type formats (.m3u .pls and .xspf)
        if map.get(s):
            s = map[s]
        # add prefix:
        if s.find("/") < 1:
            s = "audio/" + s
        #
        return s
    













<



<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<







546
547
548
549
550
551
552

553
554
555























556
557
558
559
560
561
562
            audioformat = row.get("format", self.audioformat)
            listformat = row.get("listformat", self.listformat)
            action.record(row, audioformat, listformat)
        return row





    
























    






759
760
761
762
763
764
765
766

























def nl(str):
    return rx_spc.sub(" ", str).strip()

# Combine html tag, escapes and whitespace cleanup
def unhtml(str):
    return nl(entity_decode(strip_tags(str)))



































>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
def nl(str):
    return rx_spc.sub(" ", str).strip()

# Combine html tag, escapes and whitespace cleanup
def unhtml(str):
    return nl(entity_decode(strip_tags(str)))


# Convert audio format nick/shortnames to mime types, e.g. "OGG" to "audio/ogg"
# (only used by few plugin meanwhile, could be merged with action. module now)
def mime_fmt(s):
    # clean string
    s = s.lower().strip()
    # rename
    map = {
        "audio/mp3":"audio/mpeg",  # Note the real mime type is /mpeg, but /mp3 is more understandable in the GUI
        "ogg":"ogg", "ogm":"ogg", "xiph":"ogg", "vorbis":"ogg", "vnd.xiph.vorbis":"ogg",
        "mp3":"mpeg", "mp":"mpeg", "mp2":"mpeg", "mpc":"mpeg", "mps":"mpeg",
        "aac+":"aac", "aacp":"aac",
        "realaudio":"x-pn-realaudio", "real":"x-pn-realaudio", "ra":"x-pn-realaudio", "ram":"x-pn-realaudio", "rm":"x-pn-realaudio",
        # yes, we do video
        "flv":"video/flv", "mp4":"video/mp4",
    }
    #map.update(action.listfmt_t)   # list type formats (.m3u .pls and .xspf)
    if map.get(s):
        s = map[s]
    # add prefix:
    if s.find("/") < 1:
        s = "audio/" + s
    #
    return s


Modified channels/favicon.py from [bdefdbfe53] to [7782a3f227].

149
150
151
152
153
154
155

156
157
158
159
160
161
162
                        ok = fav_google_ico2png(row["homepage"], favicon_fn)
                    else:
                        ok = fav_from_homepage(row["homepage"], favicon_fn)

                # Update TreeView
                if ok:
                    self.update_pixstore(row, pixstore, i)


            # catch HTTP Timeouts etc., so update_all() row processing just continues..
            except Exception as e:
                log.WARN("favicon.update_rows():", e)
        pass









>







149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
                        ok = fav_google_ico2png(row["homepage"], favicon_fn)
                    else:
                        ok = fav_from_homepage(row["homepage"], favicon_fn)

                # Update TreeView
                if ok:
                    self.update_pixstore(row, pixstore, i)
                    row["favicon"] = favicon_fn

            # catch HTTP Timeouts etc., so update_all() row processing just continues..
            except Exception as e:
                log.WARN("favicon.update_rows():", e)
        pass


Modified channels/live365.py from [f2cc1bc432] to [e4e6ca4688].

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

# api: streamtunter2
# title: Live365
# description: Around 5000 categorized internet radio streams, some paid ad-free ones.
# version: 0.3
# type: channel
# category: radio
# url: http://www.live365.com/
# config: -
# priority: optional
# png:
#   iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAzlJREFUOI1lk0tMXGUUx3/fvTP3zgyPGR5SKIQODdDGEtoCJdFopA9SJTFMjInGaJDEulWTGg2JApqoO+PShaAmdVEXQAxoCtgKNjYFWrQgMD6KDAylkde8Lnfunfu5IJii
#   Z3POSc7//M/i/AT/ichrbUE1NyckfN5WV0kpUjpYS4s4sdhAZnOrP9jTv/jgvHiw+ftSb5f2UEGnfuQYCIG9fg8pJa5AIdJIEf9hhNTkze5gz9ddexrXXrE1+E2v7/TZl9lcx/hxBCu5DUikzCDTFjiSrMYnyGp8vPOuaR+quNTXDqACRLve7cp5suV1YaSwpyewYts4
#   dgbhcuGprkFoOnZ0BTM8j9A85J576sQr96Pik4Xfr6l/nj8b9D7yaJ8rz8/O2FWctEXGTKP6/eQ924ZeUY21sowViSDTaaylJbSKSrCsplfNnS9cMiNC3ppayEhyX2wHAdt9l8k+cx6h68SGhzCmJ9CP1hAIPYdjGGQ21lF82cSHR0MuxzBbFU0js7FOOhzGU1tL9ulm
#   3IUHAMhtbsFbc5yd2TsAJEavIC0b78k6sGWrohUfxJybJzE8gpoXAMBeWwPAWAqTvD6Ou6QULXgYgOwzzRi3bpMcv47n4WMo6B4yWzHM+TDGz7suyN2k5x9Er6zCSSSIDQ6x+tabKD4f7tIynHgcK3oPZf3mDfSqStSCQnZmf91V2hmiFy9iLixgLa+wMTmIOTeHq6gY
#   AOuvCHp1NcmFMK6ERx0wEvGmou5OtLJSrJUV7n/wEWY4TOLKCHsnlfV8hq+hgc3eL4l9O4y7sYGErgyIofqaYEHdqbvljzWx+v6HaI5ERSCE2Pen0pE4Wh22NUXxOx1Exq+yOTVRobRMzSxGZqa6zUI/3uefYcOrsq0JEqokhcRAkhSSuBti+gw5F9qxSw+wuRrpbp6e
#   XVQBLi+vXatfngseeuGlE/5TjUQX7pCSNoZbkNIUUrqCWRCg8u0OKC9h/L2Oz0OjP73xP5g+PXeyq+R4Q+eR5qfJ8maz/ds8AIGqoySNBMs3xvhjbKT7wvfT/8K0bwHAxy31QcWRIaA1v/ww3kA+0V8mAQb8+UX9bV99tw/nfwAe2WTAAcikxQAAAABJRU5ErkJggg==
# 
# Live365 lists around 5000 radio stations. Some are paid
# entries and require a logon. This plugins tries to filter
# thoise out.











# streamtuner2 modules
from config import *
from uikit import *
import ahttp
from channels import *




|















|
>
>
>
>
>
>
>
>
>







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

# api: streamtunter2
# title: Live365
# description: Around 5000 categorized internet radio streams, some paid ad-free ones.
# version: 0.4
# type: channel
# category: radio
# url: http://www.live365.com/
# config: -
# priority: optional
# png:
#   iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAzlJREFUOI1lk0tMXGUUx3/fvTP3zgyPGR5SKIQODdDGEtoCJdFopA9SJTFMjInGaJDEulWTGg2JApqoO+PShaAmdVEXQAxoCtgKNjYFWrQgMD6KDAylkde8Lnfunfu5IJii
#   Z3POSc7//M/i/AT/ichrbUE1NyckfN5WV0kpUjpYS4s4sdhAZnOrP9jTv/jgvHiw+ftSb5f2UEGnfuQYCIG9fg8pJa5AIdJIEf9hhNTkze5gz9ddexrXXrE1+E2v7/TZl9lcx/hxBCu5DUikzCDTFjiSrMYnyGp8vPOuaR+quNTXDqACRLve7cp5suV1YaSwpyewYts4
#   dgbhcuGprkFoOnZ0BTM8j9A85J576sQr96Pik4Xfr6l/nj8b9D7yaJ8rz8/O2FWctEXGTKP6/eQ924ZeUY21sowViSDTaaylJbSKSrCsplfNnS9cMiNC3ppayEhyX2wHAdt9l8k+cx6h68SGhzCmJ9CP1hAIPYdjGGQ21lF82cSHR0MuxzBbFU0js7FOOhzGU1tL9ulm
#   3IUHAMhtbsFbc5yd2TsAJEavIC0b78k6sGWrohUfxJybJzE8gpoXAMBeWwPAWAqTvD6Ou6QULXgYgOwzzRi3bpMcv47n4WMo6B4yWzHM+TDGz7suyN2k5x9Er6zCSSSIDQ6x+tabKD4f7tIynHgcK3oPZf3mDfSqStSCQnZmf91V2hmiFy9iLixgLa+wMTmIOTeHq6gY
#   AOuvCHp1NcmFMK6ERx0wEvGmou5OtLJSrJUV7n/wEWY4TOLKCHsnlfV8hq+hgc3eL4l9O4y7sYGErgyIofqaYEHdqbvljzWx+v6HaI5ERSCE2Pen0pE4Wh22NUXxOx1Exq+yOTVRobRMzSxGZqa6zUI/3uefYcOrsq0JEqokhcRAkhSSuBti+gw5F9qxSw+wuRrpbp6e
#   XVQBLi+vXatfngseeuGlE/5TjUQX7pCSNoZbkNIUUrqCWRCg8u0OKC9h/L2Oz0OjP73xP5g+PXeyq+R4Q+eR5qfJ8maz/ds8AIGqoySNBMs3xvhjbKT7wvfT/8K0bwHAxy31QcWRIaA1v/ww3kA+0V8mAQb8+UX9bV99tw/nfwAe2WTAAcikxQAAAABJRU5ErkJggg==
# 
# Live365 lists around 5000 radio stations. Some are paid
# entries and require a logon. This plugins tries to filter
# those out.


# Non-tracking cookies:
#
# box_mc |bitrate=256|ls=3|hasFlash=Y|ab=viphp:G3|POC=8|mvd=2|SUPMsg=| (desktop player off)
#        |ab=viphp:G3|POC=10|bitrate=256|mvd=2|ls=3|hasFlash=Y|SUPMsg=| (desktop player on)
# pg_mc  |hp=A|darg=|curl=|curlt=Live365 - My Live365 - Listen Settings|
# session_mc |plr=N|site=web|
# player_mc |Vol=50|


# streamtuner2 modules
from config import *
from uikit import *
import ahttp
from channels import *
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
    def update_categories(self):
        pass


    # extract stream infos
    def update_streams(self, cat):

        # Retrieve genere index pages    
        html = ""
        for i in [1, 17, 33, 49]:
            url = "http://www.live365.com/cgi-bin/directory.cgi?first=%i&site=web&mode=3&genre=%s&charset=UTF-8&target=content" % (i, cat.lower())
            html += ahttp.get(url, feedback=self.parent.status)
        
        # Extract from JavaScript       
        rx = re.compile(r"""
                stn.set\(   " (\w+) ", \s+  " ((?:[^"\\]+|\\.)*) "\);  \s+
            """, re.X|re.I|re.S|re.M)

        # Group entries before adding them







|



|







86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
    def update_categories(self):
        pass


    # extract stream infos
    def update_streams(self, cat):

        # Retrieve genre index pages    
        html = ""
        for i in [1, 17, 33, 49]:
            url = "http://www.live365.com/cgi-bin/directory.cgi?first=%i&site=web&mode=3&genre=%s&charset=UTF-8&target=content" % (i, cat.lower())
            html += ahttp.get(url)
        
        # Extract from JavaScript       
        rx = re.compile(r"""
                stn.set\(   " (\w+) ", \s+  " ((?:[^"\\]+|\\.)*) "\);  \s+
            """, re.X|re.I|re.S|re.M)

        # Group entries before adding them

Modified channels/xiph.py from [8fa7c91382] to [6ec5e242b7].

146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
      else:
          yp = "<none/>"
      self.status("Yes, XML parsing isn't much faster either.", timeout=20)
      for entry in xml.dom.minidom.parseString(yp).getElementsByTagName("entry"):
          buffy.append({
              "title": x(entry, "server_name"),
              "url": x(entry, "listen_url"),
              "format": self.mime_fmt(x(entry, "server_type")[6:]),
              "bitrate": bitrate(x(entry, "bitrate")),
              "channels": x(entry, "channels"),
              "samplerate": x(entry, "samplerate"),
              "genre": x(entry, "genre"),
              "playing": x(entry, "current_song"),
              "listeners": 0,
              "max": 0,







|







146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
      else:
          yp = "<none/>"
      self.status("Yes, XML parsing isn't much faster either.", timeout=20)
      for entry in xml.dom.minidom.parseString(yp).getElementsByTagName("entry"):
          buffy.append({
              "title": x(entry, "server_name"),
              "url": x(entry, "listen_url"),
              "format": mime_fmt(x(entry, "server_type")[6:]),
              "bitrate": bitrate(x(entry, "bitrate")),
              "channels": x(entry, "channels"),
              "samplerate": x(entry, "samplerate"),
              "genre": x(entry, "genre"),
              "playing": x(entry, "current_song"),
              "listeners": 0,
              "max": 0,
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
              title = unhtml(title),
              homepage = ahttp.fix_url(homepage),
              playing = unhtml(playing),
              url = "http://dir.xiph.org{}".format(url),
              listformat = "xspf",
              listeners = int(listeners),
              bitrate = bitrate(bits),
              format = self.mime_fmt(guess_format(fmt)),
          ))
      return r



  # Static list of categories
  genres = [







|







227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
              title = unhtml(title),
              homepage = ahttp.fix_url(homepage),
              playing = unhtml(playing),
              url = "http://dir.xiph.org{}".format(url),
              listformat = "xspf",
              listeners = int(listeners),
              bitrate = bitrate(bits),
              format = mime_fmt(guess_format(fmt)),
          ))
      return r



  # Static list of categories
  genres = [

Modified config.py from [f7e98ed69f] to [e3065a7831].

75
76
77
78
79
80
81
82
83
84
85
86
87
88
89

    args = {}

    # start
    def __init__(self):
    
        # object==dict means conf.var is conf["var"]
        self.__dict__ = self  # let's pray this won't leak memory due to recursion issues

        # prepare
        self.defaults()
        self.xdg()
        
        # runtime
        self.share = os.path.dirname(os.path.abspath(__file__))







|







75
76
77
78
79
80
81
82
83
84
85
86
87
88
89

    args = {}

    # start
    def __init__(self):
    
        # object==dict means conf.var is conf["var"]
        self.__dict__ = self

        # prepare
        self.defaults()
        self.xdg()
        
        # runtime
        self.share = os.path.dirname(os.path.abspath(__file__))

Modified contrib/file.py from [a796fcaf0b] to [c5a4a98a96].

173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
    def file_entry(self, fn, dir):
        # basic data
        meta = {
            "title": fn,
            "filename": fn,
            "url": "file://" + dir + "/" + fn,
            "genre": "",
            "format": self.mime_fmt(fn[-3:]),
            "editable": True,
        }
        # add ID3
        meta.update(mutagen_postprocess(get_meta(dir + "/" + fn) or {}))
        return meta
        
    # check fn for .ext







|







173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
    def file_entry(self, fn, dir):
        # basic data
        meta = {
            "title": fn,
            "filename": fn,
            "url": "file://" + dir + "/" + fn,
            "genre": "",
            "format": mime_fmt(fn[-3:]),
            "editable": True,
        }
        # add ID3
        meta.update(mutagen_postprocess(get_meta(dir + "/" + fn) or {}))
        return meta
        
    # check fn for .ext

Modified contrib/listenlive.py from [98eef5d460] to [c1d1551be3].

75
76
77
78
79
80
81
82
83
84
85
86
                r.append(dict(
                    homepage = homepage,
                    playing = location,
                    title = unhtml(title),
                    url = url,
                    genre = genre[0] if genre else cat,
                    bitrate = int(bitrate),
                    format = self.mime_fmt(format),
                ))
        return r
      








|




75
76
77
78
79
80
81
82
83
84
85
86
                r.append(dict(
                    homepage = homepage,
                    playing = location,
                    title = unhtml(title),
                    url = url,
                    genre = genre[0] if genre else cat,
                    bitrate = int(bitrate),
                    format = mime_fmt(format),
                ))
        return r
      

Modified contrib/punkcast.py from [f6c4524b4e] to [f8b9acfb69].

79
80
81
82
83
84
85
86
87
88
89
90
91
        rx_sound = re.compile("""(http://[^"<>]+[.](mp3|ogg|m3u|pls|ram))""")
        html = ahttp.get(row["homepage"])
        
        # look up ANY audio url
        for uu in rx_sound.findall(html):
            log.DATA( uu )
            (url, fmt) = uu
            action.play(url, self.mime_fmt(fmt), "srv")
            return
        
        # or just open webpage
        action.browser(row["homepage"])








|





79
80
81
82
83
84
85
86
87
88
89
90
91
        rx_sound = re.compile("""(http://[^"<>]+[.](mp3|ogg|m3u|pls|ram))""")
        html = ahttp.get(row["homepage"])
        
        # look up ANY audio url
        for uu in rx_sound.findall(html):
            log.DATA( uu )
            (url, fmt) = uu
            action.play(url, mime_fmt(fmt), "srv")
            return
        
        # or just open webpage
        action.browser(row["homepage"])