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

⌈⌋ branch:  streamtuner2


Diff

Differences From Artifact [08c3ab8bab]:

To Artifact [f719a01f46]:


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


-
+



-
+












-
-

-
-
+
+
+

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






# encoding: UTF-8
# api: streamtuner2
# type: base
# type: class
# category: ui
# title: Channel plugins
# description: Base implementation for channels and feature plugins
# version: 1.2
# version: 1.3
# license: public domain
# author: mario
# url: http://fossil.include-once.org/streamtuner2/
# pack:
#    bookmarks.py configwin.py streamedit.py history.py search.py links.py 
#    icast.py internet_radio.py itunes.py jamendo.py live365.py global_key.py
#    modarchive.py myoggradio.py punkcast.py radiobrowser.py radiotray.py
#    shoutcast.py surfmusik.py timer.py tunein.py xiph.py youtube.py
#    exportcat.py useragentswitcher.py
# config: -
# priority: core
#
#
# Just exports GenericChannel and ChannelPlugin.
# GenericChannel implements the basic GUI functions and defines
# the default channel data structure. It implements base and
# fallback logic for all other channel implementations.
# the default channel data structure. It implements fallback logic
# for all other channel implementations. Only `bookmarks` uses it
# directly.
#
# Built-in channels derive directly from generic. Additional
# channels don't have a pre-defined Notebook tab in the glade
# file. They derive from the ChannelPlugins class instead, which
# adds the required gtk Widgets manually.
#
# All other plugins don't have a pre-defined Notebook tab in the
# GtkBuilder description. They derive from ChannelPlugins therefore,
# which constructs and registers the required gtk widgets manually.

# Makes module scanning available.  Checks for conf.share, so
# should pick up /usr/share/streamtuner2/channels/*.py plugins
# as well as local ./channels/*.* - Needs rework for in-zip
# searching.
#

import gtk
from uikit import uikit
from config import *
import ahttp as http
import action
import favicon
55
56
57
58
59
60
61

62
63
64
65
66
67
68
69
70
71
72

73
74
75
76
77

78
79
80


81
82
83
84
85
86
87
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







+




-





-
+
-



-
+
-
-
-
+
+








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

    # desc
    meta = { "config": [] }
    config = []
    homepage = "http://fossil.include-once.org/streamtuner2/"
    base_url = ""
    listformat = "pls"
    audioformat = "audio/mpeg" # fallback value
    config = []
    has_search = False

    # categories
    categories = ["empty", ]
    catmap = {}
    current = ""
    current = None
    default = "empty"
    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 = {}      #meta information dicts
    streams = {}      # Station list dict, associates each genre to a list of stream rows
    liststore = {}    #gtk data structure
    gtk_list = None   #gtk widget
    gtk_cat = None    #gtk widget
    gtk_list = None   # Gtk widget for station treeview
    gtk_cat = None    # Gtk widget for category columns

    # mapping of stream{} data into gtk treeview/treestore representation
    datamap = [
       # coltitle   width	[ datasrc key, type, renderer, attrs ]	[cellrenderer2], ...
       ["",		20,	["state",	str,  "pixbuf",	{}],	],
       ["Genre",	65,	['genre',	str,	"t",	{}],	],
       ["Station Title",275,["title",	str,    "text",	{"strikethrough":11, "cell-background":12, "cell-background-set":13}],  ["favicon", gtk.gdk.Pixbuf, "pixbuf", {}], ],
99
100
101
102
103
104
105

106
107
108
109
110
111
112
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104







+






    ]
    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="gtk-folder")]
    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)



    #--------------------------- initialization --------------------------------
122
123
124
125
126
127
128
129
130
131
132

133
134
135
136




137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168



169
170
171
172
173
174
175
114
115
116
117
118
119
120




121
122
123
124

125
126
127
128
129
130
131
132

133
134
135
136
137
138
139
140

141
142
143
144
145
146
147
148
149
150
151







152
153
154
155
156
157
158
159
160
161







-
-
-
-
+



-
+
+
+
+




-








-











-
-
-
-
-
-
-
+
+
+






        self.meta = plugin_meta(src = inspect.getcomments(inspect.getmodule(self)))
        self.config = self.meta.get("config", [])
        self.title = self.meta.get("title", self.module)

        # add default options values to config.conf.* dict
        conf.add_plugin_defaults(self.meta, self.module)
        
        # stub for ST2 main window / dispatcher
        self.parent = stub_parent(None)

        # only if streamtuner2 is run in graphical mode        
        # Only if streamtuner2 is run in graphical mode        
        if (parent):
            self.cache()
            self.gui(parent)
        pass

        # Stub for ST2 main window / dispatcher
        else:
            self.parent = stub_parent(None)

        
    # initialize Gtk widgets / data objects
    def gui(self, parent):
        #print(self.module + ".gui()")

        # save reference to main window/glade API
        self.parent = parent
        self.gtk_list = parent.get_widget(self.module+"_list")
        self.gtk_cat = parent.get_widget(self.module+"_cat")
        
        # category tree
        self.display_categories()
        #uikit.tree(self.gtk_cat, self.categories, title="Category", icon=gtk.STOCK_OPEN);
        
        # update column names
        for field,title in list(self.titles.items()):
            self.update_datamap(field, title=title)
        
        # prepare stream list
        if (not self.rowmap):
            for row in self.datamap:
                for x in range(2, len(row)):
                    self.rowmap.append(row[x][0])

        # load default category
        #if (self.current):
        #    self.load(self.current)
        #else:
        if True:
            uikit.columns(self.gtk_list, self.datamap, [])
            
        # Initialize stations TreeView
        uikit.columns(self.gtk_list, self.datamap, [])
        
        # add to main menu
        uikit.add_menu([parent.channelmenuitems], self.meta["title"], lambda w: parent.channel_switch_by_name(self.module) or 1)


    # Statusbar stub (defers to parent/main window, if in GUI mode)
    def status(self, *v):
        if self.parent: self.parent.status(*v)
