Merge codex/admin-ops: editor redesign, deep search, admin tools and ops

This commit is contained in:
Jonathan Sykes
2026-10-15 11:42:07 +08:00
83 changed files with 3463 additions and 196 deletions

1
.gitignore vendored
View File

@@ -54,3 +54,4 @@ plans/transport-shots/
# Search button visual review screenshots
plans/search-button-shots/
plans/search-button-final-shots/
plans/admin-ops-shots/

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ test('download and offline icons never use emoji or text glyphs', () => {
// Arrow keys are prose, not action icons. Exact lines prevent new exceptions.
const allow = new Map([
['index.html', new Set(['<div class="shortcut-row"><kbd>↑</kbd><kbd>↓</kbd><span>Volume up / down</span></div>'])],
['admin.html', new Set(['<span class="muted" style="font-size:12.5px">Shortcuts: <code>Space</code> stamp (Tap-sync) · <code>S</code> stamp selected · <code>K</code> play · <code>↑↓</code> select · <code>⌘/Ctrl S</code> save</span>'])],
['admin.html', new Set(['<div class="keyboard-hint" aria-label="Keyboard shortcuts"><strong>Shortcuts</strong> <kbd>Space</kbd> stamp in Tap-sync · <kbd>↑</kbd><kbd>↓</kbd> select · <kbd>S</kbd> stamp selected · <kbd>K</kbd> play · <kbd>⌘/Ctrl S</kbd> save</div>'])],
]);
const failures=[];
for (const file of readdirSync(__dirname).filter(f=>/\.(js|html|css)$/.test(f) && !f.endsWith('.test.js'))) {

View File

@@ -224,6 +224,7 @@
let m;
try { m = JSON.parse(e.data); } catch { return; }
if (m.type === 'hello') { myPeer = m.peer; wsRetry = 0; }
if (m.type === 'force-sync') sync();
emit(m);
};
sock.onclose = () => {

View File

@@ -521,3 +521,17 @@ contrast in Glass Stage at 320/390/768/1280px and dark/light/contrast themes. It
also captures Classic, Bento Hub, and Framework7 Swipe at 390/1280px to guard
their existing card presentation. Screenshots are written to
`plans/rec-card-shots/`.
## Admin operations screens
`npx playwright test -c playwright.admin-ops.config.js` checks the Performance,
P2P Devices, and Uploads admin tabs at 390/768/1280px in dark/light/contrast,
including AA body-text contrast, 44px controls, page overflow, and mobile
bottom-navigation clearance. Viewport screenshots are saved under
`plans/admin-ops-shots/` (ignored by Git).
For request-monitor overhead, run two local server instances against separate
data directories, one with `OPS_MONITOR=0` and one with monitoring enabled, then
run `OPS_BASE_URL=http://127.0.0.1:<off-port> OPS_ENABLED_URL=http://127.0.0.1:<on-port> node perf/admin-ops-bench.mjs`.
The harness interleaves 500 `/api/version` requests per mode and prints p50/p95,
mean latency, and the mean delta.

29
perf/admin-ops-bench.mjs Normal file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env node
// Compare local /api/version latency with request monitoring enabled and disabled.
// Start two otherwise identical server instances and set OPS_BASE_URL/OPS_ENABLED_URL.
const urls = { disabled: process.env.OPS_BASE_URL, enabled: process.env.OPS_ENABLED_URL };
if (!urls.disabled || !urls.enabled) {
console.error('Set OPS_BASE_URL and OPS_ENABLED_URL to local server URLs.');
process.exit(2);
}
const samples = { disabled: [], enabled: [] };
async function request(url) {
const start = performance.now();
const response = await fetch(`${url}/api/version`);
await response.arrayBuffer();
if (!response.ok) throw new Error(`${url} returned HTTP ${response.status}`);
return performance.now() - start;
}
for (const key of Object.keys(urls)) for (let i = 0; i < 30; i++) await request(urls[key]);
for (let i = 0; i < 500; i++) {
for (const key of i % 2 ? ['enabled', 'disabled'] : ['disabled', 'enabled']) samples[key].push(await request(urls[key]));
}
const percentile = (values, p) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * p) - 1];
const result = Object.fromEntries(Object.entries(samples).map(([key, values]) => [key, {
requests: values.length,
p50Ms: Number(percentile(values, .5).toFixed(3)),
p95Ms: Number(percentile(values, .95).toFixed(3)),
meanMs: Number((values.reduce((a, b) => a + b, 0) / values.length).toFixed(3)),
}]));
console.log(JSON.stringify({ route: '/api/version', pairs: samples.disabled.length, result,
meanDeltaPercent: Number(((result.enabled.meanMs / result.disabled.meanMs - 1) * 100).toFixed(2)) }, null, 2));

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -0,0 +1,3 @@
const { defineConfig } = require('@playwright/test');
const classic = require('./playwright.classic.config');
module.exports = defineConfig({ ...classic, testMatch: /admin-(audit|quality|cache|index|reports|backups|lyrics-search|bulk-lyrics|tools-shots)\.spec\.js/ });

View File

@@ -0,0 +1,3 @@
const { defineConfig } = require('@playwright/test');
const classic = require('./playwright.classic.config');
module.exports = defineConfig({ ...classic, testMatch: /admin-deep-search\.spec\.js/ });

View File

@@ -0,0 +1,3 @@
const { defineConfig } = require('@playwright/test');
const classic = require('./playwright.classic.config');
module.exports = defineConfig({ ...classic, testMatch: /admin-ops\.spec\.js/ });

464
server/admin-tools.js Normal file
View File

@@ -0,0 +1,464 @@
import { audit } from './audit-log.js';
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
const clientOf = db => typeof db?.execute === 'function' ? db : db?.db;
const parse = value => { try { return JSON.parse(value || '{}'); } catch { return {}; } };
const number = value => Number(value) || 0;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const zipCrcTable = Uint32Array.from({ length: 256 }, (_, value) => {
let crc = value; for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
return crc >>> 0;
});
function zipStore(files) {
const local = [], central = []; let offset = 0;
for (const file of files) {
const name = Buffer.from(file.name), data = Buffer.from(file.content);
let crc = 0xffffffff; for (const byte of data) crc = zipCrcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); crc = (crc ^ 0xffffffff) >>> 0;
const header = Buffer.alloc(30); header.writeUInt32LE(0x04034b50, 0); header.writeUInt16LE(20, 4); header.writeUInt16LE(0x0800, 6); header.writeUInt32LE(crc, 14); header.writeUInt32LE(data.length, 18); header.writeUInt32LE(data.length, 22); header.writeUInt16LE(name.length, 26);
local.push(header, name, data);
const directory = Buffer.alloc(46); directory.writeUInt32LE(0x02014b50, 0); directory.writeUInt16LE(20, 4); directory.writeUInt16LE(20, 6); directory.writeUInt16LE(0x0800, 8); directory.writeUInt32LE(crc, 16); directory.writeUInt32LE(data.length, 20); directory.writeUInt32LE(data.length, 24); directory.writeUInt16LE(name.length, 28); directory.writeUInt32LE(offset, 42);
central.push(directory, name); offset += header.length + name.length + data.length;
}
const centralBytes = Buffer.concat(central), localBytes = Buffer.concat(local), end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(files.length, 8); end.writeUInt16LE(files.length, 10); end.writeUInt32LE(centralBytes.length, 12); end.writeUInt32LE(localBytes.length, 16);
return Buffer.concat([localBytes, centralBytes, end]);
}
export function registerAdminToolsRoutes(app, { db, adminAuth, media = null, backupDir = null, backfillStep = null, pullLyrics = null, queueWhisper = null, autoStartJobs = true }) {
const sql = clientOf(db);
const backupFile = date => join(backupDir || '.', `notes-${date}.json`);
const backupDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value || '') && !Number.isNaN(Date.parse(`${value}T00:00:00Z`));
async function readBackup(date) {
if (!backupDate(date)) return null;
try {
const data = JSON.parse(await readFile(backupFile(date), 'utf8'));
if (!Array.isArray(data.notes) || data.notes.some(note => !note?.videoId || !['lyrics', 'chapters'].includes(note.kind) || !note.data)) return null;
return data;
} catch { return null; }
}
async function createBackup({ safety = false } = {}) {
if (!backupDir || typeof db.allNotes !== 'function') throw new Error('Backup storage unavailable');
await mkdir(safety ? join(backupDir, 'snapshots') : backupDir, { recursive: true });
const now = new Date();
const date = now.toISOString().slice(0, 10);
const file = safety ? join(backupDir, 'snapshots', `notes-${date}-${now.toISOString().slice(11, 19).replaceAll(':', '-')}-safety.json`) : backupFile(date);
const payload = { exportedAt: now.toISOString(), notes: await db.allNotes() };
await writeFile(file, JSON.stringify(payload));
return { file, date, notes: payload.notes.length };
}
app.get('/api/admin/backups', adminAuth, async c => {
if (!backupDir) return c.json({ ok: false, error: 'Backup storage unavailable' }, 503);
let names = []; try { names = await readdir(backupDir); } catch {}
const backups = [];
for (const name of names.filter(value => /^notes-\d{4}-\d{2}-\d{2}\.json$/.test(value)).sort().reverse()) {
const date = name.slice(6, 16); const data = await readBackup(date); const info = await stat(backupFile(date)).catch(() => null);
backups.push({ date, bytes: info?.size || 0, exportedAt: data?.exportedAt || null, notes: data?.notes.length || 0, valid: !!data });
}
return c.json({ ok: true, backups }, 200, { 'Cache-Control': 'no-store' });
});
app.get('/api/admin/backups/:date', adminAuth, async c => {
const data = await readBackup(c.req.param('date'));
if (!data) return c.json({ ok: false, error: 'Backup is missing or invalid' }, 404);
const live = await db.allNotes();
const index = rows => new Map(rows.map(row => [`${row.videoId}:${row.kind}`, JSON.stringify(row.data)]));
const old = index(data.notes), current = index(live);
const added = [...old.keys()].filter(key => !current.has(key));
const removed = [...current.keys()].filter(key => !old.has(key));
const changed = [...old.keys()].filter(key => current.has(key) && old.get(key) !== current.get(key));
return c.json({ ok: true, date: c.req.param('date'), exportedAt: data.exportedAt, total: data.notes.length,
diff: { added: added.length, removed: removed.length, changed: changed.length, preview: [...changed.slice(0, 30).map(key => ({ key, change: 'changed' })), ...added.slice(0, 15).map(key => ({ key, change: 'only in backup' })), ...removed.slice(0, 15).map(key => ({ key, change: 'only live' }))] } });
});
app.get('/api/admin/backups/:date/download', adminAuth, async c => {
const data = await readBackup(c.req.param('date'));
if (!data) return c.json({ ok: false, error: 'Backup is missing or invalid' }, 404);
c.header('Content-Disposition', `attachment; filename="notes-${c.req.param('date')}.json"`);
return c.json(data);
});
app.post('/api/admin/backups', adminAuth, async c => {
try {
const result = await createBackup();
await audit(db, 'admin', 'backup.create', result.date, { notes: result.notes });
return c.json({ ok: true, ...result });
} catch (error) { return c.json({ ok: false, error: error.message }, 503); }
});
app.post('/api/admin/backups/:date/restore', adminAuth, async c => {
const date = c.req.param('date'); const data = await readBackup(date);
if (!data) return c.json({ ok: false, error: 'Backup is missing or invalid' }, 404);
let body = {}; try { body = await c.req.json(); } catch {}
const selected = body.videoId ? data.notes.filter(note => note.videoId === body.videoId) : data.notes;
if (body.videoId && !selected.length) return c.json({ ok: false, error: 'Song is not present in this backup' }, 404);
const safety = await createBackup({ safety: true });
for (const note of selected) await db.saveNote({ videoId: note.videoId, kind: note.kind, data: note.data, source: 'restore', updatedBy: `admin (backup ${date})`, force: true });
if (!body.videoId) {
const keep = new Set(data.notes.map(note => `${note.videoId}:${note.kind}`));
for (const note of await db.allNotes()) if (!keep.has(`${note.videoId}:${note.kind}`)) {
await sql.execute({ sql: 'DELETE FROM video_notes WHERE video_id=? AND kind=?', args: [note.videoId, note.kind] });
}
}
const safetySnapshot = basename(safety.file);
await audit(db, 'admin', 'backup.restore', date, { videoId: body.videoId || null, restored: selected.length, safetySnapshot });
return c.json({ ok: true, restored: selected.length, safetySnapshot });
});
app.get('/api/admin/backups/export/sqlite-dump', adminAuth, async c => {
const schema = await sql.execute("SELECT type,name,sql FROM sqlite_master WHERE type IN ('table','index','trigger') AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END,name");
const quote = value => {
if (value == null) return 'NULL';
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL';
if (typeof value === 'bigint') return String(value);
if (value instanceof Uint8Array) return `X'${Buffer.from(value).toString('hex')}'`;
return `'${String(value).replaceAll("'", "''")}'`;
};
const statements = ['PRAGMA foreign_keys=OFF;', 'BEGIN TRANSACTION;'];
for (const table of schema.rows.filter(row => row.type === 'table')) {
statements.push(`${table.sql};`);
const rows = await sql.execute(`SELECT * FROM "${table.name.replaceAll('"', '""')}"`);
for (const row of rows.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
statements.push(`INSERT INTO "${table.name}" (${columns.map(name => `"${name.replaceAll('"', '""')}"`).join(',')}) VALUES (${columns.map(name => quote(row[name])).join(',')});`);
}
}
for (const object of schema.rows.filter(row => row.type !== 'table')) statements.push(`${object.sql};`);
statements.push('COMMIT;');
c.header('Content-Disposition', 'attachment; filename="ytplayer-notes-dump.sql"');
return c.text(statements.join('\n'), 200, { 'Content-Type': 'application/sql; charset=utf-8' });
});
app.get('/api/admin/lyrics/quality', adminAuth, async c => {
const minLines = Math.max(1, Math.min(200, Number(c.req.query('minLines')) || 8));
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
const limit = Math.max(1, Math.min(100, Number(c.req.query('limit')) || 50));
const category = ['all', 'missing', 'short', 'automatic', 'untimed', 'reports'].includes(c.req.query('category')) ? c.req.query('category') : 'all';
const conditions = {
missing: 'n.video_id IS NULL',
short: 'n.video_id IS NOT NULL AND COALESCE(json_array_length(json_extract(n.data,\'$.lines\')),0) < ?',
automatic: "n.source='auto'",
untimed: "EXISTS (SELECT 1 FROM json_each(n.data,'$.lines') line WHERE json_extract(line.value,'$.t') IS NULL)",
reports: "EXISTS (SELECT 1 FROM lyric_flags f WHERE f.video_id=m.id AND f.status='open')",
};
const parts = category === 'all'
? [conditions.missing, conditions.short, conditions.automatic, conditions.untimed, conditions.reports]
: [conditions[category]];
const where = `WHERE (${parts.join(' OR ')})`;
const args = category === 'short' ? [minLines] : category === 'all' ? [minLines] : [];
const query = `SELECT m.id,m.card,m.updated_at,n.data AS lyrics,n.source AS lyricSource,n.updated_at AS lyricUpdated,
COALESCE(json_array_length(json_extract(n.data,'$.lines')),0) AS lineCount,
EXISTS(SELECT 1 FROM json_each(n.data,'$.lines') line WHERE json_extract(line.value,'$.t') IS NULL) AS untimed,
(SELECT COUNT(*) FROM lyric_flags f WHERE f.video_id=m.id AND f.status='open') AS openReports
FROM video_meta m LEFT JOIN video_notes n ON n.video_id=m.id AND n.kind='lyrics'
${where} ORDER BY openReports DESC,lyricUpdated ASC,m.updated_at DESC LIMIT ? OFFSET ?`;
const rows = await sql.execute({ sql: query, args: [...args, limit, offset] });
const count = await sql.execute({ sql: `SELECT COUNT(*) AS total FROM video_meta m LEFT JOIN video_notes n ON n.video_id=m.id AND n.kind='lyrics' ${where}`, args });
const songs = rows.rows.map(row => {
const card = parse(row.card);
let untimed = !!row.untimed;
let lines = number(row.lineCount);
if (row.lyrics) {
const data = parse(row.lyrics); lines ||= Array.isArray(data.lines) ? data.lines.length : 0;
if (Array.isArray(data.lines)) untimed ||= data.lines.some(line => line.t == null);
}
const reasons = [];
if (!row.lyrics) reasons.push('missing');
else if (lines < minLines) reasons.push('short');
if (row.lyricSource === 'auto') reasons.push('automatic');
if (untimed) reasons.push('untimed');
if (number(row.openReports)) reasons.push('reports');
return { id: row.id, title: card.title || row.id, channel: card.channel || '', lines, source: row.lyricSource || null, untimed, openReports: number(row.openReports), reasons, updatedAt: number(row.lyricUpdated) };
});
return c.json({ ok: true, minLines, category, offset, limit, total: number(count.rows[0]?.total), songs }, 200, { 'Cache-Control': 'no-store' });
});
app.get('/api/admin/lyrics/search', adminAuth, async c => {
const query = String(c.req.query('q') || '').trim().slice(0, 120);
const hasLyrics = ['yes', 'no', 'all'].includes(c.req.query('hasLyrics')) ? c.req.query('hasLyrics') : 'yes';
const hasChapters = ['yes', 'no', 'all'].includes(c.req.query('hasChapters')) ? c.req.query('hasChapters') : 'all';
const limit = Math.max(1, Math.min(50, Number(c.req.query('limit')) || 30));
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
let hits = [], total = 0;
if (query && hasLyrics !== 'no') {
const chapterOptions = { hasChapters: hasChapters === 'yes', withoutChapters: hasChapters === 'no' };
hits = await db.searchNoteLyrics(query, { limit, offset, ...chapterOptions });
total = await db.countNoteLyrics(query, chapterOptions);
}
const terms = query.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [];
if (!query || hasLyrics === 'no') {
const parts = [];
const args = [];
if (hasLyrics === 'yes') parts.push("l.video_id IS NOT NULL AND l.data<>'{}'");
if (hasLyrics === 'no') parts.push('l.video_id IS NULL');
if (hasChapters === 'yes') parts.push("EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=m.id AND ch.kind='chapters')");
if (hasChapters === 'no') parts.push("NOT EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=m.id AND ch.kind='chapters')");
for (const term of terms) { parts.push('lower(m.hay) LIKE ?'); args.push(`%${term}%`); }
const where = parts.length ? `WHERE ${parts.join(' AND ')}` : '';
const count = await sql.execute({ sql: `SELECT COUNT(*) AS n FROM video_meta m LEFT JOIN video_notes l ON l.video_id=m.id AND l.kind='lyrics' ${where}`, args });
const rows = await sql.execute({ sql: `SELECT m.id,m.card,l.data AS lyrics,l.rev,EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=m.id AND ch.kind='chapters') AS chapters FROM video_meta m LEFT JOIN video_notes l ON l.video_id=m.id AND l.kind='lyrics' ${where} ORDER BY m.updated_at DESC LIMIT ? OFFSET ?`, args: [...args, limit, offset] });
total = number(count.rows[0]?.n);
hits = rows.rows.map(row => ({ videoId: row.id, snippet: parse(row.lyrics).lines?.slice(0, 3).map(line => line.text).join(' … ') || parse(row.card).title || row.id, rev: number(row.rev), hasChapters: !!row.chapters }));
}
if (hits.length && query && hasLyrics !== 'no') {
const ids = hits.map(item => item.videoId);
const rows = await sql.execute({ sql: `SELECT n.video_id,n.data,n.rev,m.card,EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=n.video_id AND ch.kind='chapters') AS chapters FROM video_notes n LEFT JOIN video_meta m ON m.id=n.video_id WHERE n.kind='lyrics' AND n.video_id IN (${ids.map(() => '?').join(',')})`, args: ids });
const byId = new Map(rows.rows.map(row => [row.video_id, row]));
hits = hits.map(hit => {
const row = byId.get(hit.videoId), card = parse(row?.card);
return { ...hit, title: card.title || hit.videoId, channel: card.channel || '', rev: number(row?.rev), hasChapters: !!row?.chapters };
});
}
return c.json({ ok: true, query, hasLyrics, hasChapters, total, limit, offset, results: hits }, 200, { 'Cache-Control': 'no-store' });
});
app.post('/api/admin/lyrics/search/rebuild', adminAuth, async c => {
if (typeof db.rebuildLyricsSearch !== 'function') return c.json({ ok: false, error: 'Lyrics search rebuild is unavailable.' }, 503);
const indexed = await db.rebuildLyricsSearch();
await audit(db, 'admin', 'lyrics.search.rebuild', '', { indexed });
return c.json({ ok: true, indexed });
});
const librarySelect = `SELECT id,title,channel,hay,lyrics,rev,cacheStatus FROM (
SELECT m.id,COALESCE(json_extract(m.card,'$.title'),m.id) AS title,COALESCE(json_extract(m.card,'$.channel'),'') AS channel,m.hay,
n.data AS lyrics,n.rev AS rev,c.status AS cacheStatus FROM video_meta m
LEFT JOIN video_notes n ON n.video_id=m.id AND n.kind='lyrics' LEFT JOIN media_cache c ON c.video_id=m.id
UNION ALL
SELECT u.id,u.title,COALESCE(u.artist,''),lower(u.title||' '||COALESCE(u.artist,'')),n.data,n.rev,NULL
FROM uploads u LEFT JOIN video_notes n ON n.video_id=u.id AND n.kind='lyrics'
)`;
function lyricsFilter(q) {
const words = String(q || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [];
return { where: words.length ? `WHERE ${words.map(() => 'lower(hay) LIKE ?').join(' AND ')}` : '', args: words.map(word => `%${word}%`) };
}
app.get('/api/admin/lyrics/bulk/library', adminAuth, async c => {
const limit = Math.max(1, Math.min(100, Number(c.req.query('limit')) || 50));
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
const q = String(c.req.query('q') || '').slice(0, 120), filter = lyricsFilter(q);
const [count, rows] = await Promise.all([
sql.execute({ sql: `SELECT COUNT(*) AS total FROM (${librarySelect}) library ${filter.where}`, args: filter.args }),
sql.execute({ sql: `${librarySelect} ${filter.where} ORDER BY title COLLATE NOCASE,id LIMIT ? OFFSET ?`, args: [...filter.args, limit, offset] }),
]);
return c.json({ ok: true, q, total: number(count.rows[0]?.total), limit, offset, songs: rows.rows.map(row => {
const lyrics = parse(row.lyrics), lines = Array.isArray(lyrics.lines) ? lyrics.lines.length : 0;
return { id: row.id, title: row.title || row.id, channel: row.channel || '', lines, rev: number(row.rev), cacheStatus: row.cacheStatus || null };
}) }, 200, { 'Cache-Control': 'no-store' });
});
app.post('/api/admin/lyrics/jobs', adminAuth, async c => {
let body = {}; try { body = await c.req.json(); } catch {}
const action = ['lrclib', 'whisper', 'export', 'clear'].includes(body.action) ? body.action : '';
if (!action) return c.json({ ok: false, error: 'Choose LRCLIB, Whisper, export, or clear.' }, 400);
let ids = Array.isArray(body.ids) ? body.ids : [];
if (body.allFiltered === true) {
const filter = lyricsFilter(String(body.query || '').slice(0, 120));
const rows = await sql.execute({ sql: `${librarySelect} ${filter.where} ORDER BY title COLLATE NOCASE,id LIMIT 500`, args: filter.args });
ids = rows.rows.map(row => row.id);
}
ids = [...new Set(ids.map(String).filter(id => /^[\w-]{11}$/.test(id) || /^upl_[a-f0-9]{12}$/.test(id)))].slice(0, 500);
if (!ids.length) return c.json({ ok: false, error: 'Select at least one song.' }, 400);
if (action === 'clear' && body.confirmClear !== true) return c.json({ ok: false, error: 'Clearing lyrics requires explicit confirmation.' }, 400);
const id = crypto.randomUUID(), now = Math.floor(Date.now() / 1000);
const state = { done: 0, total: ids.length, results: [], logs: [], fileReady: false };
await sql.execute({ sql: 'INSERT INTO admin_lyrics_jobs(id,action,status,ids,state,created_at,updated_at) VALUES(?,?,\'queued\',?,?,?,?)', args: [id, action, JSON.stringify(ids), JSON.stringify(state), now, now] });
await audit(db, 'admin', 'lyrics.bulk.start', id, { action, count: ids.length });
return c.json({ ok: true, job: { id, action, status: 'queued', state } }, 202);
});
app.get('/api/admin/lyrics/jobs', adminAuth, async c => {
const rows = await sql.execute('SELECT * FROM admin_lyrics_jobs ORDER BY created_at DESC LIMIT 30');
return c.json({ ok: true, jobs: rows.rows.map(row => ({ id: row.id, action: row.action, status: row.status, state: parse(row.state), error: row.error, createdAt: number(row.created_at), updatedAt: number(row.updated_at) })) }, 200, { 'Cache-Control': 'no-store' });
});
app.get('/api/admin/lyrics/jobs/:id', adminAuth, async c => {
const row = (await sql.execute({ sql: 'SELECT * FROM admin_lyrics_jobs WHERE id=?', args: [c.req.param('id')] })).rows[0];
return row ? c.json({ ok: true, job: { id: row.id, action: row.action, status: row.status, state: parse(row.state), error: row.error, createdAt: number(row.created_at), updatedAt: number(row.updated_at) } }) : c.json({ ok: false, error: 'Job not found.' }, 404);
});
app.post('/api/admin/lyrics/jobs/:id/:action', adminAuth, async c => {
const id = c.req.param('id'), action = c.req.param('action');
if (!['pause', 'resume', 'cancel'].includes(action)) return c.json({ ok: false, error: 'Unknown job action.' }, 400);
const row = (await sql.execute({ sql: 'SELECT status FROM admin_lyrics_jobs WHERE id=?', args: [id] })).rows[0];
if (!row) return c.json({ ok: false, error: 'Job not found.' }, 404);
const next = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'cancelled';
if (action === 'pause' && !['queued', 'running'].includes(row.status) || action === 'resume' && row.status !== 'paused' || action === 'cancel' && !['queued', 'running', 'paused'].includes(row.status)) return c.json({ ok: false, error: `Cannot ${action} a ${row.status} job.` }, 409);
await sql.execute({ sql: 'UPDATE admin_lyrics_jobs SET status=?,updated_at=? WHERE id=?', args: [next, Math.floor(Date.now() / 1000), id] });
await audit(db, 'admin', `lyrics.bulk.${action}`, id, { from: row.status, to: next });
return c.json({ ok: true, status: next });
});
app.get('/api/admin/lyrics/jobs/:id/export', adminAuth, async c => {
const row = (await sql.execute({ sql: 'SELECT action,status,state FROM admin_lyrics_jobs WHERE id=?', args: [c.req.param('id')] })).rows[0];
const state = parse(row?.state);
if (!row || row.action !== 'export' || row.status !== 'complete' || !state.fileReady || !backupDir) return c.json({ ok: false, error: 'Export is not ready.' }, 404);
try { const data = await readFile(join(backupDir, 'bulk-lyrics', `${c.req.param('id')}.zip`)); return new Response(data, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="lyrics-${c.req.param('id')}.zip"` } }); }
catch { return c.json({ ok: false, error: 'Export file is unavailable.' }, 404); }
});
let workerBusy = false;
let activeJobId = null;
async function runBulkJob() {
if (workerBusy) return;
workerBusy = true;
try {
const row = (await sql.execute("SELECT * FROM admin_lyrics_jobs WHERE status IN ('queued','running') ORDER BY created_at,id LIMIT 1")).rows[0];
if (!row) return;
activeJobId = row.id;
await sql.execute({ sql: "UPDATE admin_lyrics_jobs SET status='running',updated_at=? WHERE id=? AND status='queued'", args: [Math.floor(Date.now() / 1000), row.id] });
const ids = parse(row.ids), state = parse(row.state); state.total = ids.length; state.results ||= []; state.logs ||= [];
for (let index = number(state.done); index < ids.length; index++) {
const current = (await sql.execute({ sql: 'SELECT status FROM admin_lyrics_jobs WHERE id=?', args: [row.id] })).rows[0]?.status;
if (current !== 'running') return;
const videoId = ids[index]; let result;
try {
if (row.action === 'lrclib') result = pullLyrics ? await pullLyrics(videoId) : { ok: false, error: 'LRCLIB worker is unavailable.' };
else if (row.action === 'whisper') {
const queued = queueWhisper ? await queueWhisper(videoId) : { status: 503, body: { ok: false, error: 'Whisper worker is unavailable.' } };
result = { ...(queued.body || {}), ok: queued.status < 400 && queued.body?.ok, httpStatus: queued.status };
} else if (row.action === 'clear') {
const saved = await db.saveNote({ videoId, kind: 'lyrics', data: { lines: [], tags: [], offset: 0 }, source: 'admin-bulk-clear', updatedBy: 'admin bulk clear', force: true });
const resolved = await db.autoResolveFlags(videoId, new Set());
await audit(db, 'admin', 'lyrics.clear', videoId, { rev: saved.rev, resolvedReports: resolved, undoViaRevision: saved.rev - 1 });
result = { ok: true, rev: saved.rev, resolvedReports: resolved };
} else {
const note = (await db.getNotes(videoId)).lyrics;
result = note ? { ok: true, lines: note.data.lines?.length || 0 } : { ok: false, error: 'No saved lyrics to export.' };
}
} catch (error) { result = { ok: false, error: String(error.message || error).slice(0, 300) }; }
const item = { id: videoId, ok: !!result?.ok, message: result?.ok ? (result.lines != null ? `${result.lines} lines` : 'Done') : result?.error || 'Action failed.' };
state.done = index + 1; state.results.push(item); state.logs.push({ at: Date.now(), ...item }); if (state.logs.length > 80) state.logs.splice(0, state.logs.length - 80);
await sql.execute({ sql: 'UPDATE admin_lyrics_jobs SET state=?,updated_at=? WHERE id=?', args: [JSON.stringify(state), Math.floor(Date.now() / 1000), row.id] });
if (row.action === 'lrclib') await sleep(1200);
}
if (row.action === 'export') {
const files = [];
for (const videoId of ids) {
const note = (await db.getNotes(videoId)).lyrics; if (!note?.data?.lines?.length) continue;
let title = videoId;
if (videoId.startsWith('upl_')) title = (await db.getUpload(videoId))?.title || videoId;
else {
const card = (await sql.execute({ sql: 'SELECT card FROM video_meta WHERE id=?', args: [videoId] })).rows[0]; title = parse(card?.card).title || videoId;
}
const safe = String(title).normalize('NFKC').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 100) || videoId;
const lrc = note.data.lines.map(line => line.t == null ? line.text : `[${String(Math.floor(line.t / 60)).padStart(2, '0')}:${(line.t % 60).toFixed(2).padStart(5, '0')}]${line.text}`).join('\n') + '\n';
files.push({ name: `${safe}__${videoId}.lrc`, content: lrc });
}
if (!backupDir) throw Error('Export storage is unavailable.');
await mkdir(join(backupDir, 'bulk-lyrics'), { recursive: true });
await writeFile(join(backupDir, 'bulk-lyrics', `${row.id}.zip`), zipStore(files));
state.fileReady = true; state.exportedFiles = files.length;
}
await sql.execute({ sql: "UPDATE admin_lyrics_jobs SET status='complete',state=?,updated_at=? WHERE id=? AND status='running'", args: [JSON.stringify(state), Math.floor(Date.now() / 1000), row.id] });
} catch (error) {
console.error('[admin-tools] lyrics job failed:', error.message);
if (activeJobId) {
try {
await sql.execute({ sql: "UPDATE admin_lyrics_jobs SET status='failed',error=?,updated_at=? WHERE id=? AND status='running'", args: [String(error.message || error).slice(0, 300), Math.floor(Date.now() / 1000), activeJobId] });
await audit(db, 'admin', 'lyrics.bulk.failed', activeJobId, { error: String(error.message || error).slice(0, 300) });
} catch (persistError) { console.warn('[admin-tools] could not persist lyrics job failure:', persistError.message); }
}
} finally { activeJobId = null; workerBusy = false; }
}
if (autoStartJobs) {
sql.execute("UPDATE admin_lyrics_jobs SET status='queued',updated_at=unixepoch() WHERE status='running'").then(() => runBulkJob()).catch(error => console.warn('[admin-tools] could not resume lyrics jobs:', error.message));
const timer = setInterval(runBulkJob, 1000); timer.unref?.();
}
app.get('/api/admin/cache/health', adminAuth, async c => {
const limit = Math.max(1, Math.min(100, Number(c.req.query('limit')) || 50));
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
const rows = await sql.execute({ sql: "SELECT video_id,status,size,last_access,updated_at,gen,pinned FROM media_cache ORDER BY CASE WHEN status='ready' THEN 0 ELSE 1 END,last_access ASC LIMIT ? OFFSET ?", args: [limit, offset] });
const total = await sql.execute('SELECT COUNT(*) AS total FROM media_cache');
const grouped = await sql.execute('SELECT status,COUNT(*) AS count,COALESCE(SUM(size),0) AS bytes,COALESCE(SUM(CASE WHEN pinned=1 THEN size ELSE 0 END),0) AS pinnedBytes FROM media_cache GROUP BY status');
const bytes = grouped.rows.reduce((sum, row) => sum + number(row.bytes), 0);
const pinnedBytes = grouped.rows.reduce((sum, row) => sum + number(row.pinnedBytes), 0);
const budget = Math.max(0, Number(process.env.MEDIA_CACHE_MAX_BYTES) || 10 * 1024 ** 3);
const pinnedBudget = Math.max(0, Number(process.env.MEDIA_CACHE_PINNED_MAX_BYTES) || 2 * 1024 ** 3);
return c.json({ ok: true, budget, pinnedBudget, bytes, pinnedBytes, utilization: budget ? bytes / budget : 0,
statuses: grouped.rows.map(row => ({ status: row.status, count: number(row.count), bytes: number(row.bytes) })),
total: number(total.rows[0]?.total), limit, offset,
media: rows.rows.map(row => ({ id: row.video_id, status: row.status, bytes: number(row.size), lastAccess: number(row.last_access), updatedAt: number(row.updated_at), gen: number(row.gen), pinned: !!row.pinned })) }, 200, { 'Cache-Control': 'no-store' });
});
app.post('/api/admin/cache/:id/evict', adminAuth, async c => {
const id = c.req.param('id');
if (!/^[A-Za-z0-9_-]{6,64}$/.test(id)) return c.json({ ok: false, error: 'invalid media id' }, 400);
const row = await db.getMedia(id);
if (!row) return c.json({ ok: false, error: 'media not found' }, 404);
if (row.pinned) return c.json({ ok: false, error: 'Unpin this copy before evicting it.' }, 409);
if (!media?.evictNow) return c.json({ ok: false, error: 'media cache unavailable' }, 503);
await media.evictNow(id);
await audit(db, 'admin', 'cache.evict', id, { bytes: number(row.size) });
return c.json({ ok: true });
});
app.post('/api/admin/cache/:id/pin', adminAuth, async c => {
const id = c.req.param('id');
let body = {}; try { body = await c.req.json(); } catch {}
if (body.pinned !== true && body.pinned !== false) return c.json({ ok: false, error: 'pinned must be boolean' }, 400);
const target = await sql.execute({ sql: "SELECT size,pinned,status FROM media_cache WHERE video_id=?", args: [id] });
const row = target.rows[0];
if (!row) return c.json({ ok: false, error: 'media not found' }, 404);
if (body.pinned && row.status !== 'ready') return c.json({ ok: false, error: 'Only ready cache copies can be pinned.' }, 409);
const pinnedBudget = Math.max(0, Number(process.env.MEDIA_CACHE_PINNED_MAX_BYTES) || 2 * 1024 ** 3);
if (body.pinned && !row.pinned) {
const totals = await sql.execute('SELECT COALESCE(SUM(size),0) AS bytes FROM media_cache WHERE pinned=1');
if (number(totals.rows[0]?.bytes) + number(row.size) > pinnedBudget) return c.json({ ok: false, error: 'Pinned cache byte cap exceeded.' }, 409);
}
await sql.execute({ sql: 'UPDATE media_cache SET pinned=? WHERE video_id=?', args: [body.pinned ? 1 : 0, id] });
await audit(db, 'admin', body.pinned ? 'cache.pin' : 'cache.unpin', id, { bytes: number(row.size) });
return c.json({ ok: true, pinned: body.pinned });
});
app.get('/api/admin/index/health', adminAuth, async c => {
const [stats, sources, latest, backfill] = await Promise.all([
sql.execute(`SELECT COUNT(*) AS total,SUM(CASE WHEN url IS NOT NULL AND url<>'' THEN 1 ELSE 0 END) AS urls,
SUM(CASE WHEN EXISTS(SELECT 1 FROM video_details d WHERE d.video_id=video_meta.id) THEN 1 ELSE 0 END) AS details,
SUM(CASE WHEN EXISTS(SELECT 1 FROM video_thumbnails t WHERE t.video_id=video_meta.id AND t.data IS NOT NULL) THEN 1 ELSE 0 END) AS thumbnails FROM video_meta`),
sql.execute('SELECT source,COUNT(*) AS videos,SUM(discoveries) AS discoveries,MAX(last_seen) AS lastSeen FROM video_meta_sources GROUP BY source ORDER BY videos DESC'),
sql.execute('SELECT id,status,created_at,updated_at,state,error FROM deep_search_jobs ORDER BY created_at DESC LIMIT 1'),
sql.execute('SELECT source,cursor,ceiling FROM catalog_backfill ORDER BY source'),
]);
const row = stats.rows[0] || {}, total = number(row.total);
const last = latest.rows[0];
return c.json({ ok: true, total, urlCount: number(row.urls), detailCount: number(row.details), thumbnailCount: number(row.thumbnails),
urlCoverage: total ? number(row.urls) / total : 0, detailCoverage: total ? number(row.details) / total : 0, thumbnailCoverage: total ? number(row.thumbnails) / total : 0,
sources: sources.rows.map(x => ({ source: x.source, videos: number(x.videos), discoveries: number(x.discoveries), lastSeen: number(x.lastSeen) })),
lastRun: last ? { id: last.id, status: last.status, createdAt: number(last.created_at), updatedAt: number(last.updated_at), error: last.error || null, progress: parse(last.state) } : null,
backfill: backfill.rows.map(x => ({ source: x.source, cursor: number(x.cursor), ceiling: number(x.ceiling), done: number(x.cursor) >= number(x.ceiling) })) }, 200, { 'Cache-Control': 'no-store' });
});
app.post('/api/admin/index/backfill', adminAuth, async c => {
if (!backfillStep) return c.json({ ok: false, error: 'Catalog backfill is unavailable.' }, 503);
const processed = await backfillStep(100);
const state = (await sql.execute('SELECT source,cursor,ceiling FROM catalog_backfill ORDER BY source')).rows;
await audit(db, 'admin', 'index.backfill', '', { processed });
return c.json({ ok: true, processed, backfill: state.map(x => ({ source: x.source, cursor: number(x.cursor), ceiling: number(x.ceiling), done: number(x.cursor) >= number(x.ceiling) })) });
});
app.post('/api/admin/index/cleanup', adminAuth, async c => {
let body = {}; try { body = await c.req.json(); } catch {}
const days = Math.max(180, Math.min(3650, Number(body.days) || 365));
const cutoff = Date.now() - days * 86400_000;
const candidates = await sql.execute({ sql: `SELECT COUNT(*) AS n FROM video_meta m WHERE m.updated_at<?
AND NOT EXISTS(SELECT 1 FROM video_meta_sources s WHERE s.video_id=m.id)
AND NOT EXISTS(SELECT 1 FROM media_cache c WHERE c.video_id=m.id)
AND NOT EXISTS(SELECT 1 FROM listening_daily l WHERE l.video_id=m.id)`, args: [cutoff] });
const removed = number(candidates.rows[0]?.n);
await sql.execute({ sql: `DELETE FROM video_meta WHERE updated_at<?
AND NOT EXISTS(SELECT 1 FROM video_meta_sources s WHERE s.video_id=video_meta.id)
AND NOT EXISTS(SELECT 1 FROM media_cache c WHERE c.video_id=video_meta.id)
AND NOT EXISTS(SELECT 1 FROM listening_daily l WHERE l.video_id=video_meta.id)`, args: [cutoff] });
await sql.batch([
'DELETE FROM video_meta_sources WHERE video_id NOT IN (SELECT id FROM video_meta)',
'DELETE FROM video_thumbnails WHERE video_id NOT IN (SELECT id FROM video_meta)',
'DELETE FROM video_channels WHERE video_id NOT IN (SELECT id FROM video_meta)',
'DELETE FROM video_details WHERE video_id NOT IN (SELECT id FROM video_meta)',
], 'write');
await audit(db, 'admin', 'index.cleanup', '', { days, removed });
return c.json({ ok: true, removed, days });
});
return { runBulkJob };
}

157
server/admin-tools.test.js Normal file
View File

@@ -0,0 +1,157 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Hono } from 'hono';
const root = mkdtempSync(join(tmpdir(), 'ytp-admin-tools-'));
process.env.DB_PATH = join(root, 'admin.db');
process.env.MEDIA_CACHE_PINNED_MAX_BYTES = '1000';
const db = await import('./db.js');
const { registerAdminToolsRoutes } = await import('./admin-tools.js');
const app = new Hono();
const backupDir = join(root, 'backups');
const auth = async (c, next) => c.req.header('x-admin') === 'yes' ? next() : c.json({ ok: false }, 401);
let tools;
beforeAll(async () => {
await db.initDb();
tools = registerAdminToolsRoutes(app, { db, adminAuth: auth, backupDir, backfillStep: async () => 3,
pullLyrics: async () => ({ ok: true, lines: 2 }), queueWhisper: async () => ({ status: 202, body: { ok: true, job: { status: 'queued' } } }), autoStartJobs: false });
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: ['QualMiss001', JSON.stringify({ title: 'Missing lyric sample', channel: 'Choir' }), 'missing lyric sample choir', 1, Date.now()] });
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: ['Untimely001', JSON.stringify({ title: 'Untimed sample', channel: 'Band' }), 'untimed sample band', 1, Date.now()] });
await db.saveNote({ videoId: 'Untimely001', kind: 'lyrics', data: { lines: [{ t: null, text: 'A line' }, { t: 4, text: 'Next' }] }, source: 'user', force: true });
await db.db.execute({ sql: "INSERT INTO media_cache(video_id,status,size,gen) VALUES('CacheItem001','ready',700,1),('CacheItem002','ready',400,1)" });
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at,url) VALUES(?,?,?,?,?,?)', args: ['IndexSong001', JSON.stringify({ title: 'Indexed song' }), 'indexed song', 1, Date.now(), 'https://www.youtube.com/watch?v=IndexSong001'] });
await db.db.execute({ sql: "INSERT INTO video_meta_sources(video_id,source,last_seen) VALUES('IndexSong001','deep',?)", args: [Date.now()] });
await db.db.execute({ sql: "INSERT INTO video_details(video_id,metadata,updated_at) VALUES('IndexSong001','{}',?)", args: [Date.now()] });
await db.db.execute({ sql: "INSERT INTO video_thumbnails(video_id,url,data,mime,size,fetched_at) VALUES('IndexSong001','https://i.ytimg.com/vi/IndexSong001/hqdefault.jpg',x'01','image/jpeg',1,?)", args: [Date.now()] });
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,1)', args: ['OldOrphan01', JSON.stringify({ title: 'Old orphan' }), 'old orphan', 1] });
await db.db.execute({ sql: "INSERT INTO deep_search_jobs(id,status,plan,options,state,created_at,updated_at) VALUES('job-1','complete','[]','{}','{\"queriesDone\":2,\"newVideos\":5}',1,2)" });
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
describe('lyrics quality queue', () => {
test('requires admin and returns missing, untimed and short-lyrics reasons in pages', async () => {
expect((await app.request('/api/admin/lyrics/quality')).status).toBe(401);
const headers = { 'x-admin': 'yes' };
const missing = await (await app.request('/api/admin/lyrics/quality?category=missing', { headers })).json();
expect(missing.total).toBeGreaterThanOrEqual(1);
expect(missing.songs.find(song => song.id === 'QualMiss001')).toMatchObject({ id: 'QualMiss001', reasons: ['missing'] });
const untimed = await (await app.request('/api/admin/lyrics/quality?category=untimed', { headers })).json();
expect(untimed.songs[0]).toMatchObject({ id: 'Untimely001', lines: 2, untimed: true });
const short = await (await app.request('/api/admin/lyrics/quality?category=short&minLines=3', { headers })).json();
expect(short.songs.find(song => song.id === 'Untimely001').reasons).toContain('short');
const page = await (await app.request('/api/admin/lyrics/quality?limit=1&offset=1', { headers })).json();
expect(page.songs).toHaveLength(1);
});
test('pinning is authorized and enforces the configured pinned-byte cap', async () => {
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
expect((await app.request('/api/admin/cache/CacheItem001/pin', { method: 'POST', headers, body: JSON.stringify({ pinned: true }) })).status).toBe(200);
const over = await app.request('/api/admin/cache/CacheItem002/pin', { method: 'POST', headers, body: JSON.stringify({ pinned: true }) });
expect(over.status).toBe(409);
expect((await over.json()).error).toContain('byte cap');
const refused = await app.request('/api/admin/cache/CacheItem001/evict', { method: 'POST', headers, body: '{}' });
expect(refused.status).toBe(409);
const health = await (await app.request('/api/admin/cache/health', { headers: { 'x-admin': 'yes' } })).json();
expect(health.pinnedBytes).toBe(700);
});
test('index health reports coverage, source and deep-search data; repair actions are audited', async () => {
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
const health = await (await app.request('/api/admin/index/health', { headers })).json();
expect(health.total).toBeGreaterThanOrEqual(1);
expect(health.urlCount).toBeGreaterThanOrEqual(1);
expect(health.detailCount).toBe(1);
expect(health.thumbnailCount).toBe(1);
expect(health.sources[0].source).toBe('deep');
expect(health.lastRun.progress.queriesDone).toBe(2);
const step = await (await app.request('/api/admin/index/backfill', { method: 'POST', headers, body: '{}' })).json();
expect(step.processed).toBe(3);
const clean = await (await app.request('/api/admin/index/cleanup', { method: 'POST', headers, body: JSON.stringify({ days: 180 }) })).json();
expect(clean.removed).toBeGreaterThanOrEqual(1);
expect((await db.db.execute({ sql: "SELECT COUNT(*) AS n FROM video_meta WHERE id='OldOrphan01'" })).rows[0].n).toBe(0);
});
test('backup list verifies, previews, downloads, safely restores and exports a SQLite dump', async () => {
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
await db.saveNote({ videoId: 'BackupSong01', kind: 'lyrics', data: { lines: [{ t: 1, text: 'Before' }] }, force: true, source: 'user' });
const created = await (await app.request('/api/admin/backups', { method: 'POST', headers, body: '{}' })).json();
expect(created.ok).toBe(true);
await db.saveNote({ videoId: 'BackupSong01', kind: 'lyrics', data: { lines: [{ t: 1, text: 'After' }] }, force: true, source: 'user' });
const list = await (await app.request('/api/admin/backups', { headers })).json();
expect(list.backups.find(item => item.date === created.date)).toMatchObject({ valid: true, notes: created.notes });
const preview = await (await app.request(`/api/admin/backups/${created.date}`, { headers })).json();
expect(preview.diff.changed).toBe(1);
const download = await app.request(`/api/admin/backups/${created.date}/download`, { headers });
expect(download.status).toBe(200);
const restored = await (await app.request(`/api/admin/backups/${created.date}/restore`, { method: 'POST', headers, body: JSON.stringify({ videoId: 'BackupSong01' }) })).json();
expect(restored.ok).toBe(true);
expect((await db.getNotes('BackupSong01')).lyrics.data.lines[0].text).toBe('Before');
expect(existsSync(join(backupDir, 'snapshots', restored.safetySnapshot))).toBe(true);
const dump = await app.request('/api/admin/backups/export/sqlite-dump', { headers: { 'x-admin': 'yes' } });
expect(dump.status).toBe(200);
expect(await dump.text()).toContain('CREATE TABLE');
});
test('lyrics search is indexed on save, filters chapter state, and rebuilds under admin auth', async () => {
const id = 'SearchSong01';
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: [id, JSON.stringify({ title: 'Amazing grace sample', channel: 'Choir' }), 'amazing grace sample choir', 1, Date.now()] });
await db.saveNote({ videoId: id, kind: 'lyrics', data: { lines: [{ t: 1, text: 'Amazing grace will lead me home' }] }, source: 'user', force: true });
expect((await app.request('/api/admin/lyrics/search?q=amazing')).status).toBe(401);
const headers = { 'x-admin': 'yes' };
let result = await (await app.request('/api/admin/lyrics/search?q=amazing+grace&hasChapters=no', { headers })).json();
expect(result.results[0]).toMatchObject({ videoId: id, title: 'Amazing grace sample', hasChapters: false });
expect(result.results[0].snippet).toContain('\u0001');
await db.saveNote({ videoId: id, kind: 'chapters', data: { items: [{ t: 1, title: 'Verse' }] }, source: 'user', force: true });
result = await (await app.request('/api/admin/lyrics/search?q=amazing&hasChapters=yes', { headers })).json();
expect(result.results[0].videoId).toBe(id);
await db.saveNote({ videoId: id, kind: 'lyrics', data: { lines: [{ t: 1, text: 'Bright morning star' }] }, source: 'user', force: true });
expect((await (await app.request('/api/admin/lyrics/search?q=amazing', { headers })).json()).total).toBe(0);
const rebuilt = await (await app.request('/api/admin/lyrics/search/rebuild', { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: '{}' })).json();
expect(rebuilt.ok).toBe(true);
expect(rebuilt.indexed).toBeGreaterThanOrEqual(1);
});
test('bulk lyrics jobs page the library, resume, clear with revision undo, export ZIP, and cancel', async () => {
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
expect((await app.request('/api/admin/lyrics/bulk/library')).status).toBe(401);
const library = await (await app.request('/api/admin/lyrics/bulk/library?q=untimed', { headers })).json();
expect(library.songs[0]).toMatchObject({ id: 'Untimely001', title: 'Untimed sample' });
const create = async body => (await app.request('/api/admin/lyrics/jobs', { method: 'POST', headers, body: JSON.stringify(body) })).json();
const pull = (await create({ action: 'lrclib', ids: ['Untimely001'] })).job;
expect((await app.request(`/api/admin/lyrics/jobs/${pull.id}/pause`, { method: 'POST', headers, body: '{}' })).status).toBe(200);
expect((await app.request(`/api/admin/lyrics/jobs/${pull.id}/resume`, { method: 'POST', headers, body: '{}' })).status).toBe(200);
await tools.runBulkJob();
let current = await (await app.request(`/api/admin/lyrics/jobs/${pull.id}`, { headers: { 'x-admin': 'yes' } })).json();
expect(current.job).toMatchObject({ status: 'complete', state: { done: 1, results: [{ ok: true, id: 'Untimely001' }] } });
const clear = (await create({ action: 'clear', ids: ['Untimely001'], confirmClear: true })).job;
await tools.runBulkJob();
expect((await db.getNotes('Untimely001')).lyrics.data.lines).toEqual([]);
expect((await db.getNoteRev('Untimely001', 'lyrics', 1)).data.lines[0].text).toBe('A line');
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: ['ZipSong0001', JSON.stringify({ title: 'Zip Song' }), 'zip song', 1, Date.now()] });
await db.saveNote({ videoId: 'ZipSong0001', kind: 'lyrics', data: { lines: [{ t: 1.2, text: 'Saved line' }] }, source: 'user', force: true });
const exportJob = (await create({ action: 'export', ids: ['ZipSong0001'] })).job;
await tools.runBulkJob();
const archive = await app.request(`/api/admin/lyrics/jobs/${exportJob.id}/export`, { headers: { 'x-admin': 'yes' } });
expect(archive.status).toBe(200);
expect([...new Uint8Array(await archive.arrayBuffer())].slice(0, 2)).toEqual([0x50, 0x4b]);
const failingApp = new Hono();
const failingTools = registerAdminToolsRoutes(failingApp, { db, adminAuth: auth, backupDir: null, autoStartJobs: false });
const failingExport = (await (await failingApp.request('/api/admin/lyrics/jobs', { method: 'POST', headers, body: JSON.stringify({ action: 'export', ids: ['ZipSong0001'] }) })).json()).job;
await failingTools.runBulkJob();
const failed = await (await failingApp.request(`/api/admin/lyrics/jobs/${failingExport.id}`, { headers: { 'x-admin': 'yes' } })).json();
expect(failed.job).toMatchObject({ status: 'failed', error: 'Export storage is unavailable.' });
const cancelled = (await create({ action: 'whisper', ids: ['Untimely001'] })).job;
await app.request(`/api/admin/lyrics/jobs/${cancelled.id}/cancel`, { method: 'POST', headers, body: '{}' });
current = await (await app.request(`/api/admin/lyrics/jobs/${cancelled.id}`, { headers: { 'x-admin': 'yes' } })).json();
expect(current.job.status).toBe('cancelled');
});
});

53
server/audit-log.js Normal file
View File

@@ -0,0 +1,53 @@
const asJson = value => {
try { return JSON.stringify(value ?? {}); } catch { return '{}'; }
}
export async function audit(db, actor, action, target = '', detail = {}) {
const client = typeof db?.execute === 'function' ? db : db?.db;
if (!client?.execute) throw new TypeError('audit database client is unavailable');
await client.execute({
sql: 'INSERT INTO audit_log (ts,actor,action,target,detail) VALUES (unixepoch(),?,?,?,?)',
args: [String(actor || 'admin').slice(0, 120), String(action).slice(0, 120), String(target || '').slice(0, 300), asJson(detail).slice(0, 8000)],
});
}
export function registerAuditRoutes(app, { db, adminAuth }) {
const client = typeof db?.execute === 'function' ? db : db?.db;
app.get('/api/admin/audit', adminAuth, async c => {
const format = c.req.query('format');
const limit = Math.max(1, Math.min(format ? 10000 : 200, Number(c.req.query('limit')) || (format ? 10000 : 50)));
const offset = format ? 0 : Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
const filters = [];
const args = [];
for (const key of ['actor', 'action']) {
const value = String(c.req.query(key) || '').trim().slice(0, 120);
if (value) { filters.push(`${key} LIKE ?`); args.push(`%${value.replace(/[\\%_]/g, '\\$&')}%`); }
}
const search = String(c.req.query('q') || '').trim().slice(0, 160);
if (search) { filters.push('(actor LIKE ? OR action LIKE ? OR target LIKE ? OR detail LIKE ?)'); args.push(...Array(4).fill(`%${search.replace(/[\\%_]/g, '\\$&')}%`)); }
const from = Number(c.req.query('from'));
const to = Number(c.req.query('to'));
if (Number.isFinite(from) && from > 0) { filters.push('ts >= ?'); args.push(Math.floor(from)); }
if (Number.isFinite(to) && to > 0) { filters.push('ts <= ?'); args.push(Math.floor(to)); }
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
const [rows, count] = await Promise.all([
client.execute({ sql: `SELECT id,ts,actor,action,target,detail FROM audit_log ${where} ORDER BY ts DESC,id DESC LIMIT ? OFFSET ?`, args: [...args, limit, offset] }),
client.execute({ sql: `SELECT COUNT(*) AS total FROM audit_log ${where}`, args }),
]);
const events = rows.rows.map(row => ({
id: Number(row.id), ts: Number(row.ts), actor: row.actor, action: row.action, target: row.target,
detail: (() => { try { return JSON.parse(row.detail || '{}'); } catch { return {}; } })(),
}));
if (format === 'json') {
c.header('Content-Disposition', 'attachment; filename="ytplayer-audit.json"');
return c.json({ events, total: Number(count.rows[0]?.total) || 0 });
}
if (format === 'csv') {
const quote = value => `"${String(value ?? '').replaceAll('"', '""')}"`;
const body = ['id,ts,actor,action,target,detail', ...events.map(row => [row.id, new Date(row.ts * 1000).toISOString(), row.actor, row.action, row.target, JSON.stringify(row.detail)].map(quote).join(','))].join('\n');
c.header('Content-Disposition', 'attachment; filename="ytplayer-audit.csv"');
return c.text(body, 200, { 'Content-Type': 'text/csv; charset=utf-8' });
}
return c.json({ ok: true, events, total: Number(count.rows[0]?.total) || 0, limit, offset }, 200, { 'Cache-Control': 'no-store' });
});
}

35
server/audit-log.test.js Normal file
View File

@@ -0,0 +1,35 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Hono } from 'hono';
const root = mkdtempSync(join(tmpdir(), 'ytp-audit-test-'));
process.env.DB_PATH = join(root, 'audit.db');
const db = await import('./db.js');
const { audit, registerAuditRoutes } = await import('./audit-log.js');
const app = new Hono();
const auth = async (c, next) => c.req.header('x-admin') === 'yes' ? next() : c.json({ ok: false }, 401);
beforeAll(async () => {
await db.initDb();
registerAuditRoutes(app, { db, adminAuth: auth });
await audit(db, 'admin', 'notes.save.lyrics', 'video_1', { rev: 2 });
await audit(db, 'editor@example.test', 'report.resolve', '42', { batch: true });
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
describe('admin audit log', () => {
test('is authenticated, filtered, paged and exports CSV/JSON', async () => {
expect((await app.request('/api/admin/audit')).status).toBe(401);
const headers = { 'x-admin': 'yes' };
const filtered = await (await app.request('/api/admin/audit?actor=editor&action=resolve', { headers })).json();
expect(filtered.total).toBe(1);
expect(filtered.events[0]).toMatchObject({ actor: 'editor@example.test', action: 'report.resolve', target: '42', detail: { batch: true } });
const csv = await app.request('/api/admin/audit?format=csv', { headers });
expect(csv.headers.get('content-type')).toContain('text/csv');
expect(await csv.text()).toContain('notes.save.lyrics');
const json = await (await app.request('/api/admin/audit?format=json', { headers })).json();
expect(json.events).toHaveLength(2);
});
});

View File

@@ -20,6 +20,7 @@ const DB_PATH = process.env.DB_PATH || join(process.cwd(), 'data', 'ytplayer.db'
export const db = createClient({
url: 'file:' + DB_PATH,
});
let lyricsFtsAvailable = false;
// ---- Schema ----------------------------------------------------------------
@@ -106,7 +107,8 @@ export async function initDb() {
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0,
last_access INTEGER NOT NULL DEFAULT 0,
hits INTEGER NOT NULL DEFAULT 0
hits INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_media_lru
@@ -168,6 +170,15 @@ export async function initDb() {
);
CREATE INDEX IF NOT EXISTS idx_collections_queue ON metadata_collections (status,created_at);
CREATE TABLE IF NOT EXISTS deep_search_jobs (
id TEXT PRIMARY KEY, status TEXT NOT NULL, plan TEXT NOT NULL, options TEXT NOT NULL,
state TEXT NOT NULL DEFAULT '{}', error TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_deep_search_jobs ON deep_search_jobs(status,created_at);
CREATE TABLE IF NOT EXISTS deep_search_daily (
day TEXT PRIMARY KEY, queries INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS lyric_transcriptions (
id TEXT PRIMARY KEY, video_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
base_rev INTEGER NOT NULL DEFAULT 0, result TEXT, error TEXT, stage TEXT NOT NULL DEFAULT 'queued',
@@ -223,7 +234,9 @@ export async function initDb() {
size INTEGER NOT NULL DEFAULT 0,
art TEXT, -- 'embedded' | 'file' | NULL
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
plays INTEGER NOT NULL DEFAULT 0
plays INTEGER NOT NULL DEFAULT 0,
gen INTEGER NOT NULL DEFAULT 1,
art_gen INTEGER NOT NULL DEFAULT 1
);
-- "This lyric line is wrong" reports from service mode. One open row per
@@ -247,6 +260,13 @@ export async function initDb() {
CREATE INDEX IF NOT EXISTS idx_flags_video ON lyric_flags (video_id, status);
CREATE INDEX IF NOT EXISTS idx_flags_status ON lyric_flags (status, created_at DESC);
CREATE TABLE IF NOT EXISTS admin_lyrics_jobs (
id TEXT PRIMARY KEY, action TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
ids TEXT NOT NULL, state TEXT NOT NULL DEFAULT '{}', error TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_admin_lyrics_jobs ON admin_lyrics_jobs(status,created_at);
-- API tokens for scripts (lyrics injection etc.). Only a SHA-256 of the
-- token is stored; the plaintext is shown once when it is created.
CREATE TABLE IF NOT EXISTS api_tokens (
@@ -256,12 +276,29 @@ export async function initDb() {
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
last_used_at INTEGER
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL DEFAULT (unixepoch()),
actor TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log (ts DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_audit_actor_action ON audit_log (actor, action, ts DESC);
`);
await migrateVideoMeta();
await initLyricsSearch();
try { await db.execute('ALTER TABLE media_cache ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0'); } catch { /* already migrated */ }
// Uploads shared from devices (PWA share target): who sent it, and whether it
// is listed in everyone's search (admin uploads) or reachable only by link.
for (const col of ['owner TEXT', 'listed INTEGER NOT NULL DEFAULT 1']) {
try { await db.execute(`ALTER TABLE uploads ADD COLUMN ${col}`); } catch { /* already there */ }
}
try { await db.execute('ALTER TABLE uploads ADD COLUMN gen INTEGER NOT NULL DEFAULT 1'); } catch { /* already migrated */ }
try { await db.execute('ALTER TABLE uploads ADD COLUMN art_gen INTEGER NOT NULL DEFAULT 1'); } catch { /* already migrated */ }
await db.execute(`CREATE TABLE IF NOT EXISTS upload_owner_quotas (owner TEXT PRIMARY KEY,max_bytes INTEGER NOT NULL CHECK(max_bytes>=0),updated_at INTEGER NOT NULL)`);
// Integrated loudness (EBU R128 LUFS) measured by devices in the browser and
// shared, so each song is measured once for everyone.
await db.execute(`CREATE TABLE IF NOT EXISTS loudness (
@@ -284,6 +321,116 @@ export async function initDb() {
}
}
function lyricsSearchContent(value) {
try {
const data = typeof value === 'string' ? JSON.parse(value) : value;
return [...(data?.lines || []).flatMap(line => [line?.text, line?.secondary, line?.phonetic]), ...(data?.tags || [])]
.filter(Boolean).join(' ').slice(0, 500_000);
} catch { return ''; }
}
async function initLyricsSearch() {
await db.execute('CREATE TABLE IF NOT EXISTS lyrics_search_state (id INTEGER PRIMARY KEY CHECK(id=1), indexed_at INTEGER NOT NULL)');
try {
await db.execute("CREATE VIRTUAL TABLE IF NOT EXISTS lyrics_search_fts USING fts5(video_id UNINDEXED, content, tokenize='unicode61 remove_diacritics 2')");
lyricsFtsAvailable = true;
} catch {
await db.execute('CREATE TABLE IF NOT EXISTS lyrics_search_fts (video_id TEXT PRIMARY KEY, content TEXT NOT NULL)');
await db.execute('CREATE INDEX IF NOT EXISTS idx_lyrics_search_id ON lyrics_search_fts(video_id)');
lyricsFtsAvailable = false;
}
const state = await db.execute('SELECT id FROM lyrics_search_state WHERE id=1');
if (state.rows.length) return;
await db.execute('DELETE FROM lyrics_search_fts');
const rows = await db.execute("SELECT video_id,data FROM video_notes WHERE kind='lyrics'");
for (let i = 0; i < rows.rows.length; i += 100) {
const batch = rows.rows.slice(i, i + 100).map(row => ({
sql: lyricsFtsAvailable ? 'INSERT INTO lyrics_search_fts(video_id,content) VALUES(?,?)' : 'INSERT OR REPLACE INTO lyrics_search_fts(video_id,content) VALUES(?,?)',
args: [row.video_id, lyricsSearchContent(row.data)],
}));
if (batch.length) await db.batch(batch, 'write');
}
await db.execute('INSERT INTO lyrics_search_state(id,indexed_at) VALUES(1,unixepoch())');
}
export async function searchNoteLyrics(query, { limit = 30, offset = 0, hasChapters = false, withoutChapters = false } = {}) {
const words = String(query || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').match(/[\p{L}\p{N}_-]+/gu) || [];
if (!words.length) return [];
const boundedLimit = Math.max(1, Math.min(100, Number(limit) || 30));
const boundedOffset = Math.max(0, Math.min(1_000_000, Number(offset) || 0));
const chapterFilter = hasChapters ? "AND EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : withoutChapters ? "AND NOT EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : '';
if (lyricsFtsAvailable) {
const match = words.map(word => `"${word.replaceAll('"', '""')}"*`).join(' AND ');
const result = await db.execute({ sql: `SELECT video_id, snippet(lyrics_search_fts,1,char(1),char(2),' … ',18) AS snippet
FROM lyrics_search_fts WHERE lyrics_search_fts MATCH ? ${chapterFilter} ORDER BY bm25(lyrics_search_fts) LIMIT ? OFFSET ?`, args: [match, boundedLimit, boundedOffset] });
return result.rows.map(row => ({ videoId: row.video_id, snippet: row.snippet }));
}
const filters = words.map(() => 'content LIKE ?').join(' AND ');
const result = await db.execute({ sql: `SELECT video_id,substr(content,1,220) AS snippet FROM lyrics_search_fts WHERE ${filters} ORDER BY video_id LIMIT ? OFFSET ?`, args: [...words.map(word => `%${word}%`), boundedLimit, boundedOffset] });
return result.rows.map(row => ({ videoId: row.video_id, snippet: row.snippet }));
}
export async function countNoteLyrics(query, { hasChapters = false, withoutChapters = false } = {}) {
const words = String(query || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').match(/[\p{L}\p{N}_-]+/gu) || [];
if (!words.length) return 0;
const chapterFilter = hasChapters ? "AND EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : withoutChapters ? "AND NOT EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : '';
if (lyricsFtsAvailable) {
const match = words.map(word => `"${word.replaceAll('"', '""')}"*`).join(' AND ');
return Number((await db.execute({ sql: `SELECT COUNT(*) AS n FROM lyrics_search_fts WHERE lyrics_search_fts MATCH ? ${chapterFilter}`, args: [match] })).rows[0]?.n) || 0;
}
const filters = words.map(() => 'content LIKE ?').join(' AND ');
const result = await db.execute({ sql: `SELECT COUNT(*) AS n FROM lyrics_search_fts WHERE ${filters} ${chapterFilter}`, args: words.map(word => `%${word}%`) });
return Number(result.rows[0]?.n) || 0;
}
export async function rebuildLyricsSearch() {
await db.execute('DELETE FROM lyrics_search_fts');
await db.execute('DELETE FROM lyrics_search_state');
await initLyricsSearch();
return Number((await db.execute("SELECT COUNT(*) AS n FROM video_notes WHERE kind='lyrics'")).rows[0]?.n) || 0;
}
const YOUTUBE_ID = /^[\w-]{11}$/;
const folded = value => String(value || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
// These columns were added after video_meta shipped. Keep startup migration
// idempotent and page the backfill so a large catalogue never becomes one
// unbounded JS allocation.
export async function migrateVideoMeta({ pageSize = 500 } = {}) {
for (const [name, type] of [['url', 'TEXT'], ['channel_id', 'TEXT'], ['channel_url', 'TEXT'], ['published', 'TEXT'], ['lang', 'TEXT']]) {
try { await db.execute(`ALTER TABLE video_meta ADD COLUMN ${name} ${type}`); } catch { /* column already exists */ }
}
await db.execute('CREATE TABLE IF NOT EXISTS catalog_schema_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)');
const done = await db.execute({ sql: 'SELECT 1 FROM catalog_schema_migrations WHERE version=1' });
if (done.rows.length) return;
let cursor = '';
for (;;) {
const rows = (await db.execute({
sql: 'SELECT id,card,hay FROM video_meta WHERE id>? ORDER BY id LIMIT ?', args: [cursor, pageSize],
})).rows;
if (!rows.length) break;
const updates = [];
for (const row of rows) {
cursor = row.id;
let card = {};
try { card = JSON.parse(row.card || '{}'); } catch { /* malformed legacy card */ }
const url = YOUTUBE_ID.test(row.id) ? `https://www.youtube.com/watch?v=${row.id}` : null;
const channelId = card.channelId || card.channel_id || null;
const channelUrl = card.channelUrl || card.channel_url || null;
const published = card.published || card.publishedAt || card.upload_date || null;
const lang = card.lang || card.language || null;
card = { ...card, url, channelId: channelId || '', channelUrl: channelUrl || '' };
const hay = folded([card.title, card.channel, channelId, card.description,
...(Array.isArray(card.tags) ? card.tags : []), ...(Array.isArray(card.categories) ? card.categories : [])].filter(Boolean).join(' '));
updates.push({ sql: 'UPDATE video_meta SET card=?,url=?,channel_id=?,channel_url=?,published=?,lang=?,hay=? WHERE id=?',
args: [JSON.stringify(card), url, channelId, channelUrl, published, lang, hay || folded(row.hay), row.id] });
}
await db.batch(updates, 'write');
if (rows.length < pageSize) break;
}
await db.execute({ sql: 'INSERT OR IGNORE INTO catalog_schema_migrations(version,applied_at) VALUES(1,?)', args: [Date.now()] });
}
// ---- Shared video notes (lyrics / chapters) ---------------------------------
function noteRow(r) {
@@ -331,6 +478,11 @@ export async function saveNote({ videoId, kind, data, baseRev, source, updatedBy
VALUES (?, ?, ?, ?, ?, ?, unixepoch())`,
args: [videoId, kind, rev, json, source, updatedBy || null],
});
if (kind === 'lyrics') {
await tx.execute({ sql: 'DELETE FROM lyrics_search_fts WHERE video_id=?', args: [videoId] });
const content = lyricsSearchContent(data);
if (content) await tx.execute({ sql: 'INSERT INTO lyrics_search_fts(video_id,content) VALUES(?,?)', args: [videoId, content] });
}
await tx.commit();
return { ok: true, rev };
} catch (err) {
@@ -429,11 +581,15 @@ export async function listFlagsForVideo(videoId, status = 'open') {
}
// Newest first across every video; status 'all' returns both.
export async function listAllFlags({ status = 'open', limit = 200 } = {}) {
const where = status === 'all' ? '' : 'WHERE status = ?';
export async function listAllFlags({ status = 'open', limit = 200, olderThan = 0 } = {}) {
const filters = [];
const args = [];
if (status !== 'all') { filters.push('status = ?'); args.push(status); }
if (Number(olderThan) > 0) { filters.push('created_at <= ?'); args.push(Math.floor(Number(olderThan))); }
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
const r = await db.execute({
sql: `SELECT * FROM lyric_flags ${where} ORDER BY created_at DESC, id DESC LIMIT ?`,
args: status === 'all' ? [limit] : [status, limit],
args: [...args, limit],
});
return r.rows.map(flagRow);
}
@@ -519,6 +675,7 @@ const MEDIA_COLS = new Set([
'status', 'gen', 'size', 'height', 'vcodec', 'acodec', 'duration', 'optimized',
'meta', 'priority', 'auto', 'attempts', 'error', 'retry_at', 'created_at',
'updated_at', 'last_access', 'hits', 'sha256',
'pinned',
]);
function rowToObj(res, row) {
@@ -556,7 +713,7 @@ export async function listMedia() {
// Ready rows, least-recently-played first — the eviction order.
export async function listMediaLru() {
const r = await db.execute(
"SELECT video_id, size, last_access FROM media_cache WHERE status = 'ready' ORDER BY last_access ASC",
"SELECT video_id, size, last_access, pinned FROM media_cache WHERE status = 'ready' ORDER BY last_access ASC",
);
return r.rows.map((row) => rowToObj(r, row));
}
@@ -804,12 +961,13 @@ const uploadRow = (r) => ({
duration: Number(r.duration) || 0, ext: r.ext, mime: r.mime, size: Number(r.size) || 0,
art: r.art || null, createdAt: Number(r.created_at), plays: Number(r.plays) || 0,
owner: r.owner || null, listed: r.listed === undefined || r.listed === null ? true : Number(r.listed) === 1,
gen: Number(r.gen) || 1, artGen: Number(r.art_gen) || 1,
});
export async function createUpload(u) {
await db.execute({
sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, owner, listed, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())`,
sql: `INSERT INTO uploads (id, kind, title, artist, album, duration, ext, mime, size, art, owner, listed, gen, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, unixepoch())`,
args: [u.id, u.kind, u.title, u.artist || null, u.album || null, u.duration || 0, u.ext, u.mime, u.size || 0, u.art || null,
u.owner || null, u.listed === false ? 0 : 1],
});
@@ -828,7 +986,7 @@ export async function sharedUploadUsage(owner) {
}
export async function setUploadArt(id, art) {
await db.execute({ sql: 'UPDATE uploads SET art = ? WHERE id = ?', args: [art, id] });
await db.execute({ sql: 'UPDATE uploads SET art = ?, art_gen = art_gen + 1 WHERE id = ?', args: [art, id] });
}
export async function getUpload(id) {
@@ -836,6 +994,45 @@ export async function getUpload(id) {
return r.rows[0] ? uploadRow(r.rows[0]) : null;
}
export async function updateUploadMeta(id, patch = {}) {
const fields = ['title', 'artist', 'album', 'listed'].filter(k => patch[k] !== undefined);
if (!fields.length) return !!(await getUpload(id));
const values = fields.map(k => k === 'listed' ? (patch[k] ? 1 : 0) : String(patch[k] ?? '').slice(0, k === 'title' ? 300 : 200) || null);
const result = await db.execute({ sql: `UPDATE uploads SET ${fields.map(k => `${k}=?`).join(',')} WHERE id=?`, args: [...values, id] });
return !!result.rowsAffected;
}
export async function replaceUploadFile(id, upload) {
const result = await db.execute({ sql: `UPDATE uploads SET kind=?,title=?,artist=?,album=?,duration=?,ext=?,mime=?,size=?,art=?,gen=gen+1 WHERE id=?`,
args: [upload.kind, upload.title, upload.artist || null, upload.album || null, upload.duration || 0, upload.ext, upload.mime, upload.size || 0, upload.art || null, id] });
return !!result.rowsAffected;
}
export async function listAdminUploads({ q = '', limit = 100, offset = 0 } = {}) {
const args = [], where = q ? "WHERE lower(title || ' ' || ifnull(artist,'') || ' ' || ifnull(album,'')) LIKE ?" : '';
if (q) args.push(`%${String(q).toLowerCase().replace(/[\\%_]/g, '\\$&')}%`);
const rows = await db.execute({ sql: `SELECT * FROM uploads ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`, args: [...args, Math.max(1, Math.min(200, limit)), Math.max(0, offset)] });
const count = await db.execute({ sql: `SELECT COUNT(*) AS n FROM uploads ${where}`, args });
const owners = await db.execute("SELECT ifnull(owner,'(admin)') AS owner,COUNT(*) AS count,COALESCE(SUM(size),0) AS bytes FROM uploads GROUP BY owner ORDER BY bytes DESC");
return { uploads: rows.rows.map(uploadRow), total: Number(count.rows[0]?.n) || 0, owners: owners.rows.map(r => ({ owner: r.owner, count: Number(r.count), bytes: Number(r.bytes) })) };
}
export async function listUploadFileNames() {
const r = await db.execute('SELECT id,ext FROM uploads');
return r.rows.flatMap(row => [`${row.id}.${row.ext}`, `${row.id}.art.jpg`]);
}
export async function listUploadQuotas() {
const r = await db.execute('SELECT owner,max_bytes,updated_at FROM upload_owner_quotas ORDER BY owner');
return r.rows.map(x => ({ owner: x.owner, maxBytes: Number(x.max_bytes), updatedAt: Number(x.updated_at) }));
}
export async function getUploadQuota(owner) {
const r = await db.execute({ sql: 'SELECT max_bytes FROM upload_owner_quotas WHERE owner=?', args: [owner] });
return r.rows[0] ? Number(r.rows[0].max_bytes) : null;
}
export async function setUploadQuota(owner, maxBytes) {
if (maxBytes == null || Number(maxBytes) <= 0) return db.execute({ sql: 'DELETE FROM upload_owner_quotas WHERE owner=?', args: [owner] });
return db.execute({ sql: 'INSERT INTO upload_owner_quotas(owner,max_bytes,updated_at) VALUES(?,?,unixepoch()) ON CONFLICT(owner) DO UPDATE SET max_bytes=excluded.max_bytes,updated_at=excluded.updated_at', args: [owner, Math.floor(Number(maxBytes))] });
}
// Newest first; `q` matches title/artist/album (case-insensitive).
// `listedOnly`: public search shows admin uploads, never device-shared ones.
export async function listUploads({ q = '', limit = 100, listedOnly = false } = {}) {

217
server/deep-search.js Normal file
View File

@@ -0,0 +1,217 @@
import { randomUUID } from 'node:crypto';
import { db as defaultDb } from './db.js';
import * as catalog from './video-catalog.js';
import * as innertube from './innertube.js';
import * as searchCache from './search-cache.js';
import { audit } from './audit-log.js';
const MAX_SEEDS = 200, MAX_PLAN = 500, MAX_ERRORS = 50;
const clean = value => String(value || '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 200);
const fold = value => String(value || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/\s+/g, ' ').trim();
const parse = value => { try { return JSON.parse(value || '{}'); } catch { return {}; } };
const sleepDefault = ms => new Promise(resolve => setTimeout(resolve, ms));
export const DEFAULT_PRESETS = [
'worship', 'praise', 'hymns', 'gospel', 'pagsamba', 'papuri', 'mga awit ng papuri', 'ebanghelyo',
'dayeg', 'pagdayeg', 'mga alawiton', 'maayong balita', 'adoración', 'alabanza', 'himnos', 'evangelio',
'Hillsong Worship', 'Elevation Worship', 'Bethel Music', 'Planetshakers', 'Don Moen', 'Sandi Patty',
];
export const DEFAULT_MODIFIERS = ['lyrics', 'live', 'acoustic', 'instrumental', 'karaoke'];
export function expandQueryPlan({ seeds = [], modifiers = [], maxQueries = 80 } = {}) {
const seedList = (Array.isArray(seeds) ? seeds : String(seeds).split(/\r?\n/)).map(clean).filter(Boolean).slice(0, MAX_SEEDS);
const mods = (Array.isArray(modifiers) ? modifiers : String(modifiers).split(/[,\r\n]+/)).map(clean).filter(Boolean).slice(0, 30);
const cap = Math.max(1, Math.min(MAX_PLAN, Number(maxQueries) || 1));
const out = [], seen = new Set();
for (const seed of seedList) for (const modifier of ['', ...mods]) {
const query = clean(modifier ? `${seed} ${modifier}` : seed), key = fold(query);
if (key && !seen.has(key)) { seen.add(key); out.push(query); if (out.length >= cap) return out; }
}
return out;
}
function publicJob(row, indexSize = 0) {
const state = parse(row.state);
const done = Number(state.queriesDone) || 0, total = JSON.parse(row.plan || '[]').length;
const elapsed = Math.max(0, Date.now() - Number(row.created_at));
const etaMs = done ? Math.max(0, Math.round(elapsed / done * (total - done))) : null;
return { id: row.id, status: row.status, plan: JSON.parse(row.plan || '[]'), options: parse(row.options),
queriesDone: done, queriesTotal: total, current: state.current || '', newVideos: Number(state.newVideos) || 0,
totalIndexSize: indexSize, errors: state.errors || [], log: state.log || [], etaMs,
createdAt: Number(row.created_at), updatedAt: Number(row.updated_at), error: row.error || null };
}
export function createDeepSearchService({ db = defaultDb, searchDeep = innertube.searchDeep, related = innertube.related,
browseUploads = innertube.browseUploads, ingest = catalog.ingest, putSearch = searchCache.put,
sleep = sleepDefault, random = Math.random, now = Date.now, autoStart = true } = {}) {
let worker = null;
let timer = null;
const log = (state, message) => { state.log ||= []; state.log.push({ at: now(), message: String(message).slice(0, 240) }); if (state.log.length > 100) state.log.splice(0, state.log.length - 100); };
async function indexSize() { return Number((await db.execute('SELECT COUNT(*) AS n FROM video_meta')).rows[0]?.n || 0); }
async function getJob(id) { return (await db.execute({ sql: 'SELECT * FROM deep_search_jobs WHERE id=?', args: [id] })).rows[0] || null; }
async function checkpoint(job, state, status = 'running', error = null) {
const r = await db.execute({ sql: "UPDATE deep_search_jobs SET state=?,status=?,error=?,updated_at=? WHERE id=? AND status='running'", args: [JSON.stringify(state), status, error, now(), job.id] });
if (!r.rowsAffected) { const stop = new Error('Deep search paused or cancelled.'); stop.name = 'DeepSearchStopped'; throw stop; }
}
async function reserveDaily(cap) {
const day = new Date(now()).toISOString().slice(0, 10);
const r = await db.execute({ sql: `INSERT INTO deep_search_daily(day,queries) VALUES(?,1)
ON CONFLICT(day) DO UPDATE SET queries=queries+1 WHERE queries<? RETURNING queries`, args: [day, cap] });
return r.rows.length ? Number(r.rows[0].queries) : null;
}
async function run(job) {
const options = parse(job.options), plan = JSON.parse(job.plan || '[]');
const state = { queriesDone: 0, newVideos: 0, errors: [], log: [], current: '', relatedSeen: [], ...parse(job.state) };
async function saveCards(query, cards, cacheCards = cards) {
const unique = [...new Map((cards || []).filter(c => c?.id).map(c => [c.id, c])).values()];
if (unique.length) {
const validIds = unique.filter(c => /^[\w-]{11}$/.test(c.id)).map(c => c.id);
const existing = validIds.length ? (await db.execute({ sql: `SELECT id FROM video_meta WHERE id IN (${validIds.map(() => '?').join(',')})`, args: validIds })).rows : [];
const present = new Set(existing.map(row => row.id));
await ingest(unique, 'deep');
state.newVideos += validIds.filter(id => !present.has(id)).length;
}
await putSearch(query, (cacheCards || unique).slice(0, 200));
}
async function requireDailyBudget() {
const used = await reserveDaily(Math.max(1, Number(options.dailyCap) || 1000));
if (used !== null) return;
state.dailyPausedDay = new Date(now()).toISOString().slice(0, 10);
log(state, 'Daily query cap reached; job will resume tomorrow.');
await checkpoint(job, state, 'paused');
const stop = new Error('Daily cap reached.'); stop.name = 'DeepSearchStopped'; throw stop;
}
try {
for (let qi = state.queriesDone; qi < plan.length; qi++) {
const query = plan[qi];
if (state.current !== query) state.currentIds = [];
state.current = query; await checkpoint(job, state);
await requireDailyBudget();
await checkpoint(job, state);
let cards, lastError;
for (let attempt = 0; attempt < 3; attempt++) {
try {
cards = await searchDeep(query, { limit: Math.max(1, Math.min(200, Number(options.resultsPerQuery) || 50)), maxPages: 14, timeoutMs: 30000,
pageDelayMs: Math.max(0, Number(options.delayMs) || 0), jitterMs: Math.max(0, Number(options.jitterMs) || 0),
onPage: async pageCards => {
await checkpoint(job, state);
const saved = new Set(state.currentIds || []);
const fresh = pageCards.filter(card => !saved.has(card.id));
await saveCards(query, fresh, pageCards);
state.currentIds = pageCards.map(card => card.id);
await checkpoint(job, state);
} });
await checkpoint(job, state);
lastError = null; break;
} catch (error) { if (error.name === 'DeepSearchStopped') throw error; lastError = error; if (attempt < 2) await sleep(Math.min(60000, (Number(options.delayMs) || 10000) * 2 ** attempt + Math.floor(random() * 3000))); }
}
if (lastError) { state.errors.push({ query, error: String(lastError.message).slice(0, 200) }); if (state.errors.length > MAX_ERRORS) state.errors.shift(); log(state, `Search failed: ${query}`); }
else {
const saved = new Set(state.currentIds || []);
const remaining = (cards || []).filter(card => !saved.has(card.id));
await saveCards(query, remaining, cards || []);
state.currentIds = (cards || []).map(card => card.id);
const depth = Math.max(0, Math.min(2, Number(options.relatedDepth) || 0));
if (depth) {
let frontier = (cards || []).slice(0, Math.max(1, Math.min(10, Number(options.relatedTop) || 3))).map(c => ({ id: c.id, depth: 1 }));
for (let level = 1; level <= depth && frontier.length; level++) {
const next = [];
for (const item of frontier) {
if (state.relatedSeen.includes(item.id)) continue;
state.relatedSeen.push(item.id);
try {
await sleep(Math.max(0, Number(options.delayMs) || 10000) + Math.floor(random() * (Number(options.jitterMs) || 3000)));
await checkpoint(job, state);
await requireDailyBudget();
const recs = await related(item.id); await checkpoint(job, state);
await saveCards(`${query} related ${item.id}`, recs);
if (level < depth) next.push(...recs.slice(0, 2).map(c => ({ id: c.id, depth: level + 1 })));
} catch (error) { if (error.name === 'DeepSearchStopped') throw error; state.errors.push({ query: `related:${item.id}`, error: String(error.message).slice(0, 200) }); }
}
frontier = next;
}
}
log(state, `Saved ${cards?.length || 0} results for “${query}”.`);
}
state.queriesDone = qi + 1; state.current = ''; state.currentIds = []; await checkpoint(job, state);
if (qi + 1 < plan.length) await sleep(Math.max(0, Number(options.delayMs) || 10000) + Math.floor(random() * (Number(options.jitterMs) || 3000)));
}
for (const source of [...(options.channels || []), ...(options.playlists || [])].slice(0, 50)) {
state.current = `browse ${source}`; await checkpoint(job, state);
try { await requireDailyBudget(); await checkpoint(job, state); const cards = await browseUploads(source, { limit: Math.max(1, Math.min(200, Number(options.resultsPerQuery) || 50)), pageDelayMs: options.delayMs, jitterMs: options.jitterMs }); await checkpoint(job, state); await saveCards(state.current, cards); log(state, `Browsed ${cards.length} videos from ${source}.`); }
catch (error) { if (error.name === 'DeepSearchStopped') throw error; state.errors.push({ query: state.current, error: String(error.message).slice(0, 200) }); }
await checkpoint(job, state);
}
state.current = ''; log(state, 'Deep search complete.'); await checkpoint(job, state, 'complete');
} catch (error) {
if (error.name !== 'DeepSearchStopped') {
state.errors.push({ query: state.current, error: String(error.message).slice(0, 200) });
await db.execute({ sql: "UPDATE deep_search_jobs SET state=?,status='failed',error=?,updated_at=? WHERE id=? AND status='running'", args: [JSON.stringify(state), String(error.message).slice(0, 300), now(), job.id] });
}
}
}
async function runNext() {
if (worker) return worker;
worker = (async () => {
// Recover jobs interrupted by process restart; the current query is safe to repeat.
await db.execute({ sql: "UPDATE deep_search_jobs SET status='queued',updated_at=? WHERE status='running'", args: [now()] });
const today = new Date(now()).toISOString().slice(0, 10);
const paused = (await db.execute("SELECT id,state FROM deep_search_jobs WHERE status='paused'")).rows;
for (const row of paused) if (parse(row.state).dailyPausedDay && parse(row.state).dailyPausedDay < today)
await db.execute({ sql: "UPDATE deep_search_jobs SET status='queued',updated_at=? WHERE id=? AND status='paused'", args: [now(), row.id] });
const job = (await db.execute({ sql: "SELECT * FROM deep_search_jobs WHERE status='queued' ORDER BY created_at LIMIT 1" })).rows[0];
if (job) {
await db.execute({ sql: "UPDATE deep_search_jobs SET status='running',updated_at=? WHERE id=? AND status='queued'", args: [now(), job.id] });
const current = await getJob(job.id); if (current?.status === 'running') await run(current);
}
return !!job;
})().finally(() => { worker = null; });
return worker;
}
const kick = () => runNext().catch(error => console.error('[deep search]', error.message));
function start() { if (!timer) { timer = setInterval(kick, 15000); timer.unref?.(); } kick(); }
if (autoStart) start();
async function estimate(body) {
const plan = expandQueryPlan(body);
const browseSources = [...(Array.isArray(body?.channels) ? body.channels : []), ...(Array.isArray(body?.playlists) ? body.playlists : [])].map(clean).filter(Boolean).slice(0, 50).length;
const operations = plan.length + browseSources + (Number(body?.relatedDepth) > 0 ? Math.min(plan.length * (Number(body?.relatedTop) || 3) * Math.min(2, Number(body.relatedDepth)), 100) : 0);
const today = new Date(now()).toISOString().slice(0, 10);
const used = Number((await db.execute({ sql: 'SELECT queries FROM deep_search_daily WHERE day=?', args: [today] })).rows[0]?.queries || 0);
return { plan, queries: plan.length, browseSources, estimatedOperations: operations,
estimatedMinutes: Math.ceil(operations * Math.max(1, Number(body?.delaySeconds) || 10) / 60),
indexSize: await indexSize(), dailyUsed: used, dailyRemaining: Math.max(0, (Number(body?.dailyCap) || 1000) - used) };
}
function register(app, adminAuth) {
app.post('/api/admin/deep-search/estimate', adminAuth, async c => { let body = {}; try { body = await c.req.json(); } catch {} return c.json({ ok: true, ...(await estimate(body)) }); });
app.get('/api/admin/deep-search/jobs', adminAuth, async c => c.json({ ok: true, indexSize: await indexSize(), jobs: (await db.execute('SELECT * FROM deep_search_jobs ORDER BY created_at DESC LIMIT 30')).rows.map(row => publicJob(row)) }));
app.post('/api/admin/deep-search/start', adminAuth, async c => {
let body; try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'Invalid JSON.' }, 400); }
const plan = expandQueryPlan(body); if (!plan.length) return c.json({ ok: false, error: 'Add at least one seed term.' }, 400);
const active = (await db.execute("SELECT id FROM deep_search_jobs WHERE status IN ('queued','running','paused') LIMIT 1")).rows[0];
if (active) return c.json({ ok: false, error: 'A deep-search job is already active.' }, 409);
const options = { resultsPerQuery: Math.max(1, Math.min(200, Number(body.resultsPerQuery) || 50)), relatedDepth: Math.max(0, Math.min(2, Number(body.relatedDepth) || 0)), relatedTop: Math.max(1, Math.min(10, Number(body.relatedTop) || 3)), delayMs: Math.max(1000, Math.min(300000, (Number(body.delaySeconds) || 10) * 1000)), jitterMs: Math.max(0, Math.min(300000, (Number(body.jitterSeconds) || 3) * 1000)), dailyCap: Math.max(1, Math.min(10000, Number(body.dailyCap) || 1000)), channels: (Array.isArray(body.channels) ? body.channels : []).map(clean).filter(Boolean).slice(0, 25), playlists: (Array.isArray(body.playlists) ? body.playlists : []).map(clean).filter(Boolean).slice(0, 25) };
const id = randomUUID(), time = now();
await db.execute({ sql: "INSERT INTO deep_search_jobs(id,status,plan,options,state,created_at,updated_at) VALUES(?,'queued',?,?,?,?,?)", args: [id, JSON.stringify(plan), JSON.stringify(options), JSON.stringify({ queriesDone: 0, newVideos: 0, errors: [], log: [] }), time, time] });
await audit(db, 'admin', 'deep-search.start', id, { queries: plan.length });
if (autoStart) kick(); return c.json({ ok: true, id }, 202);
});
for (const action of ['pause', 'resume', 'cancel']) app.post(`/api/admin/deep-search/:id/${action}`, adminAuth, async c => {
const id = c.req.param('id');
const allowed = action === 'pause' ? ['queued', 'running'] : action === 'resume' ? ['paused'] : ['queued', 'running', 'paused'];
const target = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'cancelled';
const marks = allowed.map(() => '?').join(',');
const r = await db.execute({ sql: `UPDATE deep_search_jobs SET status=?,updated_at=? WHERE id=? AND status IN (${marks})`, args: [target, now(), id, ...allowed] });
if (r.rowsAffected) await audit(db, 'admin', `deep-search.${action}`, id);
if (action === 'resume' && r.rowsAffected && autoStart) kick();
return c.json({ ok: !!r.rowsAffected, status: r.rowsAffected ? target : undefined }, r.rowsAffected ? 200 : 409);
});
return { estimate, runNext, kick, start };
}
return { estimate, runNext, kick, start, register };
}
export function registerDeepSearchRoutes(app, options = {}) {
const service = createDeepSearchService(options);
service.register(app, options.adminAuth);
return service;
}

122
server/deep-search.test.js Normal file
View File

@@ -0,0 +1,122 @@
import { expect, test, beforeAll } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Hono } from 'hono';
const root = mkdtempSync(join(tmpdir(), 'ytp-deep-search-'));
process.env.DB_PATH = join(root, 'test.db');
const { initDb, db } = await import('./db.js');
const catalog = await import('./video-catalog.js');
const searchCache = await import('./search-cache.js');
const { createDeepSearchService, expandQueryPlan } = await import('./deep-search.js');
const auth = async (_c, next) => next();
const sample = { id: 'd33psrch001', title: 'Worship in Cebuano', channel: 'Hope Choir' };
beforeAll(async () => { await initDb(); });
function makeApp(service) {
const app = new Hono(); service.register(app, auth); return app;
}
async function request(app, path, method = 'GET', body) {
const response = await app.request(path, { method, headers: body ? { 'content-type': 'application/json' } : {}, body: body ? JSON.stringify(body) : undefined });
return { response, json: await response.json() };
}
const cleanDb = async () => {
await db.batch(['DELETE FROM deep_search_jobs', 'DELETE FROM deep_search_daily', 'DELETE FROM video_meta', 'DELETE FROM video_meta_sources', 'DELETE FROM search_cache']);
};
test.serial('query plan expands modifiers, folds accents and deduplicates', () => {
expect(expandQueryPlan({ seeds: ['Adoración', 'adoracion', ' Gospel '], modifiers: ['live', 'LIVE'], maxQueries: 30 }))
.toEqual(['Adoración', 'Adoración live', 'Gospel', 'Gospel live']);
expect(expandQueryPlan({ seeds: ['one', 'two'], modifiers: ['live'], maxQueries: 2 })).toHaveLength(2);
});
test.serial('estimate and background job ingest to catalog, source and search cache', async () => {
await cleanDb();
const service = createDeepSearchService({ db, ingest: catalog.ingest, putSearch: searchCache.put,
searchDeep: async () => [sample], sleep: async () => {}, autoStart: false });
const app = makeApp(service);
const estimate = await request(app, '/api/admin/deep-search/estimate', 'POST', { seeds: ['worship'], modifiers: ['live','lyrics'], maxQueries: 4 });
expect(estimate.json.plan).toEqual(['worship', 'worship live', 'worship lyrics']);
const created = await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship'], modifiers: [], maxQueries: 1, delaySeconds: 1 });
expect(created.response.status).toBe(202);
await service.runNext();
const jobs = await request(app, '/api/admin/deep-search/jobs');
expect(jobs.json.jobs[0].status).toBe('complete');
expect(jobs.json.jobs[0].newVideos).toBe(1);
const row = (await db.execute({ sql: 'SELECT source FROM video_meta_sources WHERE video_id=?', args: [sample.id] })).rows[0];
expect(row.source).toBe('deep');
expect((await searchCache.get('worship')).results[0].id).toBe(sample.id);
});
test.serial('pause during upstream call does not ingest; resume continues from the persisted query', async () => {
await cleanDb(); let begin, release, calls = 0;
const began = new Promise(resolve => { begin = resolve; });
const gate = new Promise(resolve => { release = resolve; });
const service = createDeepSearchService({ db, ingest: catalog.ingest, putSearch: searchCache.put, autoStart: false,
sleep: async () => {}, searchDeep: async () => { calls++; if (calls === 1) { begin(); await gate; } return [sample]; } });
const app = makeApp(service);
const created = await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship'], maxQueries: 1, delaySeconds: 1 });
const work = service.runNext(); await began;
await request(app, `/api/admin/deep-search/${created.json.id}/pause`, 'POST', {});
release(); await work;
expect((await db.execute({ sql: 'SELECT COUNT(*) AS n FROM video_meta WHERE id=?', args: [sample.id] })).rows[0].n).toBe(0);
let jobs = await request(app, '/api/admin/deep-search/jobs'); expect(jobs.json.jobs[0].status).toBe('paused');
await request(app, `/api/admin/deep-search/${created.json.id}/resume`, 'POST', {});
await service.runNext();
jobs = await request(app, '/api/admin/deep-search/jobs');
expect(jobs.json.jobs[0].status).toBe('complete'); expect(calls).toBe(2);
});
test.serial('cancelled in-flight job does not write its late result', async () => {
await cleanDb(); let begin, release;
const began = new Promise(resolve => { begin = resolve; }), gate = new Promise(resolve => { release = resolve; });
const service = createDeepSearchService({ db, autoStart: false, sleep: async () => {},
searchDeep: async () => { begin(); await gate; return [sample]; }, ingest: catalog.ingest, putSearch: searchCache.put });
const app = makeApp(service);
const created = await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship'], maxQueries: 1, delaySeconds: 1 });
const work = service.runNext(); await began;
await request(app, `/api/admin/deep-search/${created.json.id}/cancel`, 'POST', {}); release(); await work;
expect((await db.execute({ sql: 'SELECT COUNT(*) AS n FROM video_meta WHERE id=?', args: [sample.id] })).rows[0].n).toBe(0);
const jobs = await request(app, '/api/admin/deep-search/jobs'); expect(jobs.json.jobs[0].status).toBe('cancelled');
});
test.serial('page checkpoint survives a retry without double-counting its videos', async () => {
await cleanDb(); let calls = 0;
const service = createDeepSearchService({ db, autoStart: false, sleep: async () => {}, ingest: catalog.ingest, putSearch: searchCache.put,
searchDeep: async (_query, { onPage }) => {
calls++;
await onPage([sample]);
if (calls === 1) throw Error('connection reset after first page');
return [sample];
} });
const app = makeApp(service);
await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship'], maxQueries: 1, delaySeconds: 1 });
await service.runNext();
const row = (await db.execute({ sql: 'SELECT seen FROM video_meta WHERE id=?', args: [sample.id] })).rows[0];
expect(Number(row.seen)).toBe(1);
});
test.serial('daily cap pauses and worker resumes it on the next UTC day', async () => {
await cleanDb(); let time = Date.parse('2026-10-14T23:59:00Z');
const service = createDeepSearchService({ db, now: () => time, autoStart: false, sleep: async () => {},
searchDeep: async query => [{ ...sample, id: query === 'worship' ? 'd33psrch001' : 'd33psrch002' }],
ingest: catalog.ingest, putSearch: searchCache.put });
const app = makeApp(service);
const created = await request(app, '/api/admin/deep-search/start', 'POST', { seeds: ['worship','praise'], maxQueries: 2, delaySeconds: 1, dailyCap: 1 });
await service.runNext();
let jobs = await request(app, '/api/admin/deep-search/jobs'); expect(jobs.json.jobs[0].status).toBe('paused');
time += 120000; await service.runNext(); jobs = await request(app, '/api/admin/deep-search/jobs');
expect(jobs.json.jobs[0].status).toBe('complete'); expect(jobs.json.jobs[0].queriesDone).toBe(2);
});
test.serial('only one queued/running job is accepted', async () => {
await cleanDb();
const service = createDeepSearchService({ db, autoStart: false }); const app = makeApp(service);
const body = { seeds: ['worship'], maxQueries: 1 };
expect((await request(app, '/api/admin/deep-search/start', 'POST', body)).response.status).toBe(202);
expect((await request(app, '/api/admin/deep-search/start', 'POST', body)).response.status).toBe(409);
});
test.serial('cleanup test database', () => { rmSync(root, { recursive: true, force: true }); });

View File

@@ -20,6 +20,7 @@ function cardsFromSections(sections) {
const path = be.canonicalBaseUrl || (be.browseId ? `/channel/${be.browseId}` : '');
out.push({
id: v.videoId,
url: `https://www.youtube.com/watch?v=${v.videoId}`,
title: v.title?.runs?.map((r) => r.text).join('') || '(untitled)',
channel: owner.text || '',
channelId: be.browseId || '',
@@ -78,7 +79,7 @@ export async function search(q, { fetchImpl = fetch, timeoutMs = 6000 } = {}) {
// are a chain, so they are fetched one after another; every page is optional —
// a failure part-way returns what was gathered. `onPage(cards)` lets a caller
// save progress.
export async function searchDeep(q, { limit = 200, fetchImpl = fetch, timeoutMs = 6000, maxPages = 14, onPage } = {}) {
export async function searchDeep(q, { limit = 200, fetchImpl = fetch, timeoutMs = 6000, maxPages = 14, onPage, pageDelayMs = 0, jitterMs = 0 } = {}) {
const post = async (body) => {
const res = await fetchImpl('https://www.youtube.com/youtubei/v1/search?prettyPrint=false', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
@@ -92,19 +93,92 @@ export async function searchDeep(q, { limit = 200, fetchImpl = fetch, timeoutMs
const all = [];
const add = (cards) => { for (const c of cards) if (!seen.has(c.id)) { seen.add(c.id); all.push(c); } };
add(first.cards);
if (onPage) onPage(all.slice());
if (onPage) await onPage(all.slice());
let next = first.next;
for (let i = 0; i < maxPages && next && all.length < limit; i++) {
if (pageDelayMs > 0 || jitterMs > 0) await new Promise(resolve => setTimeout(resolve, Math.max(0, pageDelayMs + Math.random() * jitterMs)));
let page;
try { page = parseContinuation(await post({ continuation: next })); } catch { break; }
if (!page.cards.length) break;
add(page.cards);
if (onPage) onPage(all.slice());
if (onPage) await onPage(all.slice());
next = page.next;
}
return all.slice(0, limit);
}
function browseToken(value) {
if (!value || typeof value !== 'object') return '';
const token = value.continuationItemRenderer?.continuationEndpoint?.continuationCommand?.token;
if (token) return token;
for (const child of Object.values(value)) {
if (Array.isArray(child)) { for (const item of child) { const found = browseToken(item); if (found) return found; } }
else { const found = browseToken(child); if (found) return found; }
}
return '';
}
export function parseBrowseVideos(json) {
const cards = new Map();
function visit(value) {
if (!value || typeof value !== 'object') return;
const video = value.videoRenderer || value.gridVideoRenderer || value.richItemRenderer?.content?.videoRenderer;
const id = video?.videoId;
const title = video?.title?.runs?.map(run => run.text).join('') || video?.title?.simpleText || '';
if (id && /^[\w-]{11}$/.test(id) && title) {
const owner = video.ownerText?.runs?.[0] || video.shortBylineText?.runs?.[0] || {};
const browse = owner.navigationEndpoint?.browseEndpoint || {};
cards.set(id, { id, url: `https://www.youtube.com/watch?v=${id}`, title, channel: owner.text || '',
channelId: browse.browseId || '', channelUrl: browse.canonicalBaseUrl ? `https://www.youtube.com${browse.canonicalBaseUrl}` : '',
duration: lengthToSeconds(video.lengthText?.simpleText), thumbnail: `https://i.ytimg.com/vi/${id}/mqdefault.jpg` });
return;
}
for (const child of Object.values(value)) if (Array.isArray(child)) child.forEach(visit); else visit(child);
}
visit(json);
return [...cards.values()];
}
// YouTube browse continuation for channel uploads and playlists. Channel IDs
// are translated to their uploads playlist (UU…), while playlist URLs use VL.
export async function browseUploads(source, { limit = 200, fetchImpl = fetch, timeoutMs = 8000, maxPages = 12, pageDelayMs = 0, jitterMs = 0 } = {}) {
let id = String(source || '').trim();
let handle = '';
try {
const url = new URL(id);
id = url.searchParams.get('list') || url.pathname.match(/\/channel\/([^/]+)/)?.[1] || id;
handle = url.pathname.match(/\/@([^/]+)/)?.[1] || '';
} catch { /* raw channel or playlist id */ }
if (handle) {
const response = await fetchImpl(`https://www.youtube.com/@${encodeURIComponent(handle)}`, { signal: AbortSignal.timeout(timeoutMs) });
if (!response.ok) throw new Error(`channel lookup: HTTP ${response.status}`);
const html = await response.text();
id = html.match(/"browseId"\s*:\s*"(UC[\w-]{22})"/)?.[1] || '';
}
let browseId;
if (/^UC[\w-]{22}$/.test(id)) browseId = `UU${id.slice(2)}`;
else if (/^(?:PL|UU|LL|FL|OL)[\w-]{10,}$/.test(id)) browseId = id.startsWith('UU') || id.startsWith('LL') || id.startsWith('FL') || id.startsWith('OL') ? id : `VL${id}`;
else throw new Error('Expected a YouTube channel ID or playlist ID/URL.');
const post = async body => {
const response = await fetchImpl('https://www.youtube.com/youtubei/v1/browse?prettyPrint=false', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context: { client: CLIENT }, ...body }), signal: AbortSignal.timeout(timeoutMs),
});
if (!response.ok) throw new Error(`innertube browse: HTTP ${response.status}`);
return response.json();
};
let page = await post({ browseId });
const seen = new Set(), cards = [];
for (let i = 0; i < maxPages && cards.length < limit; i++) {
for (const card of parseBrowseVideos(page)) if (!seen.has(card.id)) { seen.add(card.id); cards.push(card); }
const token = browseToken(page);
if (!token || cards.length >= limit) break;
if (pageDelayMs > 0 || jitterMs > 0) await new Promise(resolve => setTimeout(resolve, Math.max(0, pageDelayMs + Math.random() * jitterMs)));
page = await post({ continuation: token });
}
return cards.slice(0, limit);
}
// Watch-next recommendations use compact renderers or the newer lockup model.
// Traverse only the recommendation contents, never player configuration/ads.
export function parseRelated(json) {

View File

@@ -7,7 +7,7 @@ test('parses video renderers into slim cards', () => {
const r = parseSearch(fixture);
expect(r.length).toBe(4);
expect(r[0]).toEqual({
id: 'nQWFzMvCfLE', title: 'What A Beautiful Name - Hillsong Worship', channel: 'Hillsong Worship',
id: 'nQWFzMvCfLE', url: 'https://www.youtube.com/watch?v=nQWFzMvCfLE', title: 'What A Beautiful Name - Hillsong Worship', channel: 'Hillsong Worship',
channelId: 'UC4q12NoPNySbVqwpw4iO5Vg', channelUrl: 'https://www.youtube.com/channel/UC4q12NoPNySbVqwpw4iO5Vg',
duration: 343, thumbnail: 'https://i.ytimg.com/vi/nQWFzMvCfLE/mqdefault.jpg',
});
@@ -60,3 +60,18 @@ test('related HTTP failures and unsupported shapes throw for search fallback', a
expect(() => parseRelated({ error: {} })).toThrow('response shape');
await expect(related('aaaaaaaaaaa', { fetchImpl: async () => ({ ok: false, status: 429 }) })).rejects.toThrow('429');
});
test('channel and playlist browse parses uploads and resolves handle URLs', async () => {
const { browseUploads, parseBrowseVideos } = await import('./innertube.js');
const renderer = { videoRenderer: { videoId: 'abcdefghijk', title: { simpleText: 'Upload' }, ownerText: { runs: [{ text: 'Choir', navigationEndpoint: { browseEndpoint: { browseId: 'UC1234567890123456789012', canonicalBaseUrl: '/@choir' } } }] }, lengthText: { simpleText: '2:10' } } };
expect(parseBrowseVideos({ contents: [{ gridVideoRenderer: { videoId: 'abcdefghijk', title: { simpleText: 'Upload' } } }] })[0].title).toBe('Upload');
const calls = [];
const fetchImpl = async (url, init = {}) => {
calls.push([String(url), init.body && JSON.parse(init.body)]);
if (String(url).includes('/@choir')) return new Response('"browseId":"UC1234567890123456789012"');
return Response.json({ contents: [{ gridVideoRenderer: renderer.videoRenderer }] });
};
const result = await browseUploads('https://www.youtube.com/@choir', { fetchImpl, limit: 5 });
expect(result[0].id).toBe('abcdefghijk');
expect(calls[1][1].browseId).toBe('UU1234567890123456789012');
});

View File

@@ -158,7 +158,7 @@ function metaFromInfo(info, duration) {
// getInfo async (videoId) => yt-dlp -J info — duration, live flags, meta
// ----------------------------------------------------------------------------
export function createMediaCache({
dir, db, download, getInfo,
dir, db, download, getInfo, getSourceUrl = id => `https://www.youtube.com/watch?v=${id}`,
ffmpeg = 'ffmpeg', ffprobe = 'ffprobe', nice = 'nice',
maxBytes = 10 * 1024 ** 3,
minFreeBytes = 5 * 1024 ** 3,
@@ -242,7 +242,7 @@ export function createMediaCache({
const order = db.listMediaEvictionOrder ? await db.listMediaEvictionOrder() : await db.listMediaLru();
for (const r of order) {
if (bytes + est <= maxBytes) break;
if (r.video_id === id || r.last_access > cutoff || jobs.has(r.video_id) || optActive === r.video_id) continue;
if (r.video_id === id || r.pinned || r.last_access > cutoff || jobs.has(r.video_id) || optActive === r.video_id) continue;
await evict(r.video_id, 'budget');
bytes -= r.size || 0;
}
@@ -314,7 +314,9 @@ export function createMediaCache({
await job.persisted;
try {
await setStatus(job, 'downloading');
const info = await getInfo(id);
const sourceUrl = await getSourceUrl(id);
if (!sourceUrl || !/^https:\/\/www\.youtube\.com\/watch\?v=[\w-]{11}$/.test(sourceUrl)) throw new MediaSkip('source URL is unavailable');
const info = await getInfo(id, sourceUrl);
const live = info.is_live || ['is_live', 'post_live', 'is_upcoming'].includes(info.live_status);
if (live) throw new MediaSkip('live streams are not cached');
const expected = typeof info.duration === 'number' ? info.duration : 0;
@@ -324,7 +326,7 @@ export function createMediaCache({
}
await makeRoom(id, Math.max(expected, 60) * EST_BYTES_PER_SEC);
const raw = await download(id, `${base}.dl.mp4`);
const raw = await download(id, `${base}.dl.mp4`, sourceUrl);
await setStatus(job, 'validating');
// Normalise: first video + first audio, moov atom up front so the copy
@@ -733,5 +735,6 @@ export function createMediaCache({
return n;
}
return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId, backfillHashes, adoptFile, available };
return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId, backfillHashes, adoptFile, available,
evictNow: id => evict(id, 'admin') };
}

View File

@@ -2,7 +2,7 @@
// Uses real ffmpeg fixtures and the real db.js against a throwaway DB file.
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, copyFileSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs';
import { mkdtempSync, rmSync, copyFileSync, writeFileSync, readFileSync, existsSync, readdirSync, unlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -93,6 +93,7 @@ let dirN = 0;
function makeCache({ fixture = 'good.mp4', duration = 10, ...opts } = {}) {
const dir = join(root, 'media' + dirN++);
const calls = [];
const sources = [];
const db = {
getMedia: dbmod.getMedia, upsertMedia: dbmod.upsertMedia, deleteMedia: dbmod.deleteMedia,
listMedia: dbmod.listMedia, listMediaLru: dbmod.listMediaLru, touchMedia: dbmod.touchMedia,
@@ -102,13 +103,13 @@ function makeCache({ fixture = 'good.mp4', duration = 10, ...opts } = {}) {
const cache = createMediaCache({
dir, db, log: silentLog,
getInfo: async (id) => ({ id, title: 'T ' + id, channel: 'C', duration }),
download: async (id, out) => { calls.push(id); await sleep(30); copyFileSync(fx(state.fixture), out); return out; },
download: async (id, out, sourceUrl) => { calls.push(id); sources.push({ id, sourceUrl }); await sleep(30); copyFileSync(fx(state.fixture), out); return out; },
transcode: { enabled: false },
freeBytes: () => 100 * 1024 ** 3,
backfillDelayMs: -1,
...opts,
});
return { cache, dir, calls, state };
return { cache, dir, calls, sources, state };
}
async function clearDb() {
for (const r of await dbmod.listMedia()) await dbmod.deleteMedia(r.video_id);
@@ -233,6 +234,19 @@ describe('media cache jobs', () => {
expect(calls.length).toBe(2);
});
test('missing cached files are re-fetched from the stored source URL', async () => {
await clearDb();
const id = 'srcAAAAAA01';
const sourceUrl = `https://www.youtube.com/watch?v=${id}`;
const { cache, dir, calls, sources } = makeCache({ getSourceUrl: async () => sourceUrl });
await cache.init();
const first = await cache.ensureCached(id, { priority: HIGH });
unlinkSync(join(dir, `${id}.${first.gen}.mp4`));
await cache.ensureCached(id, { priority: HIGH });
expect(calls).toHaveLength(2);
expect(sources.every(call => call.id === id && call.sourceUrl === sourceUrl)).toBe(true);
});
test('auto jobs respect the auto length limit; explicit saves do not', async () => {
await clearDb();
const { cache } = makeCache({ duration: 7200, autoMaxSeconds: 3600 });
@@ -257,6 +271,21 @@ describe('media cache jobs', () => {
expect(await cache.getReady('lruAAAAAAA3')).not.toBeNull();
});
test('pinned copies are skipped while automatic LRU eviction selects another row', async () => {
await clearDb();
const size = readFileSync(fx('good.mp4')).length;
const { cache } = makeCache({ maxBytes: size * 2 + 60 * 250 * 1024 + 50_000, duration: 10 });
await cache.init();
await cache.ensureCached('pinAAAAAA01', { priority: HIGH });
await cache.ensureCached('pinAAAAAA02', { priority: HIGH });
await dbmod.upsertMedia('pinAAAAAA01', { last_access: 1000, pinned: 1 });
await dbmod.upsertMedia('pinAAAAAA02', { last_access: 2000 });
await cache.ensureCached('pinAAAAAA03', { priority: HIGH });
expect((await dbmod.getMedia('pinAAAAAA01')).pinned).toBe(1);
expect(await dbmod.getMedia('pinAAAAAA02')).toBeNull();
expect(await cache.getReady('pinAAAAAA03')).not.toBeNull();
});
test('eviction follows listMediaEvictionOrder when the db provides it', async () => {
await clearDb();
const size = readFileSync(fx('good.mp4')).length;

View File

@@ -44,6 +44,7 @@ import { join } from 'node:path';
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
import { registerAnalyticsRoutes } from './admin-analytics.js';
import { registerTranscriptionRoutes } from './transcriptions.js';
import { audit, registerAuditRoutes } from './audit-log.js';
export const NOTE_KINDS = new Set(['lyrics', 'chapters']);
// A YouTube id or one of the server's own uploads (see uploads.js).
@@ -314,6 +315,7 @@ export function verifyAdminCookie(secret, value, nowSec = Math.floor(Date.now()
export function registerNoteRoutes(app, deps) {
const {
db, getProfile, profileNameRe, runYtdlp, adminPassword, backupDir, adminHtmlPath, workerToken,
catalog = null, media = null,
} = deps;
const ADMIN_COOKIE = 'ytp_admin';
const ADMIN_TTL = 30 * 24 * 3600;
@@ -506,6 +508,7 @@ export function registerNoteRoutes(app, deps) {
if (!r.lines.length) return c.json({ ok: false, error: 'this video has no usable captions' }, 404);
const data = sanitizeLyrics({ lines: r.lines.map((l) => ({ ...l, kind: 'line' })), tags: [], offset: 0 });
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: who.by, force: true });
await audit(db, who.by, 'lyrics.save.auto-captions', id, { rev: saved.rev, lines: data.lines.length });
return c.json({ ok: true, rev: saved.rev, lines: data.lines.length, lang: r.lang, auto: r.auto });
} catch (err) {
return c.json({ ok: false, error: err.message }, 502);
@@ -550,6 +553,7 @@ export function registerNoteRoutes(app, deps) {
offset: 0,
});
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: who.by, force: true });
await audit(db, who.by, 'lyrics.save.lrclib', id, { rev: saved.rev, lines: data.lines.length });
return c.json({ ok: true, rev: saved.rev, lines: data.lines.length, synced: hit.synced, match: { track: hit.meta.trackName, artist: hit.meta.artistName, duration: hit.meta.duration } });
} catch (err) {
return c.json({ ok: false, error: err.message }, 502);
@@ -577,6 +581,7 @@ export function registerNoteRoutes(app, deps) {
});
if (!r.ok) return c.json({ ok: false, error: 'someone else saved a newer version', conflict: true, current: r.current }, 409);
if (kind === 'lyrics') autoResolve(id, data);
await audit(db, who.by, `notes.save.${kind}`, id, { rev: r.rev, source: who.via });
return c.json({ ok: true, rev: r.rev, data });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
@@ -657,11 +662,13 @@ export function registerNoteRoutes(app, deps) {
const token = newApiToken();
const id = randomBytes(6).toString('hex');
await db.createApiToken({ id, label, tokenHash: hashToken(token) });
await audit(db, 'admin', 'token.create', id, { label });
return c.json({ ok: true, id, label, token });
});
app.delete('/api/admin/tokens/:tid', requireAdmin, async (c) => {
const ok = await db.deleteApiToken(c.req.param('tid'));
if (ok) await audit(db, 'admin', 'token.revoke', c.req.param('tid'));
return ok ? c.json({ ok: true }) : c.json({ ok: false, error: 'not found' }, 404);
});
@@ -672,9 +679,11 @@ export function registerNoteRoutes(app, deps) {
await next();
};
registerAuditRoutes(app, { db, adminAuth: requireAdminOrToken });
if (deps.analytics) registerAnalyticsRoutes(app, { adminAuth: requireAdminOrToken, runYtdlp, ...deps.analytics });
registerTranscriptionRoutes(app, {
const transcriptionService = registerTranscriptionRoutes(app, {
db, adminAuth: requireAdminOrToken, sanitizeLyrics,
workerEnabled: !!workerToken && workerToken.length >= 24,
workerAuth: async (c, next) => {
@@ -695,6 +704,7 @@ export function registerNoteRoutes(app, deps) {
const n = await db.getNotes(r.video_id);
out.push({
id: r.video_id, cacheStatus: r.status, title: meta.title || '', channel: meta.channel || meta.uploader || '',
sourceUrl: catalog ? await catalog.getSourceUrl(r.video_id) : null,
duration: Number(r.duration) || 0, lastAccess: Number(r.last_access) || 0,
lyricsRev: n.lyrics ? n.lyrics.rev : 0, lyricsLines: n.lyrics ? n.lyrics.data.lines.length : 0,
});
@@ -703,6 +713,16 @@ export function registerNoteRoutes(app, deps) {
return c.json({ ok: true, media: out });
});
app.post('/api/admin/media/:id/redownload', requireAdminOrToken, async c => {
if (!media) return c.json({ ok: false, error: 'media cache unavailable' }, 503);
try {
const result = await media.redownload(c.req.param('id'));
await audit(db, 'admin', 'media.redownload', c.req.param('id'), result);
return c.json({ ok: true, ...result }, 202);
}
catch (err) { return c.json({ ok: false, error: err.message }, 400); }
});
app.get('/api/admin/notes/recent', requireAdmin, async (c) => c.json({ ok: true, revs: await db.recentNoteRevs(150) }));
app.post('/api/admin/notes/:id/:kind/restore', requireAdmin, async (c) => {
@@ -719,6 +739,7 @@ export function registerNoteRoutes(app, deps) {
updatedBy: `admin (rev ${old.rev})`, force: true,
});
if (kind === 'lyrics') autoResolve(id, restored);
await audit(db, 'admin', `notes.restore.${kind}`, id, { fromRev: old.rev, newRev: r.rev });
return c.json({ ok: true, rev: r.rev });
});
@@ -741,12 +762,24 @@ export function registerNoteRoutes(app, deps) {
app.get('/api/admin/flags', requireAdminOrToken, async (c) => {
const status = ['open', 'resolved', 'all'].includes(c.req.query('status')) ? c.req.query('status') : 'open';
const olderThanDays = Math.max(0, Math.min(3650, Number(c.req.query('olderThanDays')) || 0));
const olderThan = olderThanDays ? Math.floor(Date.now() / 1000) - olderThanDays * 86400 : 0;
const cache = new Map();
const flags = [];
for (const f of await db.listAllFlags({ status, limit: 300 })) {
for (const f of await db.listAllFlags({ status, limit: 300, olderThan })) {
flags.push({ ...f, ...(await songLabel(f.videoId, cache)) });
}
return c.json({ ok: true, open: await db.countOpenFlags(), flags });
return c.json({ ok: true, open: await db.countOpenFlags(), flags, olderThanDays });
});
app.post('/api/admin/flags/song/:videoId/resolve', requireAdmin, async (c) => {
const videoId = c.req.param('videoId');
if (badId(videoId)) return c.json({ ok: false, error: 'invalid video id' }, 400);
const flags = await db.listFlagsForVideo(videoId, 'open');
let resolved = 0;
for (const flag of flags) if (await db.setFlagStatus(flag.id, 'resolved', 'admin')) resolved++;
if (resolved) await audit(db, 'admin', 'report.song.resolve', videoId, { resolved });
return c.json({ ok: true, resolved, open: await db.countOpenFlags() });
});
app.post('/api/admin/flags/:fid', requireAdmin, async (c) => {
@@ -755,11 +788,14 @@ export function registerNoteRoutes(app, deps) {
try { body = await c.req.json(); } catch { /* status required below */ }
if (!Number.isInteger(fid) || !['open', 'resolved'].includes(body.status)) return c.json({ ok: false, error: 'bad request' }, 400);
const ok = await db.setFlagStatus(fid, body.status, 'admin');
if (ok) await audit(db, 'admin', `report.${body.status}`, String(fid));
return ok ? c.json({ ok: true, open: await db.countOpenFlags() }) : c.json({ ok: false, error: 'not found' }, 404);
});
app.delete('/api/admin/flags/:fid', requireAdmin, async (c) => {
const ok = await db.deleteFlag(Number(c.req.param('fid')));
const id = Number(c.req.param('fid'));
const ok = await db.deleteFlag(id);
if (ok) await audit(db, 'admin', 'report.delete', String(id));
return ok ? c.json({ ok: true, open: await db.countOpenFlags() }) : c.json({ ok: false, error: 'not found' }, 404);
});
@@ -791,8 +827,36 @@ export function registerNoteRoutes(app, deps) {
console.warn('[notes] backup failed:', err.message);
}
}
async function pullLyricsForAdmin(id) {
if (badId(id)) return { ok: false, error: 'invalid video id' };
const existing = (await db.getNotes(id)).lyrics;
if (existing?.data?.lines?.length) return { ok: false, error: 'lyrics already exist; clear or edit them before pulling LRCLIB', rev: existing.rev };
let meta = {}, row = null;
if (id.startsWith('upl_')) {
const upload = await db.getUpload(id);
if (upload) meta = { title: upload.title, channel: upload.artist, album: upload.album, duration: upload.duration };
} else {
row = await db.getMedia(id);
try { meta = JSON.parse(row?.meta || '{}'); } catch {}
try {
const catalogRow = (await (db.db || db).execute({ sql: 'SELECT card FROM video_meta WHERE id=?', args: [id] })).rows[0];
meta = { ...JSON.parse(catalogRow?.card || '{}'), ...meta };
} catch {}
}
const title = cleanTitle(meta.title || ''), artist = cleanArtist(meta.channel || meta.artist || meta.uploader || ''), duration = Number(row?.duration || meta.duration || 0) || 0;
if (!title) return { ok: false, error: 'no title known for this video' };
const hit = await lrclibLookup({ title, artist, album: meta.album, duration });
if (!hit) return { ok: false, error: `no lyrics on LRCLIB for “${title}”${artist ? ` by ${artist}` : ''}` };
const data = sanitizeLyrics({ lines: hit.lines, tags: [hit.synced ? 'from LRCLIB (synced)' : 'from LRCLIB (plain text)'], offset: 0 });
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: 'admin bulk LRCLIB', force: true });
await db.autoResolveFlags(id, new Set(data.lines.map(line => line.text)));
await audit(db, 'admin', 'lyrics.save.lrclib', id, { rev: saved.rev, lines: data.lines.length, bulk: true });
return { ok: true, rev: saved.rev, lines: data.lines.length, synced: hit.synced };
}
return {
requireAdminOrToken,
pullLyricsForAdmin,
queueWhisperForAdmin: transcriptionService.enqueue,
startBackups() {
setTimeout(writeBackup, 60_000);
setInterval(writeBackup, 24 * 3600_000).unref?.();

View File

@@ -57,6 +57,8 @@ beforeAll(async () => {
backupDir: join(root, 'backups'),
adminHtmlPath: htmlPath,
workerToken: 'w'.repeat(32),
catalog: { getSourceUrl: async id => /^[\w-]{11}$/.test(id) ? `https://www.youtube.com/watch?v=${id}` : null },
media: { redownload: async id => ({ status: `queued:${id}` }) },
});
});
@@ -278,5 +280,41 @@ test('admin song list includes metadata-only phone songs for lyrics lookup', asy
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' });
expect(song).toMatchObject({ title: 'Phone song', channel: 'Phone artist', duration: 240, cacheStatus: 'failed', sourceUrl: 'https://www.youtube.com/watch?v=phoneSong01' });
expect((await app.request(`/api/admin/media/${id}/redownload`, { method: 'POST', headers: { Cookie: cookie } })).status).toBe(202);
expect((await app.request('/api/admin/media/phoneSong01/redownload', { method: 'POST' })).status).toBe(401);
});
test('Whisper enqueue reports a full queue without depending on request context', async () => {
await dbmod.upsertMedia(VID, { status: 'ready', duration: 60 });
const activeIds = Array.from({ length: 25 }, (_, index) => `QF${String(index).padStart(9, '0')}`);
const now = Math.floor(Date.now() / 1000);
await dbmod.db.execute({
sql: `INSERT INTO lyric_transcriptions(id,video_id,status,base_rev,created_at,updated_at) VALUES ${activeIds.map(() => '(?,? ,\'queued\',0,?,?)').join(',')}`,
args: activeIds.flatMap((id, index) => [String(index), id, now, now]),
});
try {
const cookie = await adminCookie();
const response = await app.request(`/api/admin/transcriptions/${VID}`, json('POST', {}, { Cookie: cookie }));
expect(response.status).toBe(429);
expect(await response.json()).toMatchObject({ ok: false, error: 'The transcription queue is full. Try again after a job finishes.' });
} finally {
await dbmod.db.execute({ sql: `DELETE FROM lyric_transcriptions WHERE video_id IN (${activeIds.map(() => '?').join(',')})`, args: activeIds });
await dbmod.deleteMedia(VID);
}
});
test('report triage filters aging flags and resolves every open report for one song', async () => {
const id = VID;
const a = await dbmod.upsertFlag({ videoId: id, index: 0, text: 'Flagged line', rev: 1, reason: 'words', note: '', reporter: 'listener-a' });
const b = await dbmod.upsertFlag({ videoId: id, index: 0, text: 'Flagged line', rev: 1, reason: 'typo', note: '', reporter: 'listener-b' });
await dbmod.db.execute({ sql: 'UPDATE lyric_flags SET created_at=? WHERE id IN (?,?)', args: [Math.floor(Date.now() / 1000) - 10 * 86400, a.id, b.id] });
const cookie = await adminCookie();
const aged = await (await app.request('/api/admin/flags?status=open&olderThanDays=7', { headers: { Cookie: cookie } })).json();
expect(aged.flags.filter(flag => flag.videoId === id)).toHaveLength(2);
const unauthorized = await app.request(`/api/admin/flags/song/${id}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
expect(unauthorized.status).toBe(401);
const response = await app.request(`/api/admin/flags/song/${id}/resolve`, json('POST', {}, { Cookie: cookie }));
expect(await response.json()).toMatchObject({ ok: true, resolved: 2 });
expect((await dbmod.listFlagsForVideo(id, 'open'))).toHaveLength(0);
});

36
server/p2p-admin.js Normal file
View File

@@ -0,0 +1,36 @@
import { audit } from './audit-log.js';
export function registerP2pAdminRoutes(app, { db, adminAuth, p2pDb, hub, stats = async () => ({}) }) {
app.get('/api/admin/p2p/devices', adminAuth, async c => {
const offset = Math.max(0, Number(c.req.query('offset')) || 0);
const page = await p2pDb.listAdminDevices({ limit: 101, offset });
const devices = page.slice(0, 100);
return c.json({ ok: true, hasMore: page.length > 100, devices: devices.map(d => ({
id: d.device_id, fingerprint: d.fingerprint || '', fingerprintShort: (d.fingerprint || d.device_id).slice(0, 12),
profile: d.profile || '', userAgent: d.user_agent || '', share: !!Number(d.share), banned: !!d.banned_at,
lastSeenAt: Number(d.last_seen_at), online: hub.isOnline(d.device_id), holdings: Number(d.holdings), holdingBytes: Number(d.holding_bytes),
bytesUp: Number(d.bytes_up), bytesDown: Number(d.bytes_down),
})), network: { online: hub.onlineCount(), ...(await stats()) }, offset }, 200, { 'Cache-Control': 'no-store' });
});
app.post('/api/admin/p2p/devices/:id/ban', adminAuth, async c => {
const id = c.req.param('id'); if (!/^dev_[0-9a-f]{16}$/.test(id)) return c.json({ ok: false, error: 'Invalid device.' }, 400);
const body = await c.req.json().catch(() => ({})); const banned = body.banned === true;
const changed = await p2pDb.setDeviceBanned(id, banned);
if (!changed) return c.json({ ok: false, error: 'Device not found.' }, 404);
if (banned) hub.disconnect(id);
await audit(db, 'admin', banned ? 'p2p.device.ban' : 'p2p.device.unban', id);
return c.json({ ok: true, banned });
});
app.post('/api/admin/p2p/devices/:id/reannounce', adminAuth, async c => {
const id = c.req.param('id'); if (!/^dev_[0-9a-f]{16}$/.test(id)) return c.json({ ok: false, error: 'Invalid device.' }, 400);
const sent = hub.send(id, { type: 'force-sync' });
await audit(db, 'admin', 'p2p.device.reannounce', id, { sent });
return c.json({ ok: true, sent });
});
app.delete('/api/admin/p2p/devices/:id/holdings', adminAuth, async c => {
const id = c.req.param('id'); if (!/^dev_[0-9a-f]{16}$/.test(id)) return c.json({ ok: false, error: 'Invalid device.' }, 400);
const removed = await p2pDb.removeDeviceHolders(id);
await audit(db, 'admin', 'p2p.device.holdings.revoke', id, { removed });
return c.json({ ok: true, removed });
});
}

26
server/p2p-admin.test.js Normal file
View File

@@ -0,0 +1,26 @@
import { test, expect } from 'bun:test';
import { Hono } from 'hono';
import { registerP2pAdminRoutes } from './p2p-admin.js';
test('P2P admin actions require auth, log mutations, and control online devices', async () => {
const events = [], calls = [];
const db = { execute: async query => { events.push(query.args); return { rowsAffected: 1 }; } };
const p2pDb = {
listAdminDevices: async ({ limit }) => { expect(limit).toBe(101); return [{ device_id: 'dev_0123456789abcdef', fingerprint: 'fingerprint', profile: 'home', user_agent: 'iPhone', share: 1, banned_at: null, last_seen_at: 42, holdings: 2, holding_bytes: 80, bytes_up: 4, bytes_down: 3 }]; },
setDeviceBanned: async (id, banned) => { calls.push(['ban', id, banned]); return true; },
removeDeviceHolders: async id => { calls.push(['remove', id]); return 2; },
};
const hub = { isOnline: () => true, onlineCount: () => 1, send: (...args) => { calls.push(['send', ...args]); return true; }, disconnect: id => calls.push(['disconnect', id]) };
const app = new Hono();
registerP2pAdminRoutes(app, { db, p2pDb, hub, stats: async () => ({ content: 5 }), adminAuth: async (c, next) => { if (c.req.header('x-admin') !== 'yes') return c.json({ ok:false },401); await next(); } });
expect((await app.request('/api/admin/p2p/devices')).status).toBe(401);
const list = await app.request('/api/admin/p2p/devices', { headers: { 'x-admin': 'yes' } });
expect(await list.json()).toMatchObject({ ok: true, network: { online: 1, content: 5 }, devices: [{ id: 'dev_0123456789abcdef', online: true, holdingBytes: 80 }] });
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
expect((await app.request('/api/admin/p2p/devices/dev_0123456789abcdef/ban', { method:'POST',headers,body:'{"banned":true}' })).status).toBe(200);
expect((await app.request('/api/admin/p2p/devices/dev_0123456789abcdef/reannounce', { method:'POST',headers,body:'{}' })).status).toBe(200);
expect((await app.request('/api/admin/p2p/devices/dev_0123456789abcdef/holdings', { method:'DELETE',headers })).status).toBe(200);
expect(calls.some(x => x[0] === 'disconnect')).toBe(true);
expect(calls.some(x => x[0] === 'send' && x[2].type === 'force-sync')).toBe(true);
expect(events).toHaveLength(3);
});

View File

@@ -37,6 +37,10 @@ export async function initP2pSchema() {
fingerprint TEXT,
profile TEXT,
share INTEGER NOT NULL DEFAULT 1,
user_agent TEXT,
banned_at INTEGER,
bytes_up INTEGER NOT NULL DEFAULT 0,
bytes_down INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL
);
@@ -60,6 +64,10 @@ export async function initP2pSchema() {
PRIMARY KEY (video_id, day)
);
`);
for (const [name, decl] of [['user_agent','TEXT'],['banned_at','INTEGER'],['bytes_up','INTEGER NOT NULL DEFAULT 0'],['bytes_down','INTEGER NOT NULL DEFAULT 0']]) {
try { await db.execute(`ALTER TABLE p2p_devices ADD COLUMN ${name} ${decl}`); }
catch (e) { if (!/duplicate column/i.test(String(e.message))) throw e; }
}
// media_cache.sha256 — the cid of the current <id>.<gen>.mp4 (plan 009).
try { await db.execute('ALTER TABLE media_cache ADD COLUMN sha256 TEXT'); }
catch (e) { if (!/duplicate column/i.test(String(e.message))) throw e; }
@@ -117,11 +125,11 @@ export async function revokeContent(cid) {
// ---- devices ------------------------------------------------------------------
export async function createDevice({ deviceId, secretHash, fingerprint, profile, now }) {
export async function createDevice({ deviceId, secretHash, fingerprint, profile, userAgent, now }) {
await db.execute({
sql: `INSERT INTO p2p_devices (device_id, secret_hash, fingerprint, profile, share, created_at, last_seen_at)
VALUES (?, ?, ?, ?, 1, ?, ?)`,
args: [deviceId, secretHash, fingerprint || null, profile || null, now, now],
sql: `INSERT INTO p2p_devices (device_id, secret_hash, fingerprint, profile, share, user_agent, created_at, last_seen_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?)`,
args: [deviceId, secretHash, fingerprint || null, profile || null, userAgent || null, now, now],
});
}
@@ -139,6 +147,26 @@ export async function touchDevice(deviceId, { now, share, profile } = {}) {
});
}
export async function listAdminDevices({ limit = 100, offset = 0 } = {}) {
const r = await db.execute({ sql: `SELECT d.device_id,d.fingerprint,d.profile,d.share,d.user_agent,d.banned_at,d.bytes_up,d.bytes_down,d.created_at,d.last_seen_at,
COUNT(CASE WHEN h.status='active' THEN 1 END) AS holdings,
COALESCE(SUM(CASE WHEN h.status='active' THEN c.size ELSE 0 END),0) AS holding_bytes
FROM p2p_devices d LEFT JOIN p2p_holders h ON h.device_id=d.device_id LEFT JOIN p2p_content c ON c.cid=h.cid
GROUP BY d.device_id ORDER BY d.last_seen_at DESC LIMIT ? OFFSET ?`, args: [Math.max(1, Math.min(200, limit)), Math.max(0, offset)] });
return rowsOf(r);
}
export async function setDeviceBanned(deviceId, banned, now = Date.now()) {
const r = await db.execute({ sql: 'UPDATE p2p_devices SET banned_at=? WHERE device_id=?', args: [banned ? now : null, deviceId] });
return !!r.rowsAffected;
}
export async function removeDeviceHolders(deviceId, now = Date.now()) {
const r = await db.execute({ sql: "UPDATE p2p_holders SET status='removed',removed_at=? WHERE device_id=? AND status='active'", args: [now, deviceId] });
return Number(r.rowsAffected || 0);
}
export async function recordDeviceTraffic(deviceId, { up = 0, down = 0 } = {}) {
await db.execute({ sql: 'UPDATE p2p_devices SET bytes_up=bytes_up+?,bytes_down=bytes_down+? WHERE device_id=?', args: [Math.max(0, Number(up) || 0), Math.max(0, Number(down) || 0), deviceId] });
}
// ---- holders (persistent; never expired by time) ------------------------------
export async function upsertHolder({ cid, deviceId, trust = 'reported', now }) {
@@ -243,7 +271,7 @@ export async function p2pStats() {
// only then the qualifying ones, least recently played first.
export async function listMediaEvictionOrder({ now, keepMinViews, keepDays, keepRecentDays }) {
const r = await db.execute({
sql: `SELECT m.video_id, m.size, m.last_access,
sql: `SELECT m.video_id, m.size, m.last_access, m.pinned,
COALESCE((SELECT SUM(v.n) FROM video_views v
WHERE v.video_id = m.video_id AND v.day >= ?), 0) AS views
FROM media_cache m WHERE m.status = 'ready'`,

View File

@@ -40,6 +40,22 @@ test('content upsert/get/known/revoke', async () => {
expect([...(await P.knownCids([CID2]))]).toEqual([]);
});
test('admin device inventory, ban state, and holding revocation', async () => {
const id = 'dev_00000000000000b1';
await P.createDevice({ deviceId: id, secretHash: 'hash', fingerprint: 'phone-fp', profile: 'family', userAgent: 'iPhone', now: T0 });
const adminCid = 'c'.repeat(64);
await P.upsertContent({ cid: adminCid, videoId: 'dQw4w9WgXcQ', size: 4096, origin: 'server', now: T0 });
await P.upsertHolder({ cid: adminCid, deviceId: id, now: T0 });
let [device] = await P.listAdminDevices();
expect(device).toMatchObject({ device_id: id, user_agent: 'iPhone', holdings: 1, holding_bytes: 4096 });
expect(await P.setDeviceBanned(id, true, T0 + 100)).toBe(true);
[device] = await P.listAdminDevices(); expect(device.banned_at).toBe(T0 + 100);
expect(await P.removeDeviceHolders(id, T0 + 200)).toBe(1);
[device] = await P.listAdminDevices(); expect(device.holdings).toBe(0);
expect(await P.setDeviceBanned(id, false, T0 + 300)).toBe(true);
[device] = await P.listAdminDevices(); expect(device.banned_at).toBeNull();
});
test('holders persist, never expire, and a full report removes missing ones', async () => {
await P.createDevice({ deviceId: 'dev_1', secretHash: 'h', fingerprint: 'fp', now: T0 });
await P.upsertHolder({ cid: CID, deviceId: 'dev_1', now: T0 });
@@ -66,8 +82,8 @@ test('views per day and window sums', async () => {
expect(await P.viewsSince('vid00000001', T0 - 30 * 86400_000)).toBe(2);
expect(await P.viewsSince('vid00000001', T0 - 50 * 86400_000)).toBe(3);
const s = await P.p2pStats();
expect(s.content).toBe(1);
expect(s.devices).toBe(1);
expect(s.content).toBe(2);
expect(s.devices).toBe(2);
});
test('listSharedContent: newest verified file per video, only with sharing holders counted', async () => {

View File

@@ -47,7 +47,7 @@ export function createP2pHub({ getDevice, authorizeProfile = async () => '', ena
const deviceId = String(m.device || '');
const secret = String(m.secret || '');
const d = /^dev_[0-9a-f]{16}$/.test(deviceId) ? await getDevice(deviceId).catch(() => null) : null;
if (!d || !same(d.secret_hash, sha(secret))) { try { ws.close(4401, 'unknown device'); } catch { /* gone */ } return; }
if (!d || d.banned_at || !same(d.secret_hash, sha(secret))) { try { ws.close(d?.banned_at ? 4403 : 4401, d?.banned_at ? 'device banned' : 'unknown device'); } catch { /* gone */ } return; }
ws.data.profile = await authorizeProfile(m.profile, m.profileSecret, deviceId).catch(() => '');
ws.data.inventory = [];
ws.data.authed = true;
@@ -107,6 +107,8 @@ export function createP2pHub({ getDevice, authorizeProfile = async () => '', ena
websocket: { open, message, close },
isOnline: (deviceId) => online.has(deviceId),
send: (deviceId, msg) => { const ws = online.get(deviceId); if (!ws) return false; send(ws, msg); return true; },
disconnect: (deviceId) => { const ws = online.get(deviceId); if (!ws) return false; try { ws.close(4403, 'device banned'); } catch { /* gone */ } return true; },
onlineDevices: () => [...online.keys()],
onlineCount: () => online.size,
};
}

View File

@@ -51,6 +51,21 @@ test('auth on open, hello with opaque peer id, bad secret is closed', async () =
expect(hub.isOnline('dev_000000000000000b')).toBe(false);
});
test('banned devices cannot authenticate and admin disconnect closes an online peer', async () => {
await p2pDb.setDeviceBanned('dev_000000000000000a', true, NOW);
const hub = createP2pHub({ getDevice: p2pDb.getDevice });
const banned = fakeWs({ budget: [] });
await hub.websocket.message(banned, JSON.stringify({ type: 'auth', device: 'dev_000000000000000a', secret: SECRET_A }));
expect(banned.closed).toBe(4403);
await p2pDb.setDeviceBanned('dev_000000000000000a', false, NOW + 1);
const online = fakeWs({ budget: [] });
await hub.websocket.message(online, JSON.stringify({ type: 'auth', device: 'dev_000000000000000a', secret: SECRET_A }));
expect(hub.disconnect('dev_000000000000000a')).toBe(true);
expect(online.closed).toBe(4403);
hub.websocket.close(online);
expect(hub.isOnline('dev_000000000000000a')).toBe(false);
});
test('legacy unconfirmed signalling is refused; close drops presence', async () => {
const hub = createP2pHub({ getDevice: p2pDb.getDevice });
const a = fakeWs({ budget: [] });

View File

@@ -105,6 +105,7 @@ export function registerIntakeRoutes(app, deps) {
);
if (!adm.ok) throw Object.assign(new Error('not admitted: ' + adm.reason), { status: 422 });
await p2pDb.upsertHolder({ cid, deviceId: d.device_id, trust: 'challenged', now: now() });
await p2pDb.recordDeviceTraffic?.(d.device_id, { up: got });
let adopted = false;
if (adopt) {
try { adopted = !!(await adopt(tk.videoId, path, { sha256: cid, probe, meta: tk.meta })).adopted; keep = adopted; }

View File

@@ -41,10 +41,12 @@ export function registerP2pRoutes(app, deps) {
if (!m) return null;
const d = await p2pDb.getDevice(m[1]);
if (!d || !same(d.secret_hash, sha(m[2]))) return null;
if (d.banned_at) return { ...d, _banned: true };
return d;
}
const requireDevice = async (c, next) => {
const d = await deviceOf(c);
if (d?._banned) return c.json({ ok: false, error: 'device access revoked' }, 403);
if (!d) return c.json({ ok: false, error: 'unknown device' }, 401);
c.set('device', d);
await next();
@@ -67,6 +69,7 @@ export function registerP2pRoutes(app, deps) {
deviceId, secretHash: sha(secret),
fingerprint: String(body.fingerprint || '').slice(0, 128) || null,
profile: String(body.profile || '').slice(0, 64) || null,
userAgent: String(c.req.header('user-agent') || '').slice(0, 500),
now: t,
});
return c.json({ ok: true, deviceId, secret });

View File

@@ -63,6 +63,15 @@ test('holdings need a valid device secret', async () => {
expect((await req('/api/p2p/holdings', { method: 'POST', body: { items: [] }, dev: { ...dev, secret: 'e'.repeat(64) } })).status).toBe(401);
});
test('banned device is denied with an explicit forbidden response', async () => {
const dev = await newDevice();
await p2pDb.setDeviceBanned(dev.deviceId, true);
const r = await req('/api/p2p/holdings', { method: 'POST', body: { items: [] }, dev });
expect(r.status).toBe(403);
expect(await r.json()).toMatchObject({ ok: false, error: 'device access revoked' });
await p2pDb.setDeviceBanned(dev.deviceId, false);
});
test('report accepts verified cids, returns unknown ones, and challenges the server-held file', async () => {
const dev = await newDevice();
const r = await (await req('/api/p2p/holdings', { method: 'POST', dev, body: { share: true, items: [

View File

@@ -6,7 +6,7 @@
"scripts": {
"start": "bun server.js",
"dev": "bun --hot server.js",
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./asset-manifest.test.js && bun test ./static-delivery.test.js"
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./audit-log.test.js && bun test ./request-monitor.test.js && bun test ./admin-tools.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admin.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./asset-manifest.test.js && bun test ./static-delivery.test.js && bun test ./video-catalog.test.js && bun test ./search-cache.test.js && bun test --timeout 60000 ./deep-search.test.js"
},
"dependencies": {
"@hono/node-server": "^1.14.0",

92
server/request-monitor.js Normal file
View File

@@ -0,0 +1,92 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
import { statfs } from 'node:fs/promises';
const routeKey = (path) => path
.replace(/\/api\/media\/[^/]+/g, '/api/media/:id')
.replace(/\/api\/download\/[^/]+/g, '/api/download/:id')
.replace(/\/api\/admin\/uploads\/[^/]+/g, '/api/admin/uploads/:id')
.replace(/\?.*$/, '');
const percentile = (values, p) => values.length ? values[Math.min(values.length - 1, Math.ceil(values.length * p) - 1)] : 0;
export function createRequestMonitor({ snapshotFile = null, diskPaths = [], adapters = {}, now = () => Date.now(), snapshotIntervalMs = 60_000 } = {}) {
const routes = new Map();
const snapshots = [];
let inflight = 0;
let lastCpu = process.cpuUsage();
let lastCpuAt = now();
let snapshotTimer;
async function sample() {
const cpuNow = process.cpuUsage();
const elapsed = Math.max(1, now() - lastCpuAt) * 1000;
const cpuPct = Math.min(100 * (cpuNow.user + cpuNow.system - lastCpu.user - lastCpu.system) / elapsed, 100);
lastCpu = cpuNow; lastCpuAt = now();
const volumes = await Promise.all(diskPaths.map(async ({ name, path }) => {
try { const fs = await statfs(path); return { name, freeBytes: Number(fs.bavail) * Number(fs.bsize), totalBytes: Number(fs.blocks) * Number(fs.bsize) }; }
catch { return { name, unavailable: true }; }
}));
const routeMetrics = Object.fromEntries([...routes].map(([key, v]) => {
const sorted = [...v.latencies].sort((a, b) => a - b);
return [key, { p50: percentile(sorted, .5), p95: percentile(sorted, .95), p99: percentile(sorted, .99), requests: v.requests, errors: v.errors, errorRate: v.requests ? v.errors / v.requests : 0 }];
}));
const [ytdlp, media, transcription, cache] = await Promise.all([
adapters.ytdlp?.() ?? null, adapters.media?.() ?? null, adapters.transcription?.() ?? null, adapters.cache?.() ?? null,
]);
return {
at: now(), routes: routeMetrics, inflight, rssBytes: process.memoryUsage().rss, cpuPercent: cpuPct,
ytdlp, media, transcription, cache, volumes,
};
}
async function snapshot() {
const value = await sample(); snapshots.push(value);
while (snapshots.length && now() - snapshots[0].at > 24 * 3600_000) snapshots.shift();
if (snapshotFile) {
try {
await mkdir(dirname(snapshotFile), { recursive: true });
const prior = (await readFile(snapshotFile, 'utf8').catch(() => '')).split('\n').filter(Boolean).map(line => { try { return JSON.parse(line); } catch { return null; } }).filter(x => x?.at >= now() - 24 * 3600_000);
const unique = new Map([...prior, value].map(item => [item.at, item]));
await writeFile(snapshotFile, [...unique.values()].sort((a,b)=>a.at-b.at).map(item => JSON.stringify(item)).join('\n') + '\n');
}
catch (error) { console.warn('[monitor] snapshot write failed:', error.message); }
}
return value;
}
async function history() {
const cutoff = now() - 24 * 3600_000;
const out = snapshots.filter(x => x.at >= cutoff);
if (snapshotFile) {
try {
const persisted = (await readFile(snapshotFile, 'utf8')).split('\n').filter(Boolean).map(line => { try { return JSON.parse(line); } catch { return null; } }).filter(x => x?.at >= cutoff);
const unique = new Map([...persisted, ...out].map(item => [item.at, item]));
return [...unique.values()].sort((a,b)=>a.at-b.at).slice(-1440);
} catch { /* no file yet */ }
}
return out.slice(-1440);
}
function middleware() {
return async (c, next) => {
const started = now(); inflight++;
let status = 500;
try { await next(); status = c.res.status; }
finally {
inflight--;
let key = routeKey(c.req.path);
if (!routes.has(key) && routes.size >= 300) key = 'other';
let row = routes.get(key);
if (!row) { row = { latencies: [], requests: 0, errors: 0 }; routes.set(key, row); }
row.requests++; if (status >= 400) row.errors++;
row.latencies.push(Math.max(0, now() - started)); if (row.latencies.length > 1000) row.latencies.shift();
}
};
}
if (snapshotIntervalMs > 0) { snapshotTimer = setInterval(() => snapshot().catch(() => {}), snapshotIntervalMs); snapshotTimer.unref?.(); }
return { middleware, sample, snapshot, history, close: () => clearInterval(snapshotTimer) };
}
export function registerRequestMonitorRoutes(app, { adminAuth, monitor }) {
app.get('/api/admin/ops/metrics', adminAuth, async c => c.json({ ok: true, current: await monitor.sample(), history: await monitor.history() }, 200, { 'Cache-Control': 'no-store' }));
}

View File

@@ -0,0 +1,34 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRequestMonitor } from './request-monitor.js';
import { Hono } from 'hono';
import { registerRequestMonitorRoutes } from './request-monitor.js';
test('request monitor records route latency, status errors, and current process health', async () => {
let t = 100;
const monitor = createRequestMonitor({ now: () => t, snapshotIntervalMs: 0, adapters: { media: () => ({ queued: 2 }) } });
const middleware = monitor.middleware();
const ctx = (path, status) => ({ req: { path }, res: { status } });
await middleware(ctx('/api/media/abc/status', 200), async () => { t += 8; });
await middleware(ctx('/api/media/abc/status', 404), async () => { t += 4; });
await middleware(ctx('/api/media/xyz/status', 503), async () => { t += 12; });
const sample = await monitor.sample();
assert.equal(sample.routes['/api/media/:id/status'].requests, 3);
assert.equal(sample.routes['/api/media/:id/status'].errors, 2);
assert.equal(sample.routes['/api/media/:id/status'].p50, 8);
assert.equal(sample.media.queued, 2);
assert.ok(sample.rssBytes > 0);
monitor.close();
});
test('monitor snapshot history is retained and its endpoint is admin protected', async () => {
let t = 10_000;
const monitor = createRequestMonitor({ now: () => t, snapshotIntervalMs: 0 });
await monitor.snapshot(); t += 60_000; await monitor.snapshot();
const app = new Hono();
registerRequestMonitorRoutes(app, { monitor, adminAuth: async (c,next) => c.req.header('x-admin') ? next() : c.json({ok:false},401) });
assert.equal((await app.request('/api/admin/ops/metrics')).status, 401);
const response = await app.request('/api/admin/ops/metrics', { headers: { 'x-admin': 'yes' } });
const data = await response.json(); assert.equal(data.history.length, 2); assert.ok(data.current.rssBytes > 0);
monitor.close();
});

View File

@@ -2,7 +2,7 @@
// repeat query — from any device, or after a restart — never waits on YouTube.
// Capped by row count (default 250,000 queries) AND bytes (default 2 GiB), LRU.
import { db } from './db.js';
import { ingest, withLocalThumbnails, trimCatalog } from './video-catalog.js';
import { ingest, withLocalThumbnails, trimCatalog, foldSearchText } from './video-catalog.js';
export const MAX_ROWS = Number(process.env.SEARCH_CACHE_MAX_ROWS) || 250_000;
export const MAX_BYTES = Number(process.env.SEARCH_CACHE_MAX_BYTES) || 2 * 1024 ** 3;
@@ -70,15 +70,28 @@ export async function rememberVideos(cards) {
// Cards whose title/channel contain EVERY word of the query, most-seen first.
export async function searchVideos(q, limit = 60) {
const words = String(q).toLowerCase().split(/\s+/).filter(Boolean).slice(0, 8);
const folded = foldSearchText(q).trim();
const words = folded.split(/\s+/).filter(Boolean).slice(0, 8);
if (!words.length) return [];
const like = (w) => '%' + w.replace(/[\\%_]/g, '\\$&') + '%';
const r = await db.execute({
sql: `SELECT card FROM video_meta WHERE ${words.map(() => "hay LIKE ? ESCAPE '\\'").join(' AND ')}
sql: `SELECT card,seen FROM video_meta WHERE ${words.map(() => "hay LIKE ? ESCAPE '\\'").join(' AND ')}
ORDER BY seen DESC, updated_at DESC LIMIT ?`,
args: [...words.map(like), limit],
args: [...words.map(like), Math.max(limit * 10, 200)],
});
return withLocalThumbnails(r.rows.map((x) => { try { return JSON.parse(x.card); } catch { return null; } }).filter(Boolean));
const titleHasAll = title => words.every(word => foldSearchText(title).includes(word));
const phrase = folded;
const ranked = r.rows.map(row => {
try {
const card = JSON.parse(row.card);
const title = foldSearchText(card.title);
const channel = foldSearchText(card.channel);
const score = (title === phrase || title.includes(phrase) ? 4 : 0) +
(titleHasAll(card.title) ? 2 : 0) + (words.some(word => channel.includes(word)) ? 1 : 0);
return { card: { ...card, url: card.url || `https://www.youtube.com/watch?v=${card.id}` }, score, seen: Number(row.seen) || 0 };
} catch { return null; }
}).filter(Boolean).sort((a, b) => b.score - a.score || b.seen - a.seen).slice(0, limit).map(x => x.card);
return withLocalThumbnails(ranked);
}
export async function trimVideos(max = MAX_VIDEOS) {

View File

@@ -1,8 +1,13 @@
import { test, expect } from 'bun:test';
import * as sc from './search-cache.js';
import { initDb } from './db.js';
import { test, expect, afterAll } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const root = mkdtempSync(join(tmpdir(), 'ytp-search-cache-'));
process.env.DB_PATH = join(root, 'test.db');
const sc = await import('./search-cache.js');
const { initDb } = await import('./db.js');
await initDb();
afterAll(() => rmSync(root, { recursive: true, force: true }));
const cards = (n) => Array.from({ length: n }, (_, i) => ({ id: 'v' + i, title: 't' + i }));
test('put/get round-trips, case-insensitive, capped at 200', async () => {
@@ -21,11 +26,11 @@ test('trim evicts least-recently-used rows over the row cap', async () => {
test('known videos are remembered and found by every word', async () => {
await sc.rememberVideos([
{ id: 'a1', title: 'Way Maker - Sinach', channel: 'Worship' },
{ id: 'b2', title: 'Way Maker (live)', channel: 'Other' },
{ id: 'c3', title: 'Goodness of God', channel: 'Worship' },
{ id: 'aaa11111111', title: 'Way Maker - Sinach', channel: 'Worship' },
{ id: 'bbb22222222', title: 'Way Maker (live)', channel: 'Other' },
{ id: 'ccc33333333', title: 'Goodness of God', channel: 'Worship' },
]);
expect((await sc.searchVideos('way maker')).map((c) => c.id).sort()).toEqual(['a1', 'b2']);
expect((await sc.searchVideos('maker worship')).map((c) => c.id)).toEqual(['a1']);
expect((await sc.searchVideos('way maker')).map((c) => c.id).sort()).toEqual(['aaa11111111', 'bbb22222222']);
expect((await sc.searchVideos('maker worship')).map((c) => c.id)).toEqual(['aaa11111111']);
expect(await sc.searchVideos('100%')).toEqual([]);
});

View File

@@ -47,6 +47,7 @@ import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getP
import { createMediaCache, HIGH, LOW, validateMedia, MediaSkip } from './media-cache.js';
import * as notesDb from './db.js';
import { registerNoteRoutes, parseLrc, sanitizeLyrics } from './notes.js';
import { registerAdminToolsRoutes } from './admin-tools.js';
import { createRemoteHub } from './remote.js';
import { createPartyHub } from './party.js';
import { registerMediaMetaRoutes } from './media-meta.js';
@@ -63,12 +64,15 @@ import { sha256Range } from './hash.js';
import * as innertube from './innertube.js';
import { createRelatedService, registerRelatedRoute } from './related.js';
import * as searchCacheDb from './search-cache.js';
import { ingest as collectVideoMetadata, syncListening, linkListening, startThumbnails } from './video-catalog.js';
import { ingest as collectVideoMetadata, syncListening, linkListening, startThumbnails, backfillStep } from './video-catalog.js';
import { registerCatalogRoutes } from './recommendations.js';
import QRCode from 'qrcode';
import { createYtdlpPool } from './ytdlp-pool.js';
import { createWarmQueue } from './warm-queue.js';
import { registerDeepSearchRoutes } from './deep-search.js';
import { dirname, join as pathJoin } from 'node:path';
import { createRequestMonitor, registerRequestMonitorRoutes } from './request-monitor.js';
import { registerP2pAdminRoutes } from './p2p-admin.js';
// A media proxy must not die because one client's stream hit an edge case
// (see /api/play cancel()): log and keep serving instead of crash-looping.
@@ -268,6 +272,7 @@ function slimEntry(j) {
return {
id,
title: pick(j, 'title') || '(untitled)',
url: /^[\w-]{11}$/.test(id) ? `https://www.youtube.com/watch?v=${id}` : null,
channel: pickChannel(j),
channelId: pickChannelId(j),
channelUrl: pickChannelUrl(j),
@@ -343,6 +348,22 @@ function channelToUrl(c) {
const app = new Hono();
app.use('*', logger());
const requestMonitor = createRequestMonitor({
snapshotFile: pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'ops-metrics.jsonl'),
snapshotIntervalMs: process.env.OPS_MONITOR === '0' ? 0 : 60_000,
diskPaths: [{ name: 'app-data', path: dirname(process.env.DB_PATH || './data/ytplayer.db') }, { name: 'media', path: process.env.MEDIA_DIR || './data/media' }],
adapters: {
ytdlp: () => ytdlpPool ? ytdlpPool.stats() : null,
media: () => media?.stats ? media.stats() : null,
transcription: async () => {
try { const rows = (await notesDb.db.execute("SELECT status,COUNT(*) AS n FROM lyric_transcriptions WHERE status IN ('queued','running') GROUP BY status")).rows;
const counts = Object.fromEntries(rows.map(row => [row.status, Number(row.n)])); return { queued: counts.queued || 0, active: counts.running || 0 };
} catch { return null; }
},
cache: () => ({ ...searchCacheCounters, memoryEntries: searchCache.size }),
},
});
if (process.env.OPS_MONITOR !== '0') app.use('*', requestMonitor.middleware());
registerCatalogRoutes(app, { resolveListener: recommendationListener });
// ============================================================================
@@ -380,6 +401,7 @@ const SEARCH_CACHE_TTL_MS = 10 * 60_000; // fresh: answered from memory
const SEARCH_CACHE_STALE_MS = 15 * 24 * 3600_000; // persistent copy: stale-while-revalidate for 15 days
const searchCache = new Map(); // lowercased q -> { yt, fetchedAt } (YouTube cards only)
const inflightSearches = new Map(); // lowercased q -> Promise<{ yt, youtubeError? }>
const searchCacheCounters = { hits: 0, misses: 0 };
// The YouTube side of one search (up to 200 cards), shared by every caller asking
// the same thing at the same time, and written to memory + the persistent cache.
@@ -431,6 +453,8 @@ const searchLibrary = async (q) => {
// This server's own library first — it still answers when YouTube is unreachable.
try { return (await notesDb.listUploads({ q, limit: 20, listedOnly: true })).map(uploads.card); } catch { return []; }
};
const withYoutubeUrl = card => card && /^[\w-]{11}$/.test(card.id || '')
? { ...card, url: `https://www.youtube.com/watch?v=${card.id}` } : card;
// GET /api/search/local?q=<query> — videos this server already knows (from every
// earlier search) plus its own uploads; answers in milliseconds so the app can
@@ -439,7 +463,7 @@ app.get('/api/search/local', async (c) => {
const q = (c.req.query('q') || '').trim();
if (!q) return c.json({ ok: false, error: 'empty query' }, 400);
const [mine, known] = await Promise.all([searchLibrary(q), searchCacheDb.searchVideos(q).catch(() => [])]);
return c.json({ ok: true, results: [...mine, ...known], local: true });
return c.json({ ok: true, results: [...mine, ...known].map(withYoutubeUrl), local: true });
});
// GET /api/search?q=<query>[&refresh=1]
@@ -453,7 +477,7 @@ app.get('/api/search', async (c) => {
const key = q.toLowerCase();
const mine = await searchLibrary(q);
const answer = (yt, extra = {}) => {
const body = JSON.stringify({ ok: true, results: [...mine, ...yt], ...extra });
const body = JSON.stringify({ ok: true, results: [...mine, ...yt].map(withYoutubeUrl), ...extra });
const headers = { 'Content-Type': 'application/json; charset=utf-8', Vary: 'Accept-Encoding' };
if (/\bgzip\b/.test(c.req.header('accept-encoding') || '') && body.length > 1024)
return new Response(Bun.gzipSync(body), { headers: { ...headers, 'Content-Encoding': 'gzip' } });
@@ -466,11 +490,13 @@ app.get('/api/search', async (c) => {
if (row) { hit = { yt: row.results, fetchedAt: row.fetchedAt }; searchCache.set(key, hit); }
}
const age = hit ? Date.now() - hit.fetchedAt : Infinity;
if (hit && age < SEARCH_CACHE_TTL_MS) return answer(hit.yt, { fetchedAt: hit.fetchedAt });
if (hit && age < SEARCH_CACHE_TTL_MS) { searchCacheCounters.hits++; return answer(hit.yt, { fetchedAt: hit.fetchedAt }); }
if (hit && age < SEARCH_CACHE_STALE_MS) {
searchCacheCounters.hits++;
fetchYoutube(q).catch(() => { /* keep serving the stale copy */ });
return answer(hit.yt, { stale: true, fetchedAt: hit.fetchedAt });
}
searchCacheCounters.misses++;
}
try {
const { yt, fetchedAt, partial } = await fetchYoutube(q);
@@ -1121,7 +1147,7 @@ const SAVE_DEFAULT_FORMAT =
// again) share ONE yt-dlp run instead of spawning another each time.
const inflightDownloads = new Map(); // key -> { promise, waiters, tmpBase }
async function ytdlpDownloadResponse(videoId, fp, formatArgs, signal) {
async function ytdlpDownloadResponse(videoId, fp, formatArgs, signal, sourceUrl = null) {
await assertNotLive(videoId);
const key = videoId + '|' + formatArgs.join(' ');
let entry = inflightDownloads.get(key);
@@ -1130,10 +1156,11 @@ async function ytdlpDownloadResponse(videoId, fp, formatArgs, signal) {
const tmp = `${tmpdir()}/${tmpBase}.mp4`;
const ctl = new AbortController();
entry = { waiters: 0, tmpBase, ctl };
entry.promise = withSaveSlot(() => {
entry.promise = withSaveSlot(async () => {
if (ctl.signal.aborted) throw new Error('save cancelled');
const url = sourceUrl || await videoCatalog.getSourceUrl(videoId) || `https://www.youtube.com/watch?v=${videoId}`;
return runYtdlpResilient([
`https://www.youtube.com/watch?v=${videoId}`,
url,
'--no-warnings', '--no-playlist',
...formatArgs,
'--limit-rate', DOWNLOAD_RATE,
@@ -1203,14 +1230,16 @@ async function ytdlpEditedDownloadResponse(videoId, fp, keep, signal, cachedSrc
try {
// 1) Grab the full source (video+audio merged) so ffmpeg has both streams
// — unless the server cache already holds it.
if (!cachedSrc) await withSaveSlot(() => runYtdlpResilient([
`https://www.youtube.com/watch?v=${videoId}`,
'--no-warnings', '--no-playlist',
if (!cachedSrc) await withSaveSlot(async () => {
const url = await videoCatalog.getSourceUrl(videoId) || `https://www.youtube.com/watch?v=${videoId}`;
return runYtdlpResilient([
url, '--no-warnings', '--no-playlist',
'-f', SAVE_MUX_FORMAT,
'--merge-output-format', 'mp4',
'--limit-rate', DOWNLOAD_RATE,
'-o', srcTmp,
], { signal }));
], { signal });
});
// 2) Trim + concat the keep segments into the final custom video.
await runFfmpeg([
'-y', '-hide_banner', '-loglevel', 'error',
@@ -1260,6 +1289,7 @@ if (!MEDIA_VOLUME_MARKER || existsSync(MEDIA_VOLUME_MARKER)) mkdirSync(MEDIA_DIR
const media = createMediaCache({
dir: MEDIA_DIR,
volumeMarker: MEDIA_VOLUME_MARKER,
getSourceUrl: videoCatalog.getSourceUrl,
db: {
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats,
// Retention (docs/p2p-architecture.md flow 9): cold copies go before popular ones.
@@ -1268,9 +1298,9 @@ const media = createMediaCache({
}),
},
getInfo: async (videoId) => (await resolveStreams(videoId)).info,
download: async (videoId, out) => {
download: async (videoId, out, sourceUrl) => {
await withSaveSlot(() => runYtdlpResilient([
`https://www.youtube.com/watch?v=${videoId}`,
sourceUrl || `https://www.youtube.com/watch?v=${videoId}`,
'--no-warnings', '--no-playlist',
'-f', SAVE_MUX_FORMAT,
'--merge-output-format', 'mp4',
@@ -2315,6 +2345,8 @@ app.get('/api/remote/qr/:code', async (c) => {
// ============================================================================
const notes = registerNoteRoutes(app, {
db: notesDb,
catalog: videoCatalog,
media,
getProfile,
profileNameRe: PROFILE_NAME_RE,
runYtdlp: runYtdlpResilient,
@@ -2328,6 +2360,12 @@ const notes = registerNoteRoutes(app, {
{ label: 'Database', path: dirname(process.env.DB_PATH || './data/ytplayer.db') },
] },
});
const deepSearch = registerDeepSearchRoutes(app, { db: notesDb, adminAuth: notes.requireAdminOrToken, autoStart: false,
searchDeep: innertube.searchDeep, related: innertube.related, browseUploads: innertube.browseUploads,
ingest: collectVideoMetadata, putSearch: searchCacheDb.put });
registerAdminToolsRoutes(app, { db: notesDb, adminAuth: notes.requireAdminOrToken, media,
backupDir: process.env.UPLOAD_BACKUP_DIR || pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'backups'), backfillStep,
pullLyrics: notes.pullLyricsForAdmin, queueWhisper: notes.queueWhisperForAdmin });
// ============================================================================
// Uploads — the server's own searchable media library (see uploads.js)
@@ -2347,6 +2385,7 @@ const uploads = registerUploadRoutes(app, {
parseLrc,
sanitizeLyrics,
});
registerRequestMonitorRoutes(app, { adminAuth: notes.requireAdminOrToken, monitor: requestMonitor });
const isUpload = (id) => uploads.isUploadId(id);
// ============================================================================
@@ -2372,6 +2411,8 @@ const p2pHub = createP2pHub({ getDevice: p2pDb.getDevice, enabled: () => P2P.ena
directProfileFails.set(key, { count: old && Date.now() - old.at < 900000 ? old.count + 1 : 1, at: Date.now() });
if (directProfileFails.size > 5000) directProfileFails.clear(); return '';
} });
registerP2pAdminRoutes(app, { db: notesDb, adminAuth: notes.requireAdminOrToken, p2pDb, hub: p2pHub,
stats: async () => ({ intakeOpen: intake.openTickets(), intakeActive: intake.active(), content: (await p2pDb.p2pStats()).content }) });
// GET /api/p2p/holders?v=<videoId>|cid=<sha256> — who holds a copy. Rows are
// persistent; `stale` flags a holder not re-verified for P2P_STALE_DAYS.
@@ -2539,6 +2580,7 @@ app.get('/*', indexHtml);
// ============================================================================
async function main() {
await initDb();
deepSearch.start();
startThumbnails();
await p2pDb.initP2pSchema();
console.log(`[ytplayer] DB ready`);

View File

@@ -1,6 +1,7 @@
// Explicit Whisper requests produce reviewable drafts, never published notes.
import { randomUUID } from 'node:crypto';
import { db as sql } from './db.js';
import { audit } from './audit-log.js';
const ID = /^([\w-]{11}|upl_[a-f0-9]{12})$/;
const LEASE_MS = 120000;
function publicJob(row) {
@@ -16,25 +17,30 @@ export function registerTranscriptionRoutes(app, { db, adminAuth, workerAuth, wo
if (id.startsWith('upl_')) return db.getUpload(id);
const row = await db.getMedia(id); return row?.status === 'ready' ? row : null;
}
app.post('/api/admin/transcriptions/:video', adminAuth, async c => {
const id = c.req.param('video');
if (!ID.test(id)) return c.json({ ok: false, error: 'invalid video id' }, 400);
if (!workerEnabled) return c.json({ ok: false, error: 'Whisper is unavailable: the lyrics worker is not configured.' }, 503);
async function enqueue(videoId) {
const id = String(videoId || '');
if (!ID.test(id)) return { status: 400, body: { ok: false, error: 'invalid video id' } };
if (!workerEnabled) return { status: 503, body: { ok: false, error: 'Whisper is unavailable: the lyrics worker is not configured.' } };
const audio = await source(id);
if (!audio) return c.json({ ok: false, error: 'Save this video on the server before transcribing its audio.' }, 409);
if (Number(audio.duration) > 3600) return c.json({ ok: false, error: 'Whisper requests are limited to one hour of audio.' }, 422);
if (!audio) return { status: 409, body: { ok: false, error: 'Save this video on the server before transcribing its audio.' } };
if (Number(audio.duration) > 3600) return { status: 422, body: { ok: false, error: 'Whisper requests are limited to one hour of audio.' } };
const notes = await db.getNotes(id);
// One active job per song and a bounded queue, including across restarts.
const existing = (await sql.execute({ sql: "SELECT * FROM lyric_transcriptions WHERE video_id=? AND status IN ('queued','running')", args: [id] })).rows[0];
if (existing) return c.json({ ok: true, job: publicJob(existing) });
if (existing) return { status: 200, body: { ok: true, job: publicJob(existing) } };
const active = Number((await sql.execute("SELECT COUNT(*) AS n FROM lyric_transcriptions WHERE status IN ('queued','running')")).rows[0].n);
if (active >= 25) return c.json({ ok: false, error: 'The transcription queue is full. Try again after a job finishes.' }, 429);
if (active >= 25) return { status: 429, body: { ok: false, error: 'The transcription queue is full. Try again after a job finishes.' } };
const time = now();
await sql.execute({ sql: `INSERT OR IGNORE INTO lyric_transcriptions (id,video_id,base_rev,created_at,updated_at)
SELECT ?,?,?,?,? WHERE (SELECT COUNT(*) FROM lyric_transcriptions WHERE status IN ('queued','running')) < 25`,
args: [randomUUID(), id, notes.lyrics?.rev || 0, time, time] });
const row = (await sql.execute({ sql: "SELECT * FROM lyric_transcriptions WHERE video_id=? AND status IN ('queued','running')", args: [id] })).rows[0];
return row ? c.json({ ok: true, job: publicJob(row) }, 202) : c.json({ ok: false, error: 'The transcription queue is full.' }, 429);
if (row) await audit(db, 'admin', 'lyrics.whisper.enqueue', id, { jobId: row.id });
return row ? { status: 202, body: { ok: true, job: publicJob(row) } } : { status: 429, body: { ok: false, error: 'The transcription queue is full.' } };
}
app.post('/api/admin/transcriptions/:video', adminAuth, async c => {
const result = await enqueue(c.req.param('video'));
return c.json(result.body, result.status);
});
app.get('/api/admin/transcriptions/:video', adminAuth, async c => {
const id = c.req.param('video');
@@ -76,4 +82,5 @@ export function registerTranscriptionRoutes(app, { db, adminAuth, workerAuth, wo
await sql.execute({ sql: "DELETE FROM lyric_transcriptions WHERE status IN ('complete','failed') AND updated_at<?", args: [time - 30 * 86400000] });
return c.json({ ok: true });
});
return { enqueue };
}

View File

@@ -23,9 +23,10 @@
* ========================================================================== */
import { spawn } from 'node:child_process';
import { createWriteStream, existsSync, mkdirSync, unlinkSync } from 'node:fs';
import { createWriteStream, existsSync, mkdirSync, unlinkSync, readdirSync, renameSync } from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { audit } from './audit-log.js';
export const UPLOAD_ID_RE = /^upl_[a-f0-9]{12}$/;
const MAX_BYTES = 4 * 1024 * 1024 * 1024; // 4 GB
@@ -104,6 +105,7 @@ export function registerUploadRoutes(app, deps) {
const artPath = (id) => locate(`${id}.art.jpg`);
const newArtPath = (id) => join(writeDir(), `${id}.art.jpg`);
const removeEverywhere = (name) => { for (const d of [uploadDir, backupDir].filter(Boolean)) { try { unlinkSync(join(d, name)); } catch { /* gone */ } } };
const removeStaleCopies = (name, currentPath) => { for (const d of [uploadDir, backupDir].filter(Boolean)) { const p = join(d, name); if (p !== currentPath) try { unlinkSync(p); } catch { /* no alternate copy */ } } };
async function probeFile(path) {
const out = await run(ffprobe, ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', path]);
@@ -181,6 +183,7 @@ export function registerUploadRoutes(app, deps) {
try {
await Bun.write(target, file);
const r = await ingest({ id, ext, target, size: file.size, filename: file.name, fields: body, artFile: body.art });
await audit(db, 'admin', 'upload.create', id, { size: file.size, kind: r.upload.kind });
return c.json({ ok: true, ...r });
} catch (err) {
cleanup(id, target);
@@ -188,6 +191,85 @@ export function registerUploadRoutes(app, deps) {
}
});
app.get('/api/admin/uploads/manage', requireAdminOrToken, async c => {
const q = String(c.req.query('q') || '').trim().slice(0, 120), offset = Math.max(0, Number(c.req.query('offset')) || 0);
const result = await db.listAdminUploads({ q, offset, limit: 100 });
result.quotas = await db.listUploadQuotas?.() || [];
const known = new Set(await db.listUploadFileNames());
const orphans = [];
for (const dir of dirs()) for (const name of readdirSync(dir).slice(0, 10000)) {
if (!name.includes('.')) continue;
const id = name.split('.')[0];
if (UPLOAD_ID_RE.test(id) && !known.has(name) && !name.includes('.replace.')) orphans.push({ name, dir });
}
return c.json({ ok: true, ...result, offset, orphans: orphans.slice(0, 200) }, 200, { 'Cache-Control': 'no-store' });
});
app.put('/api/admin/uploads/quotas/:owner', requireAdminOrToken, async c => {
const owner = c.req.param('owner'); if (!/^[\w-]{8,128}$/.test(owner)) return c.json({ ok: false, error: 'invalid owner id' }, 400);
const body = await c.req.json().catch(() => ({})), maxBytes = Number(body.maxBytes);
if (!Number.isFinite(maxBytes) || maxBytes < 0 || maxBytes > 10 * 1024 ** 4) return c.json({ ok: false, error: 'quota must be zero (unlimited) or a valid byte count' }, 400);
await db.setUploadQuota?.(owner, maxBytes || null);
await audit(db, 'admin', 'upload.quota.set', owner, { maxBytes: maxBytes || null });
return c.json({ ok: true, maxBytes: maxBytes || null });
});
app.patch('/api/admin/uploads/:id', requireAdminOrToken, async c => {
const id = c.req.param('id'); if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
const body = await c.req.json().catch(() => ({})), before = await db.getUpload(id);
if (!before) return c.json({ ok: false, error: 'not found' }, 404);
const patch = {};
for (const key of ['title','artist','album']) if (body[key] !== undefined) patch[key] = String(body[key]).trim();
if (body.listed !== undefined) patch.listed = body.listed === true;
await db.updateUploadMeta(id, patch);
await audit(db, 'admin', 'upload.update', id, patch);
return c.json({ ok: true, upload: await db.getUpload(id) });
});
app.post('/api/admin/uploads/bulk', requireAdminOrToken, async c => {
const body = await c.req.json().catch(() => ({})), ids = [...new Set((Array.isArray(body.ids) ? body.ids : []).filter(id => UPLOAD_ID_RE.test(String(id))))].slice(0, 200);
if (!ids.length || !['delete','list','unlist'].includes(body.action)) return c.json({ ok: false, error: 'choose uploads and delete, list, or unlist' }, 400);
let changed = 0;
for (const id of ids) {
const u = await db.getUpload(id); if (!u) continue;
if (body.action === 'delete') { await db.deleteUpload(id); removeEverywhere(`${u.id}.${u.ext}`); removeEverywhere(`${id}.art.jpg`); }
else await db.updateUploadMeta(id, { listed: body.action === 'list' });
await audit(db, 'admin', `upload.bulk.${body.action}`, id); changed++;
}
return c.json({ ok: true, changed });
});
app.put('/api/admin/uploads/:id/replace', requireAdminOrToken, async c => {
const id = c.req.param('id'), old = UPLOAD_ID_RE.test(id) ? await db.getUpload(id) : null;
if (!old) return c.json({ ok: false, error: 'not found' }, 404);
const filename = String(c.req.query('name') || 'replacement.' + old.ext), ext = extOf(filename);
if (!VIDEO_EXT.has(ext) && !AUDIO_EXT.has(ext)) return c.json({ ok: false, error: extError(ext) }, 415);
const declared = Number(c.req.header('content-length')); if (declared > MAX_BYTES) return c.json({ ok: false, error: 'file is larger than 4 GB' }, 413);
const reader = c.req.raw.body?.getReader(); if (!reader) return c.json({ ok: false, error: 'empty body' }, 400);
const dir = writeDir(), temp = join(dir, `${id}.replace.${randomBytes(4).toString('hex')}.${ext}`), target = join(dir, `${id}.${ext}`);
let got = 0; const out = createWriteStream(temp);
try {
try { for (;;) { const { done, value } = await reader.read(); if (done) break; got += value.byteLength; if (got > MAX_BYTES) throw Object.assign(Error('file is larger than 4 GB'), { status: 413 }); if (!out.write(value)) await new Promise(r => out.once('drain', r)); } }
finally { await new Promise(r => out.end(r)); }
if (!got || (declared > 0 && got !== declared)) throw Object.assign(Error('empty or incomplete replacement'), { status: 400 });
const probe = await probeFile(temp), info = describeProbe(probe, filename);
if (!info.hasAudio) throw Object.assign(Error('replacement has no audio stream'), { status: 415 });
const backup = `${target}.previous`;
const existingTarget = existsSync(target);
if (existingTarget) renameSync(target, backup);
try {
renameSync(temp, target);
await db.replaceUploadFile(id, { kind: info.kind, title: String(c.req.query('title') ?? old.title).slice(0,300), artist: String(c.req.query('artist') ?? old.artist).slice(0,200), album: String(c.req.query('album') ?? old.album).slice(0,200), duration: info.duration, ext, mime: MIME[ext] || (info.kind === 'video' ? 'video/mp4' : 'audio/mpeg'), size: got, art: old.art });
} catch (error) { try { unlinkSync(target); } catch {} if (existingTarget) renameSync(backup, target); throw error; }
if (existingTarget) try { unlinkSync(backup); } catch {}
removeStaleCopies(`${id}.${ext}`, target);
if (old.ext !== ext) removeEverywhere(`${id}.${old.ext}`);
await audit(db, 'admin', 'upload.replace', id, { oldExt: old.ext, ext, size: got });
return c.json({ ok: true, upload: await db.getUpload(id) });
} catch (error) { return c.json({ ok: false, error: error.status ? error.message : 'replacement failed validation' }, error.status || 422); }
finally { try { unlinkSync(temp); } catch {} }
});
// Streaming upload: the raw file is the request body, copied to disk chunk by
// chunk so a multi-GB (hour-long) video never sits in memory.
// PUT /api/admin/uploads/stream?name=talk.mp4&title=&artist=&album=
@@ -219,6 +301,7 @@ export function registerUploadRoutes(app, deps) {
if (declared > 0 && got !== declared) throw Object.assign(new Error(`upload cut short (${got} of ${declared} bytes)`), { status: 400 });
const q = (k) => String(c.req.query(k) || '');
const r = await ingest({ id, ext, target, size: got, filename: name, fields: { title: q('title'), artist: q('artist'), album: q('album') } });
await audit(db, 'admin', 'upload.create', id, { size: got, kind: r.upload.kind });
return c.json({ ok: true, ...r });
} catch (err) {
cleanup(id, target);
@@ -252,6 +335,8 @@ export function registerUploadRoutes(app, deps) {
if (declared > SHARED.fileBytes) return c.json({ ok: false, error: `shared files can be up to ${mb(SHARED.fileBytes)}` }, 413);
if (sharedActive.has(fp)) return c.json({ ok: false, error: 'this device is already uploading — wait for it to finish' }, 429);
const use = await db.sharedUploadUsage(fp);
const ownerQuota = await db.getUploadQuota?.(fp);
if (ownerQuota && use.bytes + declared > ownerQuota) return c.json({ ok: false, error: `owner storage quota of ${mb(ownerQuota)} reached` }, 413);
if (use.today >= SHARED.perDay) return c.json({ ok: false, error: `limit of ${SHARED.perDay} shared uploads a day reached` }, 429);
if (use.bytes + declared > SHARED.deviceBytes) return c.json({ ok: false, error: `this device's ${mb(SHARED.deviceBytes)} of shared uploads is used up` }, 413);
if (use.totalShared + declared > SHARED.totalBytes) return c.json({ ok: false, error: 'the server has no room for more shared uploads' }, 507);
@@ -268,8 +353,8 @@ export function registerUploadRoutes(app, deps) {
const { done, value } = await reader.read();
if (done) break;
got += value.byteLength;
if (got > SHARED.fileBytes || use.bytes + got > SHARED.deviceBytes) {
throw Object.assign(new Error(`shared files can be up to ${mb(Math.min(SHARED.fileBytes, SHARED.deviceBytes - use.bytes))}`), { status: 413 });
if (got > SHARED.fileBytes || use.bytes + got > SHARED.deviceBytes || (ownerQuota && use.bytes + got > ownerQuota)) {
throw Object.assign(new Error(`shared storage quota reached (owner cap ${ownerQuota ? mb(ownerQuota) : mb(SHARED.deviceBytes)})`), { status: 413 });
}
if (!out.write(value)) await new Promise((r) => out.once('drain', r));
}
@@ -306,6 +391,8 @@ export function registerUploadRoutes(app, deps) {
await Bun.write(tmp, buf);
await run(ffmpeg, ['-v', 'error', '-y', '-i', tmp, '-frames:v', '1', '-vf', "scale='min(800,iw)':-2", newArtPath(id)]);
await db.setUploadArt(id, 'file');
removeStaleCopies(`${id}.art.jpg`, newArtPath(id));
await audit(db, 'admin', 'upload.art.replace', id, { size: buf.byteLength });
return c.json({ ok: true });
} catch (err) {
return c.json({ ok: false, error: 'unusable image: ' + err.message }, 400);
@@ -320,6 +407,7 @@ export function registerUploadRoutes(app, deps) {
const u = await db.getUpload(id);
if (!u) return c.json({ ok: false, error: 'not found' }, 404);
await db.deleteUpload(id);
await audit(db, 'admin', 'upload.delete', id, { size: u.size });
removeEverywhere(`${u.id}.${u.ext}`);
removeEverywhere(`${id}.art.jpg`);
return c.json({ ok: true });
@@ -335,6 +423,8 @@ export function registerUploadRoutes(app, deps) {
const id = c.req.param('id');
if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
const u = await db.getUpload(id);
const requestedGen = Number(c.req.query('g'));
if (requestedGen && requestedGen !== Number(u?.gen || 1)) return c.json({ ok: false, error: 'upload generation changed' }, 409, { 'Cache-Control': 'no-store' });
if (!u || !existsSync(filePath(u))) return c.json({ ok: false, error: 'not found' }, 404);
db.touchUpload(id);
return rangeFileResponse(c, filePath(u), u.mime, 'public, max-age=31536000, immutable');
@@ -343,6 +433,8 @@ export function registerUploadRoutes(app, deps) {
app.get('/api/uploads/:id/art', async (c) => {
const id = c.req.param('id');
if (!UPLOAD_ID_RE.test(id)) return c.json({ ok: false, error: 'invalid id' }, 400);
const row = await db.getUpload(id), requestedGen = Number(c.req.query('g'));
if (requestedGen && requestedGen !== Number(row?.artGen || 1)) return c.json({ ok: false, error: 'upload artwork generation changed' }, 409, { 'Cache-Control': 'no-store' });
const p = artPath(id);
if (!existsSync(p)) return c.json({ ok: false, error: 'no cover art' }, 404);
return rangeFileResponse(c, p, 'image/jpeg', 'public, max-age=31536000, immutable');
@@ -351,20 +443,20 @@ export function registerUploadRoutes(app, deps) {
// /api/streams payload for an upload: one "Original" quality for video,
// audio + cover art for audio (the app shows the art where the video goes).
function streamsPayload(u) {
const art = u.art ? `/api/uploads/${u.id}/art` : '';
const url = `/api/uploads/${u.id}`;
const art = u.art ? `/api/uploads/${u.id}/art?g=${u.artGen || 1}` : '';
const url = `/api/uploads/${u.id}?g=${u.gen || 1}`;
return {
meta: { id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '', duration: u.duration, thumbnail: art },
audioUrl: u.kind === 'audio' ? url : '',
qualities: u.kind === 'video' ? [{ label: 'Original', height: 0, hasAudio: true, url }] : [],
upload: true,
upload: true, gen: u.gen || 1,
kind: u.kind,
art,
};
}
const card = (u) => ({
id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '',
duration: u.duration, thumbnail: u.art ? `/api/uploads/${u.id}/art` : '', upload: true, kind: u.kind,
id: u.id, title: u.title, channel: u.artist || u.album || 'Uploaded', channelId: '', channelUrl: '', gen: u.gen || 1,
duration: u.duration, thumbnail: u.art ? `/api/uploads/${u.id}/art?g=${u.artGen || 1}` : '', upload: true, kind: u.kind,
});
return { streamsPayload, card, filePath, artPath, isUploadId: (id) => UPLOAD_ID_RE.test(id) };

View File

@@ -1,7 +1,7 @@
// Admin uploads: real ffmpeg-made files through the real routes.
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs';
import { mkdtempSync, rmSync, existsSync, readFileSync, copyFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Hono } from 'hono';
@@ -32,7 +32,7 @@ beforeAll(async () => {
'-shortest', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', fx('clip.mp4'));
app = new Hono();
uploads = registerUploadRoutes(app, {
db, uploadDir: join(root, 'uploads'), parseLrc, sanitizeLyrics,
db, uploadDir: join(root, 'uploads'), backupDir: join(root, 'backup-uploads'), parseLrc, sanitizeLyrics,
requireAdminOrToken: async (c, next) => { if (c.req.header('x-admin') === 'yes') await next(); else return c.json({ ok: false, error: 'admin only' }, 401); },
rangeFileResponse: (c, path, type) => {
const body = readFileSync(path);
@@ -108,11 +108,11 @@ describe('uploads', () => {
expect(hit.uploads.map((u) => u.id)).toEqual([audioId]);
const u = await db.getUpload(audioId);
expect(uploads.streamsPayload(u)).toMatchObject({
audioUrl: `/api/uploads/${audioId}`, kind: 'audio', upload: true, art: `/api/uploads/${audioId}/art`, qualities: [],
audioUrl: `/api/uploads/${audioId}?g=1`, kind: 'audio', upload: true, art: `/api/uploads/${audioId}/art?g=1`, qualities: [],
});
expect(uploads.card(u)).toMatchObject({ id: audioId, title: 'Test Song', channel: 'Test Artist', kind: 'audio' });
const v = uploads.streamsPayload(await db.getUpload(videoId));
expect(v.qualities[0]).toMatchObject({ label: 'Original', hasAudio: true, url: `/api/uploads/${videoId}` });
expect(v.qualities[0]).toMatchObject({ label: 'Original', hasAudio: true, url: `/api/uploads/${videoId}?g=1` });
});
test('serving supports Range, and delete removes the files', async () => {
@@ -162,7 +162,45 @@ describe('streaming upload (large / long files)', () => {
const up = await (await put('/api/admin/uploads/stream?name=song.mp3', readFileSync(fx('song.mp3')))).json();
const r = await put(`/api/admin/uploads/${up.upload.id}/art`, readFileSync(fx('cover.png')));
expect((await r.json()).ok).toBe(true);
expect((await db.getUpload(up.upload.id)).art).toBeTruthy();
const updated = await db.getUpload(up.upload.id);
expect(updated).toMatchObject({ art: 'file', gen: 1, artGen: 2 });
expect(uploads.streamsPayload(updated).audioUrl).toBe(`/api/uploads/${up.upload.id}?g=1`);
expect(uploads.streamsPayload(updated).art).toBe(`/api/uploads/${up.upload.id}/art?g=2`);
expect((await app.request(`/api/uploads/${up.upload.id}/art?g=1`)).status).toBe(409);
});
test('admin manager edits metadata, replaces bytes in place with a new generation, and pages orphan reports', async () => {
const up = await (await put('/api/admin/uploads/stream?name=original.mp3', readFileSync(fx('song.mp3')))).json();
const id = up.upload.id;
expect(up.upload.gen).toBe(1);
const edited = await app.request(`/api/admin/uploads/${id}`, { method: 'PATCH', headers: { 'x-admin': 'yes', 'content-type': 'application/json' }, body: JSON.stringify({ title: 'Changed title', artist: 'New artist', listed: false }) });
expect((await edited.json()).upload).toMatchObject({ id, title: 'Changed title', artist: 'New artist', listed: false });
const replaceBytes = readFileSync(fx('clip.mp4'));
const replaced = await put(`/api/admin/uploads/${id}/replace?name=concert.mp4`, replaceBytes);
const payload = await replaced.json();
expect(replaced.status).toBe(200);
expect(payload.upload).toMatchObject({ id, ext: 'mp4', kind: 'video', gen: 2, listed: false });
expect((await app.request(`/api/uploads/${id}?g=1`)).status).toBe(409);
expect((await app.request(`/api/uploads/${id}`, { headers: { Range: 'bytes=0-9' } })).status).toBe(206);
expect(uploads.streamsPayload(payload.upload).qualities[0].url).toContain('?g=2');
const listed = await app.request('/api/admin/uploads/bulk', { method: 'POST', headers: { 'x-admin': 'yes', 'content-type': 'application/json' }, body: JSON.stringify({ ids: [id], action: 'list' }) });
expect(await listed.json()).toMatchObject({ ok: true, changed: 1 });
const manage = await app.request('/api/admin/uploads/manage?q=Changed', { headers: { 'x-admin': 'yes' } });
expect(await manage.json()).toMatchObject({ ok: true, total: 1, uploads: [expect.objectContaining({ id, listed: true, gen: 2 })] });
const quota = await app.request('/api/admin/uploads/quotas/owner-device1', { method: 'PUT', headers: { 'x-admin': 'yes', 'content-type': 'application/json' }, body: JSON.stringify({ maxBytes: 5_000_000 }) });
expect(await quota.json()).toMatchObject({ ok: true, maxBytes: 5_000_000 });
expect(await db.getUploadQuota('owner-device1')).toBe(5_000_000);
await db.setUploadQuota('owner-device1', null);
});
test('same-extension replacement removes a stale fallback copy before publishing its new generation', async () => {
const up = await (await put('/api/admin/uploads/stream?name=mirror.mp3', readFileSync(fx('song.mp3')))).json();
const staleBackup = join(root, 'backup-uploads', `${up.upload.id}.mp3`);
copyFileSync(join(root, 'uploads', `${up.upload.id}.mp3`), staleBackup);
expect(existsSync(staleBackup)).toBe(true);
const replaced = await put(`/api/admin/uploads/${up.upload.id}/replace?name=updated.mp3`, readFileSync(fx('song.mp3')));
expect((await replaced.json()).upload.gen).toBe(2);
expect(existsSync(staleBackup)).toBe(false);
});
});

View File

@@ -3,11 +3,18 @@ import { db } from './db.js';
import { mergeMissing } from './media-meta-core.js';
const VIDEO_ID = /^[\w-]{11}$/;
const SOURCES = new Set(['search', 'search-cache', 'client-search', 'channel', 'streams', 'playlist', 'profile', 'sync', 'related', 'backfill', 'collector', 'device-play']);
const SOURCES = new Set(['search', 'search-cache', 'client-search', 'channel', 'streams', 'playlist', 'profile', 'sync', 'related', 'backfill', 'collector', 'deep', 'device-play']);
const text = (v, n = 300) => typeof v === 'string' ? v.trim().slice(0, n) : '';
const positive = v => Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : 0;
const canonicalThumb = id => `https://i.ytimg.com/vi/${id}/hqdefault.jpg`;
const canonicalUrl = id => `https://www.youtube.com/watch?v=${id}`;
export const foldSearchText = value => String(value || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
export const channelKey = c => String(c.channelId || c.channel || '').toLowerCase().replace(/\s*-\s*topic$|vevo$|\s+official$/g, '').trim();
export async function getSourceUrl(id) {
if (!VIDEO_ID.test(String(id || ''))) return null;
const row = (await db.execute({ sql: 'SELECT url FROM video_meta WHERE id=?', args: [id] })).rows[0];
return row?.url || `https://www.youtube.com/watch?v=${id}`;
}
function safeThumbnail(url) {
try { const u = new URL(url); return u.protocol === 'https:' && /^(?:i|i\d)\.ytimg\.com$/.test(u.hostname) && !u.port && !u.username && !u.password; }
catch { return false; }
@@ -23,14 +30,16 @@ export function normalizeCard(v) {
// Never fetch arbitrary client URLs (including redirects or private hosts).
if (safeThumbnail(u.href)) thumbnail = u.href;
} catch { /* canonical YouTube art */ }
const card = { id: v.id, title, channel: text(v.channel), channelId: text(v.channelId, 64),
const card = { id: v.id, title, url: canonicalUrl(v.id), channel: text(v.channel), channelId: text(v.channelId ?? v.channel_id, 64),
duration: positive(v.duration), thumbnail };
try {
const u = new URL(v.channelUrl);
const u = new URL(v.channelUrl || v.channel_url);
if (u.protocol === 'https:' && /^(www\.)?youtube\.com$/.test(u.hostname)) card.channelUrl = u.href;
} catch { /* optional */ }
for (const key of ['tags', 'categories']) if (Array.isArray(v[key])) card[key] = v[key].slice(0, 30).map(x => text(x, 80)).filter(Boolean);
if (v.description) card.description = text(v.description, 1200);
if (v.published || v.publishedAt || v.upload_date) card.published = text(v.published || v.publishedAt || v.upload_date, 40);
if (v.lang || v.language) card.lang = text(v.lang || v.language, 24);
if (positive(v.viewCount ?? v.view_count)) card.viewCount = positive(v.viewCount ?? v.view_count);
return card;
}
@@ -67,9 +76,12 @@ async function ingestBatch(cards, source, fillMissing) {
await db.batch(batch.flatMap(c => {
// Sparse playlist cards must not erase richer extractor metadata.
const merged = fillMissing ? mergeMissing(old.get(c.id) || {}, c) : { ...(old.get(c.id) || {}), ...Object.fromEntries(Object.entries(c).filter(([, v]) => v !== '' && v !== 0 && (!Array.isArray(v) || v.length))) };
return [{ sql: `INSERT INTO video_meta (id,card,hay,seen,updated_at) VALUES (?,?,?,1,?)
ON CONFLICT(id) DO UPDATE SET card=excluded.card,hay=excluded.hay,seen=seen+1,updated_at=excluded.updated_at`,
args: [c.id, JSON.stringify(merged), `${merged.title} ${merged.channel || ''} ${(merged.tags || []).join(' ')}`.toLowerCase(), now] },
const hay = foldSearchText([merged.title, merged.channel, merged.channelId, merged.description,
...(merged.tags || []), ...(merged.categories || [])].filter(Boolean).join(' '));
return [{ sql: `INSERT INTO video_meta (id,card,hay,seen,updated_at,url,channel_id,channel_url,published,lang) VALUES (?,?,?,1,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET card=excluded.card,hay=excluded.hay,seen=seen+1,updated_at=excluded.updated_at,
url=excluded.url,channel_id=excluded.channel_id,channel_url=excluded.channel_url,published=excluded.published,lang=excluded.lang`,
args: [c.id, JSON.stringify(merged), hay, now, merged.url, merged.channelId || null, merged.channelUrl || null, merged.published || null, merged.lang || null] },
{ sql: `INSERT INTO video_meta_sources (video_id,source,last_seen) VALUES (?,?,?)
ON CONFLICT(video_id,source) DO UPDATE SET discoveries=discoveries+1,last_seen=excluded.last_seen`, args: [c.id, source, now] },
{ sql: `INSERT INTO video_thumbnails (video_id,url) VALUES (?,?) ON CONFLICT(video_id) DO UPDATE SET retry_at=0

View File

@@ -0,0 +1,53 @@
import { test, expect } from 'bun:test';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const root = mkdtempSync(join(tmpdir(), 'ytp-video-catalog-'));
process.env.DB_PATH = join(root, 'test.db');
const db = await import('./db.js');
const catalog = await import('./video-catalog.js');
await db.initDb();
test('catalog migration backfills canonical URLs and rich metadata, excluding upload ids', async () => {
await db.db.execute('DELETE FROM catalog_schema_migrations WHERE version=1');
await db.db.execute({ sql: 'INSERT INTO video_meta (id,card,hay,seen,updated_at) VALUES (?,?,?,?,?)',
args: ['abcDEF12345', JSON.stringify({ id: 'abcDEF12345', title: 'Café Worship', channel: 'Choir', channelId: 'UC123', channelUrl: 'https://www.youtube.com/@choir', tags: ['live'], publishedAt: '2024-01-01', language: 'en' }), 'cafe worship choir', 1, 1] });
await db.db.execute({ sql: 'INSERT INTO video_meta (id,card,hay,seen,updated_at) VALUES (?,?,?,?,?)',
args: ['upl_abcdef12', JSON.stringify({ id: 'upl_abcdef12', title: 'Upload' }), 'upload', 1, 1] });
await db.migrateVideoMeta({ pageSize: 1 });
await db.migrateVideoMeta({ pageSize: 1 });
const youtube = (await db.db.execute("SELECT * FROM video_meta WHERE id='abcDEF12345'")).rows[0];
const upload = (await db.db.execute("SELECT * FROM video_meta WHERE id='upl_abcdef12'")).rows[0];
expect(youtube.url).toBe('https://www.youtube.com/watch?v=abcDEF12345');
expect(youtube.channel_id).toBe('UC123');
expect(youtube.channel_url).toBe('https://www.youtube.com/@choir');
expect(youtube.published).toBe('2024-01-01');
expect(youtube.lang).toBe('en');
expect(youtube.hay).toContain('cafe');
expect(JSON.parse(youtube.card).url).toBe(youtube.url);
expect(upload.url).toBeNull();
});
test('ingest stores canonical source URL and channel metadata on the card and row', async () => {
await catalog.ingest([{ id: 'xyzABC12345', title: 'Música', channel: 'Choir', channel_id: 'UC456', channel_url: 'https://www.youtube.com/@choir', tags: ['Praise'], description: 'Sunday service' }], 'deep');
const row = (await db.db.execute("SELECT * FROM video_meta WHERE id='xyzABC12345'")).rows[0];
const card = JSON.parse(row.card);
expect(row.url).toBe('https://www.youtube.com/watch?v=xyzABC12345');
expect(row.channel_id).toBe('UC456');
expect(card.url).toBe(row.url);
expect(row.hay).toContain('musica');
expect(row.hay).toContain('praise');
});
test('accent-insensitive matching ranks title phrase ahead of channel match', async () => {
await catalog.ingest([
{ id: 'phrase12345', title: 'Café Worship Live', channel: 'Other' },
{ id: 'channel1234', title: 'Sunday Set', channel: 'Cafe Worship Live' },
{ id: 'words123456', title: 'Worship in the Cafe', channel: 'Other' },
], 'deep');
const search = await import('./search-cache.js');
const results = await search.searchVideos('cafe worship live');
expect(results[0].id).toBe('phrase12345');
expect(results[0].url).toBe('https://www.youtube.com/watch?v=phrase12345');
});

View File

@@ -108,5 +108,5 @@ export function createYtdlpPool({ ytdlpPath = '', size = 2, timeoutMs = 60_000,
for (const w of workers) if (!w.busy) retire(w);
}
return { run, close, recycle, get disabled() { return disabled; } };
return { run, close, recycle, stats: () => ({ queued: queue.length, workers: workers.length, busy: workers.filter(w => !!w.busy).length, disabled }), get disabled() { return disabled; } };
}

27
tests/admin-audit.spec.js Normal file
View File

@@ -0,0 +1,27 @@
const { test, expect } = require('@playwright/test');
test('audit tab filters, pages and links filtered exports', async ({ page }) => {
const requests = [];
await page.route('**/api/**', async route => {
const url = new URL(route.request().url()); requests.push(url.pathname + url.search);
let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
if (url.pathname === '/api/admin/audit') body = {
ok: true, total: 62, offset: Number(url.searchParams.get('offset')) || 0,
events: [{ id: 9, ts: 1791900000, actor: 'admin', action: 'notes.save.lyrics', target: 'dQw4w9WgXcQ', detail: { rev: 4 } }],
};
if (url.pathname === '/api/admin/flags') body = { ok: true, open: 0, flags: [] };
if (url.pathname === '/api/admin/media') body = { ok: true, media: [] };
if (url.pathname === '/api/uploads') body = { ok: true, uploads: [] };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.goto('/admin.html#audit');
await expect(page.locator('#p-audit')).toBeVisible();
await expect(page.locator('#auditTable')).toContainText('notes.save.lyrics');
await page.locator('#auditActor').fill('admin'); await page.locator('#auditAction').fill('lyrics');
await page.locator('#auditFilterForm button[type=submit]').click();
await expect.poll(() => requests.filter(x => x.startsWith('/api/admin/audit?')).length).toBeGreaterThan(1);
await expect(page.locator('#auditJson')).toHaveAttribute('href', /actor=admin.*action=lyrics.*format=json/);
await page.locator('#auditNext').click();
await expect(page.locator('#auditCount')).toContainText('51–');
});

View File

@@ -0,0 +1,25 @@
const { test, expect } = require('@playwright/test');
test('backups lists integrity, previews diff, creates a snapshot and confirms restore', async ({ page }) => {
const calls = [];
await page.route('**/api/**', async route => {
const request = route.request(), url = new URL(request.url()); calls.push({ path: url.pathname, method: request.method() });
let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
if (url.pathname === '/api/admin/backups' && request.method() === 'GET') body = { ok: true, backups: [{ date: '2026-10-14', bytes: 1024, notes: 12, valid: true }] };
if (url.pathname === '/api/admin/backups/2026-10-14') body = { ok: true, diff: { changed: 2, added: 1, removed: 0, preview: [{ key: 'song:lyrics', change: 'changed' }] } };
if (url.pathname === '/api/admin/backups' && request.method() === 'POST') body = { ok: true, date: '2026-10-14', notes: 12 };
if (url.pathname.endsWith('/restore')) body = { ok: true, restored: 12, safetySnapshot: '/data/backups/snapshots/safety.json' };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
page.on('dialog', dialog => dialog.accept());
await page.goto('/admin.html#backups');
await expect(page.locator('#backupTable')).toContainText('Verified');
await expect(page.locator('#backupDiff')).toContainText('2 changed');
await expect(page.locator('a[href="/api/admin/backups/export/sqlite-dump"]')).toBeVisible();
await page.locator('#backupNow').click();
await expect(page.locator('#backupStatus')).toContainText('Saved 12 notes');
await page.locator('#backupRestore').click();
await expect.poll(() => calls.some(call => call.path.endsWith('/restore') && call.method === 'POST')).toBe(true);
await expect(page.locator('#backupStatus')).toContainText('Safety snapshot');
});

View File

@@ -0,0 +1,43 @@
const { test, expect } = require('@playwright/test');
test('library selection queues LRCLIB, Whisper, LRC export and revision-backed clear jobs', async ({ page }) => {
const calls = []; let latestJob = null;
await page.route('**/api/**', async route => {
const request = route.request(), url = new URL(request.url());
let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
if (url.pathname === '/api/uploads') body = { ok: true, uploads: [] };
if (url.pathname === '/api/admin/lyrics/bulk/library') body = { ok: true, total: 2, limit: 50, offset: 0, songs: [
{ id: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', lines: 4, cacheStatus: 'ready' },
{ id: 'jNQXAC9IVRw', title: 'Saved song', channel: 'Band', lines: 0, cacheStatus: 'failed' },
] };
if (url.pathname === '/api/admin/lyrics/jobs' && request.method() === 'GET') body = { ok: true, jobs: latestJob ? [latestJob] : [] };
if (url.pathname === '/api/admin/lyrics/jobs' && request.method() === 'POST') {
const payload = request.postDataJSON(); calls.push(payload);
latestJob = { id: `job-${calls.length}`, action: payload.action, status: 'complete', state: { done: payload.ids?.length || 2, total: payload.ids?.length || 2, results: [{ id: 'dQw4w9WgXcQ', ok: true, message: 'Done' }], fileReady: payload.action === 'export' } };
body = { ok: true, job: latestJob };
}
if (url.pathname === '/api/admin/lyrics/jobs/job-1/pause' || url.pathname.endsWith('/resume') || url.pathname.endsWith('/cancel')) body = { ok: true, status: 'paused' };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
page.on('dialog', dialog => dialog.accept());
await page.goto('/admin.html#library');
await expect(page.locator('#bulkLibraryTable')).toContainText('Amazing grace');
await page.locator('#bulkSelectAll').check(); await page.locator('#bulkAction').selectOption('lrclib'); await page.locator('#bulkStart').click();
await expect.poll(() => calls.length).toBe(1);
expect(calls[0]).toMatchObject({ action: 'lrclib', allFiltered: true, query: '' });
await page.locator('#bulkLibraryTable [data-bulk-song="dQw4w9WgXcQ"]').check();
await page.locator('#bulkAction').selectOption('whisper'); await page.locator('#bulkStart').click();
await expect.poll(() => calls.length).toBe(2); expect(calls[1].action).toBe('whisper');
await page.locator('#bulkLibraryTable [data-bulk-song="jNQXAC9IVRw"]').check();
await page.locator('#bulkAction').selectOption('export'); await page.locator('#bulkStart').click();
await expect.poll(() => calls.length).toBe(3); expect(calls[2].action).toBe('export');
await expect(page.locator('#bulkJobs a[href*="/export"]')).toBeVisible();
await page.locator('#bulkLibraryTable [data-bulk-song="dQw4w9WgXcQ"]').check();
await page.locator('#bulkAction').selectOption('clear'); await page.locator('#bulkStart').click();
await expect.poll(() => calls.length).toBe(4); expect(calls[3]).toMatchObject({ action: 'clear', confirmClear: true });
});

25
tests/admin-cache.spec.js Normal file
View File

@@ -0,0 +1,25 @@
const { test, expect } = require('@playwright/test');
test('cache actions pin, unpin, confirm eviction and request a source re-download', async ({ page }) => {
const state = { pinned: false, evicted: false, redownloaded: false };
await page.route('**/api/**', async route => {
const req = route.request(), url = new URL(req.url()); let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { enabled: true, admin: true };
else if (url.pathname === '/api/admin/cache/health') body = { ok: true, total: 1, offset: 0, budget: 1000, pinnedBudget: 900, bytes: 950, pinnedBytes: state.pinned ? 700 : 0, utilization: .95, statuses: [{ status: 'ready', count: 1, bytes: 700 }, { status: 'queued', count: 2, bytes: 100 }, { status: 'downloading', count: 3, bytes: 150 }, { status: 'failed', count: 1, bytes: 0 }], media: [{ id: 'CacheItem001', status: 'ready', bytes: 700, lastAccess: 1791900000000, gen: 4, pinned: state.pinned }] };
else if (url.pathname === '/api/admin/cache/CacheItem001/pin' && req.method() === 'POST') { state.pinned = req.postDataJSON().pinned; body.pinned = state.pinned; }
else if (url.pathname === '/api/admin/cache/CacheItem001/evict' && req.method() === 'POST') state.evicted = true;
else if (url.pathname === '/api/admin/media/CacheItem001/redownload' && req.method() === 'POST') state.redownloaded = true;
else if (url.pathname === '/api/admin/media') body.media = [];
else if (url.pathname === '/api/uploads') body.uploads = [];
else if (url.pathname === '/api/admin/flags') body.flags = [];
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.goto('/admin.html#cache');
await expect(page.locator('#cacheTable')).toContainText('CacheItem001');
await expect(page.locator('#cacheMetrics')).toContainText('5 / 1');
await page.locator('[data-cache-pin]').click(); await expect(page.locator('[data-cache-pin]')).toHaveText('Unpin');
await page.locator('[data-cache-pin]').click(); await expect(page.locator('[data-cache-pin]')).toHaveText('Pin');
page.once('dialog', dialog => dialog.accept()); await page.locator('[data-cache-evict]').click();
page.once('dialog', dialog => dialog.accept()); await page.locator('[data-cache-redownload]').click();
expect(state).toEqual({ pinned: false, evicted: true, redownloaded: true });
});

View File

@@ -0,0 +1,38 @@
const { test, expect } = require('@playwright/test');
test('admin deep-search tab estimates, starts, controls and shows persisted progress', async ({ page }) => {
page.on('pageerror', error => console.error('pageerror:', error.message));
const state = { jobs: [], events: [] };
await page.route('**/api/**', async route => {
const req = route.request(), url = new URL(req.url());
let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { enabled: true, admin: true };
else if (url.pathname === '/api/admin/media') body = { ok: true, media: [] };
else if (url.pathname === '/api/uploads') body = { ok: true, uploads: [] };
else if (url.pathname === '/api/admin/deep-search/estimate') body = { ok: true, plan: ['worship', 'worship live'], queries: 2, estimatedMinutes: 1, indexSize: 1240, dailyRemaining: 999 };
else if (url.pathname === '/api/admin/deep-search/start') {
state.events.push('start'); state.jobs = [{ id: 'deep-1', status: 'running', plan: ['worship', 'worship live'], options: {}, queriesDone: 1, queriesTotal: 2, current: 'worship live', newVideos: 24, totalIndexSize: 1264, etaMs: 12000, errors: [], log: [{ at: Date.now(), message: 'Saved 24 results' }] }];
body = { ok: true, id: 'deep-1' };
} else if (url.pathname === '/api/admin/deep-search/jobs') body = { ok: true, indexSize: 1264, jobs: state.jobs };
else if (/\/api\/admin\/deep-search\/deep-1\/(pause|resume|cancel)$/.test(url.pathname)) {
const action = url.pathname.split('/').at(-1); state.events.push(action);
state.jobs[0].status = action === 'pause' ? 'paused' : action === 'resume' ? 'running' : 'cancelled';
body = { ok: true, status: state.jobs[0].status };
}
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.goto('/admin.html#deep-search');
await expect(page.locator('#deepSearchForm')).toBeVisible();
await expect(page.locator('#deepSeeds')).toHaveValue(/worship/);
await page.locator('#deepEstimate').click();
await expect(page.locator('#deepEstimateText')).toContainText('2 unique queries');
await page.locator('#deepStart').click();
await expect(page.locator('.deep-job')).toContainText('1/2 queries');
await page.locator('[data-deep-action="pause"]').click();
await expect(page.locator('.deep-job')).toContainText('Paused');
await page.locator('[data-deep-action="resume"]').click();
await expect(page.locator('.deep-job')).toContainText('Running');
await page.locator('[data-deep-action="cancel"]').click();
await expect(page.locator('.deep-job')).toContainText('Cancelled');
expect(state.events).toEqual(['start', 'pause', 'resume', 'cancel']);
});

22
tests/admin-index.spec.js Normal file
View File

@@ -0,0 +1,22 @@
const { test, expect } = require('@playwright/test');
test('search index tab shows source coverage and runs bounded repair actions', async ({ page }) => {
const actions = [];
await page.route('**/api/**', async route => {
const req = route.request(), url = new URL(req.url()); let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { enabled: true, admin: true };
else if (url.pathname === '/api/admin/index/health') body = { ok: true, total: 100, urlCount: 90, detailCount: 40, thumbnailCount: 75, urlCoverage: .9, detailCoverage: .4, thumbnailCoverage: .75, sources: [{ source: 'deep', videos: 70, discoveries: 300, lastSeen: 1791900000000 }], lastRun: { status: 'complete', createdAt: 1791900000000, progress: { queriesDone: 20, newVideos: 400 } }, backfill: [{ source: 'media_cache', cursor: 10, ceiling: 20, done: false }] };
else if (url.pathname === '/api/admin/index/backfill' && req.method() === 'POST') { actions.push('backfill'); body.processed = 3; }
else if (url.pathname === '/api/admin/index/cleanup' && req.method() === 'POST') { actions.push('cleanup'); body.removed = 2; }
else if (url.pathname === '/api/admin/media') body.media = [];
else if (url.pathname === '/api/uploads') body.uploads = [];
else if (url.pathname === '/api/admin/flags') body.flags = [];
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.goto('/admin.html#index');
await expect(page.locator('#indexMetrics')).toContainText('90%');
await expect(page.locator('#indexSources')).toContainText('deep');
await page.locator('#indexBackfill').click(); await expect(page.locator('#indexStatus')).toContainText('Processed 3');
page.once('dialog', dialog => dialog.accept()); await page.locator('#indexCleanup').click(); await expect(page.locator('#indexStatus')).toContainText('Removed 2');
expect(actions).toEqual(['backfill', 'cleanup']);
});

View File

@@ -0,0 +1,28 @@
const { test, expect } = require('@playwright/test');
test('lyrics full-text search highlights matches, filters, rebuilds and opens the editor', async ({ page }) => {
const requests = [];
await page.route('**/api/**', async route => {
const request = route.request(), url = new URL(request.url()); requests.push({ path: url.pathname, query: url.search, method: request.method() });
let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
if (url.pathname === '/api/admin/lyrics/search' && request.method() === 'GET') body = { ok: true, query: url.searchParams.get('q') || '', hasLyrics: 'yes', hasChapters: 'all', total: 1, limit: 30, offset: 0, results: [{ videoId: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', snippet: '\u0001Amazing grace\u0002 will lead me home', hasChapters: true, rev: 2 }] };
if (url.pathname === '/api/admin/lyrics/search/rebuild') body = { ok: true, indexed: 42 };
if (url.pathname === '/api/notes/dQw4w9WgXcQ') body = { ok: true, lyrics: { rev: 2, data: { lines: [{ t: 1, text: 'Amazing grace', kind: 'line' }] } }, chapters: null };
if (url.pathname === '/api/streams') body = { ok: true, data: { meta: { title: 'Amazing grace', duration: 30 }, qualities: [] } };
if (url.pathname === '/api/admin/flags') body = { ok: true, open: 0, flags: [] };
if (url.pathname.startsWith('/api/admin/transcriptions/')) body = { ok: true, job: null };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.goto('/admin.html#lyric-search');
await expect(page.locator('#lyricsSearchTable')).toContainText('Amazing grace');
await expect(page.locator('#lyricsSearchTable mark')).toHaveText('Amazing grace');
await page.locator('#lyricsSearchQuery').fill('amazing grace'); await page.locator('#lyricsSearchHasChapters').selectOption('yes');
await page.locator('#lyricsSearchForm button[type=submit]').click();
await expect.poll(() => requests.some(item => item.path === '/api/admin/lyrics/search' && item.query.includes('hasChapters=yes'))).toBe(true);
await page.locator('#lyricsSearchRebuild').click();
await expect.poll(() => requests.some(item => item.path === '/api/admin/lyrics/search/rebuild' && item.method === 'POST')).toBe(true);
await expect(page.locator('#lyricsSearchTable')).toContainText('Amazing grace');
await page.locator('[data-lyrics-search-open="dQw4w9WgXcQ"]').click();
await expect(page.locator('#songTitle')).toHaveText('Amazing grace');
});

View File

@@ -1,6 +1,7 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const id = '0gfX0dFLaBc';
async function fixture(page) {
async function fixture(page, theme = 'dark') {
const state = { job: null, starts: 0, saves: [] };
await page.route('**/api/**', async route => {
const req = route.request(), url = new URL(req.url()); let body = { ok: true };
@@ -16,6 +17,7 @@ async function fixture(page) {
} else if (url.pathname === '/api/notes/' + id + '/lyrics' && req.method() === 'PUT') { state.saves.push(req.postDataJSON()); body.rev = 9; }
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.addInitScript(theme => localStorage.setItem('_ytpdata', JSON.stringify({ settings: { theme } })), theme);
await page.goto('/admin.html#editor');
await expect(page.locator('[data-open="' + id + '"]').first()).toBeVisible();
return state;
@@ -165,3 +167,98 @@ test('editor loads the whole audio into the browser once and reuses it', async (
await expect(page.locator('#dur')).toHaveText('0:01');
expect(hits).toBe(1);
});
function ratio(fg, bg) {
const channel = value => {
const n = value / 255;
return n <= 0.04045 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4;
};
const lum = color => {
const values = color.startsWith('#')
? (color.slice(1).length === 3
? [...color.slice(1)].map(value => value + value).join('')
: color.slice(1)).match(/.{2}/g).slice(0, 3).map(value => parseInt(value, 16))
: color.match(/[\d.]+/g).slice(0, 3).map(Number);
return 0.2126 * channel(values[0]) + 0.7152 * channel(values[1]) + 0.0722 * channel(values[2]);
};
const [a, b] = [lum(fg), lum(bg)].sort((x, y) => y - x);
return (a + 0.05) / (b + 0.05);
}
test('Lyrics Studio stays readable, touchable and contained across themes and screen sizes', async ({ page }) => {
const shots = 'plans/editor-redesign-shots'; fs.mkdirSync(shots, { recursive: true });
await page.setViewportSize({ width: 390, height: 844 });
await fixture(page, 'light');
await expect(page.locator('html')).toHaveAttribute('data-editor-theme', 'light');
await page.locator(`[data-open="${id}"]`).first().click();
await expect(page.locator('.ln')).toHaveCount(47);
await page.locator('.ln').first().locator('.tc').click();
await expect(page.locator('.ln').first()).toHaveClass(/sel/);
await page.locator('.ln').first().evaluate(row => row.classList.add('now'));
await page.locator('#mTap').click();
await page.locator('.ln').first().evaluate(row => row.classList.add('now'));
await expect(page.locator('#tapPad')).toBeVisible();
await page.locator('.ln-text').first().fill('A corrected lyric remains in the editor while the save controls stay available.');
await expect(page.locator('#saveBar')).toBeVisible();
await page.emulateMedia({ reducedMotion: 'reduce' });
for (const theme of ['dark', 'light', 'contrast']) {
for (const width of [390, 768, 1280, 1920]) {
await page.setViewportSize({ width, height: width < 500 ? 844 : 900 });
await page.evaluate(value => { document.documentElement.dataset.editorTheme = value; }, theme);
const metrics = await page.evaluate(() => {
const css = selector => getComputedStyle(document.querySelector(selector));
const root = css('#p-editor'), title = css('#songTitle'), meta = css('#songMeta');
const primary = css('#save');
const line = css('.ln.sel .ln-text');
const row = css('.ln.sel');
const waveTokens = ['--ed-wave-played', '--ed-wave-unplayed', '--ed-wave-mark', '--ed-wave-cursor'];
const rect = selector => { const r = document.querySelector(selector).getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; };
const visibleControls = [...document.querySelectorAll('#studio button, #studio select, #studio input, #studio textarea, #saveBar button, #tapPad')]
.filter(el => el.getClientRects().length);
return {
theme: document.documentElement.dataset.editorTheme,
viewport: innerWidth,
rootScroll: document.documentElement.scrollWidth,
editorScroll: document.querySelector('#p-editor').scrollWidth,
editorClient: document.querySelector('#p-editor').clientWidth,
titleRatio: [title.color, root.backgroundColor],
metaRatio: [meta.color, root.backgroundColor],
primaryRatio: [primary.color, primary.backgroundColor],
primaryBoundary: [row.outlineColor, root.backgroundColor],
lineRatio: [line.color, line.backgroundColor],
waveRatios: waveTokens.map(token => [root.getPropertyValue(token).trim(), root.getPropertyValue('--ed-bg').trim()]),
nowBorder: css('.ln.now').borderTopColor,
selectedOutline: row.outlineColor,
keyboardHint: (() => { const el = document.querySelector('.keyboard-hint'), r = el.getBoundingClientRect(); return { visible: !!el.getClientRects().length, insideDeck: document.querySelector('.deck').contains(el), bottom: r.bottom, top: r.top }; })(),
targets: visibleControls.map(el => ({ id: el.id || el.className, width: el.getBoundingClientRect().width, height: el.getBoundingClientRect().height })),
tap: rect('#tapPad'), save: rect('#saveInner'),
focus: (() => { const el = document.querySelector('#bBack'); el.focus({ focusVisible: true }); const s = getComputedStyle(el); return { style: s.outlineStyle, width: s.outlineWidth }; })(),
reduceMotion: matchMedia('(prefers-reduced-motion: reduce)').matches,
transition: getComputedStyle(document.querySelector('.ln')).transitionDuration,
rowCount: document.querySelectorAll('.ln').length,
};
});
expect(metrics.rootScroll, `${theme} ${width}px page overflow`).toBeLessThanOrEqual(width + 1);
expect(metrics.editorScroll, `${theme} ${width}px editor overflow`).toBeLessThanOrEqual(metrics.editorClient + 1);
expect(metrics.rowCount).toBe(47);
expect(ratio(...metrics.titleRatio), `${theme} title contrast`).toBeGreaterThanOrEqual(4.5);
expect(ratio(...metrics.metaRatio), `${theme} metadata contrast`).toBeGreaterThanOrEqual(4.5);
expect(ratio(...metrics.primaryRatio), `${theme} primary action contrast ${JSON.stringify(metrics.primaryRatio)}`).toBeGreaterThanOrEqual(4.5);
expect(ratio(...metrics.primaryBoundary), `${theme} selected boundary contrast`).toBeGreaterThanOrEqual(3);
expect(ratio(...metrics.lineRatio), `${theme} lyric text contrast`).toBeGreaterThanOrEqual(4.5);
for (const colors of metrics.waveRatios) expect(ratio(...colors), `${theme} waveform contrast ${colors[0]}`).toBeGreaterThanOrEqual(3);
expect(metrics.nowBorder).not.toBe(metrics.selectedOutline);
expect(metrics.keyboardHint.visible).toBe(true);
expect(metrics.keyboardHint.insideDeck).toBe(true);
for (const target of metrics.targets) expect(target.height, `${target.id} target at ${width}px`).toBeGreaterThanOrEqual(44);
expect(metrics.focus.style).not.toBe('none');
expect(metrics.focus.width).toBe('3px');
expect(metrics.reduceMotion).toBe(true);
expect(metrics.transition).toBe('0s');
expect(metrics.tap.bottom).toBeLessThanOrEqual(metrics.save.y);
if (width < 500) expect(metrics.tap.right).toBeLessThanOrEqual(width);
await page.screenshot({ path: `${shots}/${theme}-${width}.png`, fullPage: false });
}
}
});

65
tests/admin-ops.spec.js Normal file
View File

@@ -0,0 +1,65 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const path = require('node:path');
test('monitor, P2P diagnostics and upload manager render and send guarded actions', async ({ page }) => {
const dir = path.resolve('plans/admin-ops-shots'); fs.mkdirSync(dir, { recursive: true });
const calls = [];
await page.route('**/api/**', async route => {
const req = route.request(), url = new URL(req.url()); calls.push({ path: url.pathname, method: req.method(), body: req.postData() });
let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
if (url.pathname === '/api/admin/ops/metrics') body = { ok: true, current: { at: Date.now(), inflight: 2, rssBytes: 88_000_000, cpuPercent: 11.2, ytdlp: { queued: 1, workers: 2, busy: 1 }, media: { queued: 3, active: 'vid' }, cache: { hits: 9, misses: 2 }, routes: { '/api/version': { requests: 30, p50: 2, p95: 9, p99: 14, errors: 0, errorRate: 0 } }, volumes: [{ name: 'media', freeBytes: 5_000_000_000, totalBytes: 20_000_000_000 }] }, history: [{ at: Date.now() - 60_000, routes: { '/api/version': { p95: 10 } } }, { at: Date.now(), routes: { '/api/version': { p95: 9 } } }] };
if (url.pathname === '/api/admin/p2p/devices') body = { ok: true, network: { online: 1, content: 14, intakeActive: 0, intakeOpen: 1 }, devices: [{ id: 'dev_0123456789abcdef', fingerprintShort: 'phone-4821', profile: 'family', userAgent: 'iPhone · iOS', banned: false, online: true, lastSeenAt: Date.now(), holdings: 8, holdingBytes: 900_000_000, bytesUp: 12_000, bytesDown: 0 }] };
if (url.pathname === '/api/admin/uploads/manage') body = { ok: true, total: 1, offset: 0, owners: [{ owner: '(admin)', count: 1, bytes: 1_500_000 }], uploads: [{ id: 'upl_0123456789ab', title: 'Sunday service', artist: 'Choir', album: 'Live', ext: 'mp3', size: 1_500_000, gen: 3, listed: true }], orphans: [] };
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) });
});
page.on('dialog', dialog => dialog.accept());
await page.goto('/admin.html#editor');
await page.emulateMedia({ reducedMotion: 'reduce' });
for (const theme of ['dark', 'light', 'contrast']) for (const width of [390, 768, 1280]) for (const tab of ['ops', 'devices', 'uploads']) {
await page.evaluate(theme => { document.documentElement.dataset.editorTheme = theme; }, theme);
await page.setViewportSize({ width, height: 900 });
const nav = width <= 760 ? '#tabsBottom' : '#tabsTop';
const button = page.locator(`${nav} [data-tab="${tab}"]`); await button.scrollIntoViewIfNeeded(); await button.click();
await expect(page.locator(`#p-${tab}`)).toBeVisible();
await page.waitForTimeout(30);
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > innerWidth);
expect(overflow, `${tab} page overflow at ${width}/${theme}`).toBe(false);
const ui = await page.locator(`#p-${tab}`).evaluate(panel => {
const rgb = value => (value.match(/[\d.]+/g) || []).slice(0, 3).map(Number);
const luminance = value => {
const [r, g, b] = rgb(value).map(n => n / 255).map(n => n <= .04045 ? n / 12.92 : ((n + .055) / 1.055) ** 2.4);
return .2126 * r + .7152 * g + .0722 * b;
};
const ratio = (fg, bg) => { const a = luminance(fg), b = luminance(bg); return (Math.max(a, b) + .05) / (Math.min(a, b) + .05); };
const bodyBg = getComputedStyle(document.body).backgroundColor;
const checks = [...panel.querySelectorAll('.btn:not([disabled]), .field')].filter(el => el.getClientRects().length).map(el => ({ tag: el.tagName, h: el.getBoundingClientRect().height }));
const text = [...panel.querySelectorAll('h1.page, h2, .lead, .muted, .metric span, .metric small, .tbl td, .tbl th')].filter(el => el.getClientRects().length).map(el => ratio(getComputedStyle(el).color, bodyBg));
return { checks, text };
});
expect(ui.checks.every(el => el.h >= 43.5), `${tab} controls below 44px at ${width}/${theme}: ${JSON.stringify(ui.checks)}`).toBe(true);
expect(ui.text.every(value => value >= 4.5), `${tab} heading/lead contrast below AA at ${width}/${theme}: ${ui.text}`).toBe(true);
if (width <= 760) {
const lastCard = page.locator(`#p-${tab} .card`).last();
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
const clearance = await lastCard.evaluate(el => ({ bottom: el.getBoundingClientRect().bottom, navTop: document.querySelector('#tabsBottom').getBoundingClientRect().top }));
expect(clearance.bottom, `${tab} last card remains behind bottom nav at ${width}/${theme}`).toBeLessThanOrEqual(clearance.navTop);
}
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({ path: path.join(dir, `${tab}-${theme}-${width}.png`) });
}
await page.locator('#tabsTop [data-tab="devices"]').click();
await page.locator('[data-device-sync]').click();
await page.locator('[data-device-holdings]').click();
await page.locator('[data-device-ban]').click();
await page.locator('#tabsTop [data-tab="uploads"]').click();
await page.locator('[data-upload-save]').click();
await page.locator('[data-upload-select]').check();
await page.locator('#uploadBulkAction').selectOption('unlist'); await page.locator('#uploadBulkApply').click();
expect(calls.some(c => c.path.endsWith('/reannounce'))).toBe(true);
expect(calls.some(c => c.path.endsWith('/holdings') && c.method === 'DELETE')).toBe(true);
expect(calls.some(c => c.path.endsWith('/ban') && c.method === 'POST')).toBe(true);
expect(calls.some(c => c.path.endsWith('/upl_0123456789ab') && c.method === 'PATCH')).toBe(true);
expect(calls.some(c => c.path === '/api/admin/uploads/bulk')).toBe(true);
});

View File

@@ -0,0 +1,26 @@
const { test, expect } = require('@playwright/test');
test('lyrics queue exposes issue filters and reuses LRCLIB and Whisper routes', async ({ page }) => {
const calls = [];
await page.route('**/api/**', async route => {
const req = route.request(), url = new URL(req.url()); calls.push({ path: url.pathname, method: req.method() });
let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { enabled: true, admin: true };
if (url.pathname === '/api/admin/lyrics/quality') body = { ok: true, total: 1, offset: 0, songs: [{ id: 'QualMiss001', title: 'Missing lyric sample', channel: 'Choir', lines: 0, reasons: ['missing'], openReports: 0 }] };
if (url.pathname === '/api/notes/QualMiss001/lyrics/web') body = { ok: true, rev: 2, lines: 12 };
if (url.pathname === '/api/admin/transcriptions/QualMiss001') body = { ok: true, job: { status: 'queued' } };
if (url.pathname === '/api/admin/media') body = { ok: true, media: [] };
if (url.pathname === '/api/uploads') body = { ok: true, uploads: [] };
if (url.pathname === '/api/admin/flags') body = { ok: true, open: 0, flags: [] };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.goto('/admin.html#quality');
await expect(page.locator('#p-quality')).toBeVisible();
await expect(page.locator('#qualityTable')).toContainText('Missing lyric sample');
await page.locator('#qualityCategory').selectOption('untimed'); await page.locator('#qualityFilterForm button').click();
await expect(page.locator('#qualityTable')).toContainText('Pull LRCLIB');
await page.locator('[data-quality-pull]').click(); await expect(page.locator('#qualityStatus')).toContainText('revision 2');
await page.locator('[data-quality-whisper]').click(); await expect(page.locator('#qualityStatus')).toContainText('Whisper queued');
expect(calls.some(x => x.path.endsWith('/lyrics/web') && x.method === 'POST')).toBe(true);
expect(calls.some(x => x.path.endsWith('/transcriptions/QualMiss001') && x.method === 'POST')).toBe(true);
});

View File

@@ -0,0 +1,25 @@
const { test, expect } = require('@playwright/test');
test('report triage filters age and resolves a song batch with confirmation', async ({ page }) => {
const requests = [];
await page.route('**/api/**', async route => {
const request = route.request(); const url = new URL(request.url()); requests.push({ path: url.pathname, search: url.search, method: request.method() });
let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
if (url.pathname === '/api/admin/flags' && request.method() === 'GET') body = { ok: true, open: 2, flags: [
{ id: 1, videoId: 'dQw4w9WgXcQ', title: 'Sample song', channel: 'Choir', text: 'Flagged line', reporter: 'Listener', reason: 'words', status: 'open', createdAt: 1791300000, note: 'Wrong lyric' },
{ id: 2, videoId: 'dQw4w9WgXcQ', title: 'Sample song', channel: 'Choir', text: 'Flagged line', reporter: 'Second listener', reason: 'typo', status: 'open', createdAt: 1791300000, note: '' },
] };
if (url.pathname.endsWith('/resolve') && request.method() === 'POST') body = { ok: true, resolved: 2, open: 0 };
if (url.pathname === '/api/notes/dQw4w9WgXcQ') body = { ok: true, lyrics: null, chapters: null };
if (url.pathname === '/api/admin/notes/recent') body = { ok: true, notes: [] };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
page.on('dialog', dialog => dialog.accept());
await page.goto('/admin.html#reports');
await expect(page.locator('#repList')).toContainText('Second listener');
await page.locator('#reportAge').selectOption('30');
await expect.poll(() => requests.some(r => r.path === '/api/admin/flags' && r.search.includes('olderThanDays=30'))).toBe(true);
await page.locator('[data-resolve-song="dQw4w9WgXcQ"]').click();
await expect.poll(() => requests.some(r => r.path === '/api/admin/flags/song/dQw4w9WgXcQ/resolve' && r.method === 'POST')).toBe(true);
});

View File

@@ -0,0 +1,71 @@
const { test, expect } = require('@playwright/test');
const { mkdirSync } = require('node:fs');
const path = require('node:path');
function contrast(foreground, background) {
const luminance = color => {
const channels = color.match(/[\d.]+/g).slice(0, 3).map(Number).map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2);
return channels[0] * .2126 + channels[1] * .7152 + channels[2] * .0722;
};
const a = luminance(foreground), b = luminance(background);
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
}
test('capture the new admin tools screens for visual review', async ({ page }) => {
const dir = path.resolve('plans/admin-tools-shots'); mkdirSync(dir, { recursive: true });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.route('**/api/**', async route => {
const request = route.request(), url = new URL(request.url()); let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
if (url.pathname === '/api/uploads') body.uploads = [];
if (url.pathname === '/api/admin/media') body.media = [];
if (url.pathname === '/api/admin/flags') body = { ok: true, open: 1, flags: [{ id: 1, videoId: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', text: 'Grace will lead me home', reporter: 'listener', reason: 'words', status: 'open', createdAt: 1791300000, note: 'One word is wrong' }] };
if (url.pathname === '/api/admin/lyrics/quality') body = { ok: true, total: 1, offset: 0, limit: 50, songs: [{ id: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', lines: 4, source: 'auto', untimed: true, openReports: 1, reasons: ['automatic', 'untimed', 'reports'] }] };
if (url.pathname === '/api/admin/lyrics/search') body = { ok: true, query: '', hasLyrics: 'yes', hasChapters: 'all', total: 1, limit: 30, offset: 0, results: [{ videoId: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', snippet: '\u0001Grace will lead\u0002 me home', hasChapters: true, rev: 2 }] };
if (url.pathname === '/api/admin/cache/health') body = { ok: true, total: 2, offset: 0, budget: 10_000_000, pinnedBudget: 2_000_000, bytes: 4_800_000, pinnedBytes: 1_200_000, utilization: .48, statuses: [{ status: 'ready', count: 1, bytes: 4_000_000 }, { status: 'queued', count: 1, bytes: 800_000 }, { status: 'failed', count: 1, bytes: 0 }], media: [{ id: 'dQw4w9WgXcQ', status: 'ready', bytes: 4_000_000, lastAccess: 1791900000000, gen: 4, pinned: true }] };
if (url.pathname === '/api/admin/index/health') body = { ok: true, total: 1200, urlCount: 1190, detailCount: 980, thumbnailCount: 850, urlCoverage: .99, detailCoverage: .82, thumbnailCoverage: .71, sources: [{ source: 'deep', videos: 600, discoveries: 940, lastSeen: 1791900000 }, { source: 'search', videos: 500, discoveries: 1200, lastSeen: 1791800000 }], lastRun: { id: 'run-1', status: 'complete', createdAt: 1791800000, updatedAt: 1791900000, progress: { queriesDone: 160, newVideos: 340 } }, backfill: [{ source: 'history', cursor: 840, ceiling: 1000, done: false }] };
if (url.pathname === '/api/admin/backups') body = { ok: true, backups: [{ date: '2026-10-14', bytes: 20480, notes: 124, valid: true }] };
if (url.pathname === '/api/admin/backups/2026-10-14') body = { ok: true, date: '2026-10-14', total: 124, diff: { added: 2, removed: 1, changed: 3, preview: [{ key: 'dQw4w9WgXcQ:lyrics', change: 'changed' }] } };
if (url.pathname === '/api/admin/audit') body = { ok: true, total: 42, offset: 0, events: [{ id: 8, ts: 1791900000, actor: 'admin', action: 'lyrics.save', target: 'dQw4w9WgXcQ', detail: { rev: 4, lines: 22 } }] };
if (url.pathname === '/api/admin/lyrics/bulk/library') body = { ok: true, total: 1, limit: 50, offset: 0, songs: [{ id: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', lines: 22, rev: 4, cacheStatus: 'ready' }] };
if (url.pathname === '/api/admin/lyrics/jobs') body = { ok: true, jobs: [{ id: 'bulk-1', action: 'lrclib', status: 'running', state: { done: 3, total: 12, results: [{ id: 'dQw4w9WgXcQ', ok: true, message: 'Saved revision 4' }], logs: [] } }] };
if (url.pathname === '/api/admin/notes/recent') body = { ok: true, revs: [] };
if (url.pathname === '/api/admin/analytics') body = { ok: true };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/admin.html#editor');
for (const tab of ['quality', 'lyric-search', 'cache', 'index', 'reports', 'backups', 'audit', 'library']) {
await page.locator(`#tabsTop [data-tab="${tab}"]`).click();
await expect(page.locator(`#p-${tab}`)).toBeVisible();
await page.waitForTimeout(100);
await page.screenshot({ path: path.join(dir, `${tab}.png`), fullPage: true });
}
for (const theme of ['dark', 'light', 'contrast']) {
await page.evaluate(theme => { document.documentElement.dataset.editorTheme = theme; }, theme);
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 844 });
if (width === 390) {
const nav = page.locator('#tabsBottom');
const dimensions = await nav.evaluate(node => ({ client: node.clientWidth, scroll: node.scrollWidth, document: document.documentElement.scrollWidth }));
expect(dimensions.scroll, 'phone tab strip should retain all admin destinations').toBeGreaterThan(dimensions.client);
expect(dimensions.document, 'phone tab strip must not overflow the page').toBeLessThanOrEqual(width);
const lastTab = nav.locator('[data-tab]').last();
await lastTab.scrollIntoViewIfNeeded();
await expect(lastTab).toBeInViewport();
}
const targetHeight = await page.locator('#bulkStart').evaluate(node => node.getBoundingClientRect().height);
expect(targetHeight, `bulk action target in ${theme} at ${width}px`).toBeGreaterThanOrEqual(44);
const colors = await page.locator('#bulkLyricsCard').evaluate(node => {
const card = getComputedStyle(node), muted = getComputedStyle(node.querySelector('.muted'));
const action = getComputedStyle(node.querySelector('#bulkStart'));
return { text: muted.color, surface: card.backgroundColor, actionText: action.color, actionSurface: action.backgroundColor };
});
expect(contrast(colors.text, colors.surface), `muted text contrast ${theme} ${width}px`).toBeGreaterThanOrEqual(4.5);
expect(contrast(colors.actionText, colors.actionSurface), `primary action contrast ${theme} ${width}px ${JSON.stringify(colors)}`).toBeGreaterThanOrEqual(4.5);
const overflow = await page.locator('#bulkLyricsCard').evaluate(node => node.scrollWidth > node.clientWidth);
expect(overflow, `bulk card overflow in ${theme} at ${width}px`).toBe(false);
await page.locator('#bulkLyricsCard').screenshot({ path: path.join(dir, `bulk-${theme}-${width}.png`) });
}
}
});