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:
11 files changed
+129
-43
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user