Files
radio/config.php
T
2026-07-24 13:58:13 -05:00

306 lines
14 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', '');
})(),
],
// ---- 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),
],
// ---- 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),
],
// ---- 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),
],
];
$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');
}
/**
* 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') !== '';
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') && cfg('genius.token') !== '',
'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'),
'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'),
];
}