// Song lyrics for the music player, from Lyrica (a lyrics service that tries several sources: LRCLIB, Musixmatch through Lrcmux, NetEase, Megalobiz). // The hub asks it for synced lyrics only from sources that return real lyrics (not auto-captions), checks that the answer is the same recording, // and keeps what it finds on disk so a song is looked up once. const fs = require('fs'); const atomicWrite = require('./atomic'); const path = require('path'); const crypto = require('crypto'); const cfg = { api: '', dir: '' }; 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 }); } const enabled = () => !!cfg.api; 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)); }; // "[01:23.45]text" lines (and enhanced "<01:23.45>word" tags) into timed lines; text without times stays plain. function parseLrc(text) { const t2ms = (m, s, f) => (+m * 60 + +s) * 1000 + Math.round(+('0.' + (f || '0')) * 1000); const lines = []; for (const raw of String(text || '').split(/\r?\n/)) { const tags = [...raw.matchAll(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g)]; if (!tags.length) continue; let body = raw.replace(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g, '').trim(), words = []; if (/<\d+:\d{1,2}/.test(body)) { // word timing inside the line const parts = [...body.matchAll(/<(\d+):(\d{1,2})(?:[.:](\d{1,3}))?>([^<]*)/g)]; words = parts.map(p => ({ t: t2ms(p[1], p[2], p[3]), text: p[4] })).filter(w => w.text.trim()); words.forEach((w, i) => { w.e = i + 1 < words.length ? words[i + 1].t : null; }); body = parts.map(p => p[4]).join('').replace(/\s+/g, ' ').trim(); } for (const g of tags) lines.push({ t: t2ms(g[1], g[2], g[3]), text: body, ...(words.length ? { words } : {}) }); } lines.sort((a, b) => a.t - b.t); lines.forEach((l, i) => { l.e = i + 1 < lines.length ? lines[i + 1].t : l.t + 5000; if (l.words) { const last = l.words[l.words.length - 1]; if (last.e == null) last.e = l.e; } }); return lines; } // What Lyrica answered, in the shape the player uses: { lines: [{ t, e, text, words? }], sync: 'word' | 'line' | 'none', ... } function shape(d, want) { if (!d || d.status === 'error' || !d.data) return { found: false, why: 'not found' }; const x = d.data, src = String(x.source || ''); if (/youtube/i.test(src)) return { found: false, why: 'only captions found' }; // auto-captions are not lyrics if (x.instrumental) return { found: true, instrumental: true, source: src, sync: 'none', lines: [] }; if (want && x.duration && Math.abs(+x.duration - want) > 10) return { found: false, why: 'different version' }; let lines = [], sync = 'none'; if (Array.isArray(x.timed_lyrics) && x.timed_lyrics.length) { lines = x.timed_lyrics.filter(l => String(l.text || '').trim() || l.words).map(l => { const o = { t: Math.round(l.start_time), e: Math.round(l.end_time || 0), text: String(l.text || '').trim() }; const words = Array.isArray(l.words) ? l.words.filter(w => String(w.text || '').trim()).map(w => ({ t: Math.round(w.start), e: Math.round(w.end), text: w.text })) : []; if (words.length) o.words = words; return o; }); sync = lines.some(l => l.words) ? 'word' : 'line'; } else if (/\[\d+:\d{2}/.test(x.lyrics || '')) { lines = parseLrc(x.lyrics); sync = lines.some(l => l.words) ? 'word' : 'line'; } else lines = String(x.lyrics || '').split(/\r?\n/).map(text => ({ text: text.trim() })); if (!lines.some(l => l.text)) return { found: false, why: 'empty' }; lines.forEach((l, i) => { if (sync !== 'none' && (!l.e || l.e <= l.t)) l.e = i + 1 < lines.length ? lines[i + 1].t : l.t + 5000; }); return { found: true, source: src, sync, lines, album: x.album || '' }; } async function lookup(artist, title, seconds) { const k = keyOf(artist, title), f = path.join(cfg.dir, k + '.json'); try { // already known? const c = JSON.parse(fs.readFileSync(f, 'utf8')), age = Date.now() - c.at; if (c.result && (c.result.found ? age < HIT_DAYS * 864e5 : age < MISS_HOURS * 36e5)) return c.result; } 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 */ } return result; })().finally(() => inflight.delete(k)); inflight.set(k, p); return p; } async function handle(req, res) { const u = new URL(req.url, 'http://x'); if (u.pathname !== '/api/lyrics') return json(res, 404, { error: 'Not found' }); const artist = String(u.searchParams.get('artist') || '').trim().slice(0, 200), title = String(u.searchParams.get('title') || '').trim().slice(0, 200), seconds = +u.searchParams.get('duration') || 0; if (!title) return json(res, 400, { error: 'Which song?' }); try { return json(res, 200, await lookup(artist, title, seconds)); } catch (e) { return json(res, e.status || 502, { error: e.status ? e.message : 'The lyrics service is not answering' }); } } module.exports = { init, handle, enabled, parseLrc, shape };