274 lines
13 KiB
JavaScript
274 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 & 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);
|
|
});
|
|
test('secondary and phonetic lyric fields survive validation without changing old documents', () => {
|
|
const result = N.sanitizeLyrics({ lines:[{t:1,text:'主',kind:'line',secondary:' Your grace\n is enough ',phonetic:' zhǔ\u0007 '},{t:2,text:'Legacy',kind:'line'}] });
|
|
expect(result.lines[0].secondary).toBe('Your grace\nis enough'); expect(result.lines[0].phonetic).toBe('zhǔ'); expect(result.lines[1]).toEqual({t:2,text:'Legacy',kind:'line'});
|
|
});
|