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

⌈⌋ ⎇ branch:  streamtuner2


config.py at [6671384529]

File config.py artifact 19c9d0f2fd part of check-in 6671384529


# encoding: UTF-8
# api: streamtuner2
# type: class
# title: global config object
# description: reads ~/.config/streamtuner/*.json files
# config:
#    { arg: -d,     type: str,      name: disable[], description: Omit plugin from initialization.  }
#    { arg: -e,     type: str,      name: enable[],  description: Add channel plugin.  }
#    { arg: --gtk3, type: boolean,  name: gtk3,      description: Start with Gtk3 interface. }
#    { arg: --nt,   type: boolean,  name: nothreads, description: Disable threading/gtk_idle UI. }
#    { arg: -D,     type: boolean,  name: debug,     description: Enable debug messages on console }
#    { arg: action, type: str *,    name: action[],  description: CLI interface commands. }
#    { arg: -x,     type: boolean,  name: exit,      hidden: 1 }
#    { arg: -V,     type: boolean,  name: version,   description: Print version.  }
#    { arg: -w,     type: boolean,  name: pydoc,     hiden: 1  }
# version: 2.8
# priority: core
# depends: pluginconf >= 0.1, os, json, re, zlib, pkgutil
#
# Ties together the global conf.* object. It's typically used
# in the main application and modules with:
#
#   from config import *
#
# The underlying ConfigDict class is already instantiated and
# imported as `conf` then.
#
# With .save() or .load() it handles storage as JSON. Both
# utility functions are also used for other cache files.
# More specific config stores are available per .netrc(),
# and .init_args().
#
# Whereas plugin utility code is available per plugin_meta(),
# module_list(), and get_data(). There's a prepared function
# for add_plugin_config() on initialization.
#
# Also provides a simple logging interface with log.TYPE(...),
# which is also pre-instantiated.


from __future__ import print_function
import os, glob
import sys
from traceback import format_exc
import json
import gzip
import platform
import re
from compat2and3 import gzip_decode, find_executable, PY2, PY3
import zlib
import zipfile
import inspect
import pkgutil
import argparse
from pluginconf import plugin_meta, module_list, get_data
import pluginconf


# export symbols
__all__ = ["conf", "log", "plugin_meta", "module_list", "get_data", "find_executable"]


#-- create a stub instance of config object
conf = object()

# separate instance of netrc, if needed
netrc = None




