diff --git a/frontend/admin.html b/frontend/admin.html index 5743cfa..1001d3b 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -115,6 +115,11 @@ pre { margin: 0; padding: 14px; border-radius: 14px; background: rgba(0,0,0,.35); border: 1px solid var(--line); overflow: auto; font: 12px/1.55 var(--mono); white-space: pre-wrap; word-break: break-word; max-height: 60vh; } .newtoken { margin-top: 12px; padding: 12px; border: 1px solid var(--ok); border-radius: 12px; background: rgba(74,222,128,.07); word-break: break-all; } .src { font: 11px var(--mono); color: var(--text-dim); } .src.api, .src.auto { color: var(--warn); } .src.restore { color: var(--ok); } + .audit-filters { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:10px; align-items:end; } + .audit-filters label { display:grid; gap:5px; color:var(--text-2); font-size:12px; } + .audit-tools { display:flex; gap:8px; align-items:center; flex-wrap:wrap; } + .audit-detail { color:var(--text-2); font:12px/1.4 var(--mono); max-width:36ch; overflow-wrap:anywhere; } + @media(max-width:720px) { .audit-filters { grid-template-columns:repeat(2,minmax(0,1fr)); } } /* ---------- upload card ---------- */ .sr-file { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; clip: rect(0 0 0 0); } @@ -659,6 +664,24 @@

Runs

+
+

Audit log

+

A searchable record of admin changes, with the actor and target kept alongside each action.

+
+
+ + + + + + + +
+
+
50 events per page
+
+
+

API access

Tokens let scripts write shared lyrics and chapters. A token is shown once — store it in your secrets vault.

