[FEAT] Add dumb as alternative to genius and lastfm #5
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)
|
||||
|
||||
@@ -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).
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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' => []]);
|
||||
@@ -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
@@ -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
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user