// 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> line 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, 'admin'); // 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'}); }); test('admin song list includes metadata-only phone songs for lyrics lookup', async () => { const id = 'phoneSong01'; await dbmod.upsertMedia(id, { status: 'failed', duration: 240, meta: JSON.stringify({ title: 'Phone song', channel: 'Phone artist' }) }); const cookie = await adminCookie(); const res = await app.request('/api/admin/media', { headers: { Cookie: cookie } }); const song = (await res.json()).media.find(x => x.id === id); expect(song).toMatchObject({ title: 'Phone song', channel: 'Phone artist', duration: 240, cacheStatus: 'failed' }); });