Add shared synced lyrics, chapters and personal bookmarks with revision history, API tokens and an admin page

This commit is contained in:
Jonathan Sykes
2026-09-18 18:10:10 +08:00
parent a20dd0a3a6
commit 524db986d2
14 changed files with 2493 additions and 11 deletions

View File

@@ -22,7 +22,7 @@ The PWA is what runs in production; `legacy/` holds the old native-only docs.
| `tests/` | Playwright e2e specs · unit tests live next to sources in `frontend/*.test.js` | | `tests/` | Playwright e2e specs · unit tests live next to sources in `frontend/*.test.js` |
| `legacy/` | Old native-shell docs — do not treat as current | | `legacy/` | Old native-shell docs — do not treat as current |
API endpoints: `GET /api/search|channel|streams|download/:id|version|user/data|profile/load`, API endpoints (notes/admin routes are listed in the header of `server/notes.js`): `GET /api/search|channel|streams|download/:id|version|user/data|profile/load`,
`GET /api/media/:id|media/:id/status|media/stats`, `POST /api/media/:id/redownload`, `GET /api/media/:id|media/:id/status|media/stats`, `POST /api/media/:id/redownload`,
`POST /api/user/sync|profile/create|profile/save`, `GET /sw.js` (BUILD_TAG-injected), `GET /*` static. `POST /api/user/sync|profile/create|profile/save`, `GET /sw.js` (BUILD_TAG-injected), `GET /*` static.
JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
@@ -68,6 +68,36 @@ JSON shapes mirror the Tauri Rust bridge exactly — don't change one side alone
- Ops: `curl https://worship.hesed.sbs/api/media/stats`; files under - Ops: `curl https://worship.hesed.sbs/api/media/stats`; files under
`/app/data/media` in the container. `/app/data/media` in the container.
## Lyrics · chapters · bookmarks (`server/notes.js`, `frontend/lyrics-core.js`)
- **Shared per video** (every user sees the same copy): `video_notes` holds the live
lyrics / chapters doc per `(video_id, kind)`; **every save** also lands in
`video_note_revs` as a full snapshot (history + undo). A daily JSON dump of all
live notes goes to `<DB dir>/backups/notes-YYYY-MM-DD.json` (30 kept).
- **Who writes**: a user linked to an online profile (body `profile` must exist —
same trust level as the profile API), a script with an API token
(`Authorization: Bearer ytp_…`, only a SHA-256 is stored), or the admin
(signed httpOnly cookie). Saves are optimistic: client sends `baseRev`, gets
409 + `current` if someone saved in between (UI asks "Keep mine / Load theirs").
- **Personal, not shared**: bookmarks (`data.bookmarks[videoId]`) and the per-song
sync offset (`data.lyricOffsets[videoId]`) — both ride the profile sync.
Line shows at `t + doc.offset + personal offset` (positive = later).
- Editor text format = LRC superset: `[1:23.45] line`, `# Section`, `! band cue`,
`@ Key G, 70 BPM` tags, multi-stamp LRC lines and `[offset:±ms]` accepted.
- Captions: `GET /api/notes/:id/captions` (preview) and token/admin-only
`POST /api/notes/:id/lyrics/auto` pick human subs first, else the auto track in
the spoken language (`*-orig`), never a machine translation. Auto captions of
**music** are mostly junk ("oh", "n to") — expect to hand-fix or paste lyrics.
- **Admin**: `/admin` (frontend/admin.html, never cached by the SW) — tokens,
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.
- 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
`while (children.length > 3)` with a deferred remove — the 4th toast spun
forever and Chrome killed the tab ("Target crashed" in Playwright, no JS
error). Never cap with a loop whose exit depends on a deferred removal.
## Local dev ## Local dev
```bash ```bash
@@ -192,7 +222,10 @@ Local DB file: `server/data/ytplayer.db` (gitignored). `BUILD_TAG` is computed f
## Testing ## Testing
- Unit: **`node --test frontend/*.test.js`** (sw, sw-update, async-guard, video-edit). - Unit: **`node --test frontend/*.test.js`** (sw, sw-update, async-guard, video-edit).
Server: **`cd server && bun test`** (media-cache: real ffmpeg fixtures + a temp libsql DB). Server: **`cd server && bun run test`** — runs each file in its own process (db.js is a
singleton, so two test files in one `bun test` run share one temp DB; bare `bun test`
also drags in the frontend node tests via the `public` symlink). media-cache needs a
60 s hook timeout for its ffmpeg fixtures.
Local e2e needs a CURRENT yt-dlp — a 2-month-old one 403s on every download. Local e2e needs a CURRENT yt-dlp — a 2-month-old one 403s on every download.
The directory form `node --test frontend/` fails on Node 22 with The directory form `node --test frontend/` fails on Node 22 with
`Cannot find module .../frontend` — it resolves the dir as a module, not a test `Cannot find module .../frontend` — it resolves the dir as a module, not a test

View File

@@ -9,6 +9,9 @@ services:
PORT: "3000" PORT: "3000"
DB_PATH: "/app/data/ytplayer.db" DB_PATH: "/app/data/ytplayer.db"
APP_VERSION: "1.0.0" APP_VERSION: "1.0.0"
# Unlocks /admin (API tokens, lyric/chapter history + restore). Set it in
# Dokploy's Environment tab — never commit it. Unset = admin disabled.
ADMIN_PASSWORD: "${ADMIN_PASSWORD:-}"
# Optional: override yt-dlp binary path if you mount a custom one # Optional: override yt-dlp binary path if you mount a custom one
# YTDLP_PATH: "/usr/local/bin/yt-dlp" # YTDLP_PATH: "/usr/local/bin/yt-dlp"
# Server media cache (server/media-cache.js) — defaults shown. # Server media cache (server/media-cache.js) — defaults shown.

320
frontend/admin.html Normal file
View File

@@ -0,0 +1,320 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="robots" content="noindex" />
<title>YT Player Admin</title>
<style>
:root {
--bg: #0a0a0c; --bg-1: #101013; --bg-2: #16161b; --bg-3: #1d1d24;
--line: #26262f; --text: #f5f3f0; --text-2: #b6b5bf; --text-dim: #76757f;
--accent: #ff4b32; --ok: #7ee0a8; --warn: #ffb454;
--mono: ui-monospace, "JetBrains Mono", monospace;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--bg: #f6f5f2; --bg-1: #ffffff; --bg-2: #f0efeb; --bg-3: #e7e5e0;
--line: #d9d6cf; --text: #17161a; --text-2: #45434d; --text-dim: #77757f;
--accent: #d4321d; --ok: #1f8a4c; --warn: #9a5b00;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, sans-serif; }
main { max-width: 980px; margin: 0 auto; padding: 24px 16px 60px; }
header { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
h1 { font-size: 20px; margin: 0; }
h1 span { color: var(--accent); }
h2 { font-size: 13px; font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-dim); margin: 0 0 12px; }
section { background: var(--bg-2); border: 1px solid var(--line); border-radius: 14px; padding: 16px; margin-bottom: 16px; }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
input[type=text], input[type=password] {
background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: 9px;
padding: 9px 11px; font-size: 14px; min-width: 0; flex: 1 1 220px;
}
button, .btn {
background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: 9px;
padding: 8px 12px; font-size: 13px; cursor: pointer; text-decoration: none; display: inline-block;
}
button:hover, .btn:hover { border-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.danger { color: var(--accent); }
.spacer { flex: 1; }
.muted { color: var(--text-dim); font-size: 12px; }
.msg { font-size: 13px; margin-top: 8px; min-height: 1em; }
.msg.err { color: var(--accent); } .msg.ok { color: var(--ok); }
.tbl { width: 100%; border-collapse: collapse; font-size: 13px; }
.tbl th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; padding: 6px; border-bottom: 1px solid var(--line); }
.tbl td { padding: 7px 6px; border-bottom: 1px solid var(--line); vertical-align: middle; }
.tbl code, code { font-family: var(--mono); font-size: 12px; }
.scroll { overflow-x: auto; }
.newtoken { margin-top: 10px; padding: 10px; border: 1px solid var(--ok); border-radius: 9px; background: var(--bg-1); word-break: break-all; }
pre { background: var(--bg-1); border: 1px solid var(--line); border-radius: 9px; padding: 10px; overflow-x: auto; font-family: var(--mono); font-size: 12px; white-space: pre-wrap; word-break: break-word; max-height: 50vh; }
.hidden { display: none !important; }
.src { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
.src.api, .src.auto { color: var(--warn); } .src.restore { color: var(--ok); }
</style>
</head>
<body>
<main>
<header>
<h1>YT Player <span>Admin</span></h1>
<span class="spacer"></span>
<a class="btn" href="/">← App</a>
<button id="logoutBtn" class="hidden">Log out</button>
</header>
<section id="loginBox" class="hidden">
<h2>Sign in</h2>
<form id="loginForm" class="row">
<input type="password" id="pw" placeholder="Admin password" autocomplete="current-password" />
<button class="primary" type="submit">Sign in</button>
</form>
<div id="loginMsg" class="msg"></div>
</section>
<section id="disabledBox" class="hidden">
<h2>Admin disabled</h2>
<p class="muted">Set <code>ADMIN_PASSWORD</code> in the server environment (Dokploy → Environment) and redeploy.</p>
</section>
<div id="app" class="hidden">
<section>
<h2>API tokens</h2>
<p class="muted">Tokens let scripts write shared lyrics and chapters (<code>Authorization: Bearer …</code>). The token is shown once — store it in your secrets vault.</p>
<form id="tokenForm" class="row">
<input type="text" id="tokenLabel" maxlength="60" placeholder="Label, e.g. lyrics-bot" />
<button class="primary" type="submit">Create token</button>
</form>
<div id="newToken" class="newtoken hidden"></div>
<div class="scroll"><table class="tbl" id="tokenTable"></table></div>
</section>
<section>
<h2>Inject lyrics from captions</h2>
<p class="muted">Reads the video's YouTube captions (human subtitles first, else auto captions in the spoken language) and saves them as the shared lyrics.</p>
<form id="injectForm" class="row">
<input type="text" id="injectId" placeholder="YouTube URL or video id" />
<label class="muted"><input type="checkbox" id="injectOverwrite" /> Replace existing lyrics</label>
<button class="primary" type="submit">Inject</button>
</form>
<div id="injectMsg" class="msg"></div>
</section>
<section>
<div class="row" style="margin-bottom:10px">
<h2 style="margin:0">Recent edits</h2>
<span class="spacer"></span>
<button id="refreshBtn">↻ Refresh</button>
<a class="btn" href="/api/admin/notes/export">⤓ Export all (JSON)</a>
</div>
<p class="muted">Every save is kept as a revision. Restoring writes the old version back as a new revision, so nothing is ever lost. A JSON backup of all notes is also written daily to <code>/app/data/backups</code>.</p>
<div class="scroll"><table class="tbl" id="revTable"></table></div>
<pre id="revView" class="hidden"></pre>
</section>
<section>
<h2>API reference</h2>
<pre id="apiDocs"></pre>
</section>
</div>
</main>
<script>
(function () {
'use strict';
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const when = (sec) => (sec ? new Date(sec * 1000).toLocaleString() : '—');
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: 'same-origin',
headers: opts.body ? { 'Content-Type': 'application/json' } : {},
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
const j = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
if (res.status === 401 && path !== '/api/admin/login') { showLogin(); }
return j;
}
function videoIdFrom(input) {
const s = String(input || '').trim();
if (/^[A-Za-z0-9_-]{11}$/.test(s)) return s;
const m = s.match(/(?:v=|youtu\.be\/|shorts\/|embed\/|live\/)([A-Za-z0-9_-]{11})/);
return m ? m[1] : null;
}
function showLogin() {
$('loginBox').classList.remove('hidden');
$('app').classList.add('hidden');
$('logoutBtn').classList.add('hidden');
}
async function boot() {
const me = await api('/api/admin/me');
if (!me.enabled) { $('disabledBox').classList.remove('hidden'); return; }
if (!me.admin) { showLogin(); return; }
$('loginBox').classList.add('hidden');
$('app').classList.remove('hidden');
$('logoutBtn').classList.remove('hidden');
renderDocs();
loadTokens();
loadRevs();
}
$('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
$('loginMsg').className = 'msg';
$('loginMsg').textContent = 'Signing in…';
const j = await api('/api/admin/login', { method: 'POST', body: { password: $('pw').value } });
if (!j.ok) { $('loginMsg').className = 'msg err'; $('loginMsg').textContent = j.error || 'failed'; return; }
$('pw').value = '';
$('loginMsg').textContent = '';
boot();
});
$('logoutBtn').addEventListener('click', async () => {
await api('/api/admin/logout', { method: 'POST' });
showLogin();
});
async function loadTokens() {
const j = await api('/api/admin/tokens');
const rows = (j.tokens || []).map((t) => `
<tr>
<td>${esc(t.label)}</td>
<td class="muted">${when(t.createdAt)}</td>
<td class="muted">${t.lastUsedAt ? when(t.lastUsedAt) : 'never'}</td>
<td><button class="danger" data-revoke="${esc(t.id)}">Revoke</button></td>
</tr>`).join('');
$('tokenTable').innerHTML = rows
? `<tr><th>Label</th><th>Created</th><th>Last used</th><th></th></tr>${rows}`
: '<tr><td class="muted">No tokens yet.</td></tr>';
}
$('tokenForm').addEventListener('submit', async (e) => {
e.preventDefault();
const label = $('tokenLabel').value.trim();
if (!label) return;
const j = await api('/api/admin/tokens', { method: 'POST', body: { label } });
const box = $('newToken');
box.classList.remove('hidden');
if (!j.ok) { box.textContent = j.error || 'failed'; return; }
box.innerHTML = `New token for <b>${esc(j.label)}</b> — copy it now, it won't be shown again:<br><code id="tokVal">${esc(j.token)}</code>
<div class="row" style="margin-top:8px"><button id="copyTok">Copy</button></div>`;
$('copyTok').onclick = () => navigator.clipboard.writeText(j.token).then(() => { $('copyTok').textContent = 'Copied ✓'; });
$('tokenLabel').value = '';
loadTokens();
});
$('tokenTable').addEventListener('click', async (e) => {
const id = e.target.dataset.revoke;
if (!id) return;
if (!confirm('Revoke this token? Scripts using it stop working immediately.')) return;
await api(`/api/admin/tokens/${encodeURIComponent(id)}`, { method: 'DELETE' });
loadTokens();
});
$('injectForm').addEventListener('submit', async (e) => {
e.preventDefault();
const msg = $('injectMsg');
const id = videoIdFrom($('injectId').value);
if (!id) { msg.className = 'msg err'; msg.textContent = 'Not a YouTube URL or id'; return; }
msg.className = 'msg';
msg.textContent = 'Fetching captions…';
const j = await api(`/api/notes/${id}/lyrics/auto`, { method: 'POST', body: { overwrite: $('injectOverwrite').checked } });
if (!j.ok) { msg.className = 'msg err'; msg.textContent = j.error || 'failed'; return; }
msg.className = 'msg ok';
msg.textContent = `Saved ${j.lines} lines (${j.auto ? 'auto captions' : 'subtitles'}, ${j.lang}) as revision ${j.rev}.`;
loadRevs();
});
async function loadRevs() {
const j = await api('/api/admin/notes/recent');
const rows = (j.revs || []).map((r) => `
<tr>
<td class="muted">${when(r.createdAt)}</td>
<td><a href="https://youtu.be/${esc(r.videoId)}" target="_blank" rel="noopener"><code>${esc(r.videoId)}</code></a></td>
<td>${esc(r.kind)}</td>
<td><code>v${r.rev}</code></td>
<td>${esc(r.updatedBy || '—')}</td>
<td><span class="src ${esc(r.source)}">${esc(r.source)}</span></td>
<td class="row">
<button data-view="${esc(r.videoId)}|${esc(r.kind)}|${r.rev}">View</button>
<button data-restore="${esc(r.videoId)}|${esc(r.kind)}|${r.rev}">Restore</button>
</td>
</tr>`).join('');
$('revTable').innerHTML = rows
? `<tr><th>When</th><th>Video</th><th>Kind</th><th>Rev</th><th>By</th><th>Source</th><th></th></tr>${rows}`
: '<tr><td class="muted">No edits yet.</td></tr>';
}
function asText(kind, d) {
if (kind === 'lyrics') {
const head = (d.tags || []).length ? `@ ${d.tags.join(', ')}\n` : '';
return head + (d.lines || []).map((l) => {
const t = l.t == null ? '' : `[${Math.floor(l.t / 60)}:${(l.t % 60).toFixed(2).padStart(5, '0')}] `;
return t + (l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : '') + l.text;
}).join('\n');
}
return (d.items || []).map((c) => `${Math.floor(c.t / 60)}:${String(Math.floor(c.t % 60)).padStart(2, '0')} ${c.title}${c.note ? ' — ' + c.note : ''}`).join('\n');
}
$('revTable').addEventListener('click', async (e) => {
const v = e.target.dataset.view;
const r = e.target.dataset.restore;
if (v) {
const [id, kind, rev] = v.split('|');
const j = await api(`/api/notes/${id}/${kind}/revs/${rev}`);
const pre = $('revView');
pre.classList.remove('hidden');
pre.textContent = j.ok ? `${id} · ${kind} · v${rev} by ${j.updatedBy || '—'}\n\n${asText(kind, j.data)}` : (j.error || 'failed');
pre.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} else if (r) {
const [id, kind, rev] = r.split('|');
if (!confirm(`Restore ${kind} of ${id} to v${rev}? It becomes a new revision.`)) return;
const j = await api(`/api/admin/notes/${id}/${kind}/restore`, { method: 'POST', body: { rev: Number(rev) } });
alert(j.ok ? `Restored as v${j.rev}` : (j.error || 'failed'));
loadRevs();
}
});
$('refreshBtn').addEventListener('click', loadRevs);
function renderDocs() {
const o = location.origin;
$('apiDocs').textContent = `# Read (public)
curl ${o}/api/notes/VIDEO_ID
curl ${o}/api/notes/VIDEO_ID/captions # YouTube captions as timed lines, no save
# Inject captions as the shared lyrics (token)
curl -X POST ${o}/api/notes/VIDEO_ID/lyrics/auto \\
-H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" \\
-d '{"overwrite": false}'
# Write lyrics (token). baseRev = the rev you read, or add "force": true to overwrite.
curl -X PUT ${o}/api/notes/VIDEO_ID/lyrics \\
-H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" \\
-d '{"baseRev": 0, "data": {"tags": ["Key G", "70 BPM"], "offset": 0, "lines": [
{"t": 18.0, "text": "Verse 1", "kind": "section"},
{"t": 18.0, "text": "First sung line", "kind": "line"},
{"t": null, "text": "An untimed line", "kind": "line"},
{"t": 140, "text": "Drums in", "kind": "cue"}]}}'
# Write chapters (token)
curl -X PUT ${o}/api/notes/VIDEO_ID/chapters \\
-H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" \\
-d '{"baseRev": 0, "data": {"items": [{"t": 42, "title": "Intro", "note": ""}]}}'
# History (public)
curl ${o}/api/notes/VIDEO_ID/lyrics/revs
curl ${o}/api/notes/VIDEO_ID/lyrics/revs/3
# 409 = someone saved in between: the response carries "current" (with its rev).`;
}
boot();
})();
</script>
</body>
</html>

