Files
radio/api/lyrics.php
T

171 lines
6.8 KiB
PHP

<?php
/**
* api/lyrics.php
* --------------
* Lyrics lookup, tried in order: LRCLIB (free, no key, has synced lyrics),
* then dumb (if USE_DUMB is on), then scraping the public Genius song page
* (if a Genius token is configured). Each tier only runs if the previous one
* found nothing, so dumb and Genius can both be configured at once. Returns
* JSON:
*
* {
* "found": true|false,
* "lyrics": "plain text\nwith newlines",
* "synced": [{ "time": 12.34, "text": "…" }, …], // [] when unavailable
* "url": "…",
* "source": "lrclib" | "dumb" | "genius",
* "annotations": [{ "text": "…", "path": "…" }, …] // [] unless source is "dumb"
* }
*
* Lyrics are returned as plain text (tags/timestamps stripped) so the
* front-end can render them with textContent — no HTML injection from a
* third-party page. `synced` (line-level LRC timestamps, seconds) is only
* populated when LRCLIB has synced lyrics; the Genius fallback has no timing
* data so it's always [] there. `url` is '' for LRCLIB (the front-end only
* shows a "source" link for dumb/genius). `annotations` lists Genius
* annotation links found within the lyrics (dumb only — the front-end
* splices a 📝 marker into each matching span and fetches the annotation
* body from api/annotation.php on click).
*
* Note: the Genius fallback scrapes 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' => '', 'synced' => [], 'url' => '', 'annotations' => []];
if (!cfg('lyrics.enabled')) {
json_out($empty);
}
[$artist, $title] = request_track();
if ($artist === '' && $title === '') {
json_out($empty);
}
$timeout = 5;
/**
* Parse an LRC string into [{time, text}, …] sorted by time. A line can carry
* more than one leading "[mm:ss.xx]" tag (repeated text, e.g. a chorus reused
* at several timestamps) — each tag becomes its own entry. Non-timestamp
* metadata lines ("[ar:...]", "[ti:...]", …) don't match and are skipped.
*/
function parse_lrc($lrc) {
$out = [];
foreach (explode("\n", $lrc) as $line) {
if (!preg_match_all('/\[(\d+):(\d+(?:\.\d+)?)\]/', $line, $tags, PREG_SET_ORDER)) continue;
$text = trim((string) preg_replace('/\[\d+:\d+(?:\.\d+)?\]/', '', $line));
if ($text === '') continue;
foreach ($tags as $tag) {
$out[] = ['time' => ((int) $tag[1]) * 60 + (float) $tag[2], 'text' => $text];
}
}
usort($out, function ($a, $b) { return $a['time'] <=> $b['time']; });
return $out;
}
/* -- LRCLIB (primary, no key required) -------------------------------------- */
function lrclib_lyrics($artist, $title, $timeout) {
$url = 'https://lrclib.net/api/search?track_name=' . rawurlencode($title)
. '&artist_name=' . rawurlencode($artist);
$raw = http_get($url, $timeout);
if ($raw === null) return null;
$hits = json_decode($raw, true);
if (!is_array($hits) || count($hits) === 0) return null;
$synced = !empty($hits[0]['syncedLyrics']) ? parse_lrc((string) $hits[0]['syncedLyrics']) : [];
$lyrics = trim((string) ($hits[0]['plainLyrics'] ?? ''));
if ($lyrics === '' && !empty($synced)) {
$lyrics = implode("\n", array_map(function ($l) { return $l['text']; }, $synced));
}
return $lyrics === '' ? null : ['lyrics' => $lyrics, 'synced' => $synced];
}
$lrc = lrclib_lyrics($artist, $title, $timeout);
if ($lrc !== null) {
json_out(['found' => true, 'lyrics' => $lrc['lyrics'], 'synced' => $lrc['synced'], 'url' => '', 'source' => 'lrclib', 'annotations' => []]);
}
/* -- dumb (tried before Genius; falls through to it if nothing found) ------- */
if (cfg('dumb.enabled')) {
$path = dumb_search($artist, $title, $timeout);
if ($path !== '') {
$song = dumb_song($path, $timeout);
if ($song !== null && $song['lyrics'] !== '') {
json_out([
'found' => true, 'lyrics' => $song['lyrics'], 'synced' => [],
'url' => cfg('dumb.url') . $path, 'source' => 'dumb',
'annotations' => $song['annotations'],
]);
}
}
// Nothing on dumb — fall through to the Genius API below.
}
/* -- Genius (fallback) ------------------------------------------------------- */
if (cfg('genius.token') === '') json_out($empty);
$token = cfg('genius.token');
$headers = ['Authorization: Bearer ' . $token];
/* -- 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);
$hits = dot_get($json, 'response.hits', []);
if (!is_array($hits) || count($hits) === 0) json_out($empty);
$pageUrl = (string) dot_get($hits, '0.result.url', '');
// Optionally prefer the first English-language match (a few extra API calls).
if (cfg('lyrics.prefer_english')) {
$checked = 0;
foreach ($hits as $h) {
if ($checked++ >= 5) break;
$id = dot_get($h, 'result.id', null);
if (!$id) continue;
$songRaw = http_get('https://api.genius.com/songs/' . rawurlencode((string) $id), $timeout, $headers);
if ($songRaw === null) continue;
if (dot_get(json_decode($songRaw, true), 'response.song.language', '') === 'en') {
$pageUrl = (string) dot_get($h, 'result.url', $pageUrl);
break;
}
}
}
if ($pageUrl === '') json_out($empty);
/* -- Scrape the lyric containers ------------------------------------------- */
$page = http_get($pageUrl, $timeout);
if ($page === null) {
json_out(['found' => false, 'lyrics' => '', 'synced' => [], 'url' => $pageUrl, 'source' => 'genius', 'annotations' => []]);
}
$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' => '', 'synced' => [], 'url' => $pageUrl, 'source' => 'genius', 'annotations' => []]);
}
json_out(['found' => true, 'lyrics' => $lyrics, 'synced' => [], 'url' => $pageUrl, 'source' => 'genius', 'annotations' => []]);