Files

392 lines
19 KiB
PHP

<?php
/**
* config.php
* -----------
* Single source of truth for the whole site. Reads a local `.env` file and
* merges it over sensible defaults so the project works out of the box and can
* be fully re-skinned for any station without touching PHP.
*
* Nothing secret should live in this file — put secrets (Genius token, etc.)
* in `.env`, which is gitignored. See `.env.example` for every supported key.
*/
if (!defined('RADIO_CONFIG_LOADED')) {
define('RADIO_CONFIG_LOADED', true);
/* -------------------------------------------------------------------- */
/* Load .env */
/* -------------------------------------------------------------------- */
/**
* Minimal, predictable .env parser. We deliberately avoid parse_ini_file:
* its RAW scanner keeps the surrounding quotes, and its NORMAL scanner
* rewrites bare words like true/false/none — which would silently undo a
* user setting FEATURE=false. This parser just does: skip comments/blank
* lines and an optional [section] header, split on the first "=", strip one
* layer of matching quotes, and otherwise keep the value verbatim.
*/
function radio_parse_env($path) {
$out = [];
$lines = @file($path, FILE_IGNORE_NEW_LINES);
if ($lines === false) return $out;
foreach ($lines as $line) {
$trim = trim($line);
if ($trim === '' || $trim[0] === '#' || $trim[0] === ';') continue;
if ($trim[0] === '[' && substr($trim, -1) === ']') continue; // [section]
$eq = strpos($line, '=');
if ($eq === false) continue;
$key = trim(substr($line, 0, $eq));
if ($key === '') continue;
$val = ltrim(substr($line, $eq + 1));
if ($val !== '' && ($val[0] === '"' || $val[0] === "'")) {
$q = $val[0];
$end = strpos($val, $q, 1);
$val = ($end === false) ? substr($val, 1) : substr($val, 1, $end - 1);
} else {
// Strip an inline comment introduced by " #" or " ;".
foreach ([' #', ' ;'] as $c) {
$p = strpos($val, $c);
if ($p !== false) $val = substr($val, 0, $p);
}
$val = rtrim($val);
}
$out[$key] = $val;
}
return $out;
}
$GLOBALS['__RADIO_ENV'] = [];
$envPath = __DIR__ . '/.env';
if (is_file($envPath)) {
$GLOBALS['__RADIO_ENV'] = radio_parse_env($envPath);
}
/**
* Read a raw string value from the environment / .env.
*/
function radio_env_raw($key, $default = null) {
if (array_key_exists($key, $GLOBALS['__RADIO_ENV'])) {
return $GLOBALS['__RADIO_ENV'][$key];
}
$v = getenv($key);
return ($v === false) ? $default : $v;
}
/** Read a string, trimming surrounding whitespace. */
function radio_env($key, $default = '') {
$v = radio_env_raw($key, null);
if ($v === null || $v === '') return $default;
return trim($v);
}
/** Read a boolean. Accepts 1/true/yes/on (case-insensitive). */
function radio_env_bool($key, $default = false) {
$v = radio_env_raw($key, null);
if ($v === null || $v === '') return $default;
return in_array(strtolower(trim($v)), ['1', 'true', 'yes', 'on'], true);
}
/** Read an integer with a floor. */
function radio_env_int($key, $default = 0, $min = null) {
$v = radio_env_raw($key, null);
$n = ($v === null || $v === '') ? $default : (int) $v;
if ($min !== null && $n < $min) $n = $min;
return $n;
}
/* -------------------------------------------------------------------- */
/* Build the config array */
/* -------------------------------------------------------------------- */
$config = [
// ---- Branding & metadata --------------------------------------
'site' => [
'name' => radio_env('SITE_NAME', 'My Radio'),
'short_name' => radio_env('SITE_SHORT_NAME', radio_env('SITE_NAME', 'My Radio')),
'tagline' => radio_env('SITE_TAGLINE', 'Streaming 24/7'),
'description' => radio_env('SITE_DESCRIPTION', 'Listen to my internet radio station 24/7.'),
'keywords' => radio_env('SITE_KEYWORDS', 'radio, internet radio, music, live stream'),
'url' => rtrim(radio_env('SITE_URL', ''), '/') . '/',
'locale' => radio_env('SITE_LOCALE', 'en_US'),
'lang' => radio_env('SITE_LANG', 'en'),
'author' => radio_env('SITE_AUTHOR', ''),
],
// ---- SEO / icons ----------------------------------------------
'seo' => [
'og_image' => radio_env('OG_IMAGE', ''), // absolute URL or path
'og_width' => radio_env('OG_IMAGE_WIDTH', ''),
'og_height' => radio_env('OG_IMAGE_HEIGHT', ''),
'icons_dir' => rtrim(radio_env('ICONS_DIR', 'assets/icons'), '/'),
'show_icons' => radio_env_bool('SHOW_ICONS', true),
'theme_color' => radio_env('THEME_COLOR', radio_env('THEME_ACCENT', '#e21d31')),
'tile_color' => radio_env('TILE_COLOR', radio_env('THEME_COLOR', '#5e656e')),
],
// ---- Stream & now-playing -------------------------------------
'stream' => [
'url' => radio_env('STREAM_URL', ''), // <audio> source
'type' => radio_env('STREAM_TYPE', 'audio/mpeg'),
],
// ---- Offline fallback -----------------------------------------
// Shown/played when the now-playing provider reports the stream is
// offline. The fallback audio loops like a "be right back" bed.
'offline' => [
'stream_url' => radio_env('OFFLINE_STREAM_URL', ''), // e.g. assets/audio/offline.mp3
'stream_type' => radio_env('OFFLINE_STREAM_TYPE', 'audio/mpeg'),
'art' => radio_env('OFFLINE_ART', 'assets/img/offline.svg'),
'name' => radio_env('OFFLINE_NAME', 'Off air'),
'message' => radio_env('OFFLINE_MESSAGE', ''),
'loop' => radio_env_bool('OFFLINE_LOOP', true),
],
'nowplaying' => [
// provider: icecast | azuracast | shoutcast | custom
'provider' => strtolower(radio_env('NOWPLAYING_PROVIDER', 'icecast')),
// URL the *server* fetches (may be an internal address).
'url' => radio_env('NOWPLAYING_URL', ''),
'mount' => radio_env('ICECAST_MOUNT', ''), // optional, picks a source
'delimiter' => radio_env_raw('TITLE_DELIMITER', ' - '), // splits "Artist<delim>Title"
'poll' => radio_env_int('NOWPLAYING_POLL_SECONDS', 10, 3),
'timeout' => radio_env_int('NOWPLAYING_TIMEOUT', 5, 1),
// Strings/patterns stripped from the title (e.g. " (Remastered)").
// Separate entries with "||". Set CLEANUP_IS_REGEX=true to treat
// each entry as a case-insensitive regex.
'cleanup' => array_values(array_filter(array_map('trim',
explode('||', radio_env('TITLE_CLEANUP', ''))), 'strlen')),
'cleanup_is_regex' => radio_env_bool('CLEANUP_IS_REGEX', false),
// custom-provider JSON field paths (dot notation, arrays allowed: a.b.0.c)
'custom' => [
'artist' => radio_env('CUSTOM_ARTIST_PATH', ''),
'title' => radio_env('CUSTOM_TITLE_PATH', ''),
'album' => radio_env('CUSTOM_ALBUM_PATH', ''),
'art' => radio_env('CUSTOM_ART_PATH', ''),
'listeners' => radio_env('CUSTOM_LISTENERS_PATH', ''),
// If you only have a combined "Artist - Title" field, set this
// instead of artist/title and it will be split on the delimiter.
'title_combined' => radio_env('CUSTOM_TITLE_COMBINED_PATH', ''),
],
],
// ---- Artwork & lyrics -----------------------------------------
'artwork' => [
// source: auto | provider | genius | none
// auto = provider art if present, else Genius (if configured), else fallback
// provider = only use art the stream provider gives us
// genius = always look art up on Genius
// none = always use the fallback image
'source' => strtolower(radio_env('ARTWORK_SOURCE', 'auto')),
'fallback' => radio_env('FALLBACK_ART', 'assets/img/placeholder.svg'),
// Optional Last.fm fallback when Genius has no cover.
'lastfm_key' => radio_env('LASTFM_API_KEY', ''),
],
'lyrics' => [
'enabled' => radio_env_bool('LYRICS_ENABLED', true),
// Prefer the first English-language match on Genius (a few extra
// API calls). Off by default.
'prefer_english' => radio_env_bool('LYRICS_PREFER_ENGLISH', false),
],
'genius' => [
// Base64-encoded Genius bearer token (kept for compatibility with
// the original project). You can also set GENIUS_TOKEN as plain text.
'token' => (function () {
$b64 = radio_env('GENIUS_API_BASE64', '');
if ($b64 !== '') {
$decoded = base64_decode($b64, true);
if ($decoded !== false) return trim($decoded);
}
return radio_env('GENIUS_TOKEN', '');
})(),
],
// ---- dumb (https://github.com/rramiachraf/dumb) ----------------
// A self-hosted or public Genius frontend/proxy. When enabled, it
// replaces the official Genius API as the lyrics/artwork source (no
// GENIUS_TOKEN needed) by scraping its server-rendered pages instead.
// Public instances: https://github.com/rramiachraf/dumb#public-instances
'dumb' => [
'url' => rtrim(radio_env('DUMB_URL', ''), '/'),
'enabled' => radio_env_bool('USE_DUMB', false) && radio_env('DUMB_URL', '') !== '',
],
// ---- Theme & colors -------------------------------------------
'theme' => [
'bg' => radio_env('THEME_BG', '#000000'),
'bg_mobile' => radio_env('THEME_BG_MOBILE', '#262626'),
'surface' => radio_env('THEME_SURFACE', '#1a1a1a'),
'text' => radio_env('THEME_TEXT', '#ffffff'),
'muted' => radio_env('THEME_MUTED', '#bfbfbf'),
'accent' => radio_env('THEME_ACCENT', '#e21d31'),
'accent_hover' => radio_env('THEME_ACCENT_HOVER', '#cf1b2d'),
'accent_text' => radio_env('THEME_ACCENT_TEXT', '#000000'),
'radius' => radio_env('THEME_RADIUS', '15px'),
'font' => radio_env('THEME_FONT', 'Helvetica, Calibri, Tahoma, Verdana, Arial, sans-serif'),
// Recolor the page background to the album art's average color,
// switching text light/dark for contrast.
'dynamic_bg' => radio_env_bool('THEME_DYNAMIC_BG', true),
// "Liquid Glass" refraction effect (menu/lyrics/mute/playpause
// buttons) in place of a flat THEME_ACCENT fill. See
// https://github.com/nikdelvin/liquid-glass
'liquid_glass' => radio_env_bool('THEME_LIQUID_GLASS', false),
],
// ---- Player ---------------------------------------------------
'player' => [
// rich = custom play/pause + seek/volume/time; simple = native <audio controls>
'style' => strtolower(radio_env('PLAYER_STYLE', 'rich')),
'autoplay' => radio_env_bool('PLAYER_AUTOPLAY', false),
// Show OS/lock-screen media controls via the MediaSession API.
'mediasession' => radio_env_bool('MEDIASESSION', true),
// Swap the OpenGraph image + favicon to the current album art.
'dynamic_meta' => radio_env_bool('DYNAMIC_META', true),
],
// ---- Waveform visualizer --------------------------------------
// Real-time scrolling waveform (Web Audio) shown after the listener
// presses play. Off by default. It routes playback through Web Audio,
// so a CROSS-ORIGIN stream must send CORS headers (see WAVEFORM_STREAM_CORS)
// or it is auto-disabled to protect audio.
'waveform' => [
'enabled' => radio_env_bool('WAVEFORM_ENABLED', false),
'height' => radio_env_int('WAVEFORM_HEIGHT', 72, 16),
'color' => radio_env('WAVEFORM_COLOR', ''), // empty => theme accent
'window' => radio_env_int('WAVEFORM_WINDOW_SECONDS', 30, 4),
'cors_ok' => radio_env_bool('WAVEFORM_STREAM_CORS', false),
// Which visualizer styles are exposed to visitors at all (comma
// list). 'bars'/'line'/'vu'/'spectrum'/'spectrum-mirror'/'blob'
// compete for the strip below the art (one at a time); 'circular'
// (ring around the art) and 'glow' (art pulses) can each combine
// with a strip choice and with each other.
'types' => (function () {
$known = ['bars', 'line', 'vu', 'spectrum', 'spectrum-mirror', 'blob', 'circular', 'glow'];
$listed = array_values(array_filter(array_map('trim', explode(',', radio_env('WAVEFORM_TYPES', 'bars')))));
$types = array_values(array_intersect($known, $listed));
return $types ?: ['bars'];
})(),
'default' => null, // resolved just below, once 'types' is known
],
// ---- Listeners / extras ---------------------------------------
'features' => [
'show_listeners' => radio_env_bool('SHOW_LISTENERS', true),
'listeners_link' => radio_env('LISTENERS_LINK', ''), // optional external URL
'show_history' => radio_env_bool('SHOW_HISTORY', true),
],
// ---- Analytics (optional) -------------------------------------
'analytics' => [
'matomo_url' => radio_env('MATOMO_URL', ''),
'matomo_site_id' => radio_env('MATOMO_SITE_ID', ''),
],
// ---- Runtime --------------------------------------------------
'runtime' => [
'timezone' => radio_env('TIMEZONE', 'UTC'),
'debug' => radio_env_bool('DEBUG', false),
'show_errors' => radio_env_bool('SHOW_ERRORS', false),
// Sent with every outbound request this server makes (Genius,
// Last.fm, LRCLIB, custom now-playing feeds). Override per
// deployment so multiple stations identify themselves distinctly.
'user_agent' => radio_env('OUTBOUND_USER_AGENT', 'radio/2026.07 (+https://git.dou.bet/iamdoubz/radio)'),
],
];
// Resolve the default active waveform type(s): explicit WAVEFORM_DEFAULT
// filtered down to what's actually exposed, else 'bars' if exposed, else
// the first exposed strip type, else nothing (overlays only, if any).
$defaultListed = array_values(array_filter(array_map('trim', explode(',', radio_env('WAVEFORM_DEFAULT', '')))));
$default = array_values(array_intersect($config['waveform']['types'], $defaultListed));
if (!$default) {
if (in_array('bars', $config['waveform']['types'], true)) {
$default = ['bars'];
} else {
$stripTypes = ['bars', 'line', 'vu', 'spectrum', 'spectrum-mirror', 'blob'];
$firstStrip = array_values(array_intersect($config['waveform']['types'], $stripTypes));
$default = $firstStrip ? [$firstStrip[0]] : [];
}
}
$config['waveform']['default'] = $default;
$GLOBALS['__RADIO_CONFIG'] = $config;
date_default_timezone_set($config['runtime']['timezone']);
}
/** Return the whole config array. */
function config() {
return $GLOBALS['__RADIO_CONFIG'];
}
/** Dot-path getter into the config array: cfg('theme.accent'). */
function cfg($path, $default = null) {
$node = $GLOBALS['__RADIO_CONFIG'];
foreach (explode('.', $path) as $key) {
if (is_array($node) && array_key_exists($key, $node)) {
$node = $node[$key];
} else {
return $default;
}
}
return $node;
}
/** HTML-escape helper. */
function e($str) {
return htmlspecialchars((string) $str, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/** Is the stream on the same origin as the site? (relative/unknown => yes) */
function radio_stream_same_origin() {
$streamHost = parse_url(cfg('stream.url'), PHP_URL_HOST);
if ($streamHost === null || $streamHost === false || $streamHost === '') return true;
$siteHost = parse_url(cfg('site.url'), PHP_URL_HOST);
if ($siteHost === null || $siteHost === false || $siteHost === '') return true;
return strcasecmp($streamHost, $siteHost) === 0;
}
/**
* Whether the waveform visualizer may run: enabled AND the stream is either
* same-origin or the operator has confirmed the (cross-origin) stream sends
* CORS headers. Routing cross-origin audio through Web Audio without CORS
* silences it, so we auto-disable rather than risk breaking playback.
*/
function radio_waveform_active() {
return cfg('waveform.enabled') && (radio_stream_same_origin() || cfg('waveform.cors_ok'));
}
/**
* The subset of config that is safe and useful to expose to the browser.
* Emitted as `window.RADIO_CONFIG` by the page.
*/
function radio_client_config() {
$artworkAvailable = cfg('genius.token') !== '' || cfg('artwork.lastfm_key') !== '' || cfg('dumb.enabled');
return [
'pollSeconds' => cfg('nowplaying.poll'),
'artworkSource' => cfg('artwork.source'),
'artworkLookup' => $artworkAvailable && !in_array(cfg('artwork.source'), ['none', 'provider'], true),
'fallbackArt' => cfg('artwork.fallback'),
'lyricsEnabled' => cfg('lyrics.enabled'),
'showListeners' => cfg('features.show_listeners'),
'showHistory' => cfg('features.show_history'),
'listenersLink' => cfg('features.listeners_link'),
'playerStyle' => cfg('player.style'),
'autoplay' => cfg('player.autoplay'),
'dynamicBg' => cfg('theme.dynamic_bg'),
'liquidGlass' => cfg('theme.liquid_glass'),
'mediaSession' => cfg('player.mediasession'),
'dynamicMeta' => cfg('player.dynamic_meta'),
'siteName' => cfg('site.name'),
'tagline' => cfg('site.tagline'),
'offlineStreamUrl' => cfg('offline.stream_url'),
'offlineArt' => cfg('offline.art'),
'offlineName' => cfg('offline.name'),
'offlineMessage' => cfg('offline.message'),
'offlineLoop' => cfg('offline.loop'),
'waveform' => [
'active' => radio_waveform_active(),
'height' => cfg('waveform.height'),
'color' => cfg('waveform.color'),
'window' => cfg('waveform.window'),
'types' => cfg('waveform.types'),
'default' => cfg('waveform.default'),
],
];
}