Files
ytplayer/server/transcriptions.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

98 lines
7.5 KiB
JavaScript

import { 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-transcriptions-'));
process.env.DB_PATH = join(root, 'test.db');
const db = await import('./db.js');
const { registerNoteRoutes } = await import('./notes.js');
const VID = '0gfX0dFLaBc';
const TOKEN = 'worker-test-token-'.repeat(3);
const worker = { Authorization: 'Bearer ' + TOKEN };
const json = (body = {}, headers = {}) => ({ method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body) });
let app, cookie, job, lease;
beforeAll(async () => {
await db.initDb(); await db.upsertMedia(VID, { status: 'ready', duration: 240 });
await db.saveNote({ videoId: VID, kind: 'lyrics', baseRev: 0, source: 'user', updatedBy: 'fixture', data: { lines: [{ t: 1, text: 'Original saved lyrics', kind: 'line' }], tags: [], offset: 0 } });
writeFileSync(join(root, 'admin.html'), '<title>admin</title>');
app = new Hono();
registerNoteRoutes(app, { db, getProfile: async () => null, profileNameRe: /^[\w-]{3,40}$/, runYtdlp: async () => '{}', adminPassword: 'test-password', workerToken: TOKEN, backupDir: join(root, 'backups'), adminHtmlPath: join(root, 'admin.html') });
const login = await app.request('/api/admin/login', json({ password: 'test-password' }));
cookie = login.headers.get('set-cookie').split(';')[0];
});
afterAll(() => { db.db.close(); rmSync(root, { recursive: true, force: true }); });
const admin = () => ({ Cookie: cookie });
const draft = { lines: [{ t: 2, text: 'Because You are God', kind: 'line' }, { t: 6, text: 'You can do anything', kind: 'line' }], tags: ['wrong source'], offset: 0 };
test('admin and worker authentication are enforced separately', async () => {
expect((await app.request('/api/admin/transcriptions/' + VID, json())).status).toBe(401);
expect((await app.request('/api/admin/transcriptions/' + VID)).status).toBe(401);
expect((await app.request('/api/lyrics-worker/claim', json({}, admin()))).status).toBe(401);
});
test('two button requests deduplicate and preserve published lyrics', async () => {
const first = await app.request('/api/admin/transcriptions/' + VID, json({}, admin()));
expect(first.status).toBe(202); job = (await first.json()).job;
const second = await (await app.request('/api/admin/transcriptions/' + VID, json({}, admin()))).json();
expect(second.job.id).toBe(job.id); expect(job.baseRev).toBe(1);
expect((await db.getNotes(VID)).lyrics.data.lines[0].text).toBe('Original saved lyrics');
await db.initDb();
expect((await (await app.request('/api/admin/transcriptions/' + VID, { headers: admin() })).json()).job.id).toBe(job.id);
});
test('claiming is atomic and does not expose leases to the admin UI', async () => {
const responses = await Promise.all([app.request('/api/lyrics-worker/claim', json({}, worker)), app.request('/api/lyrics-worker/claim', json({}, worker))]);
const jobs = await Promise.all(responses.map(r => r.json()));
expect(jobs.filter(r => r.job)).toHaveLength(1); lease = jobs.find(r => r.job).job.lease;
expect(jobs.find(r => r.job).job.audioPath).toBe('/api/media/' + VID + '?a=1');
const view = await (await app.request('/api/admin/transcriptions/' + VID, { headers: admin() })).json();
expect(view.job.lease).toBeUndefined(); expect(view.workerOnline).toBe(true);
});
test('heartbeat advances stages and invalid leases cannot finish jobs', async () => {
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease: 'wrong', status: 'complete', result: draft }, worker))).status).toBe(409);
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease, stage: 'transcribing' }, worker))).status).toBe(200);
const view = await (await app.request('/api/admin/transcriptions/' + VID, { headers: admin() })).json();
expect(view.job.stage).toBe('transcribing');
});
test('completion returns a sanitized review draft without overwriting saved lyrics', async () => {
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease, status: 'complete', result: draft }, worker))).status).toBe(200);
const view = await (await app.request('/api/admin/transcriptions/' + VID, { headers: admin() })).json();
expect(view.job.status).toBe('complete'); expect(view.job.result.lines).toHaveLength(2);
expect(view.job.result.tags).toEqual(['auto-transcribed (whisper)']);
expect((await db.getNotes(VID)).lyrics.rev).toBe(1);
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease, status: 'failed' }, worker))).status).toBe(409);
});
test('expired jobs are reclaimed with a new lease; stale results are rejected', async () => {
const queued = await (await app.request('/api/admin/transcriptions/' + VID, json({}, admin()))).json();
expect(queued.job.id).not.toBe(job.id); job = queued.job;
const first = (await (await app.request('/api/lyrics-worker/claim', json({}, worker))).json()).job;
await db.db.execute({ sql: 'UPDATE lyric_transcriptions SET lease_until=0 WHERE id=?', args: [job.id] });
const reclaimed = (await (await app.request('/api/lyrics-worker/claim', json({}, worker))).json()).job;
expect(reclaimed.lease).not.toBe(first.lease);
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease: first.lease, status: 'complete', result: draft }, worker))).status).toBe(409);
lease = reclaimed.lease;
});
test('empty transcripts and oversized drafts are rejected, and failures retain saved lyrics', async () => {
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease, status: 'complete', result: { lines: [] } }, worker))).status).toBe(400);
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease, result: 'x'.repeat(400001) }, worker))).status).toBe(413);
expect((await app.request('/api/lyrics-worker/jobs/' + job.id, json({ lease, status: 'failed', error: 'No sung vocals were detected.' }, worker))).status).toBe(200);
const view = await (await app.request('/api/admin/transcriptions/' + VID, { headers: admin() })).json();
expect(view.job.error).toBe('No sung vocals were detected.'); expect((await db.getNotes(VID)).lyrics.rev).toBe(1);
});
test('uncached songs, invalid IDs, and overlong audio give usable errors', async () => {
expect((await app.request('/api/admin/transcriptions/nope', json({}, admin()))).status).toBe(400);
expect((await app.request('/api/admin/transcriptions/aaaaaaaaaaa', json({}, admin()))).status).toBe(409);
await db.upsertMedia('bbbbbbbbbbb', { status: 'ready', duration: 4000 });
expect((await app.request('/api/admin/transcriptions/bbbbbbbbbbb', json({}, admin()))).status).toBe(422);
});
test('uploads use the upload media endpoint and repeated worker crashes fail visibly', async () => {
const id = 'upl_0123456789ab';
await db.createUpload({ id, kind: 'audio', title: 'Fixture upload', duration: 30, ext: 'm4a', mime: 'audio/mp4', size: 100 });
await app.request('/api/admin/transcriptions/' + id, json({}, admin()));
const claim = (await (await app.request('/api/lyrics-worker/claim', json({}, worker))).json()).job;
expect(claim.audioPath).toBe('/api/uploads/' + id);
await db.db.execute({ sql: 'UPDATE lyric_transcriptions SET lease_until=0,attempts=3 WHERE id=?', args: [claim.id] });
await app.request('/api/lyrics-worker/claim', json({}, worker));
const view = await (await app.request('/api/admin/transcriptions/' + id, { headers: admin() })).json();
expect(view.job.status).toBe('failed');
});