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.
This commit is contained in:
Jonathan Sykes
2026-10-03 07:54:11 +08:00
parent 716b61ffee
commit 2b38717c05
23 changed files with 1181 additions and 48 deletions

View File

@@ -42,6 +42,8 @@ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypt
import { mkdirSync, readdirSync, unlinkSync, writeFileSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
import { registerAnalyticsRoutes } from './admin-analytics.js';
import { registerTranscriptionRoutes } from './transcriptions.js';
export const NOTE_KINDS = new Set(['lyrics', 'chapters']);
// A YouTube id or one of the server's own uploads (see uploads.js).
@@ -59,6 +61,12 @@ function cleanText(v, max) {
return String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, max);
}
function cleanLyricText(value) {
return String(value ?? '').replace(/\r\n?/g, '\n')
.replace(/[\u0000-\u0009\u000b-\u001f\u007f]+/g, ' ')
.split('\n').map(part => part.trim()).filter(Boolean).join('\n').trim().slice(0, MAX_LINE_CHARS);
}
function cleanTime(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
@@ -76,7 +84,7 @@ export function sanitizeLyrics(input) {
const lines = [];
for (const l of rawLines) {
if (!l || typeof l !== 'object') continue;
const text = cleanText(l.text, MAX_LINE_CHARS);
const text = cleanLyricText(l.text);
if (!text) continue;
const kind = l.kind === 'section' || l.kind === 'cue' ? l.kind : 'line';
lines.push({ t: cleanTime(l.t), text, kind });
@@ -426,7 +434,7 @@ export function registerNoteRoutes(app, deps) {
if (!who) return c.json({ ok: false, error: 'link an online profile to report a lyric line' }, 401);
if (who.invalid) return c.json({ ok: false, error: 'invalid API token' }, 401);
if (flagOverBudget(who.by)) return c.json({ ok: false, error: 'too many reports — wait a few minutes' }, 429);
const text = cleanText(body.text, MAX_LINE_CHARS);
const text = cleanLyricText(body.text);
if (!text) return c.json({ ok: false, error: 'which line? (text is missing)' }, 400);
try {
const lyr = (await db.getNotes(id)).lyrics;
@@ -662,6 +670,18 @@ export function registerNoteRoutes(app, deps) {
await next();
};
if (deps.analytics) registerAnalyticsRoutes(app, { adminAuth: requireAdminOrToken, runYtdlp, ...deps.analytics });
registerTranscriptionRoutes(app, {
db, adminAuth: requireAdminOrToken, sanitizeLyrics,
workerEnabled: !!workerToken && workerToken.length >= 24,
workerAuth: async (c, next) => {
const token = (c.req.header('authorization') || '').match(/^Bearer\s+(\S+)$/i)?.[1];
if (!workerToken || workerToken.length < 24 || !token || !safeEqual(token, workerToken)) return c.json({ ok: false, error: 'lyrics worker token required' }, 401);
await next();
},
});
// Saved (server-cached) videos with whether each already has lyrics — the
// work list for batch lyric injection.
app.get('/api/admin/media', requireAdminOrToken, async (c) => {