Refector code
This commit is contained in:
1 parent
6ab627ba60
commit
2fbbf4b0fc
23 files changed
+1518
-1069
No files matched your search
@@ -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)];
|
||||
}
|
||||
@@ -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
@@ -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]);
|
||||
@@ -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);
|
||||
Reference in new issue
Block a user