From 09bf468fcf5d8f83404ffc471209e750d69bb496 Mon Sep 17 00:00:00 2001
From: iamdoubz <>
Date: Thu, 30 Jul 2026 15:11:39 -0500
Subject: [PATCH] Lyric fallback order: lrclib, dumb, genius, lastfm
---
api/_bootstrap.php | 36 +++++++++++++++++++---
api/artwork.php | 10 ++++--
api/lyrics.php | 51 ++++++++++++++++++-------------
assets/css/radio.css | 16 ++++++++++
assets/js/player.js | 73 ++++++++++++++++++++++++++++++++++++++++----
5 files changed, 152 insertions(+), 34 deletions(-)
diff --git a/api/_bootstrap.php b/api/_bootstrap.php
index f77e83f..e197b36 100644
--- a/api/_bootstrap.php
+++ b/api/_bootstrap.php
@@ -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('#]*>(.*?)#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' => ''];
//
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), '
tags can appear: the song thumbnail
diff --git a/api/artwork.php b/api/artwork.php
index 4131ad9..e0ab207 100644
--- a/api/artwork.php
+++ b/api/artwork.php
@@ -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;
diff --git a/api/lyrics.php b/api/lyrics.php
index c50a223..e488597 100644
--- a/api/lyrics.php
+++ b/api/lyrics.php
@@ -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' => []]);
diff --git a/assets/css/radio.css b/assets/css/radio.css
index c4e2f6a..b969d60 100644
--- a/assets/css/radio.css
+++ b/assets/css/radio.css
@@ -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;
diff --git a/assets/js/player.js b/assets/js/player.js
index 09eefeb..26068a8 100644
--- a/assets/js/player.js
+++ b/assets/js/player.js
@@ -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;
}