- full-screen lyrics view that follows the song: highlighted current line with a colour sweep (word by word when the source has word timing), tap a line to jump, text size, timing nudge, 'Wrong lyrics?' search - Karaoke mode: the current line large and centered - lyrics from Plex first, then the bundled Lyrica service (LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz); YouTube captions never used; wrong-length recordings refused; answers kept in /data/lyrics - lyrica/: Lyrica pinned to one commit, built by the workflow as hub-lyrica; compose service sharing the hub's network - tools/lyrics-test.js Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
bfd37967c3
commit
fc0ae05415
10 files changed
+397
-10
No files matched your search
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
// Lyrics: what Lyrica answers is turned into the player's format, wrong matches are refused, answers are remembered, and only people with the Music tab can ask.
|
||||
// Uses a fake Lyrica. Run: node tools/lyrics-test.js
|
||||
const { spawn } = require('child_process');
|
||||
const http = require('http');
|
||||
const fs = require('fs'), os = require('os'), path = require('path'), crypto = require('crypto');
|
||||
const root = path.join(__dirname, '..');
|
||||
let failed = 0;
|
||||
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
|
||||
const wait = ms => new Promise(r => setTimeout(r, ms));
|
||||
const SECRET = 'lyrics-test-secret-'.repeat(2);
|
||||
const lrcLib = require('../lyrics');
|
||||
|
||||
(async () => {
|
||||
// ---- the LRC reader (also used for lyrics that come from Plex)
|
||||
const l = lrcLib.parseLrc('[ti:x]\n[00:10.50]Hello there\n[00:14.00]<00:14.00>Second <00:14.80>line\n[00:20.00][00:30.25]Chorus\n');
|
||||
ok('LRC lines are read with their times', l.length === 4 && l[0].t === 10500 && l[0].text === 'Hello there' && l[0].e === 14000, l);
|
||||
ok('word timing inside a line is read', l[1].words && l[1].words.length === 2 && l[1].words[0].t === 14000 && l[1].words[1].t === 14800 && l[1].text === 'Second line', l[1]);
|
||||
ok('a line that repeats (two times) appears twice, in order', l[2].text === 'Chorus' && l[3].text === 'Chorus' && l[3].t === 30250, l.slice(2));
|
||||
const client = (() => { // the same reader in the app itself must agree
|
||||
const src = fs.readFileSync(path.join(root, 'public', 'app.js'), 'utf8'), a = src.indexOf('function lyrParseLrc'), b = src.indexOf('\n}\n', a) + 3;
|
||||
return new Function(src.slice(a, b) + '; return lyrParseLrc;')();
|
||||
})();
|
||||
ok('and the app reads it the same way', JSON.stringify(client('[ti:x]\n[00:10.50]Hello there\n[00:14.00]<00:14.00>Second <00:14.80>line\n[00:20.00][00:30.25]Chorus\n')) === JSON.stringify(l));
|
||||
|
||||
// ---- fake Lyrica
|
||||
const calls = []; let mode = 'ok';
|
||||
const fake = http.createServer((rq, rs) => {
|
||||
const u = new URL(rq.url, 'http://x'); calls.push(Object.fromEntries(u.searchParams));
|
||||
const j = (c, o) => { rs.writeHead(c, { 'content-type': 'application/json' }); rs.end(JSON.stringify(o)); };
|
||||
const song = u.searchParams.get('song');
|
||||
if (mode === 'busy') return j(429, { status: 'error' });
|
||||
if (mode === 'junk') { rs.writeHead(200); return rs.end('<html>nope'); }
|
||||
if (song === 'Yellow') return j(200, { status: 'success', data: { source: 'lrclib', artist: 'Coldplay', title: 'Yellow', duration: 268, hasTimestamps: true, lyrics: '[00:32.47]Look at the stars', timed_lyrics: [{ id: 'lrc_0', text: 'Look at the stars', start_time: 32470, end_time: 36650 }, { id: 'lrc_1', text: 'Look how they shine for you', start_time: 36650, end_time: 41520 }] } });
|
||||
if (song === 'Karaoke') return j(200, { status: 'success', data: { source: 'lrcmux', duration: 200, sync_level: 'word', timed_lyrics: [{ id: 'a', text: 'Hello you', start_time: 1000, end_time: 3000, words: [{ text: 'Hello', start: 1000, end: 1800 }, { text: ' ', start: 1800, end: 1900 }, { text: 'you', start: 1900, end: 3000 }] }] } });
|
||||
if (song === 'Plain Only') return j(200, { status: 'success', data: { source: 'genius', lyrics: 'First line\nSecond line\n\nThird' } });
|
||||
if (song === 'Captions') return j(200, { status: 'success', data: { source: 'youtube_subtitles', timed_lyrics: [{ text: 'noticed it getting more kind of like', start_time: 1, end_time: 2 }] } });
|
||||
if (song === 'Wrong Cut') return j(200, { status: 'success', data: { source: 'lrclib', duration: 600, timed_lyrics: [{ text: 'a live version', start_time: 1, end_time: 2 }] } });
|
||||
if (song === 'Quiet') return j(200, { status: 'success', data: { source: 'lrclib', instrumental: true } });
|
||||
j(200, { status: 'error', error: { message: 'No lyrics found' } });
|
||||
});
|
||||
await new Promise(r => fake.listen(0, '127.0.0.1', r));
|
||||
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lyrics-test-')), port = 20000 + Math.floor(Math.random() * 20000);
|
||||
fs.writeFileSync(path.join(dataDir, 'users.json'), JSON.stringify({ users: [
|
||||
{ id: 'uadmin1', name: 'Admin', role: 'admin', tabs: [], handle: 'h', epoch: 1, disabled: false, created: 1 },
|
||||
{ id: 'ufriend1', name: 'Friend', role: 'user', tabs: ['mail'], handle: 'h2', epoch: 1, disabled: false, created: 2 }] }));
|
||||
const child = spawn(process.execPath, ['server.js'], { cwd: root, env: { ...process.env, PORT: String(port), DATA_DIR: dataDir, COOKIE_SECRET: SECRET, LYRICS_API_URL: 'http://127.0.0.1:' + fake.address().port, SEAFILE_URL: 'http://127.0.0.1:9' } });
|
||||
child.stdout.on('data', () => {}); child.stderr.on('data', () => {}); await wait(1500);
|
||||
const base = 'http://127.0.0.1:' + port, exp = Date.now() + 864e5;
|
||||
const ck = uid => `hub_session=${uid}.1.${exp}.${crypto.createHmac('sha256', SECRET).update(`s${uid}.1.${exp}`).digest('base64url')}`;
|
||||
const get = (p, uid = 'uadmin1') => fetch(base + p, { redirect: 'manual', headers: { cookie: ck(uid) } });
|
||||
const ask = async (artist, title, dur) => { const r = await get('/api/lyrics?artist=' + encodeURIComponent(artist) + '&title=' + encodeURIComponent(title) + (dur ? '&duration=' + dur : '')); return { status: r.status, body: await r.json() }; };
|
||||
|
||||
let r = await get('/api/config'); ok('the app is told lyrics are available', (await r.json()).lyrics === '1');
|
||||
r = await get('/api/config', 'ufriend1'); ok('but not for someone without the Music tab', !(await r.json()).lyrics);
|
||||
r = await get('/api/lyrics?artist=a&title=b', 'ufriend1'); ok('who also cannot ask', r.status === 403, r.status);
|
||||
r = await ask('Coldplay', 'Yellow', 268);
|
||||
ok('line-synced lyrics come back in the player\'s format', r.body.found && r.body.sync === 'line' && r.body.lines.length === 2 && r.body.lines[0].t === 32470 && r.body.lines[0].e === 36650 && r.body.lines[1].text === 'Look how they shine for you', r.body);
|
||||
ok('Lyrica was asked only for real-lyrics sources, with word sync preferred', calls[0].sequence === '7,2,4,5,1' && calls[0].pass === 'true' && calls[0].timestamps === 'true' && calls[0].word === 'true', calls[0]);
|
||||
r = await ask('coldplay ', 'YELLOW'); ok('asking again (spelled a little differently) uses what was kept', r.body.found && calls.length === 1, calls.length);
|
||||
r = await ask('Someone', 'Karaoke'); ok('word-synced lyrics keep their words', r.body.sync === 'word' && r.body.lines[0].words.length === 2 && r.body.lines[0].words[1].text === 'you', r.body);
|
||||
r = await ask('Someone', 'Plain Only'); ok('lyrics without times come back as plain lines', r.body.found && r.body.sync === 'none' && r.body.lines.length === 4 && r.body.lines[0].text === 'First line', r.body);
|
||||
r = await ask('Someone', 'Captions'); ok('auto-captions are refused (they are not lyrics)', r.body.found === false, r.body);
|
||||
r = await ask('Someone', 'Wrong Cut', 200); ok('a recording of a very different length is refused', r.body.found === false && r.body.why === 'different version', r.body);
|
||||
r = await ask('Someone', 'Quiet'); ok('an instrumental is reported as one', r.body.found && r.body.instrumental === true, r.body);
|
||||
const n0 = calls.length; r = await ask('Nobody', 'Unknown Song'); const r2 = await ask('Nobody', 'Unknown Song');
|
||||
ok('"not found" is remembered for a while, not asked again at once', r.body.found === false && r2.body.found === false && calls.length === n0 + 1, calls.length - n0);
|
||||
r = await get('/api/lyrics?artist=x'); ok('a missing title is refused', r.status === 400, r.status);
|
||||
mode = 'busy'; r = await ask('Rate', 'Limited'); ok('when the lyrics service says it is busy, the app is told so', r.status === 429, r);
|
||||
mode = 'junk'; r = await ask('Rate', 'Garbled'); ok('and an unreadable answer is an error, not a "no lyrics"', r.status === 502, r);
|
||||
mode = 'ok'; r = await ask('Rate', 'Garbled'); ok('which is not remembered', r.status === 200, r);
|
||||
const par = await Promise.all([ask('Par', 'Allel'), ask('Par', 'Allel'), ask('Par', 'Allel')]); const cnt = calls.filter(c => c.song === 'Allel').length;
|
||||
ok('three people asking at once cause one lookup', cnt === 1, cnt);
|
||||
fake.close(); await wait(200); r = await ask('Zed', 'Offline'); ok('with Lyrica down, a clear error (and the hub keeps running)', r.status === 502 && (await get('/api/config')).status === 200, r);
|
||||
child.kill(); fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
console.log(failed ? '\n' + failed + ' check(s) failed' : '\nAll lyrics checks passed'); process.exit(failed ? 1 : 0);
|
||||
})();
|
||||
Reference in new issue
Block a user