# Global configuration store
#
# Autointializes itself on startup, makes conf.vars available.
# Also provides .load() and .save() for JSON data/cache files.
#
class ConfigDict(dict):

    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__))
        
        # settings from last session
        last = self.load("settings")
        if (last):
            if "share" in last:
                del last["share"]
            self.update(last)
            self.migrate()
        # store defaults in file
        else:
            self.save("settings")
            self.firstrun = 1
        try:
            self.version = plugin_meta(frame=3).get("version", "2.2")
        except Exception as e:
            self.version = "2.2"

        # temporary files
        if not os.path.exists(self.tmp):
            os.mkdir(self.tmp)
        
        # add argv
        self.args = self.init_args(argparse.ArgumentParser())
        self.apply_args(self.args)


    # some defaults
    def defaults(self):
        self.windows = platform.system()=="Windows"
        self.play = {
           "audio/mpeg": self.find_player(),
           "audio/ogg": self.find_player(),
           "audio/*": self.find_player(),
           "video/youtube": self.find_player(typ="video") + " $(youtube-dl -g %srv)",
           "video/*": self.find_player(typ="video", default="vlc"),
           "url/http": self.find_player(typ="browser"),
        }
        self.record = {
           "audio/*": self.find_player(typ="xterm", append=' -e "streamripper %srv -u ''iTunes/12.5.5 (Linux; X11; Gecko rv:57) SR/1.64''" '),   # -d /home/***USERNAME***/Musik
           "video/youtube": self.find_player(typ="video", append=' $("youtube-dl %srv")'),
        }
        self.specbuttons = {
           #"gtk-media-forward": "pavucontrol",
        }
        # Presets are redundant now. On first startup the `priority:` field of each plugin is checked.
        self.plugins = {
             # core plugins, cannot be disabled anyway
            "bookmarks": 1,
            "search": 1,
            "streamedit": 1,
            "configwin": 1,
        }
        self.tmp = os.environ.get("TEMP", "/tmp") + "/streamtuner2"
        self.nothreads = 0
        self.max_streams = 500
        self.internetradio_max_pages = 5
        self.show_bookmarks = 1
        self.show_favicons = 1
        self.load_favicon = 1
        self.heuristic_bookmark_update = 0
        self.retain_deleted = 0
        self.auto_save_appstate = 1
        self.auto_save_stations = 0
        self.reuse_m3u = 1
        self.playlist_asis = 0
        self.window_title = 0
        self.google_homepage = 0
        self.open_mode = "r" if self.windows and PY2 else "rt"
        self.pyquery = 1
        self.debug = 0
        self.status_color = "#ffeecc"
        self.searchbtn = 0

    # update old setting names
    def migrate(self):
        # 2.1.7
        if self.tmp == "/tmp":
            self.tmp = "/tmp/streamtuner2"
        
    # Add plugin names and default config: options from each .meta
    def add_plugin_defaults(self, meta, name):
        pluginconf.add_plugin_defaults(self, self.plugins, meta, name)


    # look at system binaries for standard audio players
    def find_player(self, typ="audio", default="xdg-open", append=""):
        if self.windows:
            return self.find_player_win(typ, default)
        players = {  # linux
            "audio": ["audacious %m3u", "audacious2", "exaile %pls", "xmms2", "banshee", "amarok %pls", "clementine", "qmmp", "quodlibet", "aqualung", "mp3blaster %m3u", "vlc --one-instance", "totem"],
            "video": ["umplayer", "xnoise", "gxine", "totem", "vlc --one-instance", "parole", "smplayer", "gnome-media-player", "xine", "bangarang"],
            "browser": ["opera", "midori", "firefox", "sensible-browser"],
            "xterm": ["xfce4-terminal", "x-terminal-emulator", "gnome-terminal", "xterm", "rxvt"],
        }
        for bin in players[typ]:
            if find_executable(bin.split()[0]):
                return bin
        return default

    # Windows look for c:/program files/*/*.exe
    def find_player_win(self, typ="audio", default="wmplayer %asx", append=""):
        pf = os.environ["ProgramFiles"]
        base = [pf, "c:\\windows", "c:\\program files", "c:\\windows\\internet explorer\\"]
        players = {
            "audio": ["\\VideoLAN\\VLC*\\vlc.exe", "\\VLC*\\vlc.exe", "wmplayer.exe %asx"],
            "browser": ["\\Moz*\\firefox.exe", "iexplore.exe %url"],
            "xterm": ['/D "'+pf+'\\streamripper" streamripper.exe %srv']
        }
        typ = typ if typ in players else "audio"
        for bin in players[typ]:
            for b in base:
                fn = glob.glob(b + bin)
                if len(fn):
                    return re.sub("^(.+?)((\s%\w+)?)$", '"\\g<1>"\\g<2>', fn[0], 1) + append
        return players[typ][-1]
    
        
    # http://standards.freedesktop.org/basedir-spec/basedir-spec-0.6.html
    def xdg(self, path="/streamtuner2"):
        home = os.environ.get("HOME", self.tmp)
        config = os.environ.get("XDG_CONFIG_HOME", os.environ.get("APPDATA", home+"/.config"))
        datadir = os.environ.get("XDG_CACHE_HOME", os.environ.get("APPDATA", home+"/.cache"))
        
        # storage dir
        self.dir = config + path
        self.datadir = datadir + path
        
        # create if necessary
        if (not os.path.exists(self.dir)):
            os.makedirs(self.dir)
        if (not os.path.exists(self.datadir)):
            os.makedirs(self.datadir)

        # symlink subdirs from .config to .cache
        self.xdg_move(self.dir, self.datadir, ["cache", "icons", "themes"])

    # move/symlink cache files/dirs into datadir
    def xdg_move(self, config_dir, cache_dir, folders):
        if self.windows:
            return
        for sub in folders:
            source, target = (dir+"/"+sub for dir in [config_dir, cache_dir])
            if not os.path.exists(target):
                if os.path.exists(source): # move .config/* to .cache/*
                    os.rename(source, target)
                else:
                    os.makedirs(target)    # create .cache/*
            if os.path.exists(target) and not os.path.exists(source):
                os.symlink(target, source) # symlink .config → .cache
       

    # store some configuration list/dict into a file                
    def save(self, name="settings", data=None, gz=0, nice=0):
        name = name + ".json"
        if (data is None):
            data = vars(self)
            if "args" in data:
                data.pop("args")
            nice = 1
        # target filename
        file = self.dir + "/" + name
        # check for subdir
        if (name.find("/") > 0):
            subdir = name[0:name.find("/")]
            subdir = self.datadir + "/" + subdir
            if (not os.path.exists(subdir)):
                os.mkdir(subdir)
                open(subdir+"/.nobackup", "w").close()
            file = self.datadir + "/" + name
        # encode as JSON
        try:
            data = json.dumps(data, indent=(4 if nice else None), sort_keys=True)
        except Exception as e:
            log.ERR("JSON encoding failed", e)
            return
        # .gz or normal file
        if gz:
            f = gzip.open(file+".gz", "w")
            if os.path.exists(file):
                os.unlink(file)
        else:
            f = open(file, "w")
        # write
        try:
            f.write(data.encode("utf-8"))
        except TypeError as e:
            f.write(data)  # Python3 sometimes wants to write strings rather than bytes
        f.close()

    # retrieve data from config file            
    def load(self, name):
        name = name + ".json"
        if (name.find("/") > 0):
            file = self.datadir + "/" + name
        else:
            file = self.dir + "/" + name
        try:
            # .gz or normal file
            if os.path.exists(file + ".gz"):
                f = gzip.open(file + ".gz", self.open_mode)
            elif os.path.exists(file):
                f = open(file, self.open_mode)
            else:
                return # file not found
            # decode
            r = json.load(f)
            f.close()
            return r
        except Exception as e:
            log.ERR("JSON parsing error (in "+name+")", e)
        
    # recursive dict update
    def update(self, with_new_data):
        for key,value in with_new_data.items():
            if type(value) == dict:
                self[key].update(value)
            else:
                self[key] = value
        # descends into sub-dicts instead of wiping them with subkeys

    # Shortcut to `state.json` loading (currently selected categories etc.)
    def state(self, module=None, d={}):
        if not d:
            d.update(conf.load("state") or {})
        if module:
            return d.get(module, {})
        return d

    # standard user account storage in ~/.netrc or less standard but contemporarily in ~/.config/netrc
    def netrc(self, varhosts=("shoutcast.com")):
        global netrc
        if not netrc:
            netrc = {}
            try:
                from netrc import netrc as parser
                try:
                     netrc = parser().hosts
                except:
                     netrc = parser(self.xdg() + "/netrc").hosts
            except:
                log.STAT("No .netrc")
        for server in varhosts:
            if server in netrc:
                return netrc[server]

    # Use config:-style definitions for argv extraction,
    # such as: { arg: -D, name: debug, type: bool }
    def init_args(self, ap):
        for opt in plugin_meta(frame=1).get("config"):
            kwargs = pluginconf.argparse_map(opt)
            if kwargs:
                #print(kwargs)
                ap.add_argument(*kwargs.pop("args"), **kwargs)
        return ap.parse_args()

    # Copy args fields into conf. dict
    def apply_args(self, args):
        self.debug = args.debug
        self.nothreads = args.nothreads
        if args.exit:
            sys.exit(1)
        for p_id in (args.disable or []):
            self.plugins[p_id] = 0
        for p_id in (args.enable or []):
            self.plugins[p_id] = 1


