Files
ytplayer/server/notes.test.js
Jonathan Sykes 2b38717c05 Add admin analytics, metadata collection, and grouped lyric cues
Queue reviewable Whisper drafts from the song list and lyrics editor. Preserve line breaks within one timed cue across editing, saving, reporting, and service views.

Add storage and listening analytics with a durable metadata collector, related-search depth, video limits, thumbnail storage, and a browsable metadata library.
2026-10-03 07:55:33 +08:00

270 lines
13 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('lyrics retain tight line breaks while other metadata strips controls', () => {
const result = N.sanitizeLyrics({ lines: [{ t: 12, text: ' Because You are God\r\n You can do anything\u0007 ', kind: 'line' }], tags: ['Key\nG'] });
expect(result.lines).toEqual([{ t: 12, text: 'Because You are God\nYou can do anything', kind: 'line' }]);
expect(result.tags).toEqual(['Key G']);
});
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');
});
});
test('saving a grouped cue preserves one timestamp in current and revision documents', async () => {
const id = 'groupedCue1';
const lines = [{ t: 12, text: 'Because You are God\nYou can do anything', kind: 'line' }];
const response = await app.request(`/api/notes/${id}/lyrics`, json('PUT', { data: { lines }, baseRev: 0, profile: 'josh' }));
expect(response.status).toBe(200);
const current = await (await app.request(`/api/notes/${id}`)).json();
expect(current.lyrics.data.lines).toEqual(lines);
const revision = await (await app.request(`/api/notes/${id}/lyrics/revs/1`)).json();
expect(revision.data.lines).toEqual(lines);
const flag = await app.request(`/api/notes/${id}/flags`, json('POST', { text: lines[0].text, profile: 'josh', reason: 'words' }));
expect(flag.status).toBe(200);
const flags = await (await app.request(`/api/notes/${id}/flags`)).json();
expect(flags.flags[0].text).toBe(lines[0].text);
});