/* ============================================================================ * 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/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'; export const NOTE_KINDS = new Set(['lyrics', 'chapters']); const VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/; 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 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 = cleanText(l.text, MAX_LINE_CHARS); 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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/g, "'").replace(/ /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; } // ---- 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); } }); // 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); } }); 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); 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(); }; // 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 r = await db.saveNote({ videoId: id, kind, data: sanitizeNote(kind, old.data), source: 'restore', updatedBy: `admin (rev ${old.rev})`, force: true, }); return c.json({ ok: true, rev: r.rev }); }); 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 { startBackups() { setTimeout(writeBackup, 60_000); setInterval(writeBackup, 24 * 3600_000).unref?.(); }, }; }