Add dumb: a privacy focused genius frontend alternative

This commit is contained in:
iamdoubz committed 2026-07-30 14:24:48 -05:00
1 parent 4b7f1c9c8d
commit 12bc10449e
5 files changed
+155 -46

No files matched your search

+10 -2
View File
@@ -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)
+106
View File
@@ -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;
}
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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'),