Redesign service-mode controls and add per-profile wrong-lyric-line reports
This commit is contained in:
130
server/notes.js
130
server/notes.js
@@ -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"`);
|
||||
|
||||
Reference in New Issue
Block a user