171 lines
6.8 KiB
PHP
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' => []]);
|