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

⌈⌋ ⎇ branch:  streamtuner2


Check-in [867c9f9f94]

Overview
Comment:Simplify favicon callbacks, use channel= instead of artifical pixstore= tuple. update_rows() itself extracts liststore and indicies now. Introduce `img_resize` channel option for `img` banner rescaling in favicon module.
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA1: 867c9f9f948502038772143b721a9747f5894b3b
User & Date: mario on 2015-05-23 15:28:29
Other Links: manifest | tags
Context
2015-05-23
15:29
Support custom audio handlers for soundcloud etc. Example plugin to register them (only `soundcli` so far). check-in: 20f1c3edda user: mario tags: trunk
15:28
Simplify favicon callbacks, use channel= instead of artifical pixstore= tuple. update_rows() itself extracts liststore and indicies now. Introduce `img_resize` channel option for `img` banner rescaling in favicon module. check-in: 867c9f9f94 user: mario tags: trunk
15:26
Fix `id` reference in dependency resolver. check-in: 8289ad11b4 user: mario tags: trunk
Changes

Modified channels/__init__.py from [e059e8f474] to [54759d6b53].

71
72
73
74
75
76
77

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







+







    
    # 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
    img_resize = None  # Rescale `img` references to icon size

    # 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", {}], ],

Modified channels/favicon.py from [87b5a0fc51] to [b3a8f61678].

1
2
3
4
5
6
7
8
9
10
11
12

13
14
15
16
17
18
19
1
2
3
4
5
6
7
8
9
10
11

12
13
14
15
16
17
18
19











-
+







# encoding: utf-8
# api: streamtuner2
# title: Favicons
# description: Load and display station favicons/logos.
# config:
#    { name: load_favicon, type: bool, value: 1, description: "Load favicon instantly when â–¸playing a station.", color: yellow }
#    { name: favicon_google_first, type: bool, value: 1, description: "Prefer faster Google favicon to PNG conversion service." }
#    { name: favicon_delete_stub , type: bool, value: 1, description: "Don't accept any placeholder favicons." }
#    { name: google_homepage, type: bool, value: 0, description: "Google missing station homepages right away." }
# type: feature
# category: ui
# version: 1.9
# version: 2.0
# depends: streamtuner2 >= 2.1.9, python:pil
# priority: standard
# png:
#   iVBORw0KGgoAAAANSUhEUgAAABYAAAAWBAMAAAA2mnEIAAAAJ1BMVEUAAACwDw5oKh1RRU5OTSCOTxp0Um9zcyFUhSXsbwChdp/lgCNbrA7VFTQPAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHU
#   gAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQffBQ4ENQJMtfdfAAAAmUlEQVQY02NgcAECYxBgYODeOXPmTKtVQLCAwXsmjL2YQRPINDNGsFclI7GXQdmzZ87MSoOyI0pnpgHVLAOy1c+c
#   mTkzeFWioBSUbQZkiy1mcPFpCXUxTksTFEtm8Ojp6OhQVDJWVFJi8DkDBIIgIARhKyKx3c8g2GfOBCKxFeHspg6EmiZFJDbEHB44W4CBwQNor5MSEDAAAGcoaQmD1t8TAAAAAElFTkSuQmCC
#
56
57
58
59
60
61
62
63
64






65
66
67
68
69
70
71
56
57
58
59
60
61
62


63
64
65
66
67
68
69
70
71
72
73
74
75







-
-
+
+
+
+
+
+







#  · GenericChannel presets row["favicon"] with cache image filenames
#    in any case. It calls row_to_fn() per prepare_filters hook after
#    station list updates.
#
#  · uikit.columns() merely checks row["favicon"] for file existence
#    when redrawing a station list.
#
#  · main calls .update_playing() on hooks["play"],
#    or .update_all() per menu command
#  · The main window calls .update_playing() on hooks["play"].
#    (Which passes the current row{} and row_i index, and its channel
#    object for updating the ListStore→pixbuf right away.)
#
#  · Main also calls .update_all() wrapper per menu command "Channel ›
#    Update Favicons..."



# Hook up as feature plugin
#
class favicon(object):

87
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
114
115
116
117
118








119









120






121
122
123
124
125
126
127
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




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







-
+
+
+

-




-
+













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

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







        # Prepare favicon cache directory
        conf.icon_dir = conf.dir + "/icons"
        if not os.path.exists(conf.icon_dir):
            os.mkdir(conf.icon_dir)
            open(icon_dir+"/.nobackup", "a").close()


    # Main menu "Update favicons": update favicon cache for complete list of station rows
    # Main menu "Update favicons": update favicon cache for complete list
    # of station rows. Just a wrapper now around update_rows(). Expects
    # both entries=[] and channel={} argument still.
    def update_all(self, *args, **kwargs):
        #kwargs[pixstore] = self.parent.channel()._ls, ...
        self.parent.thread(self.update_rows, *args, **kwargs)


    # Main [â–¸play] event for a single station
    def update_playing(self, row, pixstore=None, channel=None, **x):
    def update_playing(self, row, channel=None, **x):

        # Homepage search
        if conf.google_homepage and not len(row.get("homepage", "")):
            found = google_find_homepage(row)
            # Save channel list right away to preserve found homepage URL
            if found and conf.auto_save_stations:
                channel.save()
        else:
            found = False

        # Favicon only for currently playing station
        if conf.load_favicon:
            if row.get("homepage") or row.get("img"):
                self.parent.thread(self.update_rows, [row], pixstore=pixstore, fresh_homepage=found)

      
    # Run through rows[] to update "favicon" from "homepage" or "img",
                self.parent.thread(
                    self.update_rows,
                    entries=[row], channel=channel, row_i=channel.rowno(),
                    fresh_homepage=found
                )

      
    # Run through rows[] to update "favicon" cachefile from "homepage" or "img",
    # optionally display new image right away in ListStore
    #
    #  · The entries[] list can be a single row. In which case it is accompanied
    #    by its row_i index.
    #  · If it's a complete streams list, then the row index will be manually
    #    counted up.
    #  · This is needed to update the pixstore. The `channel` reference is used
    #    for accessing the displayed ListStore, and its `pix_entry` column for
    #    updates.
    #
    def update_rows(self, entries, pixstore=None, fresh_homepage=False, **x):
    def update_rows(self, entries, channel=None, row_i=None, fresh_homepage=False, **x):

        # Preserve current ListStore object - in case the channel/notebook
        # tab gets switched for longer .update_all() invocations.
        ch_ls = channel.ls if channel else None

        for i,row in enumerate(entries):
            ok = False

            # Try just once
            if row.get("homepage") in tried_urls:
                continue
            # Ignore existing ["favicon"] filename
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
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190

191
192
193
194
195
196
197
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







+
-
+









-
+

+
+
-
+








-
-
-
-
+
+
+
+


-
-
-
-
-
-










-
+







                        continue
                    else:  # For freshly added ["homepage"] when favicon already
                        ok = True  # exists in cache. Then just update pix store.

                # Download custom "img" banner/logo as favicon
                elif row.get("img"):
                    tried_urls.append(row["img"])
                    resize = row.get("img_resize", channel.img_resize)
                    ok = banner_localcopy(row["img"], favicon_fn)
                    ok = banner_localcopy(row["img"], favicon_fn, resize)

                # Fetch homepage favicon into local png
                elif row.get("homepage"):
                    tried_urls.append(row["homepage"])
                    if conf.favicon_google_first:
                        ok = fav_google_ico2png(row["homepage"], favicon_fn)
                    else:
                        ok = fav_from_homepage(row["homepage"], favicon_fn)

                # Update TreeView
                # Update TreeView (single `row_i`, or counted up `i` index)
                if ok:
                    if row_i is not None:  # single row update
                        i = row_i
                    self.update_pixstore(row, pixstore, i)
                    self.update_pixstore(row, ch_ls, channel, 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


    # Update favicon in treeview/liststore
    def update_pixstore(self, row, pixstore=None, row_i=None):
        log.FAVICON_UPDATE_PIXSTORE(pixstore, row_i)
        if not pixstore:
    # Update favicon pixbuf in treeview/liststore
    def update_pixstore(self, row, ls, channel=None, row_i=None):
        log.FAVICON_UPDATE_PIXSTORE(channel, ls, row_i)
        if not channel or not ls or row_i is None:
            return

        # Unpack ListStore, pixbuf column no, preset rowno
        ls, pix_entry, i = pixstore
        # Else use row index from update_all-iteration
        if i is None:
            i = row_i

        # Existing "favicon" cache filename
        if row.get("favicon"):
            fn = row["favicon"]
        else:
            fn = row_to_fn(row)

        # Update pixbuf in active station liststore
        if fn and os.path.exists(fn):
            try:
                p = gtk.gdk.pixbuf_new_from_file(fn)
                ls[i][pix_entry] = p
                ls[row_i][channel.pix_entry] = p
            except Exception as e:
                log.ERR("Update_pixstore image", fn, "error:", e)


    # Run after any channel .update_streams() to populate row["favicon"]
    # from `homepage` or `img` url.
    def prepare_filter_favicon(self, row):
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
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







-
+








-
+
















-
+







        url = rx_non_wordchr.sub("_", url)    # remove any non-word characters
        url = "{}/{}.png".format(conf.icon_dir, url)  # prefix cache directory
    return url


    
# Copy banner row["img"] into icons/ directory
def banner_localcopy(url, fn):
def banner_localcopy(url, fn, resize=None):

    # Check URL and target filename
    if not re.match("^https?://[\w.-]{10}", url):
        return False

    # Fetch and save
    imgdata = ahttp.get(url, binary=1, verify=False)
    if imgdata:
        return store_image(imgdata, fn)
        return store_image(imgdata, fn, resize)
    

    
# Check for valid image binary, possibly convert or resize, then save to cache filename
def store_image(imgdata, fn, resize=None):

    # Convert accepted formats -- even PNG for filtering now
    if re.match(br'^(.PNG|GIF\d+|.{0,15}(Exif|JFIF)|\x00\x00\x01\x00|.{0,255}<svg[^>]+svg)', imgdata):
        try:
            # Read from byte/str
            image = Image.open(BytesIO(imgdata))
            log.FAVICON_IMAGE_TO_PNG(image, image.size, resize)

            # Resize
            if resize and image.size[0] > resize:
                try:
                    image.thumbnail(resize, Image.ANTIALIAS)
                    image.thumbnail((resize, resize), Image.ANTIALIAS)
                except:
                    image = image.resize((resize,resize), Image.ANTIALIAS)

            # Convert to PNG via string buffer
            out = BytesIO()
            image.save(out, "PNG", quality=98)
            imgdata = out.getvalue()

Modified st2.py from [54146ab945] to [f494cf4f99].

1
2
3
4
5
6
7
8
9
10
11
12
13

14
15
16
17
18
19
20
1
2
3
4
5
6
7
8
9
10
11
12

13
14
15
16
17
18
19
20












-
+







#!/usr/bin/env python
# encoding: UTF-8
# api: python
# type: application
# title: streamtuner2
# description: Directory browser for internet radio, audio and video streams
# version: 2.1.9-beta1
# state: beta
# author: Mario Salzer <mario@include-once.org>
# license: Public Domain
# url: http://freshcode.club/projects/streamtuner2
# config:  
#   { type: env, name: http_proxy, description: proxy for HTTP access }
#   { type: env, name: HTTP_PROXY, description: proxy for HTTP access }
#   { type: env, name: XDG_CONFIG_HOME, description: relocates user .config subdirectory }
# category: sound
# depends: pygtk | gi, threading, requests, pyquery, lxml
# id: streamtuner2
# pack: *.py, gtk3.xml.gz, bin, channels/__init__.py, bundle/*.py, CREDITS, help/index.page,
#   streamtuner2.desktop, README, help/streamtuner2.1=/usr/share/man/man1/,
#   NEWS=/usr/share/doc/streamtuner2/, icon.png=/usr/share/pixmaps/streamtuner2.png
272
273
274
275
276
277
278
279
280
281
282

283
284
285
286
287
288
289
272
273
274
275
276
277
278

279
280

281
282
283
284
285
286
287
288







-


-
+









            
    # Play button
    def on_play_clicked(self, widget, event=None, *args):
        self.status("Starting player...")
        channel = self.channel()
        pixstore = [channel.ls, channel.pix_entry, channel.rowno()]
        row = channel.play()
        self.status("")
        [callback(row, pixstore=pixstore, channel=channel) for callback in self.hooks["play"]]
        [callback(row, channel=channel) for callback in self.hooks["play"]]

    # Recording: invoke streamripper for current stream URL
    def on_record_clicked(self, widget):
        self.status("Recording station...")
        row = self.channel().record()
        [callback(row) for callback in self.hooks["record"]]

328
329
330
331
332
333
334
335

336
337
338
339
340
341
342
327
328
329
330
331
332
333

334
335
336
337
338
339
340
341







-
+







    def update_categories(self, widget):
        self.thread(self.channel().reload_categories)

    # Menu invocation: refresh favicons for all stations in current streams category
    def update_favicons(self, widget):
        if "favicon" in self.features:
            ch = self.channel()
            self.features["favicon"].update_all(entries=ch.stations(), pixstore=[ch.ls, ch.pix_entry, None])
            self.features["favicon"].update_all(entries=ch.stations(), channel=ch)

    # 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"),(".jspf","*jspf"),(".smil","*smil"),(".asx","*asx"),("all files","*")])
        if fn: