Redesign service-mode controls and add per-profile wrong-lyric-line reports

This commit is contained in:
Claude
2026-09-30 15:15:07 +00:00
parent 0d68e7e72f
commit 04a74ab45d
10 changed files with 1060 additions and 104 deletions

View File

@@ -25,6 +25,11 @@
* GET /api/notes/:id/captions → YouTube captions as lyric lines (preview, no save)
* POST /api/notes/:id/lyrics/auto { overwrite? } token/admin: captions → saved lyrics
* POST /api/notes/:id/lyrics/web { overwrite? } token/admin: LRCLIB (synced when available)
* POST /api/notes/:id/flags { text, index?, reason?, note?, profile } report a wrong lyric line
* GET /api/notes/:id/flags open reports, grouped per line (X-Profile → which are mine)
* POST /api/notes/:id/flags/:fid/withdraw { profile } take back my own report
* GET /api/admin/flags?status=open|resolved|all (admin or token) reports across songs, with reporters
* POST /api/admin/flags/:fid { status: 'open'|'resolved' } · DELETE /api/admin/flags/:fid
* POST /api/admin/login | /api/admin/logout, GET /api/admin/me
* GET|POST /api/admin/tokens, DELETE /api/admin/tokens/:id
* GET /api/admin/notes/recent, GET /api/admin/notes/export
@@ -393,6 +398,85 @@ export function registerNoteRoutes(app, deps) {
}
});
// ---- Lyric line reports ---------------------------------------------------
// Anyone linked to an online profile can say "this line is wrong" (service
// mode). The description is optional. Reports are matched by the line's
// TEXT, so they follow the line if lyrics above it are edited, and resolve
// themselves once the line changes (see autoResolve below).
const FLAG_REASONS = new Set(['words', 'timing', 'typo', 'other', '']);
const flagLog = new Map();
function flagOverBudget(key) {
const now = Date.now();
const list = (flagLog.get(key) || []).filter((t) => now - t < 10 * 60_000);
list.push(now);
flagLog.set(key, list);
if (flagLog.size > 5000) flagLog.clear();
return list.length > 40;
}
const autoResolve = (id, data) => {
db.autoResolveFlags(id, new Set((data.lines || []).map((l) => l.text))).catch(() => {});
};
app.post('/api/notes/:id/flags', async (c) => {
const id = c.req.param('id');
if (badId(id)) return c.json({ ok: false, error: 'invalid video id' }, 400);
let body;
try { body = await c.req.json(); } catch { return c.json({ ok: false, error: 'invalid JSON' }, 400); }
const who = await resolveWriter(c, body);
if (!who) return c.json({ ok: false, error: 'link an online profile to report a lyric line' }, 401);
if (who.invalid) return c.json({ ok: false, error: 'invalid API token' }, 401);
if (flagOverBudget(who.by)) return c.json({ ok: false, error: 'too many reports — wait a few minutes' }, 429);
const text = cleanText(body.text, MAX_LINE_CHARS);
if (!text) return c.json({ ok: false, error: 'which line? (text is missing)' }, 400);
try {
const lyr = (await db.getNotes(id)).lyrics;
if (!lyr) return c.json({ ok: false, error: 'this song has no lyrics to report' }, 404);
// The reporter may hold an older copy: a line that is gone is already fixed.
const at = lyr.data.lines.findIndex((l) => l.text === text);
if (at < 0) return c.json({ ok: false, stale: true, rev: lyr.rev, error: 'that line was just changed — reload the lyrics' }, 409);
const idx = Number.isInteger(body.index) && lyr.data.lines[body.index] && lyr.data.lines[body.index].text === text ? body.index : at;
const reason = FLAG_REASONS.has(String(body.reason || '')) ? String(body.reason || '') : '';
const r = await db.upsertFlag({
videoId: id, index: idx, text, rev: lyr.rev, reason, note: cleanText(body.note, 500), reporter: who.by,
});
return c.json({ ok: true, id: r.id, created: r.created });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// Open reports for one song, one entry per line. Descriptions are only
// returned to the person who wrote them (X-Profile), never to everyone.
app.get('/api/notes/:id/flags', async (c) => {
const id = c.req.param('id');
if (badId(id)) return c.json({ ok: false, error: 'invalid video id' }, 400);
const me = cleanText(c.req.header('x-profile'), 40);
try {
const byText = new Map();
for (const f of await db.listFlagsForVideo(id, 'open')) {
const e = byText.get(f.text) || { index: f.index, text: f.text, count: 0, mine: false, id: null, reason: '', note: '' };
e.count++;
if (me && f.reporter === me) { e.mine = true; e.id = f.id; e.reason = f.reason; e.note = f.note; }
byText.set(f.text, e);
}
return c.json({ ok: true, flags: [...byText.values()].sort((a, b) => a.index - b.index) });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
app.post('/api/notes/:id/flags/:fid/withdraw', async (c) => {
const id = c.req.param('id');
const fid = Number(c.req.param('fid'));
if (badId(id) || !Number.isInteger(fid)) return c.json({ ok: false, error: 'not found' }, 404);
let body = {};
try { body = await c.req.json(); } catch { /* profile required below */ }
const who = await resolveWriter(c, body);
if (!who || who.invalid) return c.json({ ok: false, error: 'link an online profile first' }, 401);
const ok = await db.withdrawFlag(fid, who.by);
return ok ? c.json({ ok: true }) : c.json({ ok: false, error: 'not your open report' }, 404);
});
// Server-side injection: captions straight into the shared lyrics. For
// scripts (API token) and the admin page; never overwrites existing lyrics
// unless asked to.
@@ -482,6 +566,7 @@ export function registerNoteRoutes(app, deps) {
force: !!body.force && who.via !== 'user',
});
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);
return c.json({ ok: true, rev: r.rev, data });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
@@ -606,13 +691,56 @@ export function registerNoteRoutes(app, deps) {
try { body = await c.req.json(); } catch { /* rev required below */ }
const old = await db.getNoteRev(id, kind, Number(body.rev));
if (!old) return c.json({ ok: false, error: 'revision not found' }, 404);
const restored = sanitizeNote(kind, old.data);
const r = await db.saveNote({
videoId: id, kind, data: sanitizeNote(kind, old.data), source: 'restore',
videoId: id, kind, data: restored, source: 'restore',
updatedBy: `admin (rev ${old.rev})`, force: true,
});
if (kind === 'lyrics') autoResolve(id, restored);
return c.json({ ok: true, rev: r.rev });
});
// ---- Admin: lyric line reports ---------------------------------------------
async function songLabel(videoId, cache) {
if (cache.has(videoId)) return cache.get(videoId);
let out = { title: '', channel: '' };
try {
if (videoId.startsWith('upl_')) {
const u = await db.getUpload(videoId);
if (u) out = { title: u.title || '', channel: u.artist || '' };
} else {
const m = JSON.parse((await db.getMedia(videoId) || {}).meta || '{}');
out = { title: m.title || '', channel: m.channel || m.uploader || '' };
}
} catch { /* unknown song: the id is shown instead */ }
cache.set(videoId, out);
return out;
}
app.get('/api/admin/flags', requireAdminOrToken, async (c) => {
const status = ['open', 'resolved', 'all'].includes(c.req.query('status')) ? c.req.query('status') : 'open';
const cache = new Map();
const flags = [];
for (const f of await db.listAllFlags({ status, limit: 300 })) {
flags.push({ ...f, ...(await songLabel(f.videoId, cache)) });
}
return c.json({ ok: true, open: await db.countOpenFlags(), flags });
});
app.post('/api/admin/flags/:fid', requireAdmin, async (c) => {
const fid = Number(c.req.param('fid'));
let body = {};
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');
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')));
return ok ? c.json({ ok: true, open: await db.countOpenFlags() }) : c.json({ ok: false, error: 'not found' }, 404);
});
app.get('/api/admin/notes/export', requireAdmin, async (c) => {
const notes = await db.allNotes();
c.header('Content-Disposition', `attachment; filename="ytplayer-notes-${new Date().toISOString().slice(0, 10)}.json"`);