245
246
247
248
249
250
251
252
253


254
255
256
257
258
259
260
231
232
233
234
235
236
237


238
239
240
241
242
243
244
245
246







-
-
+
+






    # load data,
    # update treeview content
    def load(self, category, force=False):

        # get data from cache or download
        if (force or not category in self.streams):
            __print__(dbg.PROC, "load", "update_streams")
            self.parent.status("Updating streams...")
            self.parent.status(-0.1)
            self.status("Updating streams...")
            self.status(-0.1)
            if category == "empty":
                new_streams = self.empty_stub
            else:
                new_streams = self.update_streams(category)
  
            if new_streams:
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
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







-
-
+
+



-








-
-
+
+






  
                # invalidate gtk list cache
                #if (self.liststore.has_key(category)):
                #    del self.liststore[category]
  
            else:
                # parse error
                self.parent.status("category parsed empty.")
                self.streams[category] = [{"title":"no contents found on directory server","bitrate":0,"max":0,"listeners":0,"playing":"error","favourite":0,"deleted":0}]
                self.status("Category parsed empty.")
                self.streams[category] = self.nothing_found
                __print__(dbg.INFO, "Oooops, parser returned nothing for category " + category)
                
        # assign to treeview model
        #self.streams[self.default] = []
        #if (self.liststore.has_key(category)):  # was already loded before
        #    self.gtk_list.set_model(self.liststore[category])
        #else:   # currently list is new, had not been converted to gtk array before
        #    self.liststore[category] = \
        uikit.do(lambda:uikit.columns(self.gtk_list, self.datamap, self.prepare(self.streams[category])))

        # set pointer
        self.current = category
        self.parent.status("")
        self.parent.status(1.0)
        self.status("")
        self.status(1.0)
        pass
        
    # store current streams data
    def save(self):
        conf.save("cache/" + self.module, self.streams, gz=1)

404
405
406
407
408
409
410

411
412
413
414
415
416







417
418
419
420
421
422
423






424
425
426
427
428


429
430
431
432
433








434
435
436
437
438
439
440
441


















442
443
444
445
446
447
448
389
390
391
392
393
394
395
396






397
398
399
400
401
402
403







404
405
406
407
408
409





410
411





412
413
414
415
416
417
418
419








420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444







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






        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):
            __print__(dbg.PROC, self.module+".first_show()")
        
            # if category tree is empty, initialize it
            if not self.categories:
                __print__(dbg.PROC, self.module+"first_show: reload_categories");
        if (self.shown == 55555):
            return
        __print__(dbg.PROC, self.module, "→ first_show()", ", current=", self.current, ", categories=", len(self.categories))
    
        # if category tree is empty, initialize it
        if not self.categories:
            __print__(dbg.PROC, self.module, "→ first_show() → reload_categories()");
                #self.parent.thread(self.reload_categories)
                try:
                    self.reload_categories()
                except:
                    __print__(dbg.ERR, "HTTP error or extraction failure.")
                    self.categories = ["empty"]
                self.display_categories()
            try:
                self.reload_categories()
            except:
                __print__(dbg.ERR, "HTTP error or extraction failure.")
                self.categories = ["empty"]
            self.display_categories()
                self.current = self.categories.keys()[0]
                __print__(dbg.STAT, "Use first category as current =", self.current)
                self.load(self.current)
        
            # load current category

        # Select first category
            else:
                __print__(dbg.STAT, self.module+".first_show(): load current category =", self.current);
                self.load(self.current)
            
            # put selection/cursor on last position
        self.current = self.str_from_struct(self.categories) or None
        __print__(dbg.STAT, self.module, "→ first_show(); use first category as current =", self.current)
        try:
            self.load(self.current)
        except:
            pass
    
        # put selection/cursor on last position
            try:
                __print__(dbg.STAT, self.module+".first_show()", "select last known category treelist position =", self.shown)
                self.gtk_list.get_selection().select_path(self.shown)
            except:
                pass
                
            # this method will only be invoked once
            self.shown = 55555
        __print__(dbg.STAT, self.module+".first_show()", "select last known category treelist position =", self.shown)
        try:
            self.gtk_list.get_selection().select_path(self.shown)
        except:
            pass
            
        # Invoke only once
        self.shown = 55555


    # Retrieve first list value, or key from dict (-- used to get first category on init)
    def str_from_struct(self, d):
        if isinstance(d, (str)):
            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()
528
529
530
531
532
533
534
535

536
537
538
539
540
541
542
524
525
526
527
528
529
530

531
532
533
534
535
536
537
538







-
+






            "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.lt)   # list type formats (.m3u .pls and .xspf)
        #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