Files
ytplayer/server/notes.js
Jonathan Sykes 2b38717c05 Add admin analytics, metadata collection, and grouped lyric cues
Queue reviewable Whisper drafts from the song list and lyrics editor. Preserve line breaks within one timed cue across editing, saving, reporting, and service views.

Add storage and listening analytics with a durable metadata collector, related-search depth, video limits, thumbnail storage, and a browsable metadata library.
2026-10-03 07:55:33 +08:00

800 lines
38 KiB
JavaScript

/* ============================================================================
* notes.js — shared lyrics + chapters per video, API tokens, admin page API
*
* Lyrics and chapters are shared by every user of the app: one live copy per
* (video, kind) in video_notes, and a full snapshot of every save in
* video_note_revs (the server-side backup / undo history). On top of that a
* daily JSON dump of every live note is written next to the DB.
*
* Who may write:
* - a user linked to an online profile (body.profile must be an existing
* profile — the same trust level as the rest of the profile API),
* - a script holding an API token (Authorization: Bearer ytp_…),
* - the admin (signed cookie from /api/admin/login, ADMIN_PASSWORD env).
* Every write names its author in the revision row, and the admin page can
* restore any older revision, so a bad edit is always one click from undone.
*
* Saves are optimistic-concurrency: the client sends the rev it edited
* (baseRev) and gets 409 + the current copy if someone saved in between.
*
* Endpoints:
* GET /api/notes/:id → { ok, lyrics, chapters }
* PUT /api/notes/:id/:kind { data, baseRev, profile?, force? }
* GET /api/notes/:id/:kind/revs → revision list
* GET /api/notes/:id/:kind/revs/:rev → one revision's data
* 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
* GET /api/admin/media (admin or token) saved videos + lyrics status
* POST /api/admin/notes/:id/:kind/restore { rev }
* GET /admin → admin.html
* ========================================================================== */
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { mkdirSync, readdirSync, unlinkSync, writeFileSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
import { registerAnalyticsRoutes } from './admin-analytics.js';
import { registerTranscriptionRoutes } from './transcriptions.js';
export const NOTE_KINDS = new Set(['lyrics', 'chapters']);
// A YouTube id or one of the server's own uploads (see uploads.js).
const VIDEO_ID_RE = /^([A-Za-z0-9_-]{11}|upl_[a-f0-9]{12})$/;
const MAX_LINES = 1000;
const MAX_LINE_CHARS = 300;
const MAX_TAGS = 12;
const MAX_CHAPTERS = 200;
const MAX_TIME = 24 * 3600;
// ---- Validation ---------------------------------------------------------------
function cleanText(v, max) {
return String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, max);
}
function cleanLyricText(value) {
return String(value ?? '').replace(/\r\n?/g, '\n')
.replace(/[\u0000-\u0009\u000b-\u001f\u007f]+/g, ' ')
.split('\n').map(part => part.trim()).filter(Boolean).join('\n').trim().slice(0, MAX_LINE_CHARS);
}
function cleanTime(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
if (!Number.isFinite(n) || n < 0 || n > MAX_TIME) return null;
return Math.round(n * 100) / 100;
}
// Lyrics document: ordered lines (untimed ones allowed — a pasted sheet is
// timed later), each a sung line, a section header or a band cue; plus
// free-form tags (Key G, Capo 2, 70 BPM…) and a default sync offset.
export function sanitizeLyrics(input) {
if (!input || typeof input !== 'object') throw new Error('lyrics must be an object');
const rawLines = Array.isArray(input.lines) ? input.lines : [];
if (rawLines.length > MAX_LINES) throw new Error(`too many lines (max ${MAX_LINES})`);
const lines = [];
for (const l of rawLines) {
if (!l || typeof l !== 'object') continue;
const text = cleanLyricText(l.text);
if (!text) continue;
const kind = l.kind === 'section' || l.kind === 'cue' ? l.kind : 'line';
lines.push({ t: cleanTime(l.t), text, kind });
}
const tags = (Array.isArray(input.tags) ? input.tags : [])
.map((t) => cleanText(t, 40)).filter(Boolean).slice(0, MAX_TAGS);
let offset = Number(input.offset) || 0;
offset = Math.max(-30, Math.min(30, Math.round(offset * 100) / 100));
return { lines, tags, offset };
}
export function sanitizeChapters(input) {
if (!input || typeof input !== 'object') throw new Error('chapters must be an object');
const raw = Array.isArray(input.items) ? input.items : [];
if (raw.length > MAX_CHAPTERS) throw new Error(`too many chapters (max ${MAX_CHAPTERS})`);
const items = [];
for (const it of raw) {
if (!it || typeof it !== 'object') continue;
const t = cleanTime(it.t);
const title = cleanText(it.title, 100);
if (t === null || !title) continue;
items.push({ t, title, note: cleanText(it.note, 200) });
}
items.sort((a, b) => a.t - b.t);
return { items };
}
export function sanitizeNote(kind, data) {
return kind === 'lyrics' ? sanitizeLyrics(data) : sanitizeChapters(data);
}
// ---- Captions → lyric lines ---------------------------------------------------
function vttTime(s) {
const m = String(s).trim().match(/^(?:(\d+):)?(\d{1,2}):(\d{2})(?:[.,](\d{1,3}))?/);
if (!m) return null;
const h = Number(m[1] || 0), min = Number(m[2]), sec = Number(m[3]);
const ms = m[4] ? Number(m[4].padEnd(3, '0')) : 0;
return h * 3600 + min * 60 + sec + ms / 1000;
}
function decodeEntities(s) {
return s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, ' ');
}
// WebVTT → [{t, text}]. YouTube's auto captions "roll": each cue repeats the
// previous line above the new one, and every line also appears in a 10 ms
// echo cue. Emitting a line only when it isn't among the last few emitted
// collapses that to one entry per sung line, stamped when it first appears.
// Pure sound tags ([Music], [Applause], ♪) are dropped. Human subtitles wrap
// one sentence over two rows of a single cue, so `joinCue` merges a cue's
// rows into one line (auto captions must not be joined — their rows are the
// previous line plus the new one).
export function parseVtt(text, { joinCue = false } = {}) {
const out = [];
const recent = [];
const blocks = String(text || '').replace(/\r/g, '').split(/\n\s*\n/);
for (const block of blocks) {
const rows = block.split('\n');
const idx = rows.findIndex((r) => r.includes('-->'));
if (idx < 0) continue;
const t = vttTime(rows[idx].split('-->')[0]);
if (t === null) continue;
const cueRows = rows.slice(idx + 1);
for (const raw of joinCue ? [cueRows.join(' ')] : cueRows) {
const line = decodeEntities(raw.replace(/<[^>]*>/g, '')).replace(/\s+/g, ' ').trim();
if (!line) continue;
const stripped = line.replace(/\[[^\]]*\]|\([^)]*\)|[♪♫]/g, '').trim();
if (!stripped) continue;
if (recent.includes(line)) continue;
recent.push(line);
if (recent.length > 3) recent.shift();
out.push({ t: Math.round(t * 100) / 100, text: line.slice(0, MAX_LINE_CHARS) });
if (out.length >= MAX_LINES) return out;
}
}
return out;
}
// Choose the best caption track from yt-dlp's info JSON: human-made subtitles
// first (original language, then English, then any), else the auto captions
// in the video's own spoken language — never a machine translation.
export function pickCaptionTrack(info) {
const lang0 = String(info?.language || '').toLowerCase();
const vtt = (fmts) => (Array.isArray(fmts) ? fmts.find((f) => f && f.ext === 'vtt' && f.url) : null);
const rank = (k) => {
const l = k.toLowerCase();
if (lang0 && (l === lang0 || l.startsWith(lang0 + '-'))) return 0;
if (l === 'en' || l.startsWith('en-')) return 1;
return 2;
};
const subs = info?.subtitles || {};
for (const k of Object.keys(subs).filter((k) => k !== 'live_chat').sort((a, b) => rank(a) - rank(b))) {
const f = vtt(subs[k]);
if (f) return { lang: k, auto: false, url: f.url };
}
const autos = info?.automatic_captions || {};
const keys = Object.keys(autos);
const orig = keys.find((k) => k.endsWith('-orig'))
|| (lang0 && keys.find((k) => k.toLowerCase() === lang0))
|| null;
if (orig) {
const f = vtt(autos[orig]);
if (f) return { lang: orig.replace(/-orig$/, ''), auto: true, url: f.url };
}
return null;
}
// ---- Lyrics from the web (LRCLIB) ---------------------------------------------
// LRCLIB (lrclib.net) is a free, key-less, crowd-sourced lyrics database made
// for music players; it often has SYNCED lyrics, which is what this app wants.
// Titles from YouTube carry a lot of noise ("(Official Video)", "| Lyrics",
// "Karaoke | Minus-One"), so they are cleaned before the lookup.
const NOISE = /\b(official|video|audio|lyrics?|lyric|hd|hq|4k|live|mv|karaoke|minus[\s-]?one|instrumental|cover|remaster(ed)?|visualizer|performance|version)\b/gi;
// NOISE is global (used with .replace), so testing needs its own stateless
// copy — a /g regex remembers lastIndex between .test() calls.
const hasNoise = (s) => new RegExp(NOISE.source, 'i').test(s);
export function cleanTitle(raw) {
let t = String(raw || '');
t = t.split('|')[0]; // "Song | Channel extras"
t = t.replace(/\([^)]*\)|\[[^\]]*\]/g, (m) => (hasNoise(m) ? ' ' : m)); // drop noisy brackets only
t = t.replace(NOISE, ' ');
t = t.replace(/\(\s*\)|\[\s*\]/g, ' ').replace(/[-–—]\s*$/, ''); // leftovers like "[ ]"
return t.replace(/\s{2,}/g, ' ').replace(/^[\s\-–—,]+|[\s\-–—,]+$/g, '').trim();
}
export function cleanArtist(raw) {
return String(raw || '').replace(/\s*-\s*Topic$/i, '').replace(/VEVO$/i, '').replace(NOISE, ' ').replace(/\s{2,}/g, ' ').trim();
}
// "[mm:ss.xx] words" → timed lines; plain text → untimed lines.
export function parseLrc(text) {
const out = [];
for (const raw of String(text || '').replace(/\r/g, '').split('\n')) {
let rest = raw.trim();
if (!rest) continue;
if (/^\[[a-z]+:[^\]]*\]$/i.test(rest)) continue; // [ar:…] [length:…] headers
const stamps = [];
let m;
while ((m = rest.match(/^\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/))) {
stamps.push(Number(m[1]) * 60 + Number(m[2]) + (m[3] ? Number('0.' + m[3]) : 0));
rest = rest.slice(m[0].length).trim();
}
if (!rest) continue;
if (!stamps.length) out.push({ t: null, text: rest, kind: 'line' });
for (const t of stamps) out.push({ t: Math.round(t * 100) / 100, text: rest, kind: 'line' });
}
if (out.length && out.every((l) => l.t !== null)) out.sort((a, b) => a.t - b.t);
return out;
}
const norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9 ]+/g, ' ').replace(/\s+/g, ' ').trim();
// Pick the entry whose title matches and whose duration is closest (± 6 s).
export function pickLrclib(list, { title, duration }) {
const want = norm(title);
const scored = (Array.isArray(list) ? list : [])
.filter((x) => x && (x.syncedLyrics || x.plainLyrics) && !x.instrumental)
.map((x) => {
const t = norm(x.trackName);
const dd = duration && x.duration ? Math.abs(x.duration - duration) : 99;
const titleHit = t === want ? 2 : t.includes(want) || want.includes(t) ? 1 : 0;
return { x, score: titleHit * 10 + (x.syncedLyrics ? 3 : 0) - Math.min(9, dd), dd, titleHit };
})
.filter((c) => c.titleHit > 0 && (!duration || c.dd <= 6))
.sort((a, b) => b.score - a.score);
return scored.length ? scored[0].x : null;
}
async function lrclibLookup({ title, artist, album, duration }) {
const get = async (url) => {
const res = await fetch(url, { headers: { 'User-Agent': 'ytplayer (https://worship.hesed.sbs)' } });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`LRCLIB HTTP ${res.status}`);
return res.json();
};
const q = new URLSearchParams({ track_name: title, artist_name: artist || '' });
if (album) q.set('album_name', album);
if (duration) q.set('duration', String(Math.round(duration)));
let hit = null;
try { hit = await get(`https://lrclib.net/api/get?${q}`); } catch { /* fall through to search */ }
if (!hit) {
const list = await get(`https://lrclib.net/api/search?q=${encodeURIComponent(`${title} ${artist || ''}`.trim())}`);
hit = pickLrclib(list, { title, duration });
}
if (!hit) return null;
const synced = hit.syncedLyrics && parseLrc(hit.syncedLyrics);
if (synced && synced.length) return { lines: synced, synced: true, meta: hit };
const plain = hit.plainLyrics && parseLrc(hit.plainLyrics);
return plain && plain.length ? { lines: plain, synced: false, meta: hit } : null;
}
// ---- Tokens / admin cookie ------------------------------------------------------
export function hashToken(token) {
return createHash('sha256').update(String(token)).digest('hex');
}
export function newApiToken() {
return 'ytp_' + randomBytes(24).toString('base64url');
}
function safeEqual(a, b) {
const x = Buffer.from(String(a)), y = Buffer.from(String(b));
return x.length === y.length && timingSafeEqual(x, y);
}
export function signAdminCookie(secret, expSec) {
const mac = createHmac('sha256', secret).update(String(expSec)).digest('base64url');
return `${expSec}.${mac}`;
}
export function verifyAdminCookie(secret, value, nowSec = Math.floor(Date.now() / 1000)) {
if (!secret || !value) return false;
const [exp, mac] = String(value).split('.');
if (!exp || !mac || !/^\d+$/.test(exp) || Number(exp) < nowSec) return false;
const want = createHmac('sha256', secret).update(exp).digest('base64url');
return safeEqual(mac, want);
}
// ---- Routes ---------------------------------------------------------------------
export function registerNoteRoutes(app, deps) {
const {
db, getProfile, profileNameRe, runYtdlp, adminPassword, backupDir, adminHtmlPath, workerToken,
} = deps;
const ADMIN_COOKIE = 'ytp_admin';
const ADMIN_TTL = 30 * 24 * 3600;
const cookieSecret = adminPassword
? createHash('sha256').update('ytp-admin-cookie:' + adminPassword).digest()
: null;
const isAdmin = (c) => !!cookieSecret && verifyAdminCookie(cookieSecret, getCookie(c, ADMIN_COOKIE));
// Bearer token → { by, via: 'api' }; admin cookie → { via: 'admin' };
// an existing profile named in the body → { via: 'user' }; else null.
async function resolveWriter(c, body) {
const m = (c.req.header('authorization') || '').match(/^Bearer\s+(\S+)$/i);
if (m) {
// The lyrics-worker container's shared token (env, never stored in the DB).
if (workerToken && workerToken.length >= 24 && safeEqual(m[1], workerToken)) return { via: 'api', by: 'api:lyrics-worker' };
const tok = await db.useApiToken(hashToken(m[1]));
return tok ? { via: 'api', by: `api:${tok.label}` } : { invalid: true };
}
if (isAdmin(c)) return { via: 'admin', by: 'admin' };
const name = String(body?.profile || '').trim();
if (name && profileNameRe.test(name) && await getProfile(name)) return { via: 'user', by: name };
return null;
}
// Per-author write budget: plenty for a person syncing lines one tap at a
// time (saves are whole documents), little for a runaway script.
const writeLog = new Map();
function overBudget(key) {
const now = Date.now();
const list = (writeLog.get(key) || []).filter((t) => now - t < 10 * 60_000);
list.push(now);
writeLog.set(key, list);
if (writeLog.size > 5000) writeLog.clear();
return list.length > 120;
}
const badId = (id) => !VIDEO_ID_RE.test(id || '');
app.get('/api/notes/:id', async (c) => {
const id = c.req.param('id');
if (badId(id)) return c.json({ ok: false, error: 'invalid video id' }, 400);
try {
const notes = await db.getNotes(id);
const pick = (n) => (n ? { data: n.data, rev: n.rev, source: n.source, updatedBy: n.updatedBy, updatedAt: n.updatedAt } : null);
return c.json({ ok: true, lyrics: pick(notes.lyrics), chapters: pick(notes.chapters) });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// Captions preview. Results are cached per video so a room full of people
// opening the same song share one yt-dlp run.
const captionCache = new Map();
const captionInflight = new Map();
async function fetchCaptionLines(id) {
const hit = captionCache.get(id);
if (hit && Date.now() - hit.at < 6 * 3600_000) return hit.value;
if (captionInflight.has(id)) return captionInflight.get(id);
const p = (async () => {
const out = await runYtdlp([
`https://www.youtube.com/watch?v=${id}`, '-J', '--skip-download', '--no-warnings',
]);
const info = JSON.parse(out);
const track = pickCaptionTrack(info);
if (!track) return { lines: [], lang: null, auto: false };
const res = await fetch(track.url);
if (!res.ok) throw new Error(`caption download failed (HTTP ${res.status})`);
const lines = parseVtt(await res.text(), { joinCue: !track.auto });
return { lines, lang: track.lang, auto: track.auto };
})();
captionInflight.set(id, p);
try {
const value = await p;
captionCache.set(id, { at: Date.now(), value });
if (captionCache.size > 500) captionCache.delete(captionCache.keys().next().value);
return value;
} finally {
captionInflight.delete(id);
}
}
app.get('/api/notes/:id/captions', async (c) => {
const id = c.req.param('id');
if (badId(id)) return c.json({ ok: false, error: 'invalid video id' }, 400);
try {
const r = await fetchCaptionLines(id);
if (!r.lines.length) return c.json({ ok: false, error: 'this video has no usable captions' }, 404);
return c.json({ ok: true, lang: r.lang, auto: r.auto, lines: r.lines });
} catch (err) {
return c.json({ ok: false, error: err.message }, 502);
}
});
// ---- 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 = cleanLyricText(body.text);
if (!text) return c.json({ ok: false, error: 'which line? (text is missing)' }, 400);
try {
const lyr = (await db.getNotes(id)).lyrics;
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.
app.post('/api/notes/:id/lyrics/auto', 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 { /* empty body is fine */ }
const who = await resolveWriter(c, {});
if (!who || who.invalid || who.via === 'user') return c.json({ ok: false, error: 'API token or admin login required' }, 401);
try {
const existing = (await db.getNotes(id)).lyrics;
if (existing && existing.data.lines.length && !body.overwrite) {
return c.json({ ok: false, error: 'lyrics already exist — pass {"overwrite":true} to replace them', rev: existing.rev }, 409);
}
const r = await fetchCaptionLines(id);
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 });
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);
}
});
// POST /api/notes/:id/lyrics/web — look the song up on LRCLIB (timed lyrics
// when they exist) and save them. Never overwrites existing lyrics unless
// asked. Token/admin only; title & artist come from the cached media info
// unless the caller passes them.
app.post('/api/notes/:id/lyrics/web', 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 { /* optional */ }
const who = await resolveWriter(c, body);
if (!who || who.invalid) return c.json({ ok: false, error: 'link an online profile (or use an API token) to fetch lyrics' }, 401);
try {
const existing = (await db.getNotes(id)).lyrics;
if (existing && existing.data.lines.length && !body.overwrite) {
return c.json({ ok: false, error: 'lyrics already exist — pass {"overwrite":true} to replace them', rev: existing.rev }, 409);
}
let meta = {};
let row = null;
if (id.startsWith('upl_')) {
const u = await db.getUpload(id);
if (u) meta = { title: u.title, channel: u.artist, album: u.album, duration: u.duration };
} else {
try { meta = JSON.parse((await db.getMedia(id) || {}).meta || '{}'); } catch { /* none */ }
row = await db.getMedia(id);
}
const title = cleanTitle(body.title || meta.title || '');
const artist = cleanArtist(body.artist || meta.channel || meta.uploader || '');
const duration = Number(body.duration || (row && row.duration) || meta.duration || 0) || 0;
if (!title) return c.json({ ok: false, error: 'no title known for this video — pass {"title":"…","artist":"…"}' }, 400);
const hit = await lrclibLookup({ title, artist, album: body.album || meta.album, duration });
if (!hit) return c.json({ ok: false, error: `no lyrics on LRCLIB for “${title}”${artist ? ` by ${artist}` : ''}` }, 404);
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: who.by, force: true });
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);
}
});
app.put('/api/notes/:id/:kind', async (c) => {
const id = c.req.param('id');
const kind = c.req.param('kind');
if (badId(id)) return c.json({ ok: false, error: 'invalid video id' }, 400);
if (!NOTE_KINDS.has(kind)) return c.json({ ok: false, error: 'unknown kind' }, 404);
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 edit shared lyrics and chapters' }, 401);
if (who.invalid) return c.json({ ok: false, error: 'invalid API token' }, 401);
if (overBudget(who.by)) return c.json({ ok: false, error: 'too many saves — wait a few minutes' }, 429);
let data;
try { data = sanitizeNote(kind, body.data); } catch (err) { return c.json({ ok: false, error: err.message }, 400); }
try {
const r = await db.saveNote({
videoId: id, kind, data, baseRev: body.baseRev,
source: who.via === 'api' ? 'api' : 'user', updatedBy: who.by,
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);
}
});
app.get('/api/notes/:id/:kind/revs', async (c) => {
const id = c.req.param('id');
const kind = c.req.param('kind');
if (badId(id) || !NOTE_KINDS.has(kind)) return c.json({ ok: false, error: 'not found' }, 404);
try { return c.json({ ok: true, revs: await db.listNoteRevs(id, kind) }); }
catch (err) { return c.json({ ok: false, error: err.message }, 500); }
});
app.get('/api/notes/:id/:kind/revs/:rev', async (c) => {
const id = c.req.param('id');
const kind = c.req.param('kind');
const rev = Number(c.req.param('rev'));
if (badId(id) || !NOTE_KINDS.has(kind) || !Number.isInteger(rev)) return c.json({ ok: false, error: 'not found' }, 404);
try {
const r = await db.getNoteRev(id, kind, rev);
return r ? c.json({ ok: true, ...r }) : c.json({ ok: false, error: 'not found' }, 404);
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
}
});
// ---- Admin ----------------------------------------------------------------
const failedLogins = new Map(); // ip → [timestamps]
const clientIp = (c) => (c.req.header('x-forwarded-for') || '').split(',')[0].trim() || 'local';
const requireAdmin = async (c, next) => {
if (!cookieSecret) return c.json({ ok: false, error: 'admin is disabled — set ADMIN_PASSWORD on the server' }, 503);
if (!isAdmin(c)) return c.json({ ok: false, error: 'admin login required' }, 401);
await next();
};
app.post('/api/admin/login', async (c) => {
if (!cookieSecret) return c.json({ ok: false, error: 'admin is disabled — set ADMIN_PASSWORD on the server' }, 503);
const ip = clientIp(c);
const now = Date.now();
const fails = (failedLogins.get(ip) || []).filter((t) => now - t < 15 * 60_000);
if (fails.length >= 10) return c.json({ ok: false, error: 'too many attempts — try again later' }, 429);
let body = {};
try { body = await c.req.json(); } catch { /* treated as empty password */ }
const given = createHash('sha256').update(String(body.password || '')).digest();
const want = createHash('sha256').update(adminPassword).digest();
if (!timingSafeEqual(given, want)) {
fails.push(now);
failedLogins.set(ip, fails);
await new Promise((r) => setTimeout(r, 600));
return c.json({ ok: false, error: 'wrong password' }, 401);
}
failedLogins.delete(ip);
const exp = Math.floor(now / 1000) + ADMIN_TTL;
const https = (c.req.header('x-forwarded-proto') || new URL(c.req.url).protocol.replace(':', '')) === 'https';
setCookie(c, ADMIN_COOKIE, signAdminCookie(cookieSecret, exp), {
httpOnly: true, secure: https, sameSite: 'Strict', path: '/', maxAge: ADMIN_TTL,
});
return c.json({ ok: true });
});
app.post('/api/admin/logout', (c) => {
deleteCookie(c, ADMIN_COOKIE, { path: '/' });
return c.json({ ok: true });
});
app.get('/api/admin/me', (c) => c.json({ ok: true, enabled: !!cookieSecret, admin: isAdmin(c) }));
app.get('/api/admin/tokens', requireAdmin, async (c) => c.json({ ok: true, tokens: await db.listApiTokens() }));
app.post('/api/admin/tokens', requireAdmin, async (c) => {
let body = {};
try { body = await c.req.json(); } catch { /* label required below */ }
const label = cleanText(body.label, 60);
if (!label) return c.json({ ok: false, error: 'give the token a label' }, 400);
const token = newApiToken();
const id = randomBytes(6).toString('hex');
await db.createApiToken({ id, label, tokenHash: hashToken(token) });
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'));
return ok ? c.json({ ok: true }) : c.json({ ok: false, error: 'not found' }, 404);
});
// Admin cookie OR an API token — for scripts (e.g. scripts/lyrics).
const requireAdminOrToken = async (c, next) => {
const who = await resolveWriter(c, {});
if (!who || who.invalid || who.via === 'user') return c.json({ ok: false, error: 'API token or admin login required' }, 401);
await next();
};
if (deps.analytics) registerAnalyticsRoutes(app, { adminAuth: requireAdminOrToken, runYtdlp, ...deps.analytics });
registerTranscriptionRoutes(app, {
db, adminAuth: requireAdminOrToken, sanitizeLyrics,
workerEnabled: !!workerToken && workerToken.length >= 24,
workerAuth: async (c, next) => {
const token = (c.req.header('authorization') || '').match(/^Bearer\s+(\S+)$/i)?.[1];
if (!workerToken || workerToken.length < 24 || !token || !safeEqual(token, workerToken)) return c.json({ ok: false, error: 'lyrics worker token required' }, 401);
await next();
},
});
// Saved (server-cached) videos with whether each already has lyrics — the
// work list for batch lyric injection.
app.get('/api/admin/media', requireAdminOrToken, async (c) => {
const rows = (await db.listMedia()).filter((r) => r.status === 'ready');
const out = [];
for (const r of rows) {
let meta = {};
try { meta = JSON.parse(r.meta || '{}'); } catch { /* corrupt meta */ }
const n = await db.getNotes(r.video_id);
out.push({
id: r.video_id, title: meta.title || '', channel: meta.channel || meta.uploader || '',
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,
});
}
out.sort((a, b) => b.lastAccess - a.lastAccess);
return c.json({ ok: true, media: out });
});
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) => {
const id = c.req.param('id');
const kind = c.req.param('kind');
if (badId(id) || !NOTE_KINDS.has(kind)) return c.json({ ok: false, error: 'not found' }, 404);
let body = {};
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: 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"`);
return c.json({ exportedAt: new Date().toISOString(), notes });
});
let adminHtml = null;
app.get('/admin', (c) => {
try { adminHtml = adminHtml || readFileSync(adminHtmlPath, 'utf8'); }
catch { return c.text('admin page not found', 404); }
return c.html(adminHtml, 200, { 'Cache-Control': 'no-store' });
});
// ---- Daily backup ------------------------------------------------------------
// A JSON dump of every live note beside the DB, 30 days kept. The revision
// table already holds history; this is the copy that survives a bad DB file.
async function writeBackup() {
try {
mkdirSync(backupDir, { recursive: true });
const notes = await db.allNotes();
const day = new Date().toISOString().slice(0, 10);
writeFileSync(join(backupDir, `notes-${day}.json`), JSON.stringify({ exportedAt: new Date().toISOString(), notes }));
const files = readdirSync(backupDir).filter((f) => /^notes-\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort();
for (const f of files.slice(0, Math.max(0, files.length - 30))) unlinkSync(join(backupDir, f));
} catch (err) {
console.warn('[notes] backup failed:', err.message);
}
}
return {
requireAdminOrToken,
startBackups() {
setTimeout(writeBackup, 60_000);
setInterval(writeBackup, 24 * 3600_000).unref?.();
},
};
}