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

⌈⌋ branch:  streamtuner2

Artifact [098afe2087]

Artifact 098afe2087a59c0313ff5ba7c8d1d00c8e4cdcf8:

#!/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.5
# state: beta
# author: Mario Salzer <>
# license: Public Domain
# url:
# config:  
#   { 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, gtk*.xml, bin=/usr/bin/streamtuner2, channels/, bundle/*.py,
#   streamtuner2.desktop=/usr/share/applications/, README=/usr/share/doc/streamtuner2/,
#   NEWS.gz=/usr/share/doc/streamtuner2/changelog.gz, help/streamtuner2.1=/usr/share/man/man1/,
#   help/*page=/usr/share/doc/streamtuner2/help/, help/img/*=/usr/share/doc/streamtuner2/help/img/,
#   logo.png=/usr/share/pixmaps/streamtuner2.png,
# architecture: all
# Streamtuner2 is a GUI for browsing internet radio directories, music
# collections, and video services - grouped by genres or categories.
# It runs your preferred audio player, and streamripper for recording.
# It's an independent rewrite of streamtuner1. Being written in Python,
# can be more easily extended and fixed. The mix of JSON APIs, regex
# or PyQuery extraction makes list generation simpler and more robust.
# Primarily radio stations are displayed, some channels however are music
# collections. Commercial and sign-up services are not an objective.

# standard modules
import sys
import os
import re
from copy import copy
import inspect
import traceback
from threading import Thread

# add library path (either global setup, or pyzip basename)
if not os.path.dirname(__file__) in sys.path:
    sys.path.insert(0, os.path.dirname(__file__))

# initializes itself, so all conf.vars are available right away
from config import *

# gtk modules
from uikit import pygtk, gtk, gobject, uikit, ui_xml, gui_startup, AboutStreamtuner2

# custom modules
import ahttp
import action
import logo
import favicon
import channels
import channels.bookmarks
import channels.configwin
import channels.streamedit