@@ -715,9 +738,10 @@ history: '', analytics: '', 'deep-search': '', + audit: '', access: '', }; - const TABS = [['editor', 'Editor'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['analytics', 'Stats'], ['deep-search', 'Deep search'], ['access', 'API']]; + const TABS = [['editor', 'Editor'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['analytics', 'Stats'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']]; let tab = 'editor'; function buildTabs() { const html = TABS.map(([k, l]) => ``).join(''); @@ -738,6 +762,7 @@ if (name === 'access') loadTokens(); if (name === 'analytics') loadAnalytics(); if (name === 'deep-search') loadDeepSearch(); + if (name === 'audit') loadAudit(); window.scrollTo({ top: 0 }); } function setBadge(n) { @@ -889,6 +914,37 @@ const rows = (j.tokens || []).map((t) => `${esc(t.label)}${when(t.createdAt)}${t.lastUsedAt ? when(t.lastUsedAt) : 'never'}`).join(''); $('tokenTable').innerHTML = rows ? `LabelCreatedLast used${rows}` : 'No tokens yet.'; } + + /* ---------------- audit history ---------------- */ + let auditOffset = 0; + function auditQuery(format = '') { + const p = new URLSearchParams(format ? {} : { limit: '50', offset: String(auditOffset) }); + const search = $('auditSearch').value.trim(); if (search) p.set('q', search); + for (const [key, id] of [['actor', 'auditActor'], ['action', 'auditAction']]) { + const value = $(id).value.trim(); if (value) p.set(key, value); + } + for (const [key, id, end] of [['from', 'auditFrom', false], ['to', 'auditTo', true]]) { + const value = $(id).value; + if (value) p.set(key, String(Math.floor(new Date(`${value}T${end ? '23:59:59' : '00:00:00'}Z`).getTime() / 1000))); + } + if (format) p.set('format', format); + return p.toString(); + } + async function loadAudit() { + const table = $('auditTable'); table.innerHTML = 'Loading audit events…'; + try { + const j = await api('/api/admin/audit?' + auditQuery()); + if (!j.ok) throw Error(j.error || 'Audit log unavailable.'); + const rows = j.events.map(event => `${when(event.ts)}${esc(event.actor)}${esc(event.action)}${esc(event.target || '—')}${esc(JSON.stringify(event.detail || {}))}`).join(''); + table.innerHTML = rows ? `WhenActorActionTargetDetails${rows}` : 'No matching events.'; + $('auditCount').textContent = `${j.total.toLocaleString()} events · ${j.events.length ? auditOffset + 1 : 0}–${auditOffset + j.events.length}`; + $('auditPrev').disabled = auditOffset === 0; $('auditNext').disabled = auditOffset + j.events.length >= j.total; + $('auditJson').href = '/api/admin/audit?' + auditQuery('json'); $('auditCsv').href = '/api/admin/audit?' + auditQuery('csv'); + } catch (error) { table.innerHTML = `${esc(error.message)}`; } + } + $('auditFilterForm').addEventListener('submit', event => { event.preventDefault(); auditOffset = 0; loadAudit(); }); + $('auditPrev').addEventListener('click', () => { auditOffset = Math.max(0, auditOffset - 50); loadAudit(); }); + $('auditNext').addEventListener('click', () => { auditOffset += 50; loadAudit(); }); $('tokenForm').addEventListener('submit', async (e) => { e.preventDefault(); const label = $('tokenLabel').value.trim(); diff --git a/playwright.admin-audit.config.js b/playwright.admin-audit.config.js new file mode 100644 index 0000000..149dd36 --- /dev/null +++ b/playwright.admin-audit.config.js @@ -0,0 +1,3 @@ +const { defineConfig } = require('@playwright/test'); +const classic = require('./playwright.classic.config'); +module.exports = defineConfig({ ...classic, testMatch: /admin-audit\.spec\.js/ }); diff --git a/server/audit-log.js b/server/audit-log.js new file mode 100644 index 0000000..4ed78c0 --- /dev/null +++ b/server/audit-log.js @@ -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' }); + }); +} diff --git a/server/audit-log.test.js b/server/audit-log.test.js new file mode 100644 index 0000000..7a626fc --- /dev/null +++ b/server/audit-log.test.js @@ -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); + }); +}); diff --git a/server/db.js b/server/db.js index a3f8e4e..900001a 100644 --- a/server/db.js +++ b/server/db.js @@ -265,6 +265,17 @@ 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(); // Uploads shared from devices (PWA share target): who sent it, and whether it diff --git a/server/deep-search.js b/server/deep-search.js index 5e387d7..493ee7f 100644 --- a/server/deep-search.js +++ b/server/deep-search.js @@ -3,6 +3,7 @@ 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); @@ -191,6 +192,7 @@ export function createDeepSearchService({ db = defaultDb, searchDeep = innertube 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 => { @@ -199,6 +201,7 @@ export function createDeepSearchService({ db = defaultDb, searchDeep = innertube 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); }); diff --git a/server/notes.js b/server/notes.js index bf68373..99000e8 100644 --- a/server/notes.js +++ b/server/notes.js @@ -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). @@ -507,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); @@ -551,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); @@ -578,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); @@ -658,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); }); @@ -673,6 +679,8 @@ export function registerNoteRoutes(app, deps) { await next(); }; + registerAuditRoutes(app, { db, adminAuth: requireAdminOrToken }); + if (deps.analytics) registerAnalyticsRoutes(app, { adminAuth: requireAdminOrToken, runYtdlp, ...deps.analytics }); registerTranscriptionRoutes(app, { @@ -707,7 +715,11 @@ export function registerNoteRoutes(app, deps) { app.post('/api/admin/media/:id/redownload', requireAdminOrToken, async c => { if (!media) return c.json({ ok: false, error: 'media cache unavailable' }, 503); - try { return c.json({ ok: true, ...(await media.redownload(c.req.param('id'))) }, 202); } + 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); } }); @@ -727,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 }); }); @@ -763,11 +776,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); }); diff --git a/server/package.json b/server/package.json index c144a43..248c5f4 100644 --- a/server/package.json +++ b/server/package.json @@ -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 && bun test ./video-catalog.test.js && bun test ./search-cache.test.js && bun test --timeout 60000 ./deep-search.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 ./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 && 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", diff --git a/tests/admin-audit.spec.js b/tests/admin-audit.spec.js new file mode 100644 index 0000000..796d02c --- /dev/null +++ b/tests/admin-audit.spec.js @@ -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–'); +});