Add dumb: a privacy focused genius frontend alternative
This commit is contained in:
1 parent
4b7f1c9c8d
commit
12bc10449e
5 files changed
+155
-46
No files matched your search
+10
-2
@@ -141,12 +141,20 @@ LYRICS_PREFER_ENGLISH=false
|
||||
|
||||
# Genius API token — optional, only used as a lyrics fallback when LRCLIB has
|
||||
# nothing (still used as the primary/only source for album art lookups).
|
||||
# Get one at https://genius.com/api-clients (Client Access Token).
|
||||
# Provide EITHER the plain token...
|
||||
# Ignored when USE_DUMB is on below. Get one at https://genius.com/api-clients
|
||||
# (Client Access Token). Provide EITHER the plain token...
|
||||
GENIUS_TOKEN=""
|
||||
# ...OR a base64-encoded token (kept for compatibility with the original project).
|
||||
GENIUS_API_BASE64=""
|
||||
|
||||
# dumb (https://github.com/rramiachraf/dumb): a lightweight Genius frontend/
|
||||
# proxy. When on, it replaces the official Genius API as the lyrics/artwork
|
||||
# source above — no GENIUS_TOKEN needed. Point it at a public instance (see
|
||||
# https://github.com/rramiachraf/dumb#public-instances, e.g.
|
||||
# https://dumb.ducks.party) or your own self-hosted one.
|
||||
USE_DUMB=false
|
||||
DUMB_URL=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Theme & colors (also drives CSS variables — restyle without editing CSS)
|
||||
|
||||
@@ -101,3 +101,109 @@ function request_track() {
|
||||
$title = $_POST['title'] ?? $_GET['title'] ?? '';
|
||||
return [trim((string) $artist), trim((string) $title)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the inner HTML of a balanced <div> whose opening tag starts at $lt.
|
||||
* Correctly handles nested <div> elements. Shared by the Genius and dumb
|
||||
* lyrics scrapers (both extract a lyrics container out of a raw HTML page).
|
||||
*/
|
||||
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), '</div', 5) === 0) {
|
||||
$depth--;
|
||||
if ($depth === 0) return substr($html, $start, $next - $start);
|
||||
$i = $next + 5;
|
||||
} elseif (strncasecmp(substr($html, $next, 4), '<div', 4) === 0) {
|
||||
$depth++;
|
||||
$i = $next + 4;
|
||||
} else {
|
||||
$i = $next + 1;
|
||||
}
|
||||
}
|
||||
return substr($html, $start, max(0, $i - $start));
|
||||
}
|
||||
|
||||
/** Turn one container's HTML into plain text with newlines. */
|
||||
function container_to_text($fragment) {
|
||||
// Line breaks -> newlines before stripping tags.
|
||||
$fragment = preg_replace('#<br\s*/?>#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;
|
||||
}
|
||||
|
||||
/**
|
||||
* dumb (https://github.com/rramiachraf/dumb): a self-hosted/public Genius
|
||||
* frontend that server-renders search + song pages. When USE_DUMB is on,
|
||||
* these replace the official Genius API as the lyrics/artwork source (no
|
||||
* GENIUS_TOKEN needed) — same idea as the Genius HTML scrape elsewhere in
|
||||
* this codebase, just against dumb's own pages instead of genius.com.
|
||||
* Fragile by nature (scrapes another project's HTML): if dumb changes its
|
||||
* templates, these regexes may need updating.
|
||||
*/
|
||||
|
||||
/** Search dumb for a track; returns the first song result's page path (e.g. "/Artist-title-lyrics"), or '' if none. */
|
||||
function dumb_search($artist, $title, $timeout) {
|
||||
$base = cfg('dumb.url');
|
||||
if ($base === '') return '';
|
||||
$query = rawurlencode(trim($artist . ' - ' . $title, ' -'));
|
||||
$html = http_get($base . '/search?q=' . $query, $timeout);
|
||||
if ($html === null) return '';
|
||||
if (!preg_match('/<h2>\s*Songs\s*<\/h2>.*?<a[^>]+id="search-item"[^>]+href="([^"]+)"/is', $html, $m)) return '';
|
||||
return html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function dumb_song($path, $timeout) {
|
||||
$base = cfg('dumb.url');
|
||||
if ($base === '' || $path === '') return null;
|
||||
$html = http_get($base . $path, $timeout);
|
||||
if ($html === null) return null;
|
||||
|
||||
$out = ['lyrics' => '', '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)));
|
||||
}
|
||||
|
||||
// Two <img id="album-artwork"> tags can appear: the song thumbnail
|
||||
// (#metadata, always present) and the album cover (#lyrics-album-
|
||||
// container, only when the song belongs to an album — marked with a
|
||||
// title="Album: …" attribute so the two are distinguishable).
|
||||
if (preg_match_all('/<img\b[^>]*>/i', $html, $imgs)) {
|
||||
foreach ($imgs[0] as $img) {
|
||||
if (strpos($img, 'id="album-artwork"') === false) continue;
|
||||
if (!preg_match('/\bsrc="([^"]+)"/i', $img, $src)) continue;
|
||||
$url = $base . html_entity_decode($src[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
if (preg_match('/\btitle="Album:\s*([^"]*)"/i', $img, $t)) {
|
||||
$out['album'] = html_entity_decode($t[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$out['art'] = $url;
|
||||
} elseif ($out['song_art'] === '') {
|
||||
$out['song_art'] = $url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
@@ -59,8 +59,22 @@ foreach ($overrides as $rule) {
|
||||
}
|
||||
}
|
||||
|
||||
/* -- dumb (Genius-API-free) ------------------------------------------------ */
|
||||
function dumb_artwork($artist, $title) {
|
||||
$path = dumb_search($artist, $title, 5);
|
||||
if ($path === '') return null;
|
||||
$song = dumb_song($path, 5);
|
||||
if ($song === null) return null;
|
||||
|
||||
$art = $song['art'] !== '' ? $song['art'] : $song['song_art'];
|
||||
if ($art === '' && $song['album'] === '') return null;
|
||||
return ['art' => $art, 'album' => $song['album'], 'url' => cfg('dumb.url') . $path];
|
||||
}
|
||||
|
||||
/* -- Genius --------------------------------------------------------------- */
|
||||
function genius_artwork($artist, $title) {
|
||||
if (cfg('dumb.enabled')) return dumb_artwork($artist, $title);
|
||||
|
||||
$token = cfg('genius.token');
|
||||
if ($token === '') return null;
|
||||
$headers = ['Authorization: Bearer ' . $token];
|
||||
|
||||
+15
-43
@@ -3,8 +3,9 @@
|
||||
* api/lyrics.php
|
||||
* --------------
|
||||
* Lyrics lookup. Tries LRCLIB first (free, no key, has synced lyrics), then
|
||||
* falls back to scraping the public Genius song page if LRCLIB has nothing
|
||||
* and a Genius token is configured. Returns JSON:
|
||||
* 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
|
||||
* JSON:
|
||||
*
|
||||
* {
|
||||
* "found": true|false,
|
||||
@@ -81,53 +82,24 @@ if ($lrc !== null) {
|
||||
json_out(['found' => true, 'lyrics' => $lrc['lyrics'], 'synced' => $lrc['synced'], 'url' => '']);
|
||||
}
|
||||
|
||||
/* -- dumb (Genius-API-free fallback) ----------------------------------------- */
|
||||
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]);
|
||||
}
|
||||
json_out(['found' => true, 'lyrics' => $song['lyrics'], 'synced' => [], 'url' => $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 <div> whose opening tag starts at $lt.
|
||||
* Correctly handles nested <div> 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), '</div', 5) === 0) {
|
||||
$depth--;
|
||||
if ($depth === 0) return substr($html, $start, $next - $start);
|
||||
$i = $next + 5;
|
||||
} elseif (strncasecmp(substr($html, $next, 4), '<div', 4) === 0) {
|
||||
$depth++;
|
||||
$i = $next + 4;
|
||||
} else {
|
||||
$i = $next + 1;
|
||||
}
|
||||
}
|
||||
return substr($html, $start, max(0, $i - $start));
|
||||
}
|
||||
|
||||
/** Turn one container's HTML into plain text with newlines. */
|
||||
function container_to_text($fragment) {
|
||||
// Line breaks -> newlines before stripping tags.
|
||||
$fragment = preg_replace('#<br\s*/?>#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);
|
||||
|
||||
+10
-1
@@ -197,6 +197,15 @@ if (!defined('RADIO_CONFIG_LOADED')) {
|
||||
return radio_env('GENIUS_TOKEN', '');
|
||||
})(),
|
||||
],
|
||||
// ---- dumb (https://github.com/rramiachraf/dumb) ----------------
|
||||
// A self-hosted or public Genius frontend/proxy. When enabled, it
|
||||
// replaces the official Genius API as the lyrics/artwork source (no
|
||||
// GENIUS_TOKEN needed) by scraping its server-rendered pages instead.
|
||||
// Public instances: https://github.com/rramiachraf/dumb#public-instances
|
||||
'dumb' => [
|
||||
'url' => rtrim(radio_env('DUMB_URL', ''), '/'),
|
||||
'enabled' => radio_env_bool('USE_DUMB', false) && radio_env('DUMB_URL', '') !== '',
|
||||
],
|
||||
|
||||
// ---- Theme & colors -------------------------------------------
|
||||
'theme' => [
|
||||
@@ -347,7 +356,7 @@ function radio_waveform_active() {
|
||||
* Emitted as `window.RADIO_CONFIG` by the page.
|
||||
*/
|
||||
function radio_client_config() {
|
||||
$artworkAvailable = cfg('genius.token') !== '' || cfg('artwork.lastfm_key') !== '';
|
||||
$artworkAvailable = cfg('genius.token') !== '' || cfg('artwork.lastfm_key') !== '' || cfg('dumb.enabled');
|
||||
return [
|
||||
'pollSeconds' => cfg('nowplaying.poll'),
|
||||
'artworkSource' => cfg('artwork.source'),
|
||||
|
||||
Reference in new issue
Block a user