false, 'lyrics' => '', 'synced' => [], 'url' => ''];
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' => '']);
}
/* -- Genius (fallback) ------------------------------------------------------- */
if (cfg('genius.token') === '') json_out($empty);
$token = cfg('genius.token');
$headers = ['Authorization: Bearer ' . $token];
/**
* Return the inner HTML of a balanced
whose opening tag starts at $lt.
* Correctly handles nested
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), '
newlines before stripping tags.
$fragment = preg_replace('#
#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);
$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]);
}
$marker = 'data-lyrics-container="true"';
$pieces = [];
$offset = 0;
while (($mpos = strpos($page, $marker, $offset)) !== false) {
// Backtrack to the start of this opening
tag.
$lt = strrpos(substr($page, 0, $mpos), '
false, 'lyrics' => '', 'synced' => [], 'url' => $pageUrl]);
}
json_out(['found' => true, 'lyrics' => $lyrics, 'synced' => [], 'url' => $pageUrl]);