[FEAT] Add dumb as alternative to genius and lastfm #5

Merged
iamdoubz merged 6 commits from feat_dumb into main 2026-07-30 15:20:04 -05:00
10 changed files with 418 additions and 87 deletions

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)
+69 -23
View File
@@ -16,9 +16,11 @@ generic so anyone can drop in their own configuration.
- Custom player — play/pause, seek + volume, elapsed time, and a **LIVE** badge for live streams (or a plain native player with `PLAYER_STYLE=simple`).
- Now playing from **Icecast, AzuraCast, Shoutcast, or any JSON feed**, normalized server-side.
- Album art and lyrics via **Genius**, with an optional **Last.fm** art fallback.
- Album art and lyrics via **LRCLIB**, **[dumb](https://github.com/rramiachraf/dumb)**, and/or the **Genius API**, with an optional **Last.fm** art fallback — a link to the source appears in the lyrics panel.
- A single menu button (top-right) for the song history and waveform-style pickers, closing on an outside click.
- Optional **"Liquid Glass"** refraction effect on the menu/lyrics/playpause buttons (`THEME_LIQUID_GLASS`).
- Page background that recolors to the current album art (auto light/dark text).
- **MediaSession** integration so track info appears in OS/lock-screen media controls.
- **MediaSession** integration so track info (including album, once resolved) appears in OS/lock-screen media controls.
- Configurable title cleanup (strip `(Remastered)`, `[Explicit]`, `.mp3`, …).
- Recently-played history, live listener count, and optional Matomo analytics.
- Fully themeable from `.env` (CSS variables) — no CSS editing required.
@@ -28,8 +30,8 @@ generic so anyone can drop in their own configuration.
```
browser ──▶ index.php / assets/* (the page + player)
browser ──▶ api/nowplaying.php ──▶ Icecast / AzuraCast / Shoutcast / custom
browser ──▶ api/artwork.php ──▶ Genius API (optional)
browser ──▶ api/lyrics.php ──▶ Genius song page (optional)
browser ──▶ api/artwork.php ──▶ dumb / Genius API / Last.fm (optional, in that order)
browser ──▶ api/lyrics.php ──▶ LRCLIB / dumb / Genius song page (optional, in that order)
```
The browser only ever talks to *this* site. PHP fetches your stream backend
@@ -42,7 +44,7 @@ with nginx also proxying the audio stream from Icecast.
- PHP 7.4+ (8.x recommended) with cURL, served by any web server (nginx + PHP-FPM, Apache, Caddy…)
- A running stream with a now-playing endpoint — Icecast, AzuraCast, Shoutcast, or any JSON feed
- *(optional)* a free [Genius API token](https://genius.com/api-clients) for album art and lyrics
- *(optional)* a free [Genius API token](https://genius.com/api-clients) for album art and lyrics — or point `DUMB_URL` at a [dumb](https://github.com/rramiachraf/dumb) instance instead and skip the token entirely
## Quick start
@@ -65,8 +67,8 @@ documented list. The main groups are:
- **Branding & metadata** — `SITE_NAME`, `SITE_DESCRIPTION`, `SITE_KEYWORDS`, `SITE_URL`, `OG_IMAGE`.
- **Stream** — `STREAM_URL` (what the browser plays) and `STREAM_TYPE`.
- **Now playing** — `NOWPLAYING_PROVIDER`, `NOWPLAYING_URL`, and provider-specific keys (below).
- **Artwork & lyrics** — `ARTWORK_SOURCE`, `LYRICS_ENABLED`, `GENIUS_TOKEN`.
- **Theme & colors** — `THEME_BG`, `THEME_ACCENT`, `THEME_TEXT`, `THEME_FONT`, … (drive CSS variables directly).
- **Artwork & lyrics** — `ARTWORK_SOURCE`, `LYRICS_ENABLED`, `GENIUS_TOKEN`, `USE_DUMB` / `DUMB_URL`.
- **Theme & colors** — `THEME_BG`, `THEME_ACCENT`, `THEME_TEXT`, `THEME_FONT`, `THEME_LIQUID_GLASS`, … (drive CSS variables directly).
### Now-playing providers
@@ -90,32 +92,74 @@ treat each entry as a case-insensitive regex.
`ARTWORK_SOURCE` controls where cover art comes from:
- `auto` — use the art your provider supplies (e.g. AzuraCast); otherwise look it up on Genius; otherwise show the placeholder.
- `auto` — use the art your provider supplies (e.g. AzuraCast); otherwise look it up (dumb/Genius, see below); otherwise show the placeholder.
- `provider` — only ever use provider art.
- `genius` — always look art up on Genius.
- `genius` — always run the dumb/Genius lookup (the name is historical — with `USE_DUMB` on, dumb is tried first).
- `none` — always show `FALLBACK_ART`.
If Genius has no cover, set `LASTFM_API_KEY` to fall back to Last.fm artwork.
(`ARTWORK_SOURCE=lastfm` uses Last.fm only; `auto` tries Genius then Last.fm.)
Within the `genius`/`auto` lookup, each tier only runs if the previous one
found nothing, so you can configure several at once:
Lyrics require `LYRICS_ENABLED=true`. They're looked up on
[LRCLIB](https://lrclib.net/) first — free, no key needed — and only fall back
to scraping the public Genius song page if LRCLIB has nothing **and** a Genius
token is configured. If Genius changes their page layout, `api/lyrics.php`'s
scraper may need a tweak. Set `LYRICS_PREFER_ENGLISH=true` to prefer the first
English-language match when a song has several Genius entries (only applies
to the Genius fallback).
1. **dumb** (if `USE_DUMB=true`) — no API token needed, see below.
2. **Genius API** (if `GENIUS_TOKEN`/`GENIUS_API_BASE64` is set).
3. **Last.fm** (if `LASTFM_API_KEY` is set) — also used directly when `ARTWORK_SOURCE=lastfm`.
Lyrics require `LYRICS_ENABLED=true` and follow the same fall-through idea:
1. **[LRCLIB](https://lrclib.net/)** — free, no key, and the only source with synced (karaoke-style) timing.
2. **dumb** (if `USE_DUMB=true`).
3. **Genius** — scrapes the public song page (needs a token to find it via search first).
Set `LYRICS_PREFER_ENGLISH=true` to prefer the first English-language match
when a song has several Genius entries (only applies to the Genius tier).
When lyrics come from dumb or Genius, a small "Lyrics via …" link to the
source page appears under the lyrics; LRCLIB doesn't get one (it isn't a
web page).
#### dumb (Genius without a token)
[dumb](https://github.com/rramiachraf/dumb) is a lightweight, self-hostable
Genius frontend/proxy — it scrapes Genius's server-rendered pages instead of
using the (Cloudflare-gated) official API, so no `GENIUS_TOKEN` is required.
Point `DUMB_URL` at a [public instance](https://github.com/rramiachraf/dumb#public-instances)
(e.g. `https://dumb.ducks.party`) or your own:
```
USE_DUMB=true
DUMB_URL="https://dumb.ducks.party"
```
This is HTML-scraping (dumb has no JSON API), so it's as fragile as the
existing Genius scrape — if dumb changes its page templates, `dumb_search()`/
`dumb_song()` in `api/_bootstrap.php` may need updating.
### Player & appearance
- `PLAYER_STYLE` — `rich` (custom controls, default) or `simple` (native `<audio controls>`).
- `THEME_DYNAMIC_BG` — recolor the page background to the album art's average color, flipping text light/dark for contrast. Needs CORS-readable art to sample; it fails gracefully to your theme colors otherwise.
- `MEDIASESSION` — expose track title/artist/album/art to OS and lock-screen media controls.
- `MEDIASESSION` — expose track title/artist/album/art to OS and lock-screen media controls. The album shown is whatever artwork lookup resolved (dumb/Genius/Last.fm), not just the raw stream metadata.
- `PLAYER_AUTOPLAY` — attempt autoplay (most browsers block audio autoplay until the user interacts).
For a live stream the seek bar is replaced by a **LIVE** badge and an elapsed
counter, since seeking a live stream isn't meaningful.
A single menu button (☰, top-right) opens "Song List" and "Waveform Styles"
(when their features are enabled) — each toggles its own panel, and clicking
outside any open panel closes it. On iOS mobile the waveform-style picker is
hidden and the album art instead pulses with a fixed ambient CSS glow, since
the real audio-reactive glow toggle isn't reachable there.
#### Liquid Glass buttons
`THEME_LIQUID_GLASS=true` gives the menu, lyrics, and play/pause buttons a
frosted-glass refraction effect instead of a flat `THEME_ACCENT` fill —
ported from [nikdelvin/liquid-glass](https://github.com/nikdelvin/liquid-glass)
(an SVG `feDisplacementMap` filter applied via `backdrop-filter`, no new
dependency). It weakens to a clearer, less-distorted look on hover. Browsers
without `backdrop-filter: url()` support (e.g. Safari) get a plain blurred-glass
fallback instead. The volume/mute button is left out on purpose — it's a bare
icon with no button chrome to skin.
### Waveform visualizer
`WAVEFORM_ENABLED=true` shows a real-time visualizer that builds out as the
@@ -337,7 +381,9 @@ repackager and point iOS at that.
## Credits
Broadcast with [Icecast](https://icecast.org/) and
[Mixxx](https://mixxx.org/); artwork and lyrics via the
[Genius API](https://docs.genius.com/). Licensed under the terms in
[LICENSE](LICENSE).
Broadcast with [Icecast](https://icecast.org/) and [Mixxx](https://mixxx.org/);
artwork and lyrics via [LRCLIB](https://lrclib.net/), the
[Genius API](https://docs.genius.com/), and optionally
[dumb](https://github.com/rramiachraf/dumb). The Liquid Glass button effect
is ported from [nikdelvin/liquid-glass](https://github.com/nikdelvin/liquid-glass)
(MIT). Licensed under the terms in [LICENSE](LICENSE).
+132
View File
@@ -101,3 +101,135 @@ 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');
}
/**
* 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, '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');
if ($base === '' || $path === '') return null;
$html = http_get($base . $path, $timeout);
if ($html === null) return null;
$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) {
$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
// (#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;
}
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* api/annotation.php
* -------------------
* Fetches one Genius annotation's body text via a dumb instance — dumb
* exposes annotations as JSON (unlike its HTML-only search/lyrics pages),
* see dumb_extract_annotations()/dumb_song() in _bootstrap.php. Only
* available when USE_DUMB is configured. Returns:
*
* { "found": true|false, "text": "plain text" }
*
* `path` must be exactly one of the paths api/lyrics.php returned in its
* `annotations[].path` (e.g. "/29595058/Kid-cudi-lord-i-know/The-world-is-
* steady.../annotations") — validated against a strict pattern before use
* so this endpoint can't become an open path-relative proxy for whatever
* host DUMB_URL points at.
*/
require_once __DIR__ . '/_bootstrap.php';
$empty = ['found' => false, 'text' => ''];
if (!cfg('dumb.enabled')) json_out($empty);
$path = (string) ($_POST['path'] ?? $_GET['path'] ?? '');
if (!preg_match('#^/\d+/[^/]+/[^/]+/annotations$#', $path)) json_out($empty);
$raw = http_get(cfg('dumb.url') . $path, 5);
if ($raw === null) json_out($empty);
$body = (string) dot_get(json_decode($raw, true), 'body.html', '');
if ($body === '') json_out($empty);
$text = trim(container_to_text($body));
json_out(['found' => $text !== '', 'text' => $text]);
+21 -1
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": "…" }
*
@@ -59,8 +61,26 @@ 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')) {
$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;
$headers = ['Authorization: Bearer ' . $token];
+37 -54
View File
@@ -2,22 +2,30 @@
/**
* 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:
* 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": "…"
* "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
* front-end can render them with textContent — no HTML injection from a
* 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.
* data so it's always [] there. `url` is '' for LRCLIB (the front-end only
* 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.
@@ -25,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);
@@ -78,7 +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' => '']);
json_out(['found' => true, 'lyrics' => $lrc['lyrics'], 'synced' => $lrc['synced'], 'url' => '', 'source' => 'lrclib', 'annotations' => []]);
}
/* -- dumb (tried before Genius; falls through to it if nothing found) ------- */
if (cfg('dumb.enabled')) {
$path = dumb_search($artist, $title, $timeout);
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'],
]);
}
}
// Nothing on dumb — fall through to the Genius API below.
}
/* -- Genius (fallback) ------------------------------------------------------- */
@@ -87,47 +111,6 @@ 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);
@@ -159,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]);
json_out(['found' => false, 'lyrics' => '', 'synced' => [], 'url' => $pageUrl, 'source' => 'genius', 'annotations' => []]);
}
$marker = 'data-lyrics-container="true"';
@@ -181,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]);
json_out(['found' => false, 'lyrics' => '', 'synced' => [], 'url' => $pageUrl, 'source' => 'genius', 'annotations' => []]);
}
json_out(['found' => true, 'lyrics' => $lyrics, 'synced' => [], 'url' => $pageUrl]);
json_out(['found' => true, 'lyrics' => $lyrics, 'synced' => [], 'url' => $pageUrl, 'source' => 'genius', 'annotations' => []]);
+23
View File
@@ -227,6 +227,29 @@ audio[controls] { width: 100%; outline: 0; }
.lyrics-line:hover, .lyrics-line:focus-visible { opacity: .85; outline: 0; }
.lyrics-line.current { opacity: 1; font-weight: 700; }
.lyrics-source {
display: block; margin-top: 14px;
font-size: 0.75rem; opacity: .6; color: inherit;
}
.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;
+78 -6
View File
@@ -37,6 +37,7 @@
lyricsToggle: $('lyrics-toggle'),
lyrics: $('lyrics'),
lyricsLines: $('lyrics-lines'),
lyricsSource: $('lyrics-source'),
lyricsSync: $('lyrics-sync'),
lyricsOffset: $('lyrics-offset'),
historyBody: $('history-body'),
@@ -699,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;
@@ -719,6 +779,9 @@
if (el.lyrics) el.lyrics.hidden = true;
if (el.lyricsLines) el.lyricsLines.textContent = '';
if (el.lyricsSync) el.lyricsSync.hidden = true;
if (el.lyricsSource) el.lyricsSource.hidden = true;
var SOURCE_LABEL = { genius: 'Genius', dumb: 'dumb' };
var body = new URLSearchParams();
body.set('artist', track.artist || '');
@@ -738,9 +801,18 @@
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;
}
// No link for LRCLIB (its url is always ''); Genius/dumb link to the
// page the lyrics were scraped from.
if (el.lyricsSource && res.url && SOURCE_LABEL[res.source]) {
el.lyricsSource.href = res.url;
el.lyricsSource.textContent = 'Lyrics via ' + SOURCE_LABEL[res.source];
el.lyricsSource.hidden = false;
}
el.lyricsToggle.hidden = false;
})
.catch(function () {});
+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'),
+3
View File
@@ -41,6 +41,7 @@ $glassFx = $liquidGlass ? '<span class="glass-fx" aria-hidden="true"></spa
</div>
<div id="lyrics" class="lyrics" hidden>
<div id="lyrics-lines" class="lyrics-lines"></div>
<a id="lyrics-source" class="lyrics-source" href="" target="_blank" rel="noopener" hidden></a>
</div>
<?php endif; ?>
</div>
@@ -79,10 +80,12 @@ $glassFx = $liquidGlass ? '<span class="glass-fx" aria-hidden="true"></spa
<span class="time" id="total-duration">00:00</span>
<span class="live-badge" id="live-badge" hidden><span class="live-dot"></span><span class="live-text">LIVE</span></span>
</div>
<!-- Hide volume control
<div class="vol-row">
<button id="mute" class="icon-btn" type="button" aria-label="Mute"><span class="btn-icon"></span></button>
<input type="range" id="volume" class="range volume" min="0" max="100" value="100" aria-label="Volume"/>
</div>
-->
<?php endif; ?>
</section>