# Simplified print wrapper: `log.err(...)`
class log_printer(object):

    # Wrapper
    method = None
    def __getattr__(self, name):
        self.method = name
        return self.log_print
    
    # Printer
    def log_print(self, *args, **kwargs):
        # debug level
        method = self.method.upper()
        if method != "ERR":
            if "debug" in conf and not conf.debug:
                return
        # color/prefix
        if conf.windows:
            method = "[%s]" % method
        else:
            method = r"[{}[{}]".format(self.colors.get(method.split("_")[0], "47m"), method)
        # output
        print(
            method + " " + " " . join(
                format_exc() if isinstance(a, Exception) else str(a) for a in args
            ),
            file=sys.stderr
        )

    # Colors
    colors = {
        "ERR":  "31m",          # red    ERROR
        "INIT": "38;5;196m",    # red    INIT ERROR
        "WARN": "38;5;208m",    # orange WARNING
        "EXEC": "38;5;66m",     # green  EXEC
        "PROC": "32m",          # green  PROCESS
        "FAVICON":"38;5;119m",  # green  FAVICON
        "CONF": "33m",          # brown  CONFIG DATA
        "DND":  "1;33;41m",     # yl/red DRAG'N'DROP
        "UI":   "34m",          # blue   USER INTERFACE BEHAVIOUR
        "UIKIT":"38;5;222;48;5;235m", # THREAD/UIKIT/IDLE TASKS
        "APPSTATE":"38;5;200m", # magenta APPSTATE RESTORE
        "HTTP": "35m",          # magenta HTTP REQUEST
        "DATA": "36m",          # cyan   DATA
        "INFO": "38;5;238m",    # lgray  INFO
        "STAT": "37m",          # gray   CONFIG STATE
    }

# instantiate right away
log = log_printer()


# populate global conf instance
conf = ConfigDict()
log.PROC("ConfigDict() initialized")

# tie in pluginconf.*
pluginconf.log_ERR = log.ERR
pluginconf.module_base = "config"
pluginconf.plugin_base = ["channels", "plugins"]#, conf.share+"/channels", conf.dir+"/plugins"]