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

⌈⌋ branch:  streamtuner2


Diff

Differences From Artifact [51e13176b4]:

To Artifact [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
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
# Only export plugin classes and a few utility functions
__all__ = [
    "GenericChannel", "ChannelPlugin", "use_rx",
    "GenericChannel", "ChannelPlugin", "use_rx", "mime_fmt",
    "entity_decode", "strip_tags", "nl", "unhtml", "to_int"
]
__path__.insert(0, conf.dir + "/plugins")



# generic channel module                            ---------------------------------------
# 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
    # 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

    # gui + data
    # 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
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
    titles = {}   # For easier adapting of column titles in datamap

    # for empty grouping / categories
    # 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            
    # 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
    # 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
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
    # 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        
    # 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
    # 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
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 is any
    # 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)
        
    # make private copy of .datamap and modify field (title= only ATM)

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

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

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

    # 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 stream list for display (called immediataly before .columns() refreshing)
    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(),
    # Stream list cleanup - 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):
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
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
431
432







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

-
+







            if url:
                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)
    

    # 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

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



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






759
760
761
762
763
764
765
766

























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