Lyric fallback order: lrclib, dumb, genius, lastfm

This commit is contained in:
iamdoubz
2026-07-30 15:11:39 -05:00
parent 0bd54f7e3f
commit 09bf468fcf
5 changed files with 152 additions and 34 deletions
+31 -5
View File
@@ -165,11 +165,33 @@ function dumb_search($artist, $title, $timeout) {
return html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
/**
* Extract Genius annotation ("referent") links out of a raw lyrics HTML
* fragment. Returns an ordered list of ['text' => plain annotated lyric
* text, 'path' => dumb path to fetch the annotation JSON from]. Matched
* structurally — href = "/{id}/{song-slug}/{verse-slug}" — rather than by
* CSS class, since Genius's class names are build-hashed (styled-components)
* and change on their end without notice.
*/
function dumb_extract_annotations($fragment) {
$out = [];
if (!preg_match_all('#<a\s+href="(/\d+/[^"/]+/[^"/]+)"[^>]*>(.*?)</a>#is', $fragment, $matches, PREG_SET_ORDER)) {
return $out;
}
foreach ($matches as $m) {
$text = trim(container_to_text($m[2]));
if ($text === '') continue;
$out[] = ['text' => $text, 'path' => $m[1] . '/annotations'];
}
return $out;
}
/**
* Fetch + parse a dumb song page ($path from dumb_search()). Returns
* ['lyrics' => plain text, 'album' => name, 'art' => absolute album art url,
* 'song_art' => absolute song thumbnail url] (any field may be ''), or null
* if the page couldn't be fetched.
* ['lyrics' => plain text, 'annotations' => [{text, path}, …] (see
* dumb_extract_annotations), 'album' => name, 'art' => absolute album art
* url, 'song_art' => absolute song thumbnail url] (any field may be ''/[]),
* or null if the page couldn't be fetched.
*/
function dumb_song($path, $timeout) {
$base = cfg('dumb.url');
@@ -177,14 +199,18 @@ function dumb_song($path, $timeout) {
$html = http_get($base . $path, $timeout);
if ($html === null) return null;
$out = ['lyrics' => '', 'album' => '', 'art' => '', 'song_art' => ''];
$out = ['lyrics' => '', 'annotations' => [], 'album' => '', 'art' => '', 'song_art' => ''];
// <div id="lyrics"> wraps the raw Genius lyrics HTML that dumb already
// extracted server-side (no data-lyrics-container markers left in it).
$idPos = strpos($html, 'id="lyrics"');
if ($idPos !== false) {
$lt = strrpos(substr($html, 0, $idPos), '<div');
if ($lt !== false) $out['lyrics'] = trim(container_to_text(inner_balanced_div($html, $lt)));
if ($lt !== false) {
$fragment = inner_balanced_div($html, $lt);
$out['annotations'] = dumb_extract_annotations($fragment);
$out['lyrics'] = trim(container_to_text($fragment));
}
}
// Two <img id="album-artwork"> tags can appear: the song thumbnail
+8 -2
View File
@@ -4,7 +4,9 @@
* ---------------
* Optional album-art lookup. The browser calls this only when the stream
* provider itself did not supply artwork and ARTWORK_SOURCE allows a lookup.
* Tries Genius first, then Last.fm (whichever have keys configured). Returns:
* Tries dumb (if USE_DUMB is on), then the official Genius API (if a token
* is configured), then Last.fm — each tier only runs if the previous one
* found nothing, so dumb and Genius can both be configured at once. Returns:
*
* { "art": "https://…" | "", "album": "…", "url": "…" }
*
@@ -73,7 +75,11 @@ function dumb_artwork($artist, $title) {
/* -- Genius --------------------------------------------------------------- */
function genius_artwork($artist, $title) {
if (cfg('dumb.enabled')) return dumb_artwork($artist, $title);
if (cfg('dumb.enabled')) {
$res = dumb_artwork($artist, $title);
if ($res !== null) return $res;
// Nothing on dumb — fall through to the official API below.
}
$token = cfg('genius.token');
if ($token === '') return null;
+30 -21
View File
@@ -2,17 +2,19 @@
/**
* api/lyrics.php
* --------------
* Lyrics lookup. Tries LRCLIB first (free, no key, has synced lyrics), then
* falls back to dumb (if USE_DUMB is on) or scraping the public Genius song
* page (if a Genius token is configured) when LRCLIB has nothing. Returns
* 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"
* "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
@@ -20,7 +22,10 @@
* 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).
* 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.
@@ -28,7 +33,7 @@
require_once __DIR__ . '/_bootstrap.php';
$empty = ['found' => false, 'lyrics' => '', 'synced' => [], 'url' => ''];
$empty = ['found' => false, 'lyrics' => '', 'synced' => [], 'url' => '', 'annotations' => []];
if (!cfg('lyrics.enabled')) {
json_out($empty);
@@ -81,19 +86,23 @@ function lrclib_lyrics($artist, $title, $timeout) {
$lrc = lrclib_lyrics($artist, $title, $timeout);
if ($lrc !== null) {
json_out(['found' => true, 'lyrics' => $lrc['lyrics'], 'synced' => $lrc['synced'], 'url' => '', 'source' => 'lrclib']);
json_out(['found' => true, 'lyrics' => $lrc['lyrics'], 'synced' => $lrc['synced'], 'url' => '', 'source' => 'lrclib', 'annotations' => []]);
}
/* -- dumb (Genius-API-free fallback) ----------------------------------------- */
/* -- dumb (tried before Genius; falls through to it if nothing found) ------- */
if (cfg('dumb.enabled')) {
$path = dumb_search($artist, $title, $timeout);
if ($path === '') json_out($empty);
$song = dumb_song($path, $timeout);
$url = cfg('dumb.url') . $path;
if ($song === null || $song['lyrics'] === '') {
json_out(['found' => false, 'lyrics' => '', 'synced' => [], 'url' => $url, 'source' => 'dumb']);
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'],
]);
}
}
json_out(['found' => true, 'lyrics' => $song['lyrics'], 'synced' => [], 'url' => $url, 'source' => 'dumb']);
// Nothing on dumb — fall through to the Genius API below.
}
/* -- Genius (fallback) ------------------------------------------------------- */
@@ -133,7 +142,7 @@ 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']);
json_out(['found' => false, 'lyrics' => '', 'synced' => [], 'url' => $pageUrl, 'source' => 'genius', 'annotations' => []]);
}
$marker = 'data-lyrics-container="true"';
@@ -155,7 +164,7 @@ $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']);
json_out(['found' => false, 'lyrics' => '', 'synced' => [], 'url' => $pageUrl, 'source' => 'genius', 'annotations' => []]);
}
json_out(['found' => true, 'lyrics' => $lyrics, 'synced' => [], 'url' => $pageUrl, 'source' => 'genius']);
json_out(['found' => true, 'lyrics' => $lyrics, 'synced' => [], 'url' => $pageUrl, 'source' => 'genius', 'annotations' => []]);
+16
View File
@@ -234,6 +234,22 @@ audio[controls] { width: 100%; outline: 0; }
.lyrics-source:hover, .lyrics-source:focus-visible { opacity: 1; text-decoration: underline; }
.lyrics-source[hidden] { display: none; }
/* dumb-only: Genius annotations found in the lyrics (see renderAnnotatedLyrics). */
.lyrics-annotated { text-decoration: underline dotted; text-underline-offset: 3px; }
.annotation-toggle {
background: none; border: none; cursor: pointer; padding: 0 2px;
font-size: 0.85em; line-height: 1; vertical-align: baseline;
opacity: .75;
}
.annotation-toggle:hover, .annotation-toggle:focus-visible { opacity: 1; outline: 0; }
.annotation-body {
display: block; white-space: normal; /* .lyrics has white-space: pre-line; this is prose */
margin: 4px 0 8px; padding: 8px 10px;
background: rgba(255, 255, 255, 0.08); border-radius: 8px;
font-size: 0.85rem; opacity: .85;
}
.annotation-body[hidden] { display: none; }
.lyrics-sync {
position: absolute; top: 48px; right: 10px; z-index: 2;
display: flex; align-items: center; gap: 6px;
+67 -6
View File
@@ -700,17 +700,76 @@
// clock to that line, which is how we recover from loading mid-song (we
// have no reliable elapsed time on most providers) or from drift building
// up over a long track. The buffering-delay nudge above is left as-is.
// Same delegated listener also handles the 📝 annotation toggles dumb
// lyrics can have (see renderAnnotatedLyrics below).
if (el.lyricsLines) {
el.lyricsLines.addEventListener('click', function (e) {
var btn = e.target.closest('.lyrics-line');
if (!btn) return;
var line = syncedLines.filter(function (l) { return l.el === btn; })[0];
if (!line) return;
trackStartedAt = Date.now() + lyricsOffsetMs - line.time * 1000;
startKaraoke();
var lineBtn = e.target.closest('.lyrics-line');
if (lineBtn) {
var line = syncedLines.filter(function (l) { return l.el === lineBtn; })[0];
if (!line) return;
trackStartedAt = Date.now() + lyricsOffsetMs - line.time * 1000;
startKaraoke();
return;
}
var noteBtn = e.target.closest('.annotation-toggle');
if (noteBtn) toggleAnnotation(noteBtn);
});
}
// Splice a 📝 marker after each annotated span instead of one flat text
// blob, so a listener can open the annotation right under that lyric.
// `text` is the full plain-text lyrics; annotations are matched against it
// in order (moving the search cursor forward each time) rather than by
// index, since dumb only gives us the annotated substrings + their paths.
function renderAnnotatedLyrics(text, annotations) {
var cursor = 0;
annotations.forEach(function (a) {
var idx = text.indexOf(a.text, cursor);
if (idx === -1) return; // shouldn't happen; leave it out of the markup rather than misplace it
if (idx > cursor) el.lyricsLines.appendChild(document.createTextNode(text.slice(cursor, idx)));
var mark = document.createElement('span');
mark.className = 'lyrics-annotated';
mark.textContent = a.text;
el.lyricsLines.appendChild(mark);
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'annotation-toggle';
btn.textContent = '📝';
btn.setAttribute('aria-label', 'Show annotation');
btn.setAttribute('aria-expanded', 'false');
btn.dataset.path = a.path;
el.lyricsLines.appendChild(btn);
cursor = idx + a.text.length;
});
if (cursor < text.length) el.lyricsLines.appendChild(document.createTextNode(text.slice(cursor)));
}
function toggleAnnotation(btn) {
var body = btn.nextElementSibling;
if (body && body.classList.contains('annotation-body')) {
var show = body.hidden;
body.hidden = !show;
btn.setAttribute('aria-expanded', show ? 'true' : 'false');
return;
}
body = document.createElement('div');
body.className = 'annotation-body';
body.textContent = 'Loading…';
btn.insertAdjacentElement('afterend', body);
btn.setAttribute('aria-expanded', 'true');
var params = new URLSearchParams();
params.set('path', btn.dataset.path);
api('api/annotation.php', { method: 'POST', body: params })
.then(function (res) {
body.textContent = (res.found && res.text) ? res.text : 'No annotation found.';
})
.catch(function () {
body.textContent = 'Could not load annotation.';
});
}
function loadLyrics(track) {
if (!CFG.lyricsEnabled || !el.lyricsToggle) return;
el.lyricsToggle.hidden = true;
@@ -742,6 +801,8 @@
var panelOpen = el.lyrics && !el.lyrics.hidden;
if (el.lyricsSync) el.lyricsSync.hidden = !panelOpen;
if (panelOpen) startKaraoke();
} else if (res.annotations && res.annotations.length) {
renderAnnotatedLyrics(res.lyrics, res.annotations);
} else {
el.lyricsLines.textContent = res.lyrics;
}