From 71fc51fb504d8fcb66faab60d73cd749475f0f03 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 19 Sep 2026 08:33:00 +0800 Subject: [PATCH] Add a lyrics-only service mode view with the sung line centred and auto-sized, and a free local faster-whisper script to transcribe saved songs --- CLAUDE.md | 17 +++ frontend/app.js | 159 +++++++++++++++++++++++++++- frontend/index.html | 8 ++ frontend/styles.css | 63 +++++++++++ scripts/lyrics/auto_lyrics.py | 180 ++++++++++++++++++++++++++++++++ scripts/lyrics/requirements.txt | 1 + server/notes.js | 27 +++++ server/notes.test.js | 4 + 8 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 scripts/lyrics/auto_lyrics.py create mode 100644 scripts/lyrics/requirements.txt diff --git a/CLAUDE.md b/CLAUDE.md index 2249fbd..981c307 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -91,6 +91,23 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone edit feed with view/restore, caption injection, JSON export, API examples. Disabled unless `ADMIN_PASSWORD` is set (Dokploy Environment tab; compose passes `${ADMIN_PASSWORD:-}`). Login is rate-limited per IP. +- **Service mode → 🎵 Lyrics** (`data.settings.serviceLyrics`, `serviceLyricsAutoscroll`): + lyrics-only view; the sung line is centred, highlighted and sized to the + largest font that fits ONE row. Sizing is measured with a hidden real + `.sl-line` in TWO passes — Bricolage Grotesque has an optical-size axis, so + glyphs get relatively wider at small sizes and a single 100px measurement + over-fits on phones. Re-fit (in place, never rebuilding rows — that would + move the scroll position) on resize, `document.fonts` load, and +900 ms. + Lyrics-only and the 📺 video panel are mutually exclusive. +- **Free batch transcription**: `scripts/lyrics/auto_lyrics.py` (faster-whisper + `large-v3-turbo` int8 on CPU, no API key/credits; venv at + `~/.local/share/lyrics-asr/.venv`). `--missing` uses `GET /api/admin/media` + (admin or token). Measured on the 16-core laptop: ~0.3–0.65× real time; + openai-whisper medium was ~2.5×. **`vad_filter` must stay False** — VAD calls + sung music non-speech and returns an empty transcript. Karaoke/minus-one + tracks have no vocals (reported "instrumental?"); their lyrics were read from + the on-screen text (frame sampling + OCR), not audio. ElevenLabs Scribe also + works but spends the TTS credit balance (free tier = 10k/month). - Offline: the last-seen notes per video are cached in localStorage (`ytpNotesCache`, 200 videos), so saved songs keep their lyrics offline. - **Toast cap bug (fixed with this feature):** `toast()` used diff --git a/frontend/app.js b/frontend/app.js index 6a7446d..c41ac6b 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -2283,7 +2283,7 @@ function updateNowPlayingActions() { const Notes = (() => { const LC = window.LyricsCore; const panel = $('notesPanel'); - const noop = { load() {}, tick() {}, addBookmark() {}, handleSyncKey() { return false; } }; + const noop = { load() {}, tick() {}, addBookmark() {}, handleSyncKey() { return false; }, serviceRefresh() {} }; if (!panel || !LC) return noop; const CACHE_KEY = 'ytpNotesCache'; @@ -2343,8 +2343,10 @@ const Notes = (() => { endSync(); if (!supported(meta)) { st.videoId = null; + st.lyrics = null; panel.classList.add('hidden'); renderSeekMarkers(); + renderService(true); return; } const id = meta.id; @@ -2384,6 +2386,7 @@ const Notes = (() => { // ---- rendering ---- function renderAll() { renderLyrics(); + renderService(true); renderMarkers(); renderSeekMarkers(); renderOffset(); @@ -2496,6 +2499,7 @@ const Notes = (() => { } function tick(cur, force = false) { + tickService(cur, force); if (!st.videoId || panel.classList.contains('hidden')) return; const dur = (Player.master && Player.master.duration) || 0; if (dur && isFinite(dur) && Math.abs(dur - st.lastDur) > 0.5) renderSeekMarkers(); @@ -2910,6 +2914,153 @@ const Notes = (() => { ]); } + // ---- service mode: lyrics only ---- + // The sung line sits in the middle of the screen at the largest size that + // still fits on ONE row (measured per line with a canvas), highlighted; its + // neighbours shrink and fade with distance. Auto-scroll keeps it centred. + const sv = { key: '', idx: -2, timer: null }; + const serviceOn = () => isServiceModeOpen() && !!data.settings.serviceLyrics; + const serviceAuto = () => data.settings.serviceLyricsAutoscroll !== false; + const sungLines = () => lines().map((l, i) => ({ ...l, i })).filter((l) => l.kind === 'line'); + + // Measured with a hidden row styled exactly like .sl-line, so the size + // comes from the same font resolution the screen uses (a canvas can + // measure with a fallback face while the web font's 800 weight loads). + function fitSizes(list, texts) { + const width = list.clientWidth * 0.92 * 0.97; // 4% side padding + slack + const maxPx = Math.max(30, Math.min(list.clientHeight * 0.17, 160)); + const minPx = 18; + // A real .sl-line (same font rules) forced onto one unpadded row. + const probe = document.createElement('div'); + probe.className = 'sl-line'; + probe.style.cssText = 'position:absolute;left:0;top:0;visibility:hidden;white-space:nowrap;width:auto;padding:0;font-size:100px;transition:none'; + list.appendChild(probe); + const clamp = (px) => Math.max(minPx, Math.min(maxPx, px)); + const widthAt = (px) => { probe.style.fontSize = px + 'px'; return probe.getBoundingClientRect().width; }; + // Two passes: the display font has an optical-size axis, so glyphs get + // relatively WIDER as the size drops — a single measurement at 100px + // overestimates what fits at phone sizes. + const out = texts.map((text) => { + probe.textContent = text; + const w100 = widthAt(100); + if (!w100) return maxPx; + const est = clamp((width / w100) * 100); + const wEst = widthAt(est); + return Math.floor(clamp(wEst ? est * (width / wEst) : est)); + }); + probe.remove(); + return out; + } + + // Re-measure every row in place (no rebuild, so the scroll position and + // the highlight are untouched). + function applyFits() { + const list = $('serviceLyricsList'); + const rows = [...list.querySelectorAll('.sl-line')]; + if (!rows.length || !list.clientWidth) return; + const fits = fitSizes(list, rows.map((el) => el.textContent)); + rows.forEach((el, k) => el.style.setProperty('--fit', fits[k] + 'px')); + } + + function renderServiceButtons() { + const on = !!data.settings.serviceLyrics; + const lb = $('serviceLyricsBtn'); + lb.textContent = on ? '🎵 Lyrics on' : '🎵 Lyrics off'; + lb.classList.toggle('on', on); + lb.setAttribute('aria-pressed', String(on)); + const ab = $('serviceAutoscrollBtn'); + ab.classList.toggle('hidden', !on); + ab.textContent = serviceAuto() ? '⇅ Auto-scroll on' : '⇅ Auto-scroll off'; + ab.classList.toggle('on', serviceAuto()); + ab.setAttribute('aria-pressed', String(serviceAuto())); + } + + function renderService(force = false) { + renderServiceButtons(); + const on = serviceOn(); + $('serviceMode').classList.toggle('lyrics-on', on); + $('serviceLyrics').classList.toggle('hidden', !on); + if (!on) { sv.key = ''; return; } + const list = $('serviceLyricsList'); + const items = sungLines(); + const key = `${st.videoId}|${st.lyrics ? st.lyrics.rev : 0}|${items.length}|${list.clientWidth}x${list.clientHeight}`; + if (key === sv.key && !force) return; + sv.key = key; + sv.idx = -2; + // Half-height spacers let the first and last line reach the centre. + const pad = Math.round(list.clientHeight / 2); + list.style.paddingTop = pad + 'px'; + list.style.paddingBottom = pad + 'px'; + if (!items.length) { + const title = (current && current.meta && current.meta.title) || ''; + list.innerHTML = `
${st.videoId ? 'No lyrics for this song yet' : 'Nothing playing'}${title ? `${escapeHtml(title)}` : ''}
`; + return; + } + list.innerHTML = items.map((l) => + `
${escapeHtml(l.text)}
`).join(''); + applyFits(); + tickService(now(), true); + // One more fit once the view has settled (fonts, first layout). + clearTimeout(sv.refit); + sv.refit = setTimeout(() => { if (serviceOn()) applyFits(); }, 900); + } + + function centreOn(list, el) { + const go = () => list.scrollTo({ top: Math.max(0, el.offsetTop - list.clientHeight / 2 + el.offsetHeight / 2), behavior: 'smooth' }); + go(); + // Font sizes animate, so re-centre once the line has grown. + clearTimeout(sv.timer); + sv.timer = setTimeout(go, 380); + } + + function tickService(cur, force = false) { + if (!serviceOn()) return; + const list = $('serviceLyricsList'); + const idx = LC.activeIndex(lines(), cur, offset()); + if (idx === sv.idx && !force) return; + sv.idx = idx; + const rows = [...list.querySelectorAll('.sl-line')]; + const pos = rows.findIndex((el) => Number(el.dataset.i) === idx); + rows.forEach((el, k) => { + const d = pos < 0 ? 99 : Math.abs(k - pos); + el.classList.toggle('active', d === 0); + el.classList.toggle('near', d === 1); + el.classList.toggle('past', pos >= 0 && k < pos); + }); + if (serviceAuto() && rows.length) centreOn(list, pos >= 0 ? rows[pos] : rows[0]); + } + + function serviceRefresh() { renderService(true); } + + $('serviceLyricsBtn').addEventListener('click', () => { + data.settings.serviceLyrics = !data.settings.serviceLyrics; + // Lyrics fill the screen; the inline video panel would squeeze them. + if (data.settings.serviceLyrics && data.settings.serviceVideo) { + data.settings.serviceVideo = false; + updateServiceVideoUI(); + } + persist(); + renderService(true); + }); + $('serviceAutoscrollBtn').addEventListener('click', () => { + data.settings.serviceLyricsAutoscroll = !serviceAuto(); + persist(); + renderServiceButtons(); + if (serviceAuto()) tickService(now(), true); + }); + $('serviceLyricsList').addEventListener('click', (e) => { + const row = e.target.closest('.sl-line'); + const l = row && lines()[Number(row.dataset.i)]; + if (l && l.t !== null && l.t !== undefined) Player.seek(Math.max(0, l.t + offset())); + }); + window.addEventListener('resize', () => { if (serviceOn()) renderService(); }); + // Sizes are measured with the web font; a measurement taken while it was + // still loading used the narrower fallback, so re-fit once fonts land. + if (document.fonts) { + document.fonts.ready.then(() => { if (serviceOn()) applyFits(); }); + document.fonts.addEventListener('loadingdone', () => { if (serviceOn()) applyFits(); }); + } + // ---- tabs + events ---- function showTab(tab) { st.tab = tab; @@ -2984,7 +3135,7 @@ const Notes = (() => { else if (btn.dataset.act === 'del') deleteMarker(kind, i); }); - return { load, tick, addBookmark, handleSyncKey }; + return { load, tick, addBookmark, handleSyncKey, serviceRefresh }; })(); // ============================================================================ @@ -4046,8 +4197,11 @@ function toggleServiceVideo() { return; } data.settings.serviceVideo = !data.settings.serviceVideo; + // Video and lyrics-only share the screen; turning one on turns the other off. + if (data.settings.serviceVideo && data.settings.serviceLyrics) data.settings.serviceLyrics = false; persist(); updateServiceVideoUI(); + Notes.serviceRefresh(); } function enterServiceMode(pl) { @@ -4072,6 +4226,7 @@ function enterServiceMode(pl) { document.body.classList.add('service-open'); updateServiceVideoUI(); updateServiceMode(); + Notes.serviceRefresh(); acquireWakeLock(); } diff --git a/frontend/index.html b/frontend/index.html index c89e362..6712cf0 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -319,6 +319,10 @@
Screen may sleep
+ + @@ -331,6 +335,10 @@
Last song
+ +
diff --git a/frontend/styles.css b/frontend/styles.css index d7be2ba..51f7dd2 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -3715,3 +3715,66 @@ body.remote-open { overflow: hidden; } .rv-add-item b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rv-add-item small { color: var(--text-dim); font-size: 11px; } .rv-add-item.added { border-color: #7ee0a8; } + +/* ============================================================================ + * Service mode — lyrics only (Notes.renderService in app.js) + * ========================================================================== */ +.service-mode.lyrics-on .service-meta, +.service-mode.lyrics-on .service-upnext { display: none; } +.service-mode.lyrics-on .service-center { + max-width: none; + padding: 8px 0; + min-height: 0; + justify-content: stretch; +} +.service-lyrics { + position: relative; + flex: 1 1 auto; + width: 100%; + min-height: 40vh; +} +.sl-list { + position: absolute; + inset: 0; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: none; + font-family: var(--display); + /* Soft fade at the top and bottom edges so lines drift in and out. */ + -webkit-mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent); + mask-image: linear-gradient(transparent, #000 14%, #000 86%, transparent); +} +.sl-list::-webkit-scrollbar { display: none; } +.sl-line { + box-sizing: border-box; + margin: 0 auto; + padding: 0.16em 4%; + width: 100%; + text-align: center; + font-weight: 800; + line-height: 1.15; + letter-spacing: -0.01em; + /* --fit = the size at which THIS line fills one row; others are fractions. */ + font-size: calc(var(--fit, 40px) * 0.48); + color: var(--text-2); + opacity: 0.42; + overflow-wrap: anywhere; + cursor: pointer; + border-radius: 18px; + transition: font-size 0.35s var(--ease), opacity 0.35s var(--ease), color 0.35s var(--ease), background-color 0.35s var(--ease); +} +.sl-line.near { font-size: calc(var(--fit, 40px) * 0.6); opacity: 0.7; } +.sl-line.past { opacity: 0.28; } +.sl-line.active { + font-size: var(--fit, 40px); + opacity: 1; + color: var(--text); + background-color: color-mix(in srgb, var(--accent) 18%, transparent); + text-shadow: 0 0 32px var(--accent-glow); +} +.sl-line.untimed { cursor: default; } +.sl-empty { + display: flex; flex-direction: column; gap: 10px; align-items: center; + font-size: clamp(22px, 4vw, 40px); font-weight: 700; color: var(--text-2); +} +.sl-empty small { font-family: var(--ui); font-size: 16px; font-weight: 500; color: var(--text-dim); } diff --git a/scripts/lyrics/auto_lyrics.py b/scripts/lyrics/auto_lyrics.py new file mode 100644 index 0000000..79e7659 --- /dev/null +++ b/scripts/lyrics/auto_lyrics.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +"""auto_lyrics.py — transcribe saved songs locally and inject them as shared lyrics. + +Free and offline: faster-whisper (CTranslate2, int8, CPU) runs on this machine — +no API key, no credits. Audio comes from the server's own cache +(/api/media/?a=1), results go to /api/notes//lyrics with baseRev, so a +song someone already has lyrics for is never overwritten unless --overwrite. + +Setup (once): + uv venv ~/.local/share/lyrics-asr/.venv --python 3.12 + ~/.local/share/lyrics-asr/.venv/bin/pip install -r scripts/lyrics/requirements.txt +Run: + YTP_TOKEN=ytp_… .venv/bin/python scripts/lyrics/auto_lyrics.py --missing + YTP_ADMIN_PASSWORD=… … --ids KcrXlpg0LKI,65vdbMHh4mo --dry-run +Measured on the 16-core WSL laptop: large-v3-turbo ≈ 0.65× real time +(6.5-min song in ~4 min); openai-whisper medium was ~2.5× real time. +Karaoke / minus-one tracks have no vocals; they are reported as "instrumental" +and skipped (their words are on screen — see the repo CLAUDE.md). +""" +import argparse +import json +import os +import re +import sys +import tempfile +import time +import urllib.error +import urllib.request +import http.cookiejar + +FILLER = re.compile(r"^(?:(?:oh|ooh|ohh|oh-oh|ah|ahh|hey|yeah|mm|mm-mm|mm-mm-mm|hmm|whoa|woah|la|na|uh|come on)[\s,.!?-]*)+$", re.I) +KEEP_CAP = {'I', "I'm", "I'll", "I've", "I'd", 'You', 'Your', "You're", 'Yours', 'Lord', 'God', 'Jesus', 'Christ', + 'He', 'His', 'Him', 'Thee', 'Thy', 'Thou', 'Father', 'Spirit', 'Holy', 'Savior', 'Saviour', 'King', 'Lamb', + 'Earth', 'Heaven', 'Panginoon', 'Diyos', 'Hesus', 'Ikaw', 'Iyo', 'Iyong'} + + +def segment(words, max_words=9, gap_break=1.0): + """Word timings -> sung lines. Whisper punctuates songs sparsely, so lines + break on sentence ends, pauses, capitalised line starts and a length cap; + 1-2 word fragments (a held note split a phrase) fold into the next line.""" + ws = [w for w in words if w['text'].strip()] + groups, cur = [], [] + for w in ws: + if cur: + gap = w['start'] - cur[-1]['end'] + prev = cur[-1]['text'].strip() + word = w['text'].strip() + n = len(cur) + cap_start = word[:1].isupper() and word.strip('",.!?') not in KEEP_CAP + if (re.search(r'[.!?]$', prev) or gap >= gap_break or n >= max_words + or (re.search(r'[,;:]$', prev) and n >= 4) or (cap_start and n >= 3) + or (word in ('You', 'I') and n >= 5)): + groups.append(cur) + cur = [] + cur.append(w) + if cur: + groups.append(cur) + folded, i = [], 0 + while i < len(groups): + g = groups[i] + if (len(g) <= 2 and i + 1 < len(groups) and not re.search(r'[.!?]$', g[-1]['text'].strip()) + and groups[i + 1][0]['start'] - g[-1]['end'] < 4): + groups[i + 1] = g + groups[i + 1] + else: + folded.append(g) + i += 1 + lines = [] + for g in folded: + text = re.sub(r'\s+([,.!?;:])', r'\1', ' '.join(w['text'].strip() for w in g)).strip().rstrip(',;:') + if re.sub(r'[\W_]+', '', text): + lines.append({'t': round(g[0]['start'], 2), 'end': g[-1]['end'], 'text': text[:300], 'n': len(g)}) + out = [] + for i, l in enumerate(lines): + nxt = lines[i + 1]['t'] if i + 1 < len(lines) else l['end'] + 99 + prv = lines[i - 1]['end'] if i else -99 + if l['n'] == 1 and nxt - l['end'] > 4 and l['t'] - prv > 4: + continue # isolated one-word line: intro/outro hallucination + if FILLER.match(l['text']): + continue + out.append({'t': l['t'], 'text': l['text'], 'kind': 'line'}) + return out + + +class Api: + def __init__(self, base, token=None, password=None): + self.base = base.rstrip('/') + self.token = token + self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) + if not token and password: + st, r = self.call('POST', '/api/admin/login', {'password': password}) + if st != 200: + sys.exit(f'admin login failed: {r}') + + def call(self, method, path, body=None, raw=False): + headers = {'Content-Type': 'application/json'} + if self.token: + headers['Authorization'] = f'Bearer {self.token}' + req = urllib.request.Request(self.base + path, method=method, headers=headers, + data=json.dumps(body).encode() if body is not None else None) + try: + with self.opener.open(req, timeout=600) as r: + data = r.read() + return r.status, data if raw else json.loads(data or b'{}') + except urllib.error.HTTPError as e: + data = e.read() + try: + return e.code, json.loads(data or b'{}') + except ValueError: + return e.code, {'error': data[:200].decode('utf-8', 'replace')} + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--base', default=os.environ.get('YTP_BASE', 'https://worship.hesed.sbs')) + pick = ap.add_mutually_exclusive_group(required=True) + pick.add_argument('--missing', action='store_true', help='every saved video without lyrics') + pick.add_argument('--ids', help='comma-separated video ids') + ap.add_argument('--overwrite', action='store_true', help='replace existing lyrics (kept in history)') + ap.add_argument('--model', default='large-v3-turbo') + ap.add_argument('--language', default=None, help='e.g. en, tl (default: auto-detect)') + ap.add_argument('--threads', type=int, default=os.cpu_count() or 4) + ap.add_argument('--dry-run', action='store_true', help='transcribe and print, do not upload') + ap.add_argument('--min-words', type=int, default=25, help='fewer words = treat as instrumental') + args = ap.parse_args() + + api = Api(args.base, os.environ.get('YTP_TOKEN'), os.environ.get('YTP_ADMIN_PASSWORD')) + if args.missing: + st, r = api.call('GET', '/api/admin/media') + if st != 200: + sys.exit(f'listing saved videos failed ({st}): {r.get("error")} — set YTP_TOKEN or YTP_ADMIN_PASSWORD') + todo = [m['id'] for m in r['media'] if args.overwrite or not m['lyricsLines']] + else: + todo = [x.strip() for x in args.ids.split(',') if x.strip()] + print(f'{len(todo)} video(s) to transcribe with {args.model}', flush=True) + if not todo: + return + + from faster_whisper import WhisperModel # imported late: listing works without it + model = WhisperModel(args.model, device='cpu', compute_type='int8', cpu_threads=args.threads) + summary = [] + for vid in todo: + st, cur = api.call('GET', f'/api/notes/{vid}') + live = (cur or {}).get('lyrics') if st == 200 else None + if live and live['data']['lines'] and not args.overwrite: + summary.append((vid, 'skip: has lyrics')) + continue + st, audio = api.call('GET', f'/api/media/{vid}?a=1', raw=True) + if st != 200: + summary.append((vid, f'no cached audio ({st})')) + continue + with tempfile.NamedTemporaryFile(suffix='.m4a') as f: + f.write(audio) + f.flush() + t0 = time.time() + # vad_filter must stay OFF: it classifies sung music as non-speech + # and silently drops the whole song. + segs, info = model.transcribe(f.name, language=args.language, word_timestamps=True, vad_filter=False, + beam_size=5, condition_on_previous_text=False) + words = [{'text': w.word.strip(), 'start': w.start, 'end': w.end} + for s in segs for w in (s.words or []) if w.word.strip()] + took = time.time() - t0 + if len(words) < args.min_words: + summary.append((vid, f'instrumental? only {len(words)} words — skipped')) + continue + lines = segment(words) + doc = {'lines': lines, 'tags': ['auto-transcribed (whisper)'], 'offset': 0} + head = ' / '.join(l['text'] for l in lines[:3]) + print(f'{vid}: {len(lines)} lines, lang={info.language}, {took:.0f}s for {info.duration:.0f}s audio | {head[:100]}', flush=True) + if args.dry_run: + print(json.dumps(doc, ensure_ascii=False)[:2000]) + summary.append((vid, f'dry-run {len(lines)} lines')) + continue + body = {'data': doc, 'baseRev': live['rev'] if live else 0} + st, r = api.call('PUT', f'/api/notes/{vid}/lyrics', body) + summary.append((vid, f'saved rev {r.get("rev")}' if st == 200 else f'upload failed {st}: {r.get("error")}')) + print('\n'.join(f'{v} {s}' for v, s in summary)) + + +if __name__ == '__main__': + main() diff --git a/scripts/lyrics/requirements.txt b/scripts/lyrics/requirements.txt new file mode 100644 index 0000000..38d3ea5 --- /dev/null +++ b/scripts/lyrics/requirements.txt @@ -0,0 +1 @@ +faster-whisper>=1.2 diff --git a/server/notes.js b/server/notes.js index 3cf173e..9966078 100644 --- a/server/notes.js +++ b/server/notes.js @@ -27,6 +27,7 @@ * 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 * ========================================================================== */ @@ -436,6 +437,32 @@ export function registerNoteRoutes(app, deps) { 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) => { diff --git a/server/notes.test.js b/server/notes.test.js index 3da29bd..520943c 100644 --- a/server/notes.test.js +++ b/server/notes.test.js @@ -191,6 +191,10 @@ describe('routes', () => { const recent = await (await app.request('/api/admin/notes/recent', { headers: { Cookie: cookie } })).json(); expect(recent.revs[0].rev).toBe(4); + // Media work list: token or admin only. + expect((await app.request('/api/admin/media')).status).toBe(401); + expect((await app.request('/api/admin/media', { headers: auth })).status).toBe(200); + // Revoked tokens stop working. await app.request(`/api/admin/tokens/${created.id}`, { method: 'DELETE', headers: { Cookie: cookie } }); res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: { lines: [] }, baseRev: 4 }, auth));