Files
ytplayer/server/notes.test.js

248 lines
12 KiB
JavaScript

// Shared lyrics/chapters routes against a real temp libsql DB and a Hono app.
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Hono } from 'hono';
const root = mkdtempSync(join(tmpdir(), 'ytp-notes-test-'));
process.env.DB_PATH = join(root, 'test.db');
const dbmod = await import('./db.js');
const N = await import('./notes.js');
const VID = 'dQw4w9WgXcQ';
const PASSWORD = 'correct horse';
let app;
let captionCalls = 0;
const VTT = `WEBVTT
Kind: captions
00:00:01.000 --> 00:00:03.000
[Music]
00:00:04.000 --> 00:00:06.000 align:start position:0%
first<00:00:04.500><c> line</c>
00:00:06.000 --> 00:00:06.010
first line
00:00:06.010 --> 00:00:08.000
first line
second &amp; line
`;
beforeAll(async () => {
await dbmod.initDb();
const htmlPath = join(root, 'admin.html');
writeFileSync(htmlPath, '<!doctype html><title>admin</title>');
// Captions: yt-dlp -J is faked; its vtt URL is served by a local server.
const vttServer = Bun.serve({ port: 0, fetch: () => new Response(VTT) });
app = new Hono();
N.registerNoteRoutes(app, {
db: dbmod,
getProfile: async (name) => (name === 'josh' ? { data: '{}' } : null),
profileNameRe: /^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$/,
runYtdlp: async () => {
captionCalls++;
return JSON.stringify({
language: 'en',
automatic_captions: {
'en-orig': [{ ext: 'vtt', url: `http://localhost:${vttServer.port}/c.vtt` }],
fr: [{ ext: 'vtt', url: 'http://invalid/fr.vtt' }],
},
});
},
adminPassword: PASSWORD,
backupDir: join(root, 'backups'),
adminHtmlPath: htmlPath,
workerToken: 'w'.repeat(32),
});
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
const json = (method, body, headers = {}) => ({
method, headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body),
});
async function adminCookie() {
const res = await app.request('/api/admin/login', json('POST', { password: PASSWORD }));
expect(res.status).toBe(200);
return res.headers.get('set-cookie').split(';')[0];
}
describe('pure helpers', () => {
test('sanitizeLyrics drops junk and clamps values', () => {
const d = N.sanitizeLyrics({
lines: [{ t: 5.555, text: ' hi\u0007 ', kind: 'line' }, { t: -1, text: 'x', kind: 'weird' }, { text: '' }, null],
tags: ['Key G', '', 'x'.repeat(80)],
offset: 99,
});
expect(d.lines).toEqual([{ t: 5.56, text: 'hi', kind: 'line' }, { t: null, text: 'x', kind: 'line' }]);
expect(d.tags).toEqual(['Key G', 'x'.repeat(40)]);
expect(d.offset).toBe(30);
});
test('sanitizeChapters requires time + title and sorts', () => {
const d = N.sanitizeChapters({ items: [{ t: 30, title: 'B' }, { t: 5, title: 'A', note: 'n' }, { t: null, title: 'no time' }] });
expect(d.items).toEqual([{ t: 5, title: 'A', note: 'n' }, { t: 30, title: 'B', note: '' }]);
});
test('parseVtt collapses rolling auto captions and drops sound tags', () => {
expect(N.parseVtt(VTT)).toEqual([{ t: 4, text: 'first line' }, { t: 6.01, text: 'second & line' }]);
});
test('parseVtt joinCue merges a wrapped human subtitle into one line', () => {
const vtt = 'WEBVTT\n\n00:00:01.200 --> 00:00:04.000\nAll right, so here we are,\nin front of the elephants\n';
expect(N.parseVtt(vtt, { joinCue: true })).toEqual([{ t: 1.2, text: 'All right, so here we are, in front of the elephants' }]);
});
test('pickCaptionTrack prefers human subs, else original-language auto captions', () => {
const manual = N.pickCaptionTrack({ language: 'tl', subtitles: { en: [{ ext: 'vtt', url: 'e' }], tl: [{ ext: 'vtt', url: 't' }] } });
expect(manual).toEqual({ lang: 'tl', auto: false, url: 't' });
const auto = N.pickCaptionTrack({ automatic_captions: { es: [{ ext: 'vtt', url: 's' }], 'tl-orig': [{ ext: 'vtt', url: 'o' }] } });
expect(auto).toEqual({ lang: 'tl', auto: true, url: 'o' });
expect(N.pickCaptionTrack({ automatic_captions: { es: [{ ext: 'vtt', url: 's' }] } })).toBeNull();
});
test('cleanTitle / cleanArtist strip YouTube noise', () => {
expect(N.cleanTitle('Hosanna - Hillsong Worship (Official Live Video) [HD]')).toBe('Hosanna - Hillsong Worship');
expect(N.cleanTitle('Kay Buti-Buti Mo Panginoon Karaoke | Minus-One | Instrumental')).toBe('Kay Buti-Buti Mo Panginoon');
expect(N.cleanTitle('Still (Lyrics)')).toBe('Still');
expect(N.cleanArtist('Hillsong Worship - Topic')).toBe('Hillsong Worship');
expect(N.cleanArtist('BethelVEVO')).toBe('Bethel');
});
test('parseLrc reads synced and plain lyrics', () => {
const synced = N.parseLrc('[ar:Someone]\n[00:12.50]first line here\n[00:20.00][01:05.25]repeated line\n');
expect(synced).toEqual([
{ t: 12.5, text: 'first line here', kind: 'line' },
{ t: 20, text: 'repeated line', kind: 'line' },
{ t: 65.25, text: 'repeated line', kind: 'line' },
]);
const plain = N.parseLrc('one line\n\nanother line');
expect(plain).toEqual([{ t: null, text: 'one line', kind: 'line' }, { t: null, text: 'another line', kind: 'line' }]);
});
test('pickLrclib prefers a title match, synced lyrics and the closest duration', () => {
const list = [
{ trackName: 'As The Deer', duration: 120, plainLyrics: 'x' },
{ trackName: 'As The Deer', duration: 249, syncedLyrics: '[00:01.00]x', plainLyrics: 'x' },
{ trackName: 'As The Deer (Instrumental)', duration: 248, instrumental: true, plainLyrics: 'x' },
{ trackName: 'Something else', duration: 250, syncedLyrics: '[00:01.00]y' },
];
expect(N.pickLrclib(list, { title: 'As the Deer', duration: 249 }).duration).toBe(249);
expect(N.pickLrclib(list, { title: 'No such song', duration: 249 })).toBeNull();
});
test('admin cookie signature and expiry', () => {
const v = N.signAdminCookie('k', 2000);
expect(N.verifyAdminCookie('k', v, 1000)).toBe(true);
expect(N.verifyAdminCookie('k', v, 3000)).toBe(false);
expect(N.verifyAdminCookie('other', v, 1000)).toBe(false);
expect(N.verifyAdminCookie('k', '2000.forged', 1000)).toBe(false);
});
});
describe('routes', () => {
test('writing requires an existing profile', async () => {
const lyrics = { lines: [{ t: 1, text: 'hello', kind: 'line' }], tags: [], offset: 0 };
let res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: lyrics, baseRev: 0 }));
expect(res.status).toBe(401);
res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: lyrics, baseRev: 0, profile: 'nobody' }));
expect(res.status).toBe(401);
res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: lyrics, baseRev: 0, profile: 'josh' }));
expect(res.status).toBe(200);
expect((await res.json()).rev).toBe(1);
});
test('a stale baseRev gets 409 with the current copy', async () => {
const lyrics = { lines: [{ t: 2, text: 'stale edit', kind: 'line' }] };
const res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: lyrics, baseRev: 0, profile: 'josh' }));
expect(res.status).toBe(409);
const j = await res.json();
expect(j.current.rev).toBe(1);
expect(j.current.data.lines[0].text).toBe('hello');
});
test('GET returns both kinds; revisions are listed and readable', async () => {
await app.request(`/api/notes/${VID}/chapters`, json('PUT', { data: { items: [{ t: 42, title: 'Intro' }] }, baseRev: 0, profile: 'josh' }));
const all = await (await app.request(`/api/notes/${VID}`)).json();
expect(all.lyrics.rev).toBe(1);
expect(all.lyrics.updatedBy).toBe('josh');
expect(all.chapters.data.items[0].title).toBe('Intro');
const revs = await (await app.request(`/api/notes/${VID}/lyrics/revs`)).json();
expect(revs.revs.map((r) => r.rev)).toEqual([1]);
const one = await (await app.request(`/api/notes/${VID}/lyrics/revs/1`)).json();
expect(one.data.lines[0].text).toBe('hello');
expect((await app.request('/api/notes/not-an-id')).status).toBe(400);
});
test('captions preview parses the original-language auto track', async () => {
const j = await (await app.request(`/api/notes/${VID}/captions`)).json();
expect(j.ok).toBe(true);
expect(j.lang).toBe('en');
expect(j.lines.map((l) => l.text)).toEqual(['first line', 'second & line']);
});
test('admin login, API tokens, auto-inject and restore', async () => {
expect((await app.request('/api/admin/login', json('POST', { password: 'nope' }))).status).toBe(401);
expect((await app.request('/api/admin/tokens')).status).toBe(401);
const cookie = await adminCookie();
const created = await (await app.request('/api/admin/tokens', json('POST', { label: 'lyrics-bot' }, { Cookie: cookie }))).json();
expect(created.token.startsWith('ytp_')).toBe(true);
const auth = { Authorization: `Bearer ${created.token}` };
// Auto-inject refuses to clobber existing lyrics unless asked.
let res = await app.request(`/api/notes/${VID}/lyrics/auto`, json('POST', {}, auth));
expect(res.status).toBe(409);
res = await app.request(`/api/notes/${VID}/lyrics/auto`, json('POST', { overwrite: true }, auth));
const inj = await res.json();
expect(inj.ok).toBe(true);
expect(inj.rev).toBe(2);
expect(captionCalls).toBe(1); // preview result was reused
// A profile alone may not trigger injection.
expect((await app.request(`/api/notes/${VID}/lyrics/auto`, json('POST', { profile: 'josh' }))).status).toBe(401);
// Token PUT is attributed to the token and may force past a stale rev.
res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: { lines: [{ t: 3, text: 'api', kind: 'line' }] }, baseRev: 0, force: true }, auth));
expect((await res.json()).rev).toBe(3);
const cur = await (await app.request(`/api/notes/${VID}`)).json();
expect(cur.lyrics.updatedBy).toBe('api:lyrics-bot');
expect(cur.lyrics.source).toBe('api');
// Restore rev 1 → becomes rev 4 with the original text.
res = await app.request(`/api/admin/notes/${VID}/lyrics/restore`, json('POST', { rev: 1 }, { Cookie: cookie }));
expect((await res.json()).rev).toBe(4);
const after = await (await app.request(`/api/notes/${VID}`)).json();
expect(after.lyrics.data.lines[0].text).toBe('hello');
expect(after.lyrics.source).toBe('restore');
const recent = await (await app.request('/api/admin/notes/recent', { headers: { Cookie: cookie } })).json();
expect(recent.revs[0].rev).toBe(4);
// Media work list: token or admin only.
expect((await app.request('/api/admin/media')).status).toBe(401);
expect((await app.request('/api/admin/media', { headers: auth })).status).toBe(200);
// The lyrics-worker's env token works without a DB row.
const wk = { Authorization: `Bearer ${'w'.repeat(32)}` };
expect((await app.request('/api/admin/media', { headers: wk })).status).toBe(200);
res = await app.request(`/api/notes/${VID}/chapters`, json('PUT', { data: { items: [{ t: 5, title: 'W' }] }, baseRev: 0, force: true }, wk));
expect(res.status).toBe(200);
expect((await (await app.request(`/api/notes/${VID}`)).json()).chapters.updatedBy).toBe('api:lyrics-worker');
// Revoked tokens stop working.
await app.request(`/api/admin/tokens/${created.id}`, { method: 'DELETE', headers: { Cookie: cookie } });
res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: { lines: [] }, baseRev: 4 }, auth));
expect(res.status).toBe(401);
});
test('admin page is served', async () => {
const res = await app.request('/admin');
expect(res.status).toBe(200);
expect(await res.text()).toContain('admin');
});
});