Radio: artist and song in the player, lyrics for the song that is on (LRCLIB), synced for The Journey FM

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaC9HmtHMdRP8dAbN4wAKj
This commit is contained in:
Claude committed 2026-10-25 16:32:21 +00:00
1 parent 1e46aad9ea
commit 387736fb56
11 files changed
+129 -43

No files matched your search

+29 -11
View File
@@ -6,15 +6,16 @@ const atomicWrite = require('./atomic');
const path = require('path');
const crypto = require('crypto');
const cfg = { api: '', dir: '' };
const cfg = { api: '', dir: '', lrc: '' };
const HIT_DAYS = 90, MISS_HOURS = 24;
const inflight = new Map();
function init({ dataDir, apiUrl }) {
cfg.api = (apiUrl || '').replace(/\/$/, ''); cfg.dir = path.join(dataDir, 'lyrics');
if (cfg.api) fs.mkdirSync(cfg.dir, { recursive: true, mode: 0o700 });
cfg.lrc = process.env.LYRICS_LRCLIB === '0' ? '' : (process.env.LRCLIB_URL || 'https://lrclib.net').replace(/\/+$/, ''); // LRCLIB: free synced and plain lyrics, no key (set LYRICS_LRCLIB=0 to never ask it)
if (cfg.api || cfg.lrc) fs.mkdirSync(cfg.dir, { recursive: true, mode: 0o700 });
}
const enabled = () => !!cfg.api;
const enabled = () => !!(cfg.api || cfg.lrc);
const norm = s => String(s || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/\(.*?\)|\[.*?\]/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim();
const keyOf = (artist, title) => crypto.createHash('sha1').update(norm(artist) + '|' + norm(title)).digest('hex');
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
@@ -62,6 +63,19 @@ function shape(d, want) {
return { found: true, source: src, sync, lines, album: x.album || '' };
}
/** LRCLIB: the exact song first, then a search for the closest title. Synced lines when it has them, otherwise plain words. */
async function lrclib(artist, title, seconds) {
const get = async p => { const r = await fetch(cfg.lrc + p, { headers: { 'user-agent': 'NE-Hub/1.0 (self-hosted)' }, signal: AbortSignal.timeout(15000) }); return r.ok ? r.json().catch(() => null) : null; };
let x = await get('/api/get?' + new URLSearchParams({ artist_name: artist, track_name: title }));
if (!x || x.statusCode) { const list = await get('/api/search?' + new URLSearchParams({ track_name: title, ...(artist ? { artist_name: artist } : {}) })); x = Array.isArray(list) ? list.find(c => norm(c.trackName) === norm(title) && (!artist || norm(c.artistName).includes(norm(artist).split(' ')[0]) || norm(artist).includes(norm(c.artistName).split(' ')[0]))) : null; }
if (!x) return { found: false, why: 'not found' };
if (x.instrumental) return { found: true, instrumental: true, source: 'LRCLIB', sync: 'none', lines: [] };
if (seconds && x.duration && Math.abs(+x.duration - seconds) > 10) return { found: false, why: 'different version' };
const timed = /\[\d+:\d{2}/.test(x.syncedLyrics || '') ? parseLrc(x.syncedLyrics) : [];
if (timed.length >= 2) return { found: true, source: 'LRCLIB', sync: 'line', lines: timed.filter(l => l.text), album: x.albumName || '' };
const lines = String(x.plainLyrics || '').split(/\r?\n/).map(text => ({ text: text.trim() })); return lines.some(l => l.text) ? { found: true, source: 'LRCLIB', sync: 'none', lines, album: x.albumName || '' } : { found: false, why: 'empty' };
}
async function lookup(artist, title, seconds) {
const k = keyOf(artist, title), f = path.join(cfg.dir, k + '.json');
try { // already known?
@@ -70,14 +84,18 @@ async function lookup(artist, title, seconds) {
} catch { /* not cached */ }
if (inflight.has(k)) return inflight.get(k);
const p = (async () => {
const u = new URL(cfg.api + '/lyrics/'); // sources that give real lyrics, Musixmatch's word-synced first; never YouTube captions
u.search = new URLSearchParams({ artist, song: title, timestamps: 'true', word: 'true', sequence: '7,2,4,5,1', pass: 'true' });
const r = await fetch(u, { signal: AbortSignal.timeout(45000) });
if (r.status === 429) throw Object.assign(new Error('The lyrics service is busy; try again in a minute'), { status: 429 });
const d = await r.json().catch(() => null);
if (!d) throw Object.assign(new Error('The lyrics service gave an unreadable answer'), { status: 502 });
const result = shape(d, seconds);
try { atomicWrite(f, JSON.stringify({ at: Date.now(), artist, title, result }), { mode: 0o600 }); } catch { /* still answer */ }
let result = { found: false, why: 'not found' };
if (cfg.api) {
const u = new URL(cfg.api + '/lyrics/'); // sources that give real lyrics, Musixmatch's word-synced first; never YouTube captions
u.search = new URLSearchParams({ artist, song: title, timestamps: 'true', word: 'true', sequence: '7,2,4,5,1', pass: 'true' });
const r = await fetch(u, { signal: AbortSignal.timeout(45000) });
if (r.status === 429) throw Object.assign(new Error('The lyrics service is busy; try again in a minute'), { status: 429 });
const d = await r.json().catch(() => null);
if (!d) throw Object.assign(new Error('The lyrics service gave an unreadable answer'), { status: 502 });
result = shape(d, seconds);
}
if (!result.found && cfg.lrc && result.why !== 'different version') { try { const alt = await lrclib(artist, title, seconds); if (alt.found || !cfg.api) result = alt; } catch (e) { if (!cfg.api) throw Object.assign(new Error('The lyrics service is not answering'), { status: 502 }); } }
if (result.why !== 'different version') { try { atomicWrite(f, JSON.stringify({ at: Date.now(), artist, title, result }), { mode: 0o600 }); } catch { /* still answer */ } } // (whether a version is "different" depends on the length asked about, so that answer is not kept)
return result;
})().finally(() => inflight.delete(k));
inflight.set(k, p); return p;