View File

@@ -269,7 +269,7 @@ const DEFAULT_SETTINGS = {
autoBackupIntervalDays: 7, autoBackupIntervalDays: 7,
serviceVideo: false, // show the video panel inside service mode (lyric videos) serviceVideo: false, // show the video panel inside service mode (lyric videos)
}; };
let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null }; let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, bookmarks: {}, lyricOffsets: {}, lastAutoBackup: 0, profile: null };
let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel' let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
let searchResults = []; let searchResults = [];
// Distinguishes "never searched" (hide the list pane, show the landing) from // Distinguishes "never searched" (hide the list pane, show the landing) from
@@ -377,6 +377,8 @@ function profilePayload() {
rememberPos: data.rememberPos, rememberPos: data.rememberPos,
playCount: data.playCount, playCount: data.playCount,
abMarkers: data.abMarkers, abMarkers: data.abMarkers,
bookmarks: data.bookmarks || {},
lyricOffsets: data.lyricOffsets || {},
}; };
} }
@@ -416,6 +418,8 @@ function applyProfileData(name, payload, updatedAt) {
if (payload.rememberPos && typeof payload.rememberPos === 'object') data.rememberPos = payload.rememberPos; if (payload.rememberPos && typeof payload.rememberPos === 'object') data.rememberPos = payload.rememberPos;
if (payload.playCount && typeof payload.playCount === 'object') data.playCount = payload.playCount; if (payload.playCount && typeof payload.playCount === 'object') data.playCount = payload.playCount;
if (payload.abMarkers && typeof payload.abMarkers === 'object') data.abMarkers = payload.abMarkers; if (payload.abMarkers && typeof payload.abMarkers === 'object') data.abMarkers = payload.abMarkers;
if (payload.bookmarks && typeof payload.bookmarks === 'object') data.bookmarks = payload.bookmarks;
if (payload.lyricOffsets && typeof payload.lyricOffsets === 'object') data.lyricOffsets = payload.lyricOffsets;
if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings }; if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings };
data.profile = { name, syncedAt: updatedAt || 0 }; data.profile = { name, syncedAt: updatedAt || 0 };
API.saveData(data).catch(() => {}); API.saveData(data).catch(() => {});
@@ -966,11 +970,14 @@ function toast(msg, { duration = 2200 } = {}) {
setTimeout(() => t.remove(), 280); setTimeout(() => t.remove(), 280);
}, duration); }, duration);
// Cap at 3 toasts — remove oldest // Cap at 3 toasts — fade out the oldest. Removal waits for the exit
while (container.children.length > 3) { // animation, so only count toasts that aren't already leaving: the old
const first = container.firstChild; // `while (children.length > 3)` never removed anything synchronously and
first.classList.add('toast-exit'); // froze the page for good the moment a 4th toast appeared.
setTimeout(() => first.remove(), 280); const live = [...container.children].filter((c) => !c.classList.contains('toast-exit'));
for (const old of live.slice(0, Math.max(0, live.length - 3))) {
old.classList.add('toast-exit');
setTimeout(() => old.remove(), 280);
} }
return t; return t;
} }
@@ -1622,6 +1629,7 @@ const Player = {
// Shared UI updates once `current` is populated (cached or streamed). // Shared UI updates once `current` is populated (cached or streamed).
afterLoad() { afterLoad() {
addToHistory(current.meta); addToHistory(current.meta);
Notes.load(current.meta);
buildQualityMenu(); buildQualityMenu();
els.playerPane.classList.remove('empty'); els.playerPane.classList.remove('empty');
els.controls.classList.remove('hidden'); els.controls.classList.remove('hidden');
@@ -2262,6 +2270,721 @@ function updateNowPlayingActions() {
btn.title = 'Save this video for offline playback'; btn.title = 'Save this video for offline playback';
} }
} }
// ============================================================================
// Lyrics · chapters · bookmarks
// ============================================================================
// Lyrics and chapters are shared per video by every user (server/notes.js
// keeps each save as a revision). Bookmarks and the sync offset are personal
// and ride along with the profile sync (data.bookmarks / data.lyricOffsets).
// The last copy seen of each video's shared notes is kept in localStorage so
// saved songs still show their lyrics offline.
const Notes = (() => {
const LC = window.LyricsCore;
const panel = $('notesPanel');
const noop = { load() {}, tick() {}, addBookmark() {}, handleSyncKey() { return false; } };
if (!panel || !LC) return noop;
const CACHE_KEY = 'ytpNotesCache';
const CACHE_MAX = 200;
const st = {
videoId: null, lyrics: null, chapters: null, tab: 'lyrics',
activeIdx: -1, lastUserScroll: 0, sync: null, loadSeq: 0,
statusMsg: '', sectionText: '', lastDur: 0, chipKey: '', addLabel: '',
};
// ---- small helpers ----
const supported = (meta) => WEB && meta && !meta.custom && /^[A-Za-z0-9_-]{11}$/.test(meta.id || '');
const profileName = () => (data.profile && data.profile.name) || '';
const lines = () => (st.sync ? st.sync.lines : (st.lyrics && st.lyrics.data && st.lyrics.data.lines) || []);
const chapters = () => (st.chapters && st.chapters.data && st.chapters.data.items) || [];
const bookmarks = () => {
if (!st.videoId || !data.bookmarks) return [];
return Array.isArray(data.bookmarks[st.videoId]) ? data.bookmarks[st.videoId] : [];
};
const docOffset = () => (st.lyrics && Number(st.lyrics.data.offset)) || 0;
const personalOffset = () => (st.videoId && data.lyricOffsets && Number(data.lyricOffsets[st.videoId])) || 0;
const offset = () => docOffset() + personalOffset();
const now = () => (Player.master && Player.master.currentTime) || 0;
const round2 = (n) => Math.round(n * 100) / 100;
function readCache() {
try { return JSON.parse(localStorage.getItem(CACHE_KEY) || '{}') || {}; } catch { return {}; }
}
function writeCache(id, entry) {
try {
const c = readCache();
c[id] = { ...(c[id] || {}), ...entry, at: Date.now() };
const ids = Object.keys(c);
if (ids.length > CACHE_MAX) {
ids.sort((a, b) => (c[a].at || 0) - (c[b].at || 0))
.slice(0, ids.length - CACHE_MAX).forEach((k) => delete c[k]);
}
localStorage.setItem(CACHE_KEY, JSON.stringify(c));
} catch { /* storage full or blocked — lyrics just won't be offline */ }
}
function requireProfile() {
if (profileName()) return true;
toast('Link an online profile (Settings → Online profile) to edit shared lyrics and chapters.', { duration: 5000 });
return false;
}
function setStatus(msg) { st.statusMsg = msg || ''; renderStatus(); }
function renderStatus() {
const el = $('notesStatus');
if (el) el.textContent = st.statusMsg || st.sectionText || '';
}
// ---- loading ----
async function load(meta) {
if (st.sync && meta && meta.id === st.videoId) return; // a fallback reload mid-sync
endSync();
if (!supported(meta)) {
st.videoId = null;
panel.classList.add('hidden');
renderSeekMarkers();
return;
}
const id = meta.id;
const seq = ++st.loadSeq;
st.videoId = id;
st.activeIdx = -1;
st.lastDur = 0;
const cached = readCache()[id];
st.lyrics = cached ? cached.lyrics || null : null;
st.chapters = cached ? cached.chapters || null : null;
panel.classList.remove('hidden');
setStatus(cached ? '' : 'Loading…');
renderAll();
try {
const res = await fetch(`/api/notes/${encodeURIComponent(id)}`);
const j = await res.json();
if (seq !== st.loadSeq) return;
if (!j || !j.ok) throw new Error((j && j.error) || 'failed');
st.lyrics = j.lyrics;
st.chapters = j.chapters;
writeCache(id, { lyrics: j.lyrics, chapters: j.chapters });
setStatus('');
if (!st.sync) renderAll();
} catch {
if (seq !== st.loadSeq) return;
setStatus(cached ? 'Offline copy' : 'Offline');
}
}
function applyNote(id, kind, note) {
writeCache(id, { [kind]: note });
if (id !== st.videoId) return;
st[kind] = note;
renderAll();
}
// ---- rendering ----
function renderAll() {
renderLyrics();
renderMarkers();
renderSeekMarkers();
renderOffset();
}
function renderLyrics() {
const list = $('lyricsList');
const tagsEl = $('lyricsTags');
const ls = lines();
const tags = (st.lyrics && st.lyrics.data && st.lyrics.data.tags) || [];
tagsEl.innerHTML = tags.map((t) => `<span class="lyr-tag">${escapeHtml(t)}</span>`).join('');
tagsEl.classList.toggle('hidden', !tags.length);
$('lyricsSyncBtn').disabled = !ls.length || !!st.sync;
$('lyricsHistoryBtn').disabled = !st.lyrics;
$('lyricsEditBtn').disabled = !!st.sync;
st.activeIdx = -1;
if (!ls.length) {
list.innerHTML = `
<div class="notes-empty">
<div>No lyrics yet for this song.</div>
<div class="notes-empty-actions">
<button class="np-btn" type="button" data-act="paste">✎ Add lyrics</button>
<button class="np-btn" type="button" data-act="captions">⤓ Import YouTube captions</button>
</div>
<div class="notes-empty-hint">Lyrics and chapters are shared with everyone who plays this song.</div>
</div>`;
st.sectionText = '';
renderStatus();
return;
}
list.innerHTML = ls.map((l, i) => {
const time = l.t === null || l.t === undefined ? '' : LC.fmtClock(l.t);
if (l.kind === 'section') {
return `<div class="lyr-section" data-i="${i}">${escapeHtml(l.text)}${time ? `<span class="lyr-section-time"> · ${time}</span>` : ''}</div>`;
}
if (l.kind === 'cue') {
return `<div class="lyr-cue" data-i="${i}"><span>🥁 ${escapeHtml(l.text)}</span><span class="lyr-cue-in"></span></div>`;
}
return `<div class="lyr-line${time ? '' : ' untimed'}" data-i="${i}"><span class="lyr-time">${time}</span><span class="lyr-text">${escapeHtml(l.text)}</span></div>`;
}).join('');
if (st.sync) renderSyncPointer();
else tick(now(), true);
}
function renderOffset() {
const p = personalOffset();
$('lyrOffVal').textContent = `${p > 0 ? '+' : ''}${p.toFixed(1)}s`;
$('lyrOffVal').classList.toggle('nonzero', p !== 0);
const auto = data.settings.lyricsAutoscroll !== false;
$('lyrAutoscroll').checked = auto;
}
function markerItems() {
const out = [];
chapters().forEach((c, i) => out.push({ ...c, kind: 'chapter', i }));
bookmarks().forEach((b, i) => out.push({ ...b, kind: 'bookmark', i }));
return out.sort((a, b) => a.t - b.t);
}
function renderMarkers() {
const chs = chapters();
const bms = bookmarks();
const count = chs.length + bms.length;
$('markersCount').textContent = count ? String(count) : '';
$('chaptersHistoryBtn').disabled = !st.chapters;
const chips = markerItems();
$('markerChips').innerHTML = chips.map((m) => `
<button class="marker-chip ${m.kind}" type="button" data-t="${m.t}" title="${escapeHtml(m.note || m.title)}">
<span class="marker-chip-time">${LC.fmtClock(m.t)}</span> ${m.kind === 'bookmark' ? '📌 ' : ''}${escapeHtml(m.title)}
</button>`).join('');
$('markerChips').classList.toggle('hidden', !chips.length);
const row = (m, kind, i) => `
<div class="marker-row" data-kind="${kind}" data-i="${i}">
<button class="mk-time" type="button" data-act="jump" title="Jump here">${LC.fmtClock(m.t)}</button>
<div class="mk-body">
<div class="mk-title">${escapeHtml(m.title)}</div>
${m.note ? `<div class="mk-note">${escapeHtml(m.note)}</div>` : ''}
</div>
<button class="mk-btn" type="button" data-act="jump" title="Play from here">▶</button>
<button class="mk-btn" type="button" data-act="edit" title="Edit">✎</button>
<button class="mk-btn" type="button" data-act="del" title="Delete">🗑</button>
</div>`;
$('chapterList').innerHTML = chs.length
? chs.map((c, i) => row(c, 'chapter', i)).join('')
: '<div class="notes-empty small">No chapters yet — tap + Chapter while the song plays.</div>';
$('bookmarkList').innerHTML = bms.length
? bms.map((b, i) => row(b, 'bookmark', i)).join('')
: '<div class="notes-empty small">No bookmarks — tap 📌 or press P.</div>';
st.chipKey = '';
}
function renderSeekMarkers() {
const host = $('seekMarkers');
if (!host) return;
const dur = (Player.master && Player.master.duration) || (current && current.meta && current.meta.duration) || 0;
st.lastDur = dur;
if (!st.videoId || !dur || !isFinite(dur)) { host.innerHTML = ''; return; }
host.innerHTML = markerItems()
.filter((m) => m.t <= dur)
.map((m) => `<span class="seek-tick ${m.kind}" style="left:${((m.t / dur) * 100).toFixed(3)}%"></span>`)
.join('');
}
// ---- playback tick (timeupdate) ----
function scrollToRow(list, el) {
const top = el.offsetTop - list.clientHeight / 2 + el.clientHeight / 2;
list.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
function tick(cur, force = false) {
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();
// "+ Chapter at 2:41" follows the playhead.
const label = `+ Chapter at ${LC.fmtClock(cur)}`;
if (label !== st.addLabel) { st.addLabel = label; $('addChapterBtn').textContent = label; }
// Active chip.
const active = LC.activeMarker(markerItems(), cur);
const key = active ? `${active.kind}:${active.i}` : '';
if (key !== st.chipKey) {
st.chipKey = key;
$('markerChips').querySelectorAll('.marker-chip.active').forEach((el) => el.classList.remove('active'));
if (active) {
const chip = $('markerChips').querySelector(`.marker-chip[data-t="${active.t}"]`);
if (chip) chip.classList.add('active');
}
}
if (st.sync) return;
const ls = lines();
if (!ls.length) return;
const off = offset();
const idx = LC.activeIndex(ls, cur, off);
const list = $('lyricsList');
if (idx !== st.activeIdx || force) {
const prev = list.querySelector('.lyr-line.active');
if (prev) prev.classList.remove('active');
const el = idx >= 0 ? list.querySelector(`[data-i="${idx}"]`) : null;
if (el) {
el.classList.add('active');
const auto = data.settings.lyricsAutoscroll !== false;
if (auto && st.tab === 'lyrics' && Date.now() - st.lastUserScroll > 4000) scrollToRow(list, el);
}
st.activeIdx = idx;
const sec = LC.activeSection(ls, cur, off);
st.sectionText = sec ? `${sec.text} · now` : '';
renderStatus();
}
// Band cues count down over their last minute.
list.querySelectorAll('.lyr-cue').forEach((el) => {
const l = ls[Number(el.dataset.i)];
const inEl = el.querySelector('.lyr-cue-in');
if (!l || l.t === null || l.t === undefined || !inEl) return;
const left = l.t + off - cur;
const txt = left > 0 && left <= 60 ? `in ${LC.fmtClock(Math.ceil(left))}` : '';
if (inEl.textContent !== txt) inEl.textContent = txt;
el.classList.toggle('soon', !!txt);
});
}
// ---- saving shared notes ----
async function putNote(id, kind, payload, baseRev) {
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/${kind}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: payload, baseRev: baseRev || 0, profile: profileName() }),
});
const j = await res.json().catch(() => null);
if (res.status === 409 && j && j.conflict) return { conflict: true, current: j.current };
if (!j || !j.ok) throw new Error((j && j.error) || `HTTP ${res.status}`);
return j;
}
function askConflict(kind, current) {
return new Promise((resolve) => {
const body = document.createElement('div');
const who = current && current.updatedBy ? escapeHtml(current.updatedBy) : 'Someone';
body.innerHTML = `<p class="modal-text">${who} saved a newer version of these ${kind} while you were editing.
Keep yours (theirs stays in the history) or load theirs?</p>`;
showModal('Newer version on the server', body, [
{ label: 'Load theirs', onClick: () => { closeModal(); resolve(false); } },
{ label: 'Keep mine', primary: true, onClick: () => { closeModal(); resolve(true); } },
]);
});
}
// 'saved' | 'theirs' | 'failed'
async function saveShared(kind, payload, baseRev) {
const id = st.videoId;
if (!id || !requireProfile()) return 'failed';
try {
let r = await putNote(id, kind, payload, baseRev);
if (r.conflict) {
if (!(await askConflict(kind, r.current))) {
applyNote(id, kind, r.current);
return 'theirs';
}
r = await putNote(id, kind, payload, r.current ? r.current.rev : 0);
if (r.conflict) throw new Error('someone saved again — try once more');
}
applyNote(id, kind, {
data: r.data, rev: r.rev, source: 'user', updatedBy: profileName(),
updatedAt: Math.floor(Date.now() / 1000),
});
toast(`Saved ✓ — shared with everyone`);
return 'saved';
} catch (err) {
toast('⚠ Could not save: ' + err.message, { duration: 4000 });
return 'failed';
}
}
// ---- lyrics editor ----
async function captionsText() {
const id = st.videoId;
if (!id) return null;
toast('Fetching YouTube captions…');
try {
const res = await fetch(`/api/notes/${encodeURIComponent(id)}/captions`);
const j = await res.json().catch(() => null);
if (!j || !j.ok) throw new Error((j && j.error) || `HTTP ${res.status}`);
toast(`Imported ${j.lines.length} lines from ${j.auto ? 'auto-generated ' : ''}captions (${j.lang}) — check the words before saving.`, { duration: 5000 });
return LC.toText({ lines: j.lines.map((l) => ({ ...l, kind: 'line' })), tags: [] });
} catch (err) {
toast('⚠ ' + err.message, { duration: 4000 });
return null;
}
}
function openEditor(prefill, { importCaptions = false, note = '' } = {}) {
if (!st.videoId || !requireProfile()) return;
const baseRev = st.lyrics ? st.lyrics.rev : 0;
const body = document.createElement('div');
body.className = 'lyr-editor';
body.innerHTML = `
<p class="lyr-editor-help">One lyric line per line. Optional: <code>[1:23.45]</code> times a line
(or time them later with Tap-sync), <code># Chorus</code> is a section, <code>! Drums in</code> a band cue,
<code>@ Key G, Capo 2, 70 BPM</code> adds tags. Pasted .lrc files work as-is.</p>
${note ? `<p class="lyr-editor-note">${escapeHtml(note)}</p>` : ''}
<textarea class="lyr-editor-text" spellcheck="false" placeholder="Paste or type the lyrics…"></textarea>
<div class="lyr-editor-tools">
<button type="button" class="np-btn" data-act="captions">⤓ Import YouTube captions</button>
<button type="button" class="np-btn" data-act="stamp">⏱ Stamp current time on this line</button>
</div>`;
const ta = body.querySelector('textarea');
ta.value = prefill != null ? prefill : (st.lyrics ? LC.toText(st.lyrics.data) : '');
ta.addEventListener('keydown', (e) => e.stopPropagation());
const runCaptions = async () => {
const text = await captionsText();
if (text != null) ta.value = text;
};
body.querySelector('[data-act="captions"]').addEventListener('click', runCaptions);
body.querySelector('[data-act="stamp"]').addEventListener('click', () => {
const pos = ta.selectionStart;
const start = ta.value.lastIndexOf('\n', pos - 1) + 1;
const endNl = ta.value.indexOf('\n', start);
const end = endNl < 0 ? ta.value.length : endNl;
const lineText = ta.value.slice(start, end).replace(/^(\[[^\]]*\]\s*)+/, '');
const stamp = `[${LC.fmtStamp(Math.max(0, now() - docOffset()))}] `;
ta.value = ta.value.slice(0, start) + stamp + lineText + ta.value.slice(end);
// Move to the next line so repeated taps walk down the sheet.
const next = ta.value.indexOf('\n', start);
const caret = next < 0 ? ta.value.length : next + 1;
ta.focus();
ta.setSelectionRange(caret, caret);
});
showModal(st.lyrics ? 'Edit lyrics' : 'Add lyrics', body, [
{ label: 'Cancel', onClick: closeModal },
{ label: 'Save for everyone', primary: true, onClick: async () => {
const text = ta.value;
const doc = LC.fromText(text);
const payload = {
lines: doc.lines,
tags: doc.tags,
offset: doc.offset !== null ? doc.offset : docOffset(),
};
closeModal();
const r = await saveShared('lyrics', payload, baseRev);
if (r === 'failed') openEditor(text, { note: 'Not saved yet — your text is still here.' });
} },
]);
if (importCaptions) runCaptions();
else setTimeout(() => ta.focus(), 50);
}
// ---- tap-sync ----
function startSync() {
if (!st.videoId || !requireProfile()) return;
const src = (st.lyrics && st.lyrics.data && st.lyrics.data.lines) || [];
if (!src.length) return;
st.sync = {
lines: src.map((l) => ({ ...l })),
ptr: 0,
history: [],
baseRev: st.lyrics.rev,
videoId: st.videoId,
};
panel.classList.add('syncing');
$('lyricsSyncBar').classList.remove('hidden');
st.tab = 'lyrics';
showTab('lyrics');
renderLyrics();
toast('Play the song · tap Stamp line (or Enter) as each line starts', { duration: 4000 });
}
function renderSyncPointer() {
const s = st.sync;
if (!s) return;
const list = $('lyricsList');
list.querySelectorAll('.sync-ptr').forEach((el) => el.classList.remove('sync-ptr'));
const el = list.querySelector(`[data-i="${s.ptr}"]`);
if (el) { el.classList.add('sync-ptr'); scrollToRow(list, el); }
const total = s.lines.length;
const next = s.lines[s.ptr];
$('lyricsSyncHint').textContent = next
? `${s.ptr + 1}/${total} · next: “${next.text.slice(0, 60)}”`
: `All ${total} lines stamped — save when ready.`;
$('lyricsUndoBtn').disabled = !s.history.length;
}
function stamp() {
const s = st.sync;
if (!s || s.ptr >= s.lines.length) return;
s.history.push({ i: s.ptr, prev: s.lines[s.ptr].t });
s.lines[s.ptr].t = round2(Math.max(0, now() - docOffset()));
s.ptr++;
renderLyrics();
}
function undoStamp() {
const s = st.sync;
const h = s && s.history.pop();
if (!h) return;
s.lines[h.i].t = h.prev;
s.ptr = h.i;
renderLyrics();
}
function endSync() {
if (!st.sync) return;
st.sync = null;
panel.classList.remove('syncing');
$('lyricsSyncBar').classList.add('hidden');
renderLyrics();
}
async function finishSync() {
const s = st.sync;
if (!s) return;
const payload = { ...(st.lyrics ? st.lyrics.data : {}), lines: s.lines };
const r = await saveShared('lyrics', payload, s.baseRev);
if (r !== 'failed') endSync();
}
function handleSyncKey(e) {
if (!st.sync) return false;
if (e.key === 'Enter' || e.key === 't') { e.preventDefault(); stamp(); return true; }
if (e.key === 'Backspace') { e.preventDefault(); undoStamp(); return true; }
if (e.key === 'Escape') { e.preventDefault(); endSync(); return true; }
return false;
}
// ---- history (both kinds) ----
async function openHistory(kind) {
const id = st.videoId;
if (!id) return;
let revs = [];
try {
const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}/${kind}/revs`)).json();
if (!j.ok) throw new Error(j.error || 'failed');
revs = j.revs;
} catch (err) {
toast('⚠ Could not load history: ' + err.message);
return;
}
const live = st[kind] ? st[kind].rev : 0;
const body = document.createElement('div');
body.className = 'notes-history';
body.innerHTML = revs.length ? revs.map((r) => `
<div class="hist-row">
<div class="hist-main">
<span class="hist-rev">v${r.rev}</span>
<span class="hist-who">${escapeHtml(r.updatedBy || 'unknown')}</span>
<span class="hist-src">${escapeHtml(r.source)}</span>
<span class="hist-when">${new Date(r.createdAt * 1000).toLocaleString()}</span>
</div>
${r.rev === live ? '<span class="hist-live">current</span>' : `<button class="np-btn" type="button" data-rev="${r.rev}">Restore</button>`}
</div>`).join('') : '<div class="notes-empty small">No history yet.</div>';
body.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-rev]');
if (!btn) return;
try {
const j = await (await fetch(`/api/notes/${encodeURIComponent(id)}/${kind}/revs/${btn.dataset.rev}`)).json();
if (!j.ok) throw new Error(j.error || 'failed');
closeModal();
await saveShared(kind, j.data, live);
} catch (err) {
toast('⚠ Could not restore: ' + err.message);
}
});
showModal(kind === 'lyrics' ? '🕘 Lyrics history' : '🕘 Chapters history', body, [
{ label: 'Close', primary: true, onClick: closeModal },
]);
}
// ---- chapters & bookmarks ----
function markerForm(title, init) {
return new Promise((resolve) => {
const body = document.createElement('div');
body.className = 'marker-form';
body.innerHTML = `
<label>Time <input type="text" class="mf-time" inputmode="decimal" placeholder="m:ss" /></label>
<label>Title <input type="text" class="mf-title" maxlength="100" placeholder="e.g. Key change → D" /></label>
<label>Note <input type="text" class="mf-note" maxlength="200" placeholder="optional" /></label>`;
const $t = body.querySelector('.mf-time');
const $ti = body.querySelector('.mf-title');
const $n = body.querySelector('.mf-note');
$t.value = LC.fmtClock(init.t);
$ti.value = init.title || '';
$n.value = init.note || '';
const submit = () => {
const t = LC.parseStamp($t.value);
const ttl = $ti.value.trim();
if (t === null) { toast('Time must look like 1:23'); return; }
if (!ttl) { toast('Give it a title'); return; }
closeModal();
resolve({ t, title: ttl, note: $n.value.trim() });
};
body.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); submit(); } });
showModal(title, body, [
{ label: 'Cancel', onClick: () => { closeModal(); resolve(null); } },
{ label: 'Save', primary: true, onClick: submit },
]);
setTimeout(() => $ti.focus(), 50);
});
}
async function addChapter() {
if (!st.videoId || !requireProfile()) return;
const baseRev = st.chapters ? st.chapters.rev : 0;
const v = await markerForm('+ Add chapter', { t: Math.floor(now()), title: '' });
if (!v) return;
await saveShared('chapters', { items: [...chapters(), v] }, baseRev);
}
async function editMarker(kind, i) {
const list = kind === 'chapter' ? chapters() : bookmarks();
const item = list[i];
if (!item) return;
if (kind === 'chapter' && !requireProfile()) return;
const baseRev = st.chapters ? st.chapters.rev : 0;
const v = await markerForm(kind === 'chapter' ? '✎ Edit chapter' : '✎ Edit bookmark', item);
if (!v) return;
const next = list.slice();
next[i] = v;
if (kind === 'chapter') await saveShared('chapters', { items: next }, baseRev);
else setBookmarks(next);
}
async function deleteMarker(kind, i) {
if (kind === 'chapter') {
if (!requireProfile()) return;
const next = chapters().filter((_, j) => j !== i);
await saveShared('chapters', { items: next }, st.chapters ? st.chapters.rev : 0);
} else {
setBookmarks(bookmarks().filter((_, j) => j !== i));
toast('Bookmark removed');
}
}
function setBookmarks(list) {
if (!st.videoId) return;
if (!data.bookmarks) data.bookmarks = {};
const sorted = list.slice().sort((a, b) => a.t - b.t);
if (sorted.length) data.bookmarks[st.videoId] = sorted;
else delete data.bookmarks[st.videoId];
persist();
renderMarkers();
renderSeekMarkers();
}
function addBookmark() {
if (!st.videoId) { toast('Bookmarks need a YouTube video'); return; }
const t = round2(now());
const n = bookmarks().length + 1;
setBookmarks([...bookmarks(), { t, title: `Bookmark ${n}`, note: '' }]);
toast(`📌 Bookmarked ${LC.fmtClock(t)} — rename it under Chapters & bookmarks`);
}
function openExport() {
const chs = chapters();
const bms = bookmarks();
if (!chs.length && !bms.length) { toast('Nothing to export yet'); return; }
const title = (current && current.meta && current.meta.title) || st.videoId;
const build = (withBm) => LC.toYouTubeTimestamps(withBm ? [...chs, ...bms] : chs);
const body = document.createElement('div');
body.className = 'marker-export';
body.innerHTML = `
<p class="modal-text">YouTube timestamp format — paste into a description, setlist or chat.</p>
<label class="mx-inc"><input type="checkbox" class="mx-bm" ${chs.length ? '' : 'checked'} /> Include my bookmarks</label>
<textarea class="mx-text" readonly spellcheck="false"></textarea>`;
const ta = body.querySelector('textarea');
const inc = body.querySelector('.mx-bm');
const refresh = () => { ta.value = build(inc.checked); };
inc.addEventListener('change', refresh);
refresh();
showModal('⤓ Export chapters', body, [
{ label: 'Download .txt', onClick: () => {
const blob = new Blob([`${title}\nhttps://youtu.be/${st.videoId}\n\n${ta.value}\n`], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${String(title).replace(/[^\w\- ]+/g, '').trim().slice(0, 60) || st.videoId}-chapters.txt`;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 2000);
} },
{ label: 'Copy', primary: true, onClick: async () => {
try { await navigator.clipboard.writeText(ta.value); toast('Copied ✓'); }
catch { ta.select(); toast('Select-all done — copy with Ctrl/⌘+C'); }
} },
]);
}
// ---- tabs + events ----
function showTab(tab) {
st.tab = tab;
panel.querySelectorAll('.notes-tab').forEach((b) => b.classList.toggle('active', b.dataset.tab === tab));
$('lyricsPane').classList.toggle('hidden', tab !== 'lyrics');
$('markersPane').classList.toggle('hidden', tab !== 'markers');
if (tab === 'lyrics') tick(now(), true);
}
panel.querySelectorAll('.notes-tab').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab)));
$('lyricsEditBtn').addEventListener('click', () => openEditor(null));
$('lyricsSyncBtn').addEventListener('click', startSync);
$('lyricsHistoryBtn').addEventListener('click', () => openHistory('lyrics'));
$('chaptersHistoryBtn').addEventListener('click', () => openHistory('chapters'));
$('lyricsStampBtn').addEventListener('click', (e) => { e.currentTarget.blur(); stamp(); });
$('lyricsUndoBtn').addEventListener('click', undoStamp);
$('lyricsSyncCancel').addEventListener('click', endSync);
$('lyricsSyncDone').addEventListener('click', finishSync);
const nudge = (d) => {
if (!st.videoId) return;
if (!data.lyricOffsets) data.lyricOffsets = {};
const v = Math.round((personalOffset() + d) * 10) / 10;
if (v) data.lyricOffsets[st.videoId] = v;
else delete data.lyricOffsets[st.videoId];
persist();
renderOffset();
tick(now(), true);
};
$('lyrOffMinus').addEventListener('click', () => nudge(-0.5));
$('lyrOffPlus').addEventListener('click', () => nudge(0.5));
$('lyrAutoscroll').addEventListener('change', (e) => {
data.settings.lyricsAutoscroll = e.target.checked;
persist();
if (e.target.checked) { st.lastUserScroll = 0; tick(now(), true); }
});
const list = $('lyricsList');
['wheel', 'touchstart', 'touchmove'].forEach((ev) =>
list.addEventListener(ev, () => { st.lastUserScroll = Date.now(); }, { passive: true }));
list.addEventListener('click', (e) => {
const act = e.target.closest('[data-act]');
if (act) {
if (act.dataset.act === 'paste') openEditor('');
else if (act.dataset.act === 'captions') openEditor('', { importCaptions: true });
return;
}
const row = e.target.closest('[data-i]');
if (!row) return;
const i = Number(row.dataset.i);
if (st.sync) { st.sync.ptr = i; renderSyncPointer(); return; }
const l = lines()[i];
if (l && l.t !== null && l.t !== undefined) {
st.lastUserScroll = 0;
Player.seek(Math.max(0, l.t + offset()));
}
});
$('markerChips').addEventListener('click', (e) => {
const chip = e.target.closest('.marker-chip');
if (chip) Player.seek(Number(chip.dataset.t));
});
$('addChapterBtn').addEventListener('click', addChapter);
$('addBookmarkBtn').addEventListener('click', addBookmark);
$('exportMarkersBtn').addEventListener('click', openExport);
$('markersPane').addEventListener('click', (e) => {
const btn = e.target.closest('.marker-row [data-act]');
if (!btn) return;
const row = btn.closest('.marker-row');
const kind = row.dataset.kind;
const i = Number(row.dataset.i);
const item = (kind === 'chapter' ? chapters() : bookmarks())[i];
if (btn.dataset.act === 'jump' && item) Player.seek(item.t);
else if (btn.dataset.act === 'edit') editMarker(kind, i);
else if (btn.dataset.act === 'del') deleteMarker(kind, i);
});
return { load, tick, addBookmark, handleSyncKey };
})();
function updateProgress() { function updateProgress() {
const cur = Player.master.currentTime || 0; const cur = Player.master.currentTime || 0;
const dur = Player.master.duration || current?.meta?.duration || 0; const dur = Player.master.duration || current?.meta?.duration || 0;
@@ -2270,6 +2993,7 @@ function updateProgress() {
if (dur) els.seek.value = String((cur / dur) * 1000); if (dur) els.seek.value = String((cur / dur) * 1000);
updateMiniBar(); updateMiniBar();
updateServiceProgress(cur, dur); updateServiceProgress(cur, dur);
Notes.tick(cur);
// A-B loop: passing B behaves like the track ending. Loop the A-B segment only // A-B loop: passing B behaves like the track ending. Loop the A-B segment only
// when "loop current" is on; otherwise B is the effective end of the track and // when "loop current" is on; otherwise B is the effective end of the track and
// we advance per the repeat settings (next track, or wrap when "repeat list"). // we advance per the repeat settings (next track, or wrap when "repeat list").
@@ -4833,7 +5557,9 @@ document.querySelectorAll('.chip').forEach((c) => {
// Keyboard // Keyboard
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT') return; if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return;
// Tap-sync owns Enter/Backspace while it runs (see lyrics tap-sync).
if (Notes.handleSyncKey(e)) return;
if (e.code === 'Space') { e.preventDefault(); Player.toggle(); } if (e.code === 'Space') { e.preventDefault(); Player.toggle(); }
else if (e.code === 'ArrowRight') Player.seek(Player.master.currentTime + 5); else if (e.code === 'ArrowRight') Player.seek(Player.master.currentTime + 5);
else if (e.code === 'ArrowLeft') Player.seek(Math.max(0, Player.master.currentTime - 5)); else if (e.code === 'ArrowLeft') Player.seek(Math.max(0, Player.master.currentTime - 5));
@@ -4845,6 +5571,7 @@ document.querySelectorAll('.chip').forEach((c) => {
else if (e.key === 'q') { if (current && current.meta) addToQueue(current.meta); } else if (e.key === 'q') { if (current && current.meta) addToQueue(current.meta); }
else if (e.key === 'a') { if (current) setAbA(); } else if (e.key === 'a') { if (current) setAbA(); }
else if (e.key === 'b') { if (current) setAbB(); } else if (e.key === 'b') { if (current) setAbB(); }
else if (e.key === 'p') { if (current) Notes.addBookmark(); }
else if (e.key === '?') toggleShortcutHelp(); else if (e.key === '?') toggleShortcutHelp();
else if (e.key === 'Escape') { else if (e.key === 'Escape') {
if (isServiceModeOpen()) exitServiceMode(); if (isServiceModeOpen()) exitServiceMode();
@@ -5208,6 +5935,8 @@ function importBackup(e) {
Object.assign(data.rememberPos, imp.rememberPos || {}); Object.assign(data.rememberPos, imp.rememberPos || {});
for (const [id, cnt] of Object.entries(imp.playCount || {})) data.playCount[id] = Math.max(data.playCount[id] || 0, cnt); for (const [id, cnt] of Object.entries(imp.playCount || {})) data.playCount[id] = Math.max(data.playCount[id] || 0, cnt);
Object.assign(data.abMarkers, imp.abMarkers || {}); Object.assign(data.abMarkers, imp.abMarkers || {});
Object.assign(data.bookmarks, imp.bookmarks || {});
Object.assign(data.lyricOffsets, imp.lyricOffsets || {});
persist(); persist();
closeModal(); closeModal();
render(); render();
@@ -5467,6 +6196,8 @@ async function boot() {
rememberPos: loaded.rememberPos || {}, rememberPos: loaded.rememberPos || {},
playCount: loaded.playCount || {}, playCount: loaded.playCount || {},
abMarkers: loaded.abMarkers || {}, abMarkers: loaded.abMarkers || {},
bookmarks: loaded.bookmarks || {},
lyricOffsets: loaded.lyricOffsets || {},
lastAutoBackup: loaded.lastAutoBackup || 0, lastAutoBackup: loaded.lastAutoBackup || 0,
settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) }, settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) },
profile: loaded.profile || null, profile: loaded.profile || null,

View File

@@ -141,6 +141,7 @@
<span id="curTime" class="time">0:00</span> <span id="curTime" class="time">0:00</span>
<div class="seek-wrap"> <div class="seek-wrap">
<input id="seek" class="seek" type="range" min="0" max="1000" value="0" /> <input id="seek" class="seek" type="range" min="0" max="1000" value="0" />
<div id="seekMarkers" class="seek-markers" aria-hidden="true"></div>
<div id="abIndicator" class="ab-indicator hidden"> <div id="abIndicator" class="ab-indicator hidden">
<span id="abALabel" class="ab-label">A: --</span> <span id="abALabel" class="ab-label">A: --</span>
<span style="flex:1"></span> <span style="flex:1"></span>
@@ -209,6 +210,58 @@
</div> </div>
</div> </div>
<!-- Lyrics · chapters · bookmarks (shared notes, notes.js on the server) -->
<div id="notesPanel" class="notes-panel hidden">
<div class="notes-tabs" role="tablist">
<button class="notes-tab active" data-tab="lyrics" role="tab" type="button">Lyrics</button>
<button class="notes-tab" data-tab="markers" role="tab" type="button">Chapters &amp; bookmarks <span id="markersCount" class="notes-count"></span></button>
<span id="notesStatus" class="notes-status"></span>
</div>
<div id="lyricsPane" class="notes-pane">
<div class="lyrics-toolbar">
<button id="lyricsEditBtn" class="np-btn" type="button" title="Edit the shared lyrics">✎ Edit</button>
<button id="lyricsSyncBtn" class="np-btn" type="button" title="Tap along to time each line">⏱ Tap-sync</button>
<button id="lyricsHistoryBtn" class="np-btn" type="button" title="Earlier versions">🕘 History</button>
<span class="notes-spacer"></span>
<span class="lyr-offset" title="Your personal sync offset for this song — positive shows lyrics later">
<button id="lyrOffMinus" class="lyr-off-btn" type="button">−0.5s</button>
<span id="lyrOffVal" class="lyr-off-val">0.0s</span>
<button id="lyrOffPlus" class="lyr-off-btn" type="button">+0.5s</button>
</span>
<label class="lyr-auto"><input type="checkbox" id="lyrAutoscroll" checked /> Autoscroll</label>
</div>
<div id="lyricsSyncBar" class="lyrics-syncbar hidden">
<span id="lyricsSyncHint" class="lyrics-sync-hint"></span>
<button id="lyricsStampBtn" class="np-btn primary-btn" type="button">Stamp line ⏎</button>
<button id="lyricsUndoBtn" class="np-btn" type="button">↶ Undo</button>
<button id="lyricsSyncCancel" class="np-btn" type="button">Cancel</button>
<button id="lyricsSyncDone" class="np-btn" type="button">✓ Save timings</button>
</div>
<div id="lyricsTags" class="lyrics-tags"></div>
<div id="lyricsList" class="lyrics-list"></div>
</div>
<div id="markersPane" class="notes-pane hidden">
<div id="markerChips" class="marker-chips"></div>
<div class="markers-toolbar">
<button id="addChapterBtn" class="np-btn" type="button">+ Chapter</button>
<button id="addBookmarkBtn" class="np-btn" type="button" title="Pin a private bookmark here [P]">📌 Bookmark</button>
<span class="notes-spacer"></span>
<button id="chaptersHistoryBtn" class="np-btn" type="button" title="Earlier versions of the shared chapters">🕘</button>
<button id="exportMarkersBtn" class="np-btn" type="button">⤓ Export</button>
</div>
<div class="markers-section">
<div class="markers-heading">Chapters <span class="markers-scope">shared with everyone</span></div>
<div id="chapterList" class="marker-list"></div>
</div>
<div class="markers-section">
<div class="markers-heading">My bookmarks <span class="markers-scope">only you</span></div>
<div id="bookmarkList" class="marker-list"></div>
</div>
</div>
</div>
<!-- Up next queue --> <!-- Up next queue -->
<div id="upnext" class="upnext hidden"> <div id="upnext" class="upnext hidden">
<div class="upnext-header"> <div class="upnext-header">
@@ -357,6 +410,7 @@
<div class="shortcut-row"><kbd>Q</kbd><span>Add current video to queue</span></div> <div class="shortcut-row"><kbd>Q</kbd><span>Add current video to queue</span></div>
<div class="shortcut-row"><kbd>A</kbd><span>Set A-B loop start point</span></div> <div class="shortcut-row"><kbd>A</kbd><span>Set A-B loop start point</span></div>
<div class="shortcut-row"><kbd>B</kbd><span>Set A-B loop end point</span></div> <div class="shortcut-row"><kbd>B</kbd><span>Set A-B loop end point</span></div>
<div class="shortcut-row"><kbd>P</kbd><span>Pin a bookmark at the current time</span></div>
<div class="shortcut-row"><kbd>?</kbd><span>Show this help</span></div> <div class="shortcut-row"><kbd>?</kbd><span>Show this help</span></div>
<div class="shortcut-row"><kbd>Esc</kbd><span>Close overlay / cancel selection</span></div> <div class="shortcut-row"><kbd>Esc</kbd><span>Close overlay / cancel selection</span></div>
</div> </div>
@@ -366,6 +420,7 @@
<script src="fingerprint.js"></script> <script src="fingerprint.js"></script>
<script src="opfs.js"></script> <script src="opfs.js"></script>
<script src="video-edit.js"></script> <script src="video-edit.js"></script>
<script src="lyrics-core.js"></script>
<script src="async-guard.js"></script> <script src="async-guard.js"></script>
<script src="sw-update.js"></script> <script src="sw-update.js"></script>
<script src="app.js"></script> <script src="app.js"></script>

165
frontend/lyrics-core.js Normal file
View File

@@ -0,0 +1,165 @@
/* ============================================================================
* lyrics-core.js — pure helpers for synced lyrics, chapters and bookmarks
*
* No DOM. Used by app.js (window.LyricsCore) and unit-tested under node.
*
* Editor text format (a superset of LRC, so pasted .lrc files just work):
* [0:18.00] Worthy of every song we could ever sing timed line
* Worthy of all the praise untimed line
* [0:18] # Verse 1 section header
* ! Full dynamic swell into Bridge band cue
* @ Key G, Capo 2 (Play F), 70 BPM tags
* [00:12.00][01:30.00] repeated line LRC multi-stamp
* [offset:+500] LRC offset (ms, + = sooner)
* Other LRC headers ([ar:], [ti:], …) are ignored.
*
* Sync offset: a line is shown at (t + offset) seconds of playback, so a
* positive offset makes the lyrics appear later.
* ========================================================================== */
(function (root) {
'use strict';
const STAMP_RE = /^\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/;
const HEADER_RE = /^\[([a-z]+):([^\]]*)\]$/i;
function parseStamp(str) {
const m = String(str || '').trim().match(/^(?:(\d+):)?(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?$/);
if (!m) return null;
const h = Number(m[1] || 0);
const frac = m[4] ? Number('0.' + m[4]) : 0;
return h * 3600 + Number(m[2]) * 60 + Number(m[3]) + frac;
}
// 83.456 → "1:23.46" (editor stamps keep hundredths).
function fmtStamp(t) {
const cs = Math.round(Math.max(0, Number(t) || 0) * 100);
const m = Math.floor(cs / 6000);
const s = Math.floor((cs % 6000) / 100);
const c = cs % 100;
return `${m}:${String(s).padStart(2, '0')}.${String(c).padStart(2, '0')}`;
}
// 83.9 → "1:23"; ≥1 h → "1:02:03" (YouTube chapter / display form).
function fmtClock(t) {
const total = Math.max(0, Math.floor(Number(t) || 0));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return h
? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
: `${m}:${String(s).padStart(2, '0')}`;
}
function splitTags(s) {
return String(s).split(/[,|·]/).map((x) => x.trim()).filter(Boolean);
}
// Editor text → { lines, tags, offset }.
function fromText(text) {
const lines = [];
const tags = [];
let offset = null;
for (const rawLine of String(text || '').replace(/\r/g, '').split('\n')) {
let rest = rawLine.trim();
if (!rest) continue;
const header = rest.match(HEADER_RE);
if (header && !/^\d/.test(header[1])) {
if (header[1].toLowerCase() === 'offset') {
const ms = Number(header[2]);
if (Number.isFinite(ms)) offset = Math.round((-ms / 1000) * 100) / 100;
}
continue;
}
const stamps = [];
let m;
while ((m = rest.match(STAMP_RE))) {
const frac = m[3] ? Number('0.' + m[3]) : 0;
stamps.push(Number(m[1]) * 60 + Number(m[2]) + frac);
rest = rest.slice(m[0].length).trim();
}
if (rest.startsWith('@')) { tags.push(...splitTags(rest.slice(1))); continue; }
let kind = 'line';
if (rest.startsWith('#')) { kind = 'section'; rest = rest.replace(/^#+/, '').trim(); }
else if (rest.startsWith('!')) { kind = 'cue'; rest = rest.slice(1).trim(); }
if (!rest) continue;
if (!stamps.length) lines.push({ t: null, text: rest, kind });
for (const t of stamps) lines.push({ t: Math.round(t * 100) / 100, text: rest, kind });
}
// A fully timed sheet (typical .lrc with repeated-chorus stamps) is put in
// time order; a partly timed one keeps the order it was written in.
if (lines.length && lines.every((l) => l.t !== null)) {
lines.sort((a, b) => a.t - b.t);
}
return { lines, tags, offset };
}
function toText(doc) {
const out = [];
if (doc && Array.isArray(doc.tags) && doc.tags.length) out.push('@ ' + doc.tags.join(', '));
for (const l of (doc && doc.lines) || []) {
const stamp = l.t === null || l.t === undefined ? '' : `[${fmtStamp(l.t)}] `;
const mark = l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : '';
out.push(stamp + mark + l.text);
}
return out.join('\n');
}
// Index (into lines) of the sung line playing at `time`, or -1. Sections
// and cues are never "the active line" — the highlight follows the words.
function activeIndex(lines, time, offset) {
const now = Number(time) - (Number(offset) || 0);
let best = -1;
let bestT = -Infinity;
(lines || []).forEach((l, i) => {
if (l.kind !== 'line' || l.t === null || l.t === undefined) return;
if (l.t <= now && l.t >= bestT) { best = i; bestT = l.t; }
});
return best;
}
// The section header that the active position falls under (for "Chorus in
// progress"-style labels), or null.
function activeSection(lines, time, offset) {
const now = Number(time) - (Number(offset) || 0);
let best = null;
for (const l of lines || []) {
if (l.kind === 'section' && l.t !== null && l.t !== undefined && l.t <= now) {
if (!best || l.t >= best.t) best = l;
}
}
return best;
}
function timedCount(lines) {
return (lines || []).filter((l) => l.kind === 'line' && l.t !== null && l.t !== undefined).length;
}
// Chapters / bookmarks: [{t, title, note}] sorted by t.
function activeMarker(items, time) {
let best = null;
for (const it of items || []) if (it.t <= time && (!best || it.t >= best.t)) best = it;
return best;
}
// YouTube-description form: "0:00 Intro". YouTube only turns a list into
// chapters when the first one is at 0:00, so an "Start" entry is added when
// the list doesn't begin there.
function toYouTubeTimestamps(items, { ensureZero = true } = {}) {
const sorted = (items || []).slice().sort((a, b) => a.t - b.t);
const rows = sorted.map((it) => `${fmtClock(it.t)} ${it.title}`);
if (ensureZero && sorted.length && sorted[0].t >= 1) rows.unshift('0:00 Start');
return rows.join('\n');
}
const LyricsCore = {
parseStamp, fmtStamp, fmtClock, fromText, toText,
activeIndex, activeSection, timedCount, activeMarker, toYouTubeTimestamps,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = LyricsCore;
} else {
root.LyricsCore = LyricsCore;
}
})(typeof globalThis !== 'undefined' ? globalThis : this);

View File

@@ -0,0 +1,80 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const LC = require('./lyrics-core');
test('fromText: timed, untimed, sections, cues, tags', () => {
const doc = LC.fromText([
'@ Key G, Capo 2 (Play F) | 70 BPM',
'[0:18] # Verse 1',
'[0:18.00] Worthy of every song',
'an untimed line',
'! Drums in',
'',
].join('\n'));
assert.deepStrictEqual(doc.tags, ['Key G', 'Capo 2 (Play F)', '70 BPM']);
assert.deepStrictEqual(doc.lines, [
{ t: 18, text: 'Verse 1', kind: 'section' },
{ t: 18, text: 'Worthy of every song', kind: 'line' },
{ t: null, text: 'an untimed line', kind: 'line' },
{ t: null, text: 'Drums in', kind: 'cue' },
]);
assert.strictEqual(doc.offset, null);
});
test('fromText: LRC multi-stamps expand and a fully timed sheet is time-sorted', () => {
const doc = LC.fromText('[ti:Song]\n[ar:Band]\n[offset:+500]\n[00:30.00]b\n[00:10.00][00:50.00]chorus');
assert.deepStrictEqual(doc.lines.map((l) => [l.t, l.text]), [[10, 'chorus'], [30, 'b'], [50, 'chorus']]);
assert.strictEqual(doc.offset, -0.5); // LRC +500 ms = sooner = our -0.5 s
});
test('toText round-trips through fromText', () => {
const doc = {
tags: ['Key G'],
lines: [
{ t: 83.456, text: 'line one', kind: 'line' },
{ t: null, text: 'Bridge', kind: 'section' },
{ t: 90, text: 'swell', kind: 'cue' },
],
};
const text = LC.toText(doc);
assert.strictEqual(text, '@ Key G\n[1:23.46] line one\n# Bridge\n[1:30.00] ! swell');
const back = LC.fromText(text);
assert.deepStrictEqual(back.lines.map((l) => l.kind), ['line', 'section', 'cue']);
assert.strictEqual(back.lines[0].t, 83.46);
});
test('activeIndex follows sung lines only and honours the offset', () => {
const lines = [
{ t: 10, text: 'Verse', kind: 'section' },
{ t: 10, text: 'a', kind: 'line' },
{ t: null, text: 'untimed', kind: 'line' },
{ t: 20, text: 'b', kind: 'line' },
{ t: 25, text: 'cue', kind: 'cue' },
];
assert.strictEqual(LC.activeIndex(lines, 5, 0), -1);
assert.strictEqual(LC.activeIndex(lines, 12, 0), 1);
assert.strictEqual(LC.activeIndex(lines, 26, 0), 3);
// +2 s offset: the "b" line shows at 22 s of playback, not 20.
assert.strictEqual(LC.activeIndex(lines, 21, 2), 1);
assert.strictEqual(LC.activeIndex(lines, 22, 2), 3);
assert.strictEqual(LC.activeSection(lines, 12, 0).text, 'Verse');
assert.strictEqual(LC.timedCount(lines), 2);
});
test('clock and stamp formatting', () => {
assert.strictEqual(LC.fmtClock(83.9), '1:23');
assert.strictEqual(LC.fmtClock(3723), '1:02:03');
assert.strictEqual(LC.fmtStamp(5.5), '0:05.50');
assert.strictEqual(LC.parseStamp('1:23.5'), 83.5);
assert.strictEqual(LC.parseStamp('1:02:03'), 3723);
assert.strictEqual(LC.parseStamp('nope'), null);
});
test('YouTube export adds a 0:00 entry only when needed', () => {
const items = [{ t: 130, title: 'Key change → D' }, { t: 42, title: 'Intro' }];
assert.strictEqual(LC.toYouTubeTimestamps(items), '0:00 Start\n0:42 Intro\n2:10 Key change → D');
assert.strictEqual(LC.toYouTubeTimestamps([{ t: 0, title: 'Top' }]), '0:00 Top');
assert.strictEqual(LC.activeMarker(items, 60).title, 'Intro');
assert.strictEqual(LC.activeMarker(items, 10), null);
});

View File

@@ -1238,7 +1238,13 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
box-shadow: var(--shadow); box-shadow: var(--shadow);
font-size: 13.5px; font-size: 13.5px;
font-weight: 500; font-weight: 500;
white-space: nowrap; /* Short toasts stay on one line; longer ones wrap inside the viewport
instead of running off both edges on a phone. */
width: max-content;
max-width: min(560px, calc(100vw - 32px));
box-sizing: border-box;
white-space: normal;
text-align: center;
animation: toastIn 0.3s var(--ease); animation: toastIn 0.3s var(--ease);
pointer-events: auto; pointer-events: auto;
} }
@@ -3400,3 +3406,202 @@ html[data-layout="bento-hub"] .mini-bar-inner {
html[data-layout="living-hymnal"] .app { grid-template-columns: 220px 1fr; } html[data-layout="living-hymnal"] .app { grid-template-columns: 220px 1fr; }
} }
/* ============================================================================
* Lyrics · chapters · bookmarks panel (Notes in app.js)
* ========================================================================== */
.notes-panel {
margin-top: 16px;
background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
border: 1px solid var(--line);
border-radius: var(--radius);
overflow: hidden;
}
.notes-tabs {
display: flex; align-items: center; gap: 4px;
padding: 6px 10px 0;
border-bottom: 1px solid var(--line-soft);
}
.notes-tab {
background: transparent; border: none; cursor: pointer;
color: var(--text-dim);
font-family: var(--mono); font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.14em;
padding: 9px 8px 10px;
border-bottom: 2px solid transparent;
transition: color 0.16s, border-color 0.16s;
}
.notes-tab:hover { color: var(--text-2); }
.notes-tab.active { color: var(--text); border-bottom-color: var(--accent); }
.notes-count {
display: inline-block; min-width: 16px; padding: 1px 5px; margin-left: 4px;
border-radius: 8px; background: var(--bg-3); color: var(--text-2);
font-size: 9px; letter-spacing: 0; text-align: center;
}
.notes-count:empty { display: none; }
.notes-status {
margin-left: auto; min-width: 0;
font-family: var(--mono); font-size: 10px; color: var(--accent-bright);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.notes-pane { padding: 12px 14px 14px; }
.notes-spacer { flex: 1; }
.lyrics-toolbar, .markers-toolbar {
display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
margin-bottom: 10px;
}
.lyrics-toolbar .np-btn, .markers-toolbar .np-btn, .lyrics-syncbar .np-btn { padding: 6px 10px; font-size: 12px; }
.lyr-offset {
display: inline-flex; align-items: center; gap: 2px;
border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 2px; background: var(--bg-1);
}
.lyr-off-btn {
background: transparent; border: none; color: var(--text-2); cursor: pointer;
font-family: var(--mono); font-size: 11px; padding: 4px 6px; border-radius: 6px;
}
.lyr-off-btn:hover { background: var(--bg-3); color: var(--text); }
.lyr-off-val { font-family: var(--mono); font-size: 11px; color: var(--text-dim); min-width: 38px; text-align: center; }
.lyr-off-val.nonzero { color: var(--accent-bright); }
.lyr-auto { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-2); cursor: pointer; }
.lyrics-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
.lyr-tag {
font-family: var(--mono); font-size: 11px; color: var(--text-2);
background: var(--bg-3); border: 1px solid var(--line); border-radius: 999px;
padding: 3px 9px;
}
.lyrics-list {
position: relative;
max-height: min(52vh, 460px);
overflow-y: auto;
overscroll-behavior: contain;
padding: 4px 2px 40vh 2px; /* tail room so the last line can centre */
scrollbar-width: thin;
}
.lyr-section {
margin: 14px 0 6px;
font-family: var(--mono); font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.16em; color: var(--accent);
}
.lyr-section:first-child { margin-top: 2px; }
.lyr-section-time { color: var(--text-dim); font-weight: 400; }
.lyr-line {
display: flex; gap: 12px; align-items: baseline;
padding: 6px 8px; border-radius: var(--radius-sm);
cursor: pointer; color: var(--text-2);
transition: background 0.2s var(--ease), color 0.2s var(--ease);
}
.lyr-line:hover { background: var(--bg-3); }
.lyr-line.untimed { cursor: default; }
.lyr-time { flex: none; width: 38px; font-family: var(--mono); font-size: 10px; color: var(--text-dim); }
.lyr-text { font-size: 15px; line-height: 1.45; }
.lyr-line.active { background: var(--bg-3); color: var(--text); }
.lyr-line.active .lyr-text { font-weight: 700; font-size: 17px; }
.lyr-line.active .lyr-time { color: var(--accent-bright); }
.lyr-cue {
display: flex; justify-content: space-between; gap: 10px;
margin: 8px 0; padding: 8px 10px; border-radius: var(--radius-sm);
border: 1px dashed var(--line); color: var(--text-2);
font-family: var(--mono); font-size: 11px; cursor: pointer;
}
.lyr-cue.soon { border-color: var(--accent); color: var(--text); }
.lyr-cue-in { color: var(--accent-bright); white-space: nowrap; }
.lyrics-syncbar {
display: flex; align-items: center; flex-wrap: wrap; gap: 6px;
margin-bottom: 10px; padding: 8px 10px;
border: 1px solid var(--accent); border-radius: var(--radius-sm);
background: var(--bg-1);
}
.lyrics-sync-hint { flex: 1 1 180px; min-width: 0; font-size: 12px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lyrics-syncbar .primary-btn { border-color: var(--accent); color: var(--text); background: var(--accent-deep); }
.notes-panel.syncing .lyr-line { cursor: pointer; }
.lyrics-list .sync-ptr { outline: 2px solid var(--accent); outline-offset: -2px; background: var(--bg-3); color: var(--text); }
.notes-empty { padding: 18px 6px; text-align: center; color: var(--text-2); font-size: 13px; }
.notes-empty.small { padding: 8px 4px; text-align: left; font-size: 12px; color: var(--text-dim); }
.notes-empty-actions { display: flex; justify-content: center; flex-wrap: wrap; gap: 8px; margin: 12px 0 8px; }
.notes-empty-hint { font-size: 11px; color: var(--text-dim); }
.marker-chips { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 8px; margin-bottom: 6px; scrollbar-width: thin; }
.marker-chip {
flex: none; cursor: pointer;
border: 1px solid var(--line); background: var(--bg-1); color: var(--text-2);
border-radius: 999px; padding: 5px 11px; font-size: 12px; white-space: nowrap;
transition: border-color 0.16s, color 0.16s;
}
.marker-chip:hover { color: var(--text); border-color: var(--text-dim); }
.marker-chip.active { border-color: var(--accent); color: var(--text); background: var(--bg-3); }
.marker-chip-time { font-family: var(--mono); font-size: 10px; color: var(--accent-bright); }
.marker-chip.bookmark .marker-chip-time { color: #7ec8ff; }
.markers-section { margin-top: 12px; }
.markers-heading {
font-family: var(--mono); font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.14em; color: var(--text-dim);
margin-bottom: 6px;
}
.markers-scope { font-weight: 400; letter-spacing: 0.04em; text-transform: none; margin-left: 6px; }
.marker-row {
display: flex; align-items: center; gap: 8px;
padding: 7px 6px; border-bottom: 1px solid var(--line-soft);
}
.marker-row:last-child { border-bottom: none; }
.mk-time {
flex: none; width: 52px; text-align: left; cursor: pointer;
background: transparent; border: none; padding: 0;
font-family: var(--mono); font-size: 12px; color: var(--accent-bright);
}
.marker-row[data-kind="bookmark"] .mk-time { color: #7ec8ff; }
.mk-body { flex: 1; min-width: 0; }
.mk-title { font-size: 13px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mk-note { font-size: 11px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mk-btn {
flex: none; background: transparent; border: 1px solid transparent; border-radius: 7px;
color: var(--text-dim); cursor: pointer; padding: 4px 6px; font-size: 12px;
}
.mk-btn:hover { color: var(--text); border-color: var(--line); background: var(--bg-3); }
.seek-markers { position: absolute; left: 0; right: 0; top: 0; height: 0; pointer-events: none; }
.seek-tick {
position: absolute; top: 2px; width: 2px; height: 10px; margin-left: -1px;
border-radius: 1px; background: var(--accent-bright); opacity: 0.85;
}
.seek-tick.bookmark { background: #7ec8ff; }
/* Modals opened by the notes panel */
.lyr-editor-help, .modal-text { margin: 0 0 10px; font-size: 12px; line-height: 1.5; color: var(--text-2); }
.lyr-editor-help code { font-family: var(--mono); font-size: 11px; color: var(--text); }
.lyr-editor-note { margin: 0 0 8px; font-size: 12px; color: var(--accent-bright); }
.lyr-editor-text, .mx-text {
width: 100%; min-height: min(46vh, 360px); resize: vertical; box-sizing: border-box;
background: var(--bg-1); color: var(--text); border: 1px solid var(--line);
border-radius: var(--radius-sm); padding: 10px;
font-family: var(--mono); font-size: 12px; line-height: 1.55;
}
.mx-text { min-height: 180px; }
.lyr-editor-tools { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.mx-inc { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-2); margin-bottom: 8px; }
.marker-form { display: flex; flex-direction: column; gap: 10px; }
.marker-form label { display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: var(--text-dim); }
.marker-form input {
background: var(--bg-1); color: var(--text); border: 1px solid var(--line);
border-radius: var(--radius-sm); padding: 9px 10px; font-size: 14px;
}
.notes-history { display: flex; flex-direction: column; gap: 2px; max-height: 50vh; overflow-y: auto; }
.hist-row { display: flex; align-items: center; gap: 10px; padding: 7px 2px; border-bottom: 1px solid var(--line-soft); }
.hist-main { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: baseline; font-size: 12px; }
.hist-rev { font-family: var(--mono); color: var(--accent-bright); }
.hist-who { color: var(--text); }
.hist-src, .hist-when { color: var(--text-dim); font-size: 11px; }
.hist-live { font-family: var(--mono); font-size: 10px; color: #7ee0a8; }
@media (max-width: 560px) {
.notes-pane { padding: 10px 10px 12px; }
.lyr-text { font-size: 14px; }
.lyr-line.active .lyr-text { font-size: 16px; }
.lyr-auto { margin-left: auto; }
.notes-status { display: none; }
}

View File

@@ -61,6 +61,8 @@ const SHELL = [
'/fingerprint.js', '/fingerprint.js',
'/opfs.js', '/opfs.js',
'/opfs-worker.js', '/opfs-worker.js',
'/video-edit.js',
'/lyrics-core.js',
'/app.js', '/app.js',
'/manifest.webmanifest', '/manifest.webmanifest',
'/icons/icon-192.png', '/icons/icon-192.png',
@@ -143,6 +145,9 @@ self.addEventListener('fetch', (e) => {
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified // Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
if (request.method !== 'GET' && request.method !== 'HEAD') return; if (request.method !== 'GET' && request.method !== 'HEAD') return;
// The admin page is online-only and must never be served from a cache.
if (url.pathname === '/admin' || url.pathname.startsWith('/admin.')) return;
// API calls and binary downloads → network only, no caching // API calls and binary downloads → network only, no caching
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
e.respondWith(fetch(request)); e.respondWith(fetch(request));

View File

@@ -111,9 +111,181 @@ export async function initDb() {
CREATE INDEX IF NOT EXISTS idx_media_lru CREATE INDEX IF NOT EXISTS idx_media_lru
ON media_cache (status, last_access); ON media_cache (status, last_access);
-- Shared per-video documents (kind = lyrics | chapters), visible to every
-- user. The live copy is here; every save also lands in video_note_revs
-- as a full snapshot, which is the server-side backup and undo history.
CREATE TABLE IF NOT EXISTS video_notes (
video_id TEXT NOT NULL,
kind TEXT NOT NULL,
data TEXT NOT NULL,
rev INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'user', -- user | auto | api | restore
updated_by TEXT,
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (video_id, kind)
);
CREATE TABLE IF NOT EXISTS video_note_revs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id TEXT NOT NULL,
kind TEXT NOT NULL,
rev INTEGER NOT NULL,
data TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'user',
updated_by TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_note_revs
ON video_note_revs (video_id, kind, rev);
CREATE INDEX IF NOT EXISTS idx_note_revs_time
ON video_note_revs (created_at DESC);
-- API tokens for scripts (lyrics injection etc.). Only a SHA-256 of the
-- token is stored; the plaintext is shown once when it is created.
CREATE TABLE IF NOT EXISTS api_tokens (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
last_used_at INTEGER
);
`); `);
} }
// ---- Shared video notes (lyrics / chapters) ---------------------------------
function noteRow(r) {
return {
videoId: r.video_id, kind: r.kind, data: JSON.parse(r.data), rev: Number(r.rev),
source: r.source, updatedBy: r.updated_by || null, updatedAt: Number(r.updated_at),
};
}
export async function getNotes(videoId) {
const r = await db.execute({ sql: 'SELECT * FROM video_notes WHERE video_id = ?', args: [videoId] });
const out = {};
for (const row of r.rows) out[row.kind] = noteRow(row);
return out;
}
// Write a new revision when `baseRev` still matches the live one (0 = none
// yet). Returns { ok: true, rev } or { ok: false, current } on a conflict, so
// two people editing at once never silently overwrite each other. `force`
// skips the check (admin restore, API callers that ask for it).
export async function saveNote({ videoId, kind, data, baseRev, source, updatedBy, force = false }) {
const json = JSON.stringify(data);
const tx = await db.transaction('write');
try {
const cur = await tx.execute({
sql: 'SELECT rev FROM video_notes WHERE video_id = ? AND kind = ?', args: [videoId, kind],
});
const liveRev = cur.rows[0] ? Number(cur.rows[0].rev) : 0;
if (!force && Number(baseRev || 0) !== liveRev) {
await tx.rollback();
const all = await getNotes(videoId);
return { ok: false, current: all[kind] || null };
}
const rev = liveRev + 1;
await tx.execute({
sql: `INSERT INTO video_notes (video_id, kind, data, rev, source, updated_by, updated_at)
VALUES (?, ?, ?, ?, ?, ?, unixepoch())
ON CONFLICT(video_id, kind) DO UPDATE SET
data = excluded.data, rev = excluded.rev, source = excluded.source,
updated_by = excluded.updated_by, updated_at = excluded.updated_at`,
args: [videoId, kind, json, rev, source, updatedBy || null],
});
await tx.execute({
sql: `INSERT INTO video_note_revs (video_id, kind, rev, data, source, updated_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, unixepoch())`,
args: [videoId, kind, rev, json, source, updatedBy || null],
});
await tx.commit();
return { ok: true, rev };
} catch (err) {
try { await tx.rollback(); } catch { /* already closed */ }
throw err;
} finally {
tx.close();
}
}
export async function listNoteRevs(videoId, kind, limit = 50) {
const r = await db.execute({
sql: `SELECT rev, source, updated_by, created_at, length(data) AS bytes
FROM video_note_revs WHERE video_id = ? AND kind = ?
ORDER BY rev DESC LIMIT ?`,
args: [videoId, kind, limit],
});
return r.rows.map((x) => ({
rev: Number(x.rev), source: x.source, updatedBy: x.updated_by || null,
createdAt: Number(x.created_at), bytes: Number(x.bytes),
}));
}
export async function getNoteRev(videoId, kind, rev) {
const r = await db.execute({
sql: 'SELECT * FROM video_note_revs WHERE video_id = ? AND kind = ? AND rev = ?',
args: [videoId, kind, rev],
});
const x = r.rows[0];
if (!x) return null;
return {
rev: Number(x.rev), data: JSON.parse(x.data), source: x.source,
updatedBy: x.updated_by || null, createdAt: Number(x.created_at),
};
}
// Latest edits across every video — the admin page's moderation feed.
export async function recentNoteRevs(limit = 100) {
const r = await db.execute({
sql: `SELECT video_id, kind, rev, source, updated_by, created_at, length(data) AS bytes
FROM video_note_revs ORDER BY created_at DESC, id DESC LIMIT ?`,
args: [limit],
});
return r.rows.map((x) => ({
videoId: x.video_id, kind: x.kind, rev: Number(x.rev), source: x.source,
updatedBy: x.updated_by || null, createdAt: Number(x.created_at), bytes: Number(x.bytes),
}));
}
export async function allNotes() {
const r = await db.execute('SELECT * FROM video_notes ORDER BY video_id, kind');
return r.rows.map(noteRow);
}
// ---- API tokens ---------------------------------------------------------------
export async function createApiToken({ id, label, tokenHash }) {
await db.execute({
sql: 'INSERT INTO api_tokens (id, label, token_hash, created_at) VALUES (?, ?, ?, unixepoch())',
args: [id, label, tokenHash],
});
}
export async function listApiTokens() {
const r = await db.execute('SELECT id, label, created_at, last_used_at FROM api_tokens ORDER BY created_at DESC');
return r.rows.map((x) => ({
id: x.id, label: x.label, createdAt: Number(x.created_at),
lastUsedAt: x.last_used_at == null ? null : Number(x.last_used_at),
}));
}
export async function deleteApiToken(id) {
const r = await db.execute({ sql: 'DELETE FROM api_tokens WHERE id = ?', args: [id] });
return (r.rowsAffected || 0) > 0;
}
// Resolve a token hash to its row and stamp last use. null = unknown token.
export async function useApiToken(tokenHash) {
const r = await db.execute({ sql: 'SELECT id, label FROM api_tokens WHERE token_hash = ?', args: [tokenHash] });
const x = r.rows[0];
if (!x) return null;
db.execute({ sql: 'UPDATE api_tokens SET last_used_at = unixepoch() WHERE id = ?', args: [x.id] }).catch(() => {});
return { id: x.id, label: x.label };
}
// ---- Media cache ------------------------------------------------------------- // ---- Media cache -------------------------------------------------------------
const MEDIA_COLS = new Set([ const MEDIA_COLS = new Set([

490
server/notes.js Normal file
View File

@@ -0,0 +1,490 @@
/* ============================================================================
* 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
* 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(/&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;
}
// ---- 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,
} = 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) {
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);
});
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?.();
},
};
}

205
server/notes.test.js Normal file
View File

@@ -0,0 +1,205 @@
// Shared lyrics/chapters routes against a real temp libsql DB and a Hono app.
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Hono } from 'hono';
const root = mkdtempSync(join(tmpdir(), 'ytp-notes-test-'));
process.env.DB_PATH = join(root, 'test.db');
const dbmod = await import('./db.js');
const N = await import('./notes.js');
const VID = 'dQw4w9WgXcQ';
const PASSWORD = 'correct horse';
let app;
let captionCalls = 0;
const VTT = `WEBVTT
Kind: captions
00:00:01.000 --> 00:00:03.000
[Music]
00:00:04.000 --> 00:00:06.000 align:start position:0%
first<00:00:04.500><c> line</c>
00:00:06.000 --> 00:00:06.010
first line
00:00:06.010 --> 00:00:08.000
first line
second &amp; line
`;
beforeAll(async () => {
await dbmod.initDb();
const htmlPath = join(root, 'admin.html');
writeFileSync(htmlPath, '<!doctype html><title>admin</title>');
// Captions: yt-dlp -J is faked; its vtt URL is served by a local server.
const vttServer = Bun.serve({ port: 0, fetch: () => new Response(VTT) });
app = new Hono();
N.registerNoteRoutes(app, {
db: dbmod,
getProfile: async (name) => (name === 'josh' ? { data: '{}' } : null),
profileNameRe: /^[A-Za-z0-9][A-Za-z0-9_-]{2,39}$/,
runYtdlp: async () => {
captionCalls++;
return JSON.stringify({
language: 'en',
automatic_captions: {
'en-orig': [{ ext: 'vtt', url: `http://localhost:${vttServer.port}/c.vtt` }],
fr: [{ ext: 'vtt', url: 'http://invalid/fr.vtt' }],
},
});
},
adminPassword: PASSWORD,
backupDir: join(root, 'backups'),
adminHtmlPath: htmlPath,
});
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
const json = (method, body, headers = {}) => ({
method, headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body),
});
async function adminCookie() {
const res = await app.request('/api/admin/login', json('POST', { password: PASSWORD }));
expect(res.status).toBe(200);
return res.headers.get('set-cookie').split(';')[0];
}
describe('pure helpers', () => {
test('sanitizeLyrics drops junk and clamps values', () => {
const d = N.sanitizeLyrics({
lines: [{ t: 5.555, text: ' hi\u0007 ', kind: 'line' }, { t: -1, text: 'x', kind: 'weird' }, { text: '' }, null],
tags: ['Key G', '', 'x'.repeat(80)],
offset: 99,
});
expect(d.lines).toEqual([{ t: 5.56, text: 'hi', kind: 'line' }, { t: null, text: 'x', kind: 'line' }]);
expect(d.tags).toEqual(['Key G', 'x'.repeat(40)]);
expect(d.offset).toBe(30);
});
test('sanitizeChapters requires time + title and sorts', () => {
const d = N.sanitizeChapters({ items: [{ t: 30, title: 'B' }, { t: 5, title: 'A', note: 'n' }, { t: null, title: 'no time' }] });
expect(d.items).toEqual([{ t: 5, title: 'A', note: 'n' }, { t: 30, title: 'B', note: '' }]);
});
test('parseVtt collapses rolling auto captions and drops sound tags', () => {
expect(N.parseVtt(VTT)).toEqual([{ t: 4, text: 'first line' }, { t: 6.01, text: 'second & line' }]);
});
test('parseVtt joinCue merges a wrapped human subtitle into one line', () => {
const vtt = 'WEBVTT\n\n00:00:01.200 --> 00:00:04.000\nAll right, so here we are,\nin front of the elephants\n';
expect(N.parseVtt(vtt, { joinCue: true })).toEqual([{ t: 1.2, text: 'All right, so here we are, in front of the elephants' }]);
});
test('pickCaptionTrack prefers human subs, else original-language auto captions', () => {
const manual = N.pickCaptionTrack({ language: 'tl', subtitles: { en: [{ ext: 'vtt', url: 'e' }], tl: [{ ext: 'vtt', url: 't' }] } });
expect(manual).toEqual({ lang: 'tl', auto: false, url: 't' });
const auto = N.pickCaptionTrack({ automatic_captions: { es: [{ ext: 'vtt', url: 's' }], 'tl-orig': [{ ext: 'vtt', url: 'o' }] } });
expect(auto).toEqual({ lang: 'tl', auto: true, url: 'o' });
expect(N.pickCaptionTrack({ automatic_captions: { es: [{ ext: 'vtt', url: 's' }] } })).toBeNull();
});
test('admin cookie signature and expiry', () => {
const v = N.signAdminCookie('k', 2000);
expect(N.verifyAdminCookie('k', v, 1000)).toBe(true);
expect(N.verifyAdminCookie('k', v, 3000)).toBe(false);
expect(N.verifyAdminCookie('other', v, 1000)).toBe(false);
expect(N.verifyAdminCookie('k', '2000.forged', 1000)).toBe(false);
});
});
describe('routes', () => {
test('writing requires an existing profile', async () => {
const lyrics = { lines: [{ t: 1, text: 'hello', kind: 'line' }], tags: [], offset: 0 };
let res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: lyrics, baseRev: 0 }));
expect(res.status).toBe(401);
res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: lyrics, baseRev: 0, profile: 'nobody' }));
expect(res.status).toBe(401);
res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: lyrics, baseRev: 0, profile: 'josh' }));
expect(res.status).toBe(200);
expect((await res.json()).rev).toBe(1);
});
test('a stale baseRev gets 409 with the current copy', async () => {
const lyrics = { lines: [{ t: 2, text: 'stale edit', kind: 'line' }] };
const res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: lyrics, baseRev: 0, profile: 'josh' }));
expect(res.status).toBe(409);
const j = await res.json();
expect(j.current.rev).toBe(1);
expect(j.current.data.lines[0].text).toBe('hello');
});
test('GET returns both kinds; revisions are listed and readable', async () => {
await app.request(`/api/notes/${VID}/chapters`, json('PUT', { data: { items: [{ t: 42, title: 'Intro' }] }, baseRev: 0, profile: 'josh' }));
const all = await (await app.request(`/api/notes/${VID}`)).json();
expect(all.lyrics.rev).toBe(1);
expect(all.lyrics.updatedBy).toBe('josh');
expect(all.chapters.data.items[0].title).toBe('Intro');
const revs = await (await app.request(`/api/notes/${VID}/lyrics/revs`)).json();
expect(revs.revs.map((r) => r.rev)).toEqual([1]);
const one = await (await app.request(`/api/notes/${VID}/lyrics/revs/1`)).json();
expect(one.data.lines[0].text).toBe('hello');
expect((await app.request('/api/notes/not-an-id')).status).toBe(400);
});
test('captions preview parses the original-language auto track', async () => {
const j = await (await app.request(`/api/notes/${VID}/captions`)).json();
expect(j.ok).toBe(true);
expect(j.lang).toBe('en');
expect(j.lines.map((l) => l.text)).toEqual(['first line', 'second & line']);
});
test('admin login, API tokens, auto-inject and restore', async () => {
expect((await app.request('/api/admin/login', json('POST', { password: 'nope' }))).status).toBe(401);
expect((await app.request('/api/admin/tokens')).status).toBe(401);
const cookie = await adminCookie();
const created = await (await app.request('/api/admin/tokens', json('POST', { label: 'lyrics-bot' }, { Cookie: cookie }))).json();
expect(created.token.startsWith('ytp_')).toBe(true);
const auth = { Authorization: `Bearer ${created.token}` };
// Auto-inject refuses to clobber existing lyrics unless asked.
let res = await app.request(`/api/notes/${VID}/lyrics/auto`, json('POST', {}, auth));
expect(res.status).toBe(409);
res = await app.request(`/api/notes/${VID}/lyrics/auto`, json('POST', { overwrite: true }, auth));
const inj = await res.json();
expect(inj.ok).toBe(true);
expect(inj.rev).toBe(2);
expect(captionCalls).toBe(1); // preview result was reused
// A profile alone may not trigger injection.
expect((await app.request(`/api/notes/${VID}/lyrics/auto`, json('POST', { profile: 'josh' }))).status).toBe(401);
// Token PUT is attributed to the token and may force past a stale rev.
res = await app.request(`/api/notes/${VID}/lyrics`, json('PUT', { data: { lines: [{ t: 3, text: 'api', kind: 'line' }] }, baseRev: 0, force: true }, auth));
expect((await res.json()).rev).toBe(3);
const cur = await (await app.request(`/api/notes/${VID}`)).json();
expect(cur.lyrics.updatedBy).toBe('api:lyrics-bot');
expect(cur.lyrics.source).toBe('api');
// Restore rev 1 → becomes rev 4 with the original text.
res = await app.request(`/api/admin/notes/${VID}/lyrics/restore`, json('POST', { rev: 1 }, { Cookie: cookie }));
expect((await res.json()).rev).toBe(4);
const after = await (await app.request(`/api/notes/${VID}`)).json();
expect(after.lyrics.data.lines[0].text).toBe('hello');
expect(after.lyrics.source).toBe('restore');
const recent = await (await app.request('/api/admin/notes/recent', { headers: { Cookie: cookie } })).json();
expect(recent.revs[0].rev).toBe(4);
// 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));
expect(res.status).toBe(401);
});
test('admin page is served', async () => {
const res = await app.request('/admin');
expect(res.status).toBe(200);
expect(await res.text()).toContain('admin');
});
});

View File

@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "bun server.js", "start": "bun server.js",
"dev": "bun --hot server.js" "dev": "bun --hot server.js",
"test": "bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js"
}, },
"dependencies": { "dependencies": {
"hono": "^4.7.10", "hono": "^4.7.10",

View File

@@ -39,6 +39,9 @@ import { createHash } from 'node:crypto';
import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox, import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox,
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.js'; getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.js';
import { createMediaCache, HIGH, LOW } from './media-cache.js'; import { createMediaCache, HIGH, LOW } from './media-cache.js';
import * as notesDb from './db.js';
import { registerNoteRoutes } from './notes.js';
import { dirname, join as pathJoin } from 'node:path';
// A media proxy must not die because one client's stream hit an edge case // A media proxy must not die because one client's stream hit an edge case
// (see /api/play cancel()): log and keep serving instead of crash-looping. // (see /api/play cancel()): log and keep serving instead of crash-looping.
@@ -1554,6 +1557,19 @@ app.get('/api/user/data', async (c) => {
} }
}); });
// ============================================================================
// Shared lyrics + chapters, API tokens, admin page — see notes.js
// ============================================================================
const notes = registerNoteRoutes(app, {
db: notesDb,
getProfile,
profileNameRe: PROFILE_NAME_RE,
runYtdlp: runYtdlpResilient,
adminPassword: process.env.ADMIN_PASSWORD || '',
backupDir: pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'backups'),
adminHtmlPath: './public/admin.html',
});
// ============================================================================ // ============================================================================
// GET /sw.js — serve the service worker with BUILD_TAG injected // GET /sw.js — serve the service worker with BUILD_TAG injected
// //
@@ -1623,6 +1639,7 @@ async function main() {
await initDb(); await initDb();
console.log(`[ytplayer] DB ready`); console.log(`[ytplayer] DB ready`);
await media.init(); await media.init();
notes.startBackups();
console.log(`[ytplayer] Starting on port ${PORT}`); console.log(`[ytplayer] Starting on port ${PORT}`);
// Bun.serve is the native Bun HTTP server // Bun.serve is the native Bun HTTP server