Lyric fallback order: lrclib, dumb, genius, lastfm
This commit is contained in:
+31
-5
@@ -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
@@ -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
@@ -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' => []]);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user