Security headers, cross-site write refusal, atomic JSON writes, admin backup download, /healthz and Docker health check, OnlyOffice requires a JWT secret, npm test, CI audit, arm64 hub image. New Links tab for personal bookmarks, saved with per-person settings. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
96 lines
6.1 KiB
JavaScript
96 lines
6.1 KiB
JavaScript
// 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 };
|