Refector code

This commit is contained in:
iamdoubz committed 2026-07-24 05:34:07 -05:00
1 parent 6ab627ba60
commit 2fbbf4b0fc
23 files changed
+1518 -1069

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
<?php
/**
* api/_bootstrap.php
* ------------------
* Shared setup for every endpoint under /api: loads config, applies error
* settings, and provides small HTTP / JSON helpers.
*/
require_once __DIR__ . '/../config.php';
if (cfg('runtime.show_errors')) {
ini_set('display_errors', '1');
ini_set('display_startup_errors', '1');
error_reporting(E_ALL);
} else {
error_reporting(0);
}
/** Send a JSON response and stop. */
function json_out($data, $status = 200) {
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Cache-Control: no-cache, must-revalidate, max-age=0');
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
/** Plain-text response and stop (used by the lyrics endpoint). */
function text_out($text, $status = 200) {
http_response_code($status);
header('Content-Type: text/plain; charset=utf-8');
header('Access-Control-Allow-Origin: *');
header('Cache-Control: no-cache, must-revalidate, max-age=0');
echo $text;
exit;
}
/**
* Perform an HTTP GET and return the body (or null on failure).
* Falls back to file_get_contents when cURL is unavailable.
*/
function http_get($url, $timeout = 5, $headers = []) {
if ($url === '') return null;
if (function_exists('curl_init')) {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => (int) $timeout,
CURLOPT_TIMEOUT => (int) $timeout,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 3,
CURLOPT_USERAGENT => 'radio-site/1.0 (+https://github.com)',
]);
if (!empty($headers)) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
$body = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($body === false || $code >= 400) return null;
return $body;
}
// Fallback without cURL.
$ctx = stream_context_create([
'http' => [
'timeout' => (int) $timeout,
'header' => implode("\r\n", $headers),
'ignore_errors' => true,
],
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
]);
$body = @file_get_contents($url, false, $ctx);
return ($body === false) ? null : $body;
}
/**
* Read a value out of a decoded-JSON structure using dot notation.
* Supports array indexes: "a.b.0.c".
*/
function dot_get($data, $path, $default = null) {
if ($path === '' || $path === null) return $default;
$node = $data;
foreach (explode('.', $path) as $key) {
if (is_array($node) && array_key_exists($key, $node)) {
$node = $node[$key];
} elseif (is_object($node) && isset($node->$key)) {
$node = $node->$key;
} else {
return $default;
}
}
return $node;
}
/** Read the artist/title the browser sends to artwork/lyrics endpoints. */
function request_track() {
$artist = $_POST['artist'] ?? $_GET['artist'] ?? '';
$title = $_POST['title'] ?? $_GET['title'] ?? '';
return [trim((string) $artist), trim((string) $title)];
}
+82
View File
@@ -0,0 +1,82 @@
<?php
/**
* api/artwork.php
* ---------------
* Optional album-art lookup via the Genius API. The browser calls this only
* when the stream provider itself did not supply artwork and ARTWORK_SOURCE
* allows Genius. Returns JSON:
*
* { "art": "https://…" | "", "album": "…", "url": "https://genius.com/…" }
*
* With no Genius token configured it simply returns empty fields, and the
* front-end falls back to the configured placeholder image.
*/
require_once __DIR__ . '/_bootstrap.php';
$empty = ['art' => '', 'album' => '', 'url' => ''];
$token = cfg('genius.token');
$source = cfg('artwork.source');
if ($token === '' || $source === 'none' || $source === 'provider') {
json_out($empty);
}
[$artist, $title] = request_track();
if ($artist === '' && $title === '') {
json_out($empty);
}
/* -- Operator-defined overrides (config/overrides.json) -------------------- */
$overridesFile = __DIR__ . '/../config/overrides.json';
if (is_file($overridesFile)) {
$ov = json_decode((string) file_get_contents($overridesFile), true);
foreach (($ov['artwork'] ?? []) as $rule) {
$ra = $rule['artist'] ?? null;
$rt = $rule['title'] ?? null;
$artistMatch = ($ra === null || strcasecmp($ra, $artist) === 0);
$titleMatch = ($rt === null || strcasecmp($rt, $title) === 0);
if ($artistMatch && $titleMatch && !empty($rule['art'])) {
json_out(['art' => $rule['art'], 'album' => ($rule['album'] ?? ''), 'url' => '']);
}
}
}
/* -- Genius lookup --------------------------------------------------------- */
$headers = ['Authorization: Bearer ' . $token];
$timeout = 5;
$query = rawurlencode(trim($artist . ' - ' . $title, ' -'));
$search = http_get('https://api.genius.com/search?q=' . $query, $timeout, $headers);
if ($search === null) json_out($empty);
$json = json_decode($search, true);
$hit = dot_get($json, 'response.hits.0.result', null);
if (!$hit) json_out($empty);
$out = [
'art' => (string) ($hit['header_image_url'] ?? $hit['song_art_image_url'] ?? ''),
'album' => '',
'url' => (string) ($hit['url'] ?? ''),
];
// Pull the album name (and better art) from the full song record.
if (!empty($hit['id'])) {
$songRaw = http_get('https://api.genius.com/songs/' . rawurlencode((string) $hit['id']), $timeout, $headers);
if ($songRaw !== null) {
$song = json_decode($songRaw, true);
$s = dot_get($song, 'response.song', null);
if ($s) {
$out['album'] = (string) dot_get($s, 'album.name', '');
$betterArt = (string) ($s['song_art_image_url'] ?? $s['header_image_url'] ?? '');
if ($betterArt !== '') $out['art'] = $betterArt;
}
}
}
// Treat Genius' generic default cover as "no art" so the placeholder shows.
if (strpos($out['art'], 'default_cover_image') !== false) {
$out['art'] = '';
}
json_out($out);
+112
View File
@@ -0,0 +1,112 @@
<?php
/**
* api/lyrics.php
* --------------
* Optional lyrics lookup. Finds the song on Genius, then scrapes the lyric
* containers from the public song page. Returns JSON:
*
* { "found": true|false, "lyrics": "plain text\nwith newlines", "url": "…" }
*
* Lyrics are returned as plain text (tags stripped) so the front-end can render
* them with textContent — no HTML injection from a third-party page.
*
* Note: this scrapes Genius' HTML, which can change at any time. If Genius
* restructures their page the extraction may need updating.
*/
require_once __DIR__ . '/_bootstrap.php';
$empty = ['found' => false, 'lyrics' => '', 'url' => ''];
if (!cfg('lyrics.enabled') || cfg('genius.token') === '') {
json_out($empty);
}
[$artist, $title] = request_track();
if ($artist === '' && $title === '') {
json_out($empty);
}
$token = cfg('genius.token');
$timeout = 5;
$headers = ['Authorization: Bearer ' . $token];
/**
* Return the inner HTML of a balanced <div> whose opening tag starts at $lt.
* Correctly handles nested <div> elements.
*/
function inner_balanced_div($html, $lt) {
$open = strpos($html, '>', $lt);
if ($open === false) return '';
$i = $open + 1;
$start = $i;
$len = strlen($html);
$depth = 1;
while ($i < $len && $depth > 0) {
$next = strpos($html, '<', $i);
if ($next === false) break;
if (strncasecmp(substr($html, $next, 5), '</div', 5) === 0) {
$depth--;
if ($depth === 0) return substr($html, $start, $next - $start);
$i = $next + 5;
} elseif (strncasecmp(substr($html, $next, 4), '<div', 4) === 0) {
$depth++;
$i = $next + 4;
} else {
$i = $next + 1;
}
}
return substr($html, $start, max(0, $i - $start));
}
/** Turn one container's HTML into plain text with newlines. */
function container_to_text($fragment) {
// Line breaks -> newlines before stripping tags.
$fragment = preg_replace('#<br\s*/?>#i', "\n", $fragment);
// Drop any stray script/style.
$fragment = preg_replace('#<(script|style)\b[^>]*>.*?</\1>#is', '', $fragment);
// Block-level tags imply a line break.
$fragment = preg_replace('#</(p|div)>#i', "\n", $fragment);
$text = strip_tags($fragment);
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
return $text;
}
/* -- Find the song on Genius ----------------------------------------------- */
$query = rawurlencode(trim($artist . ' - ' . $title, ' -'));
$search = http_get('https://api.genius.com/search?q=' . $query, $timeout, $headers);
if ($search === null) json_out($empty);
$json = json_decode($search, true);
$pageUrl = (string) dot_get($json, 'response.hits.0.result.url', '');
if ($pageUrl === '') json_out($empty);
/* -- Scrape the lyric containers ------------------------------------------- */
$page = http_get($pageUrl, $timeout);
if ($page === null) {
json_out(['found' => false, 'lyrics' => '', 'url' => $pageUrl]);
}
$marker = 'data-lyrics-container="true"';
$pieces = [];
$offset = 0;
while (($mpos = strpos($page, $marker, $offset)) !== false) {
// Backtrack to the start of this opening <div ...> tag.
$lt = strrpos(substr($page, 0, $mpos), '<div');
if ($lt === false) { $offset = $mpos + strlen($marker); continue; }
$inner = inner_balanced_div($page, $lt);
$text = trim(container_to_text($inner));
if ($text !== '') $pieces[] = $text;
$offset = $mpos + strlen($marker);
}
$lyrics = trim(implode("\n\n", $pieces));
// Trim stray indentation left on each line, then collapse blank-line runs.
$lyrics = implode("\n", array_map('trim', explode("\n", $lyrics)));
$lyrics = preg_replace("/\n{3,}/", "\n\n", $lyrics);
if ($lyrics === '') {
json_out(['found' => false, 'lyrics' => '', 'url' => $pageUrl]);
}
json_out(['found' => true, 'lyrics' => $lyrics, 'url' => $pageUrl]);
+150
View File
@@ -0,0 +1,150 @@
<?php
/**
* api/nowplaying.php
* ------------------
* Fetches the station's current metadata from whatever backend the operator
* uses (Icecast, AzuraCast, Shoutcast, or a custom JSON feed) and returns a
* single normalized shape the browser can rely on:
*
* {
* "online": true|false,
* "artist": "…",
* "title": "…",
* "album": "…",
* "art": "…" | "", // empty if the provider has no art
* "listeners": 12 | null,
* "listeners_peak": 34 | null
* }
*
* Doing this server-side avoids browser CORS problems and hides internal
* backend addresses (e.g. 127.0.0.1 / LAN IPs) from visitors.
*/
require_once __DIR__ . '/_bootstrap.php';
$provider = cfg('nowplaying.provider');
$url = cfg('nowplaying.url');
$timeout = cfg('nowplaying.timeout');
$delim = cfg('nowplaying.delimiter');
$out = [
'online' => false,
'artist' => '',
'title' => '',
'album' => '',
'art' => '',
'listeners' => null,
'listeners_peak' => null,
];
/** Split "Artist <delim> Title" into [artist, title]. */
function split_title($combined, $delim) {
$combined = trim((string) $combined);
if ($combined === '') return ['', ''];
if ($delim !== '' && strpos($combined, $delim) !== false) {
$parts = explode($delim, $combined, 2);
return [trim($parts[0]), trim($parts[1])];
}
// No delimiter found — treat the whole thing as the title.
return ['', $combined];
}
$raw = http_get($url, $timeout);
if ($raw === null) {
json_out($out); // backend unreachable -> offline
}
$data = json_decode($raw, true);
if (!is_array($data)) {
json_out($out);
}
switch ($provider) {
/* --------------------------------------------------------------- */
case 'azuracast':
$np = $data['now_playing'] ?? [];
$song = $np['song'] ?? [];
$out['artist'] = (string) ($song['artist'] ?? '');
$out['title'] = (string) ($song['title'] ?? '');
$out['album'] = (string) ($song['album'] ?? '');
$out['art'] = (string) ($song['art'] ?? '');
if ($out['artist'] === '' && $out['title'] === '' && !empty($song['text'])) {
[$out['artist'], $out['title']] = split_title($song['text'], $delim);
}
$out['listeners'] = isset($data['listeners']['current']) ? (int) $data['listeners']['current'] : null;
$out['listeners_peak'] = isset($data['listeners']['total']) ? (int) $data['listeners']['total'] : null;
$out['online'] = !empty($data['is_online']) && ($out['title'] !== '' || $out['artist'] !== '');
break;
/* --------------------------------------------------------------- */
case 'shoutcast':
$combined = $data['streamtitle'] ?? $data['songtitle'] ?? '';
[$out['artist'], $out['title']] = split_title($combined, $delim);
$out['listeners'] = isset($data['currentlisteners']) ? (int) $data['currentlisteners'] : null;
$out['listeners_peak'] = isset($data['peaklisteners']) ? (int) $data['peaklisteners'] : null;
$out['online'] = ($out['title'] !== '' || $out['artist'] !== '');
break;
/* --------------------------------------------------------------- */
case 'custom':
$c = cfg('nowplaying.custom');
if (!empty($c['title_combined'])) {
[$out['artist'], $out['title']] = split_title(dot_get($data, $c['title_combined'], ''), $delim);
} else {
$out['artist'] = (string) dot_get($data, $c['artist'], '');
$out['title'] = (string) dot_get($data, $c['title'], '');
}
$out['album'] = (string) dot_get($data, $c['album'], '');
$out['art'] = (string) dot_get($data, $c['art'], '');
$listeners = dot_get($data, $c['listeners'], null);
$out['listeners'] = ($listeners === null) ? null : (int) $listeners;
$out['online'] = ($out['title'] !== '' || $out['artist'] !== '');
break;
/* --------------------------------------------------------------- */
case 'icecast':
default:
$ice = $data['icestats'] ?? [];
$source = $ice['source'] ?? null;
if ($source === null) break;
// "source" can be a single object or a list of mounts.
$sources = isset($source[0]) ? $source : [$source];
$mount = cfg('nowplaying.mount');
$chosen = null;
if ($mount !== '') {
foreach ($sources as $s) {
$listenurl = (string) ($s['listenurl'] ?? '');
if ($listenurl !== '' && substr($listenurl, -strlen($mount)) === $mount) {
$chosen = $s;
break;
}
}
}
if ($chosen === null) {
// First source that actually has a title, else the first one.
foreach ($sources as $s) {
if (!empty($s['title']) || !empty($s['artist'])) { $chosen = $s; break; }
}
if ($chosen === null) $chosen = $sources[0];
}
// Liquidsoap can emit artist/title separately; prefer that.
if (!empty($chosen['artist']) || !empty($chosen['title'])) {
$maybeArtist = (string) ($chosen['artist'] ?? '');
$maybeTitle = (string) ($chosen['title'] ?? '');
if ($maybeArtist !== '' && $maybeTitle !== '') {
$out['artist'] = trim($maybeArtist);
$out['title'] = trim($maybeTitle);
} else {
[$out['artist'], $out['title']] = split_title($maybeTitle !== '' ? $maybeTitle : $maybeArtist, $delim);
}
}
$out['listeners'] = isset($chosen['listeners']) ? (int) $chosen['listeners'] : null;
$out['listeners_peak'] = isset($chosen['listener_peak']) ? (int) $chosen['listener_peak'] : null;
$out['online'] = ($out['title'] !== '' || $out['artist'] !== '');
break;
}
json_out($out);