802 lines
38 KiB
JavaScript
802 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';
|
|
const line = { t: cleanTime(l.t), text, kind };
|
|
if (kind === 'line') { const secondary = cleanLyricText(l.secondary), phonetic = cleanLyricText(l.phonetic); if (secondary) line.secondary = secondary; if (phonetic) line.phonetic = phonetic; }
|
|
lines.push(line);
|
|
}
|
|
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;
|
|
}
|
|
|
|
// ---- 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();
|
|
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, cacheStatus: r.status, 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?.();
|
|
},
|
|
};
|
|
}
|