Files
hub/tools/lyrics-test.js
T

79 lines
8.3 KiB
JavaScript

#!/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', 'lyrics-view.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_LRCLIB: '0', 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);
})();