Add shared synced lyrics, chapters and personal bookmarks with revision history, API tokens and an admin page
This commit is contained in:
37
CLAUDE.md
37
CLAUDE.md
@@ -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` |
|
||||
| `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`,
|
||||
`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
|
||||
@@ -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
|
||||
`/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
|
||||
|
||||
```bash
|
||||
@@ -192,7 +222,10 @@ Local DB file: `server/data/ytplayer.db` (gitignored). `BUILD_TAG` is computed f
|
||||
|
||||
## Testing
|
||||
- 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.
|
||||
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
|
||||
|
||||
@@ -9,6 +9,9 @@ services:
|
||||
PORT: "3000"
|
||||
DB_PATH: "/app/data/ytplayer.db"
|
||||
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
|
||||
# YTDLP_PATH: "/usr/local/bin/yt-dlp"
|
||||
# Server media cache (server/media-cache.js) — defaults shown.
|
||||
|
||||
320
frontend/admin.html
Normal file
320
frontend/admin.html
Normal 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
745
frontend/app.js
745
frontend/app.js
@@ -269,7 +269,7 @@ const DEFAULT_SETTINGS = {
|
||||
autoBackupIntervalDays: 7,
|
||||
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 searchResults = [];
|
||||
// Distinguishes "never searched" (hide the list pane, show the landing) from
|
||||
@@ -377,6 +377,8 @@ function profilePayload() {
|
||||
rememberPos: data.rememberPos,
|
||||
playCount: data.playCount,
|
||||
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.playCount && typeof payload.playCount === 'object') data.playCount = payload.playCount;
|
||||
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 };
|
||||
data.profile = { name, syncedAt: updatedAt || 0 };
|
||||
API.saveData(data).catch(() => {});
|
||||
@@ -966,11 +970,14 @@ function toast(msg, { duration = 2200 } = {}) {
|
||||
setTimeout(() => t.remove(), 280);
|
||||
}, duration);
|
||||
|
||||
// Cap at 3 toasts — remove oldest
|
||||
while (container.children.length > 3) {
|
||||
const first = container.firstChild;
|
||||
first.classList.add('toast-exit');
|
||||
setTimeout(() => first.remove(), 280);
|
||||
// Cap at 3 toasts — fade out the oldest. Removal waits for the exit
|
||||
// animation, so only count toasts that aren't already leaving: the old
|
||||
// `while (children.length > 3)` never removed anything synchronously and
|
||||
// froze the page for good the moment a 4th toast appeared.
|
||||
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;
|
||||
}
|
||||
@@ -1622,6 +1629,7 @@ const Player = {
|
||||
// Shared UI updates once `current` is populated (cached or streamed).
|
||||
afterLoad() {
|
||||
addToHistory(current.meta);
|
||||
Notes.load(current.meta);
|
||||
buildQualityMenu();
|
||||
els.playerPane.classList.remove('empty');
|
||||
els.controls.classList.remove('hidden');
|
||||
@@ -2262,6 +2270,721 @@ function updateNowPlayingActions() {
|
||||
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() {
|
||||
const cur = Player.master.currentTime || 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);
|
||||
updateMiniBar();
|
||||
updateServiceProgress(cur, dur);
|
||||
Notes.tick(cur);
|
||||
// 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
|
||||
// we advance per the repeat settings (next track, or wrap when "repeat list").
|
||||
@@ -4833,7 +5557,9 @@ document.querySelectorAll('.chip').forEach((c) => {
|
||||
|
||||
// Keyboard
|
||||
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(); }
|
||||
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));
|
||||
@@ -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 === 'a') { if (current) setAbA(); }
|
||||
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 === 'Escape') {
|
||||
if (isServiceModeOpen()) exitServiceMode();
|
||||
@@ -5208,6 +5935,8 @@ function importBackup(e) {
|
||||
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);
|
||||
Object.assign(data.abMarkers, imp.abMarkers || {});
|
||||
Object.assign(data.bookmarks, imp.bookmarks || {});
|
||||
Object.assign(data.lyricOffsets, imp.lyricOffsets || {});
|
||||
persist();
|
||||
closeModal();
|
||||
render();
|
||||
@@ -5467,6 +6196,8 @@ async function boot() {
|
||||
rememberPos: loaded.rememberPos || {},
|
||||
playCount: loaded.playCount || {},
|
||||
abMarkers: loaded.abMarkers || {},
|
||||
bookmarks: loaded.bookmarks || {},
|
||||
lyricOffsets: loaded.lyricOffsets || {},
|
||||
lastAutoBackup: loaded.lastAutoBackup || 0,
|
||||
settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) },
|
||||
profile: loaded.profile || null,
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
<span id="curTime" class="time">0:00</span>
|
||||
<div class="seek-wrap">
|
||||
<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">
|
||||
<span id="abALabel" class="ab-label">A: --</span>
|
||||
<span style="flex:1"></span>
|
||||
@@ -209,6 +210,58 @@
|
||||
</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 & 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 -->
|
||||
<div id="upnext" class="upnext hidden">
|
||||
<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>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>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>Esc</kbd><span>Close overlay / cancel selection</span></div>
|
||||
</div>
|
||||
@@ -366,6 +420,7 @@
|
||||
<script src="fingerprint.js"></script>
|
||||
<script src="opfs.js"></script>
|
||||
<script src="video-edit.js"></script>
|
||||
<script src="lyrics-core.js"></script>
|
||||
<script src="async-guard.js"></script>
|
||||
<script src="sw-update.js"></script>
|
||||
<script src="app.js"></script>
|
||||
|
||||
165
frontend/lyrics-core.js
Normal file
165
frontend/lyrics-core.js
Normal 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);
|
||||
80
frontend/lyrics-core.test.js
Normal file
80
frontend/lyrics-core.test.js
Normal 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);
|
||||
});
|
||||
@@ -1238,7 +1238,13 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 13.5px;
|
||||
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);
|
||||
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; }
|
||||
}
|
||||
|
||||
|
||||
/* ============================================================================
|
||||
* 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; }
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ const SHELL = [
|
||||
'/fingerprint.js',
|
||||
'/opfs.js',
|
||||
'/opfs-worker.js',
|
||||
'/video-edit.js',
|
||||
'/lyrics-core.js',
|
||||
'/app.js',
|
||||
'/manifest.webmanifest',
|
||||
'/icons/icon-192.png',
|
||||
@@ -143,6 +145,9 @@ self.addEventListener('fetch', (e) => {
|
||||
// Only intercept GET/HEAD — let POST (sync endpoint) go through unmodified
|
||||
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
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
e.respondWith(fetch(request));
|
||||
|
||||
172
server/db.js
172
server/db.js
@@ -111,9 +111,181 @@ export async function initDb() {
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_media_lru
|
||||
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 -------------------------------------------------------------
|
||||
|
||||
const MEDIA_COLS = new Set([
|
||||
|
||||
490
server/notes.js
Normal file
490
server/notes.js
Normal 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(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ');
|
||||
}
|
||||
|
||||
// WebVTT → [{t, text}]. YouTube's auto captions "roll": each cue repeats the
|
||||
// previous line above the new one, and every line also appears in a 10 ms
|
||||
// echo cue. Emitting a line only when it isn't among the last few emitted
|
||||
// collapses that to one entry per sung line, stamped when it first appears.
|
||||
// Pure sound tags ([Music], [Applause], ♪) are dropped. Human subtitles wrap
|
||||
// one sentence over two rows of a single cue, so `joinCue` merges a cue's
|
||||
// rows into one line (auto captions must not be joined — their rows are the
|
||||
// previous line plus the new one).
|
||||
export function parseVtt(text, { joinCue = false } = {}) {
|
||||
const out = [];
|
||||
const recent = [];
|
||||
const blocks = String(text || '').replace(/\r/g, '').split(/\n\s*\n/);
|
||||
for (const block of blocks) {
|
||||
const rows = block.split('\n');
|
||||
const idx = rows.findIndex((r) => r.includes('-->'));
|
||||
if (idx < 0) continue;
|
||||
const t = vttTime(rows[idx].split('-->')[0]);
|
||||
if (t === null) continue;
|
||||
const cueRows = rows.slice(idx + 1);
|
||||
for (const raw of joinCue ? [cueRows.join(' ')] : cueRows) {
|
||||
const line = decodeEntities(raw.replace(/<[^>]*>/g, '')).replace(/\s+/g, ' ').trim();
|
||||
if (!line) continue;
|
||||
const stripped = line.replace(/\[[^\]]*\]|\([^)]*\)|[♪♫]/g, '').trim();
|
||||
if (!stripped) continue;
|
||||
if (recent.includes(line)) continue;
|
||||
recent.push(line);
|
||||
if (recent.length > 3) recent.shift();
|
||||
out.push({ t: Math.round(t * 100) / 100, text: line.slice(0, MAX_LINE_CHARS) });
|
||||
if (out.length >= MAX_LINES) return out;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Choose the best caption track from yt-dlp's info JSON: human-made subtitles
|
||||
// first (original language, then English, then any), else the auto captions
|
||||
// in the video's own spoken language — never a machine translation.
|
||||
export function pickCaptionTrack(info) {
|
||||
const lang0 = String(info?.language || '').toLowerCase();
|
||||
const vtt = (fmts) => (Array.isArray(fmts) ? fmts.find((f) => f && f.ext === 'vtt' && f.url) : null);
|
||||
const rank = (k) => {
|
||||
const l = k.toLowerCase();
|
||||
if (lang0 && (l === lang0 || l.startsWith(lang0 + '-'))) return 0;
|
||||
if (l === 'en' || l.startsWith('en-')) return 1;
|
||||
return 2;
|
||||
};
|
||||
const subs = info?.subtitles || {};
|
||||
for (const k of Object.keys(subs).filter((k) => k !== 'live_chat').sort((a, b) => rank(a) - rank(b))) {
|
||||
const f = vtt(subs[k]);
|
||||
if (f) return { lang: k, auto: false, url: f.url };
|
||||
}
|
||||
const autos = info?.automatic_captions || {};
|
||||
const keys = Object.keys(autos);
|
||||
const orig = keys.find((k) => k.endsWith('-orig'))
|
||||
|| (lang0 && keys.find((k) => k.toLowerCase() === lang0))
|
||||
|| null;
|
||||
if (orig) {
|
||||
const f = vtt(autos[orig]);
|
||||
if (f) return { lang: orig.replace(/-orig$/, ''), auto: true, url: f.url };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- Tokens / admin cookie ------------------------------------------------------
|
||||
|
||||
export function hashToken(token) {
|
||||
return createHash('sha256').update(String(token)).digest('hex');
|
||||
}
|
||||
|
||||
export function newApiToken() {
|
||||
return 'ytp_' + randomBytes(24).toString('base64url');
|
||||
}
|
||||
|
||||
function safeEqual(a, b) {
|
||||
const x = Buffer.from(String(a)), y = Buffer.from(String(b));
|
||||
return x.length === y.length && timingSafeEqual(x, y);
|
||||
}
|
||||
|
||||
export function signAdminCookie(secret, expSec) {
|
||||
const mac = createHmac('sha256', secret).update(String(expSec)).digest('base64url');
|
||||
return `${expSec}.${mac}`;
|
||||
}
|
||||
|
||||
export function verifyAdminCookie(secret, value, nowSec = Math.floor(Date.now() / 1000)) {
|
||||
if (!secret || !value) return false;
|
||||
const [exp, mac] = String(value).split('.');
|
||||
if (!exp || !mac || !/^\d+$/.test(exp) || Number(exp) < nowSec) return false;
|
||||
const want = createHmac('sha256', secret).update(exp).digest('base64url');
|
||||
return safeEqual(mac, want);
|
||||
}
|
||||
|
||||
// ---- Routes ---------------------------------------------------------------------
|
||||
|
||||
export function registerNoteRoutes(app, deps) {
|
||||
const {
|
||||
db, getProfile, profileNameRe, runYtdlp, adminPassword, backupDir, adminHtmlPath,
|
||||
} = 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
205
server/notes.test.js
Normal 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 & 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');
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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": {
|
||||
"hono": "^4.7.10",
|
||||
|
||||
@@ -39,6 +39,9 @@ import { createHash } from 'node:crypto';
|
||||
import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, createSharedPlaylist, getSharedPlaylist, queueInboxPlaylist, listInbox, deleteInboxItem, countInbox,
|
||||
getMedia, upsertMedia, deleteMedia, listMedia, listMediaLru, touchMedia, mediaStats } from './db.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
|
||||
// (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
|
||||
//
|
||||
@@ -1623,6 +1639,7 @@ async function main() {
|
||||
await initDb();
|
||||
console.log(`[ytplayer] DB ready`);
|
||||
await media.init();
|
||||
notes.startBackups();
|
||||
console.log(`[ytplayer] Starting on port ${PORT}`);
|
||||
|
||||
// Bun.serve is the native Bun HTTP server
|
||||
|
||||
Reference in New Issue
Block a user