# This represents the main window, dispatches Gtk events,
# and shares most application behaviour with the channel modules.
class StreamTunerTwo(gtk.Builder):

    # object containers
    widgets = {}     # non-glade widgets (the manually instantiated ones)
    channels = {}    # channel modules
    features = {}    # non-channel plugins
    working = []     # threads
    add_signals = {} # channel gtk-handler signals
    hooks = {
        "play": [favicon.download_playing],  # observers queue here
        "init": [],
        "config_load": [],
        "config_save": [],
    meta = plugin_meta()

    # status variables
    channel_names = ["bookmarks"]    # order of channel notebook tabs
    current_channel = "bookmarks"    # currently selected channel name (as index in self.channels{})

    # constructor
    def __init__(self):
        # Load stylesheet, instantiate GtkBuilder in self, menu and logo hooks
        gui_startup(0/20.0), uikit.load_theme(conf.get("theme"))
        gui_startup(1/20.0), gtk.Builder.__init__(self)
        gui_startup(1/20.0), gtk.Builder.add_from_string(self, ui_xml)
        gui_startup(3/20.0), self.img_logo.set_from_pixbuf(uikit.pixbuf(logo.png))

        # initialize built-in plugins
        self.channels = {
          "bookmarks": channels.bookmarks.bookmarks(parent=self),   # this the remaining built-in channel
        # dialogs that are connected to main
        self.features = {
          "configwin": channels.configwin.configwin(self),
          "streamedit": channels.streamedit.streamedit(self),

        # early module coupling
        action.main = self            # action (play/record) module needs a reference to main window for gtk interaction and some URL/URI callbacks
        self.action = action.action   # shorter name (could also become a features. entry...) = self.status  # http module gives status feedbacks too
        # append other channel modules and plugins

        # load application state (widget sizes, selections, etc.)
            winlayout = conf.load("window")
            if (winlayout):
                uikit.app_restore(self, winlayout)
            # selection values
            winstate = conf.load("state")
            if (winstate):
                for id in winstate.keys():
                    self.channels[id].current = winstate[id]["current"]
                    self.channels[id].shown = winlayout[id+"_list"].get("row:selected", 0)   # actually just used as boolean flag (for late loading of stream list), selection bar has been positioned before already
            pass # fails for disabled/reordered plugin channels

        # late plugin initializations
        [callback(self) for callback in self.hooks["init"]]

        # display current open channel/notebook tab
        self.current_channel = self.current_channel_gtk()
        except: __print__(dbg.INIT, "main.__init__: current_channel.first_show() initialization error")

        # bind gtk/glade event names to functions
            "gtk_main_quit" : self.gtk_main_quit,                # close window
            # treeviews / notebook
            "on_stream_row_activated" : self.on_play_clicked,    # double click in a streams list
            "on_category_clicked": self.on_category_clicked,     # new selection in category list
            "on_notebook_channels_switch_page": self.channel_switch,   # channel notebook tab changed
            "station_context_menu": lambda tv,ev: self.station_context_menu(tv,ev),
            # toolbar
            "on_play_clicked" : self.on_play_clicked,
            "on_record_clicked": self.on_record_clicked,
            "on_homepage_stream_clicked": self.on_homepage_stream_clicked,
            "on_reload_clicked": self.on_reload_clicked,
            "on_stop_clicked": self.on_stop_clicked, #@TODO: button is long gone
            "on_homepage_channel_clicked" : self.on_homepage_channel_clicked,
            "double_click_channel_tab": self.on_homepage_channel_clicked,
            # menu
            "menu_toolbar_standard": lambda w: (self.toolbar.unset_style(), self.toolbar.unset_icon_size()),
            "menu_toolbar_style_icons": lambda w: (self.toolbar.set_style(gtk.TOOLBAR_ICONS)),
            "menu_toolbar_style_both": lambda w: (self.toolbar.set_style(gtk.TOOLBAR_BOTH)),
            "menu_toolbar_size_small": lambda w: (self.toolbar.set_icon_size(gtk.ICON_SIZE_SMALL_TOOLBAR)),
            "menu_toolbar_size_medium": lambda w: (self.toolbar.set_icon_size(gtk.ICON_SIZE_DND)),
            "menu_toolbar_size_large": lambda w: (self.toolbar.set_icon_size(gtk.ICON_SIZE_DIALOG)),
            "menu_notebook_pos_top": lambda w: self.notebook_channels.set_tab_pos(2),
            "menu_notebook_pos_left": lambda w: self.notebook_channels.set_tab_pos(0),
            "menu_notebook_pos_right": lambda w: self.notebook_channels.set_tab_pos(1),
            "menu_notebook_pos_bottom": lambda w: self.notebook_channels.set_tab_pos(3),
            # win_config
            "config_cancel": self.configwin.hide,
            "config_play_list_edit_col0": lambda w,path,txt: (self.configwin.list_edit(self.config_play, path, 0, txt)),
            "config_play_list_edit_col1": lambda w,path,txt: (self.configwin.list_edit(self.config_play, path, 1, txt)),
            "config_record_list_edit_col0": lambda w,path,txt: (self.configwin.list_edit(self.config_record, path, 0, txt)),
            "config_record_list_edit_col1": lambda w,path,txt: (self.configwin.list_edit(self.config_record, path, 1, txt)),
            # else
            "update_categories": self.update_categories,
            "update_favicons": self.update_favicons,
            "app_state": self.app_state,
            "bookmark": self.bookmark,
            "save_as": self.save_as,
            "menu_about": lambda w: AboutStreamtuner2(self),
            "menu_onlineforum": lambda w: self.action.browser(""),
            "menu_fossilwiki": lambda w: self.action.browser(""),
            "menu_projhomepage": lambda w: self.action.browser(""),
           # "menu_bugreport": lambda w: BugReport(),
            "menu_copy": self.menu_copy,
            "delete_entry": self.delete_entry,
            # search dialog
            "true": lambda w,*args: True,
            # win_streamedit
            "streamedit_cancel": self.streamedit.cancel,
        }, **self.add_signals))
        # actually display main window

    #-- Shortcut for glade.get_widget()
    # Allows access to widgets as direct attributes instead of using .get_widget()
    # Also looks in self.channels[] for the named channel plugins
    def __getattr__(self, name):
        if (name in self.channels):
            return self.channels[name]     # like self.shoutcast
        elif (name in self.features):
            return self.features[name]     # like self.configwin
            return self.get_object(name)   # or gives an error if neither exists

    # Custom-named widgets are available from .widgets{} not via .get_widget()
    def get_widget(self, name):
        if name in self.widgets:
            return self.widgets[name]
            return gtk.Builder.get_object(self, name)
    # returns the currently selected directory/channel object (remembered position)
    def channel(self):
        return self.channels[self.current_channel]

    # returns the currently selected directory/channel object (from gtk)
    def current_channel_gtk(self):
        i = self.notebook_channels.get_current_page()
        try: return self.channel_names[i]
        except: return "bookmarks"

    # Notebook tab clicked
    def channel_switch(self, notebook, page, page_num=0, *args):

        # can be called from channelmenu as well:
        if type(page) == str:
            self.current_channel = page
        # notebook invocation:
        else: #if type(page_num) == int:
            self.current_channel = self.channel_names[page_num]
        # if first selected, load current category
            __print__(dbg.PROC, "channel_switch: try .first_show",;
            __print__(dbg.INIT, "channel .first_show() initialization error")

    # Convert ListStore iter to row number
    def rowno(self):
        (model, iter) = self.model_iter()
        return model.get_path(iter)[0]

    # Currently selected entry in stations list, return complete data dict
    def row(self):
        return [self.rowno()]

    # return ListStore object and Iterator for currently selected row in gtk.TreeView station list
    def model_iter(self):
    # Fetches a single varname from currently selected station entry
    def selected(self, name="url"):
        return self.row().get(name)

    # Play button
    def on_play_clicked(self, widget, event=None, *args):
        row = self.row()
        if row:
            [callback(row) for callback in self.hooks["play"]]

    # Recording: invoke streamripper for current stream URL
    def on_record_clicked(self, widget):
        row = self.row()
        self.action.record(row.get("url"), row.get("format", "audio/mpeg"), "url/direct", row=row)

    # Open stream homepage in web browser
    def on_homepage_stream_clicked(self, widget):
        url = self.selected("homepage")             

    # Browse to channel homepage (double click on notebook tab)
    def on_homepage_channel_clicked(self, widget, event=2):
        if event == 2 or event.type == gtk.gdk._2BUTTON_PRESS:
            __print__(dbg.UI, "dblclick")

    # Reload stream list in current channel-category
    def on_reload_clicked(self, widget=None, reload=1):
        __print__(dbg.UI, "reload", reload, self.current_channel, self.channels[self.current_channel],
        category =
            lambda: (,reload), reload and self.bookmarks.heuristic_update(self.current_channel,category)  )

    # Thread a function, add to worker pool (for utilizing stop button)
    def thread(self, target, *args):
        thread = Thread(target=target, args=args)

    # Stop reload/update threads
    def on_stop_clicked(self, widget):
        __print__(dbg.ERR, "STOP is no longer available")
        #while self.working:
            #thread = self.working.pop()

    # Click in category list
    def on_category_clicked(self, widget, event, *more):
        category =
        __print__(dbg.UI, "on_category_clicked", category, self.current_channel)
        self.on_reload_clicked(None, reload=0)

    # Add current selection to bookmark store
    def bookmark(self, widget):
        # code to update current list (set icon just in on-screen liststore, it would be updated with next display() anyhow - and there's no need to invalidate the ls cache, because that's referenced by model anyhow)
            (model,iter) = self.model_iter()
            model.set_value(iter, 0, gtk.STOCK_ABOUT)
        # refresh bookmarks tab

    # Reload category tree
    def update_categories(self, widget):

    # Menu invocation: refresh favicons for all stations in current streams category
    def update_favicons(self, widget):
        entries =

    # Save stream to file (.m3u)
    def save_as(self, widget):
        row = self.row()
        default_fn = row["title"] + ".m3u"
        fn = uikit.save_file("Save Stream", None, default_fn, [(".m3u","*m3u"),(".pls","*pls"),(".xspf","*xspf"),(".smil","*smil"),(".asx","*asx"),("all files","*")])
        if fn:
  , fn)

    # Save current stream URL into clipboard
    def menu_copy(self, w):

    # Remove a stream entry
    def delete_entry(self, w):
        n = self.rowno()
        del[ n ]

    # Alternative Notebook channel tabs between TOP and LEFT position
    def switch_notebook_tabs_position(self, w, pos):

    # shortcut to statusbar
    # (hacked to work from within threads, circumvents the statusbar msg pool actually)
    def status(self, text="", sbar_msg=[]):
        # init
        sbar_cid = self.get_widget("statusbar").get_context_id("messages")
        # remove text
        while ((not text) and (type(text)==str) and len(sbar_msg)):
        # progressbar
        if (type(text)==float):
            if text >= 0.999 or text < 0.0:  # completed
            else:  # show percentage
       or self.progress.set_fraction(text))
                if (text <= 0):  # unknown state
        # add text
        elif (type(text)==str):
  , text))

    # load plugins from /usr/share/streamtuner2/channels/
    def load_plugin_channels(self):

        # initialize plugin modules (pre-ordered)
        ls = module_list()
        for module in ls:
            gui_startup(4/20.0 + 13.5/20.0 * float(ls.index(module))/len(ls), "loading module "+module)
            # skip module if disabled
            if conf.plugins.get(module, 1) == False:
                __print__(dbg.STAT, "disabled plugin:", module)
            # or if it's a built-in (already imported)
            elif module in self.features or module in self.channels:
            # load plugin
                plugin = __import__("channels."+module, globals(), None, [""])
                #print [name for name,c in inspect.getmembers(plugin) if inspect.isclass(c)]
                plugin_class = plugin.__dict__[module]
                plugin_obj = plugin_class(parent=self)

                # add to .channels{}
                if issubclass(plugin_class, channels.GenericChannel):
                    self.channels[module] = plugin_obj
                    if module not in self.channel_names:  # skip (glade) built-in channels
                # or .features{} for other plugin types
                    self.features[module] = plugin_obj
            except Exception as e:
                __print__(dbg.INIT, "load_plugin_channels: error initializing:", module, ", exception:")

    # store window/widget states (sizes, selections, etc.)
    def app_state(self, widget):
        # gtk widget states
        widgetnames = ["win_streamtuner2", "toolbar", "notebook_channels", ] \
                    + [id+"_list" for id in self.channel_names] \
                    + [id+"_cat" for id in self.channel_names]"window", uikit.app_state(wTree=self, widgetnames=widgetnames), nice=1)
        # object vars
        channelopts = {} #dict([(id, {"current":self.channels[id].current}) for id in self.channel_names])
        for id in self.channels.keys():
            if (self.channels[id]):
                channelopts[id] = {"current":self.channels[id].current}"state", channelopts, nice=1)

    # end application and gtk+ main loop
    def gtk_main_quit(self, widget, *x):
        if conf.auto_save_appstate:
            try:  # doesn't work with gtk3 yet (probably just hooking at the wrong time)

    # Right clicking a stream/station in the treeview to make context menu pop out.
    def station_context_menu(self, treeview, event):
        if event.button >= 3:
            path = treeview.get_path_at_pos(int(event.x), int(event.y))[0]
            treeview.set_cursor(path, None, False)
                  parent_menu_shell=None, parent_menu_item=None, func=None,
                  button=event.button, activate_time=event.time,
            return None
        # else pass on to normal left-button signal handler
            return False

# startup procedure
def main():

    # graphical
    if len(sys.argv) < 2 or "--gtk3" in sys.argv:

        # prepare for threading in Gtk+ callbacks

        # prepare main window
        main = StreamTunerTwo()

        # first invocation
        if (conf.get("firstrun")):
            del conf.firstrun

        # run
        __print__(dbg.PROC, r" gtk_main_quit ")
    # invoke command-line interface
        import cli

# run
if __name__ == "__main__":