Files
ytplayer/frontend/admin.html
2026-09-30 08:02:52 +00:00

897 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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); }
/* --- lyric editor --- */
#leWrap { margin-top: 12px; }
#leWave { width: 100%; height: 72px; display: block; border: 1px solid var(--line); border-radius: 9px; background: var(--bg-1); cursor: pointer; touch-action: none; }
.le-clock { font-family: var(--mono); font-size: 18px; min-width: 96px; text-align: center; }
.le-clock b { color: var(--accent); }
.le-lines { max-height: 56vh; overflow-y: auto; border: 1px solid var(--line); border-radius: 9px; background: var(--bg-1); }
.le-line { display: flex; gap: 6px; align-items: center; padding: 4px 6px; border-bottom: 1px solid var(--line); }
.le-line:last-child { border-bottom: 0; }
.le-line.now { background: color-mix(in srgb, var(--accent) 16%, transparent); }
.le-line.next { background: color-mix(in srgb, var(--accent) 7%, transparent); }
.le-line.untimed .le-t { color: var(--warn); }
.le-t { font-family: var(--mono); font-size: 12px; width: 74px; flex: none; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 5px 4px; text-align: center; }
.le-line input.le-text { flex: 1 1 auto; min-width: 80px; background: transparent; border: 0; color: var(--text); font-size: 14px; padding: 5px 4px; }
.le-line input.le-text:focus { outline: 1px solid var(--accent); border-radius: 6px; }
.le-line.section input.le-text { font-weight: 700; color: var(--text-2); }
.le-line.cue input.le-text { font-style: italic; color: var(--warn); }
.le-mini { padding: 3px 6px; font-size: 12px; line-height: 1.1; flex: none; }
.le-kind { background: var(--bg-2); color: var(--text-2); border: 1px solid var(--line); border-radius: 6px; font-size: 11px; padding: 4px; flex: none; }
#leRaw { width: 100%; min-height: 40vh; background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: 9px; padding: 10px; font-family: var(--mono); font-size: 12px; }
.pill { font-family: var(--mono); font-size: 11px; border: 1px solid var(--line); border-radius: 999px; padding: 2px 8px; color: var(--text-dim); }
.pill.on { color: var(--accent); border-color: var(--accent); }
</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">Lyric editor</h2>
<span class="spacer"></span>
<span id="leState" class="pill">no song loaded</span>
</div>
<p class="muted">Play the song here and time every line against it: <b>Set</b> stamps the playhead onto a line, <b>Tap</b> mode walks down the lines as you tap (Space), and the waveform scrubs. Saving writes a new shared revision — the old one stays restorable below.</p>
<form id="leLoadForm" class="row">
<input type="text" id="leId" placeholder="YouTube URL, video id, or upl_… id" />
<button class="primary" type="submit">Load</button>
<button type="button" id="leFromWeb" title="Fetch lyrics from LRCLIB for this song">⤓ LRCLIB</button>
</form>
<div id="leMsg" class="msg"></div>
<div id="leWrap" class="hidden">
<div class="row" style="margin-bottom:8px">
<b id="leTitle"></b><span class="muted" id="leMeta"></span>
</div>
<canvas id="leWave" height="72"></canvas>
<div class="row" style="margin:8px 0">
<button type="button" id="lePlay">▶︎ Play</button>
<button type="button" id="leBack">« 5s</button>
<button type="button" id="leFwd">5s »</button>
<span class="le-clock"><b id="leCur">0:00.00</b> / <span id="leDur">0:00</span></span>
<select id="leRate" class="le-kind" title="Playback speed">
<option value="0.5">0.5×</option><option value="0.75">0.75×</option>
<option value="1" selected>1×</option><option value="1.25">1.25×</option><option value="1.5">1.5×</option>
</select>
<button type="button" id="leTap">⏱ Tap mode</button>
<span class="spacer"></span>
<label class="muted"><input type="checkbox" id="leFollow" checked /> Follow</label>
<button type="button" id="leRawBtn">✎ Text</button>
</div>
<div class="row" style="margin-bottom:8px">
<input type="text" id="leTags" placeholder="Tags — e.g. Key G, 70 BPM, from LRCLIB (synced)" />
<label class="muted">Shift all <input type="text" id="leShift" value="0.0" style="width:66px;flex:none" /> s</label>
<button type="button" id="leShiftBtn">Apply shift</button>
<button type="button" id="leAddBtn">+ Line</button>
</div>
<div class="le-lines" id="leLines"></div>
<textarea id="leRaw" class="hidden" spellcheck="false"></textarea>
<div class="row" style="margin-top:10px">
<button class="primary" id="leSave" type="button">Save lyrics</button>
<button type="button" id="leReload">Discard changes</button>
<span class="spacer"></span>
<span class="muted" id="leDirty"></span>
</div>
</div>
</section>
<section>
<h2>Upload video / audio</h2>
<p class="muted">Uploads are served by this server and appear in search and in the app's 📁 Library. Title, artist, duration, cover art and embedded lyrics are read from the file; a separate cover image and manual title/artist override them.</p>
<form id="upForm" class="row">
<input type="file" id="upFile" accept="video/*,audio/*" required />
<input type="text" id="upTitle" placeholder="Title (optional)" />
<input type="text" id="upArtist" placeholder="Artist (optional)" />
<label class="muted" style="display:flex;gap:6px;align-items:center">Cover <input type="file" id="upArt" accept="image/*" /></label>
<button class="primary" type="submit">Upload</button>
</form>
<div id="upProgress" class="msg"></div>
<div class="scroll"><table class="tbl" id="upTable"></table></div>
</section>
<section>
<div class="row" style="margin-bottom:10px">
<h2 style="margin:0">Peer-to-peer</h2>
<span class="spacer"></span>
<button id="p2pRefreshBtn">↻ Refresh</button>
</div>
<p class="muted">Devices share verified copies with each other (docs/p2p-architecture.md). A file's hash is only
added after the server itself hashed and validated it. The malware scan is off by default
(<code>P2P_MALWARE_SCAN=1</code> to enable). Holders are never expired — ones not re-checked for the
stale period are only marked stale.</p>
<div id="p2pSummary" class="msg"></div>
<div class="scroll"><table class="tbl" id="p2pTable"></table></div>
</section>
<section>
<div class="row" style="margin-bottom:10px">
<h2 style="margin:0">Recent edits</h2>
<span class="spacer"></span>
<button id="refreshBtn">↻ Refresh</button>
<a class="btn" href="/api/admin/notes/export">⤓ Export all (JSON)</a>
</div>
<p class="muted">Every save is kept as a revision. Restoring writes the old version back as a new revision, so nothing is ever lost. A JSON backup of all notes is also written daily to <code>/app/data/backups</code>.</p>
<div class="scroll"><table class="tbl" id="revTable"></table></div>
<pre id="revView" class="hidden"></pre>
</section>
<section>
<h2>API reference</h2>
<pre id="apiDocs"></pre>
</section>
</div>
</main>
<script>
(function () {
'use strict';
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const when = (sec) => (sec ? new Date(sec * 1000).toLocaleString() : '—');
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: 'same-origin',
headers: opts.body ? { 'Content-Type': 'application/json' } : {},
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
const j = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
if (res.status === 401 && path !== '/api/admin/login') { showLogin(); }
return j;
}
function videoIdFrom(input) {
const s = String(input || '').trim();
if (/^[A-Za-z0-9_-]{11}$/.test(s) || /^upl_[a-f0-9]{12}$/.test(s)) return s;
const m = s.match(/(?:v=|youtu\.be\/|shorts\/|embed\/|live\/)([A-Za-z0-9_-]{11})/)
|| s.match(/(upl_[a-f0-9]{12})/);
return m ? m[1] : null;
}
function showLogin() {
$('loginBox').classList.remove('hidden');
$('app').classList.add('hidden');
$('logoutBtn').classList.add('hidden');
}
async function loadP2p() {
const j = await api('/api/admin/p2p');
if (!j.ok) { $('p2pSummary').textContent = j.error || 'P2P unavailable'; return; }
const c = j.config, s = j.stats;
$('p2pSummary').textContent =
`P2P ${c.enabled ? 'ON' : 'OFF'} · malware scan ${c.malwareScan ? 'ON' : 'off'} · stale after ${c.staleDays} d · ` +
`keep ≥${c.keepMinViews} views/${c.keepDays} d or played in ${c.keepRecentDays} d — ` +
`${s.content} verified (${s.revoked} revoked) · ${s.devices} devices, ${s.online} online · ` +
`${s.holders} holdings over ${s.heldCids} files · intake ${s.intakeActive} running / ${s.intakeOpen} open`;
const mb = (b) => (Number(b) / 1048576).toFixed(1) + ' MB';
$('p2pTable').innerHTML = '<tr><th>Video</th><th>cid</th><th>Origin</th><th>Scan</th><th>Size</th><th>Verified</th><th>Holders</th><th></th></tr>' +
j.recent.map((r) => {
let title = '';
try { title = JSON.parse(r.meta || '{}').title || ''; } catch { /* no meta */ }
return `<tr${r.status === 'revoked' ? ' class="muted"' : ''}><td>${esc(title || r.video_id)}<br><small>${esc(r.video_id)}</small></td>` +
`<td><code>${esc(r.cid.slice(0, 12))}</code></td><td>${esc(r.origin)}</td><td>${esc(r.scan)}</td><td>${mb(r.size)}</td>` +
`<td>${esc(new Date(Number(r.verified_at)).toLocaleString())}</td><td>${r.holders}</td>` +
`<td>${r.status === 'verified' ? `<button data-revoke="${esc(r.cid)}">Revoke</button>` : 'revoked'}</td></tr>`;
}).join('');
}
$('p2pRefreshBtn').addEventListener('click', loadP2p);
$('p2pTable').addEventListener('click', async (e) => {
const b = e.target.closest('[data-revoke]');
if (!b || !confirm('Revoke this file? Devices will stop sharing it and the server will never accept it again.')) return;
const j = await api('/api/admin/p2p/revoke', { method: 'POST', body: { cid: b.dataset.revoke } });
if (!j.ok) alert(j.error || 'failed');
loadP2p();
});
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();
loadUploads();
loadP2p();
const want = videoIdFrom(new URLSearchParams(location.search).get('v') || '');
if (want) leOpen(want);
}
$('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>
${r.kind === 'lyrics' ? `<button data-edit="${esc(r.videoId)}">Edit</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 (e.target.dataset.edit) { leOpen(e.target.dataset.edit); return; }
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);
async function loadUploads() {
const j = await api('/api/uploads?limit=200');
const rows = (j.uploads || []).map((u) => `
<tr>
<td>${u.art ? `<img src="/api/uploads/${u.id}/art" alt="" style="width:56px;height:32px;object-fit:cover;border-radius:4px" />` : '<span class="muted">—</span>'}</td>
<td>${esc(u.title)}<br><span class="muted">${esc(u.artist || '')}</span></td>
<td>${u.kind}</td>
<td class="muted">${Math.round(u.duration)}s · ${(u.size / 1048576).toFixed(1)} MB</td>
<td><code>${esc(u.id)}</code></td>
<td><a class="btn" href="/?v=${esc(u.id)}" target="_blank" rel="noopener">Play</a>
<button data-lyrics="${esc(u.id)}">Lyrics</button>
<button class="danger" data-del="${esc(u.id)}">Delete</button></td>
</tr>`).join('');
$('upTable').innerHTML = rows
? `<tr><th></th><th>Title</th><th>Kind</th><th>Size</th><th>Id</th><th></th></tr>${rows}`
: '<tr><td class="muted">Nothing uploaded yet.</td></tr>';
}
$('upForm').addEventListener('submit', (e) => {
e.preventDefault();
const f = $('upFile').files[0];
if (!f) return;
const fd = new FormData();
fd.append('file', f);
if ($('upArt').files[0]) fd.append('art', $('upArt').files[0]);
if ($('upTitle').value.trim()) fd.append('title', $('upTitle').value.trim());
if ($('upArtist').value.trim()) fd.append('artist', $('upArtist').value.trim());
const msg = $('upProgress');
msg.className = 'msg';
msg.textContent = `Uploading ${f.name}…`;
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/admin/uploads');
xhr.withCredentials = true;
xhr.upload.onprogress = (ev) => { if (ev.lengthComputable) msg.textContent = `Uploading ${f.name}… ${Math.round((ev.loaded / ev.total) * 100)}%`; };
xhr.onload = () => {
let j = {};
try { j = JSON.parse(xhr.responseText); } catch { /* non-JSON */ }
if (xhr.status === 200 && j.ok) {
msg.className = 'msg ok';
msg.textContent = `Added “${j.upload.title}” (${j.upload.kind}${j.upload.art ? ', cover art' : ''}${j.lyricLines ? `, ${j.lyricLines} lyric lines` : ''}).`;
$('upForm').reset();
loadUploads();
} else {
msg.className = 'msg err';
msg.textContent = j.error || `Upload failed (HTTP ${xhr.status})`;
}
};
xhr.onerror = () => { msg.className = 'msg err'; msg.textContent = 'Upload failed — network error'; };
xhr.send(fd);
});
$('upTable').addEventListener('click', async (e) => {
if (e.target.dataset.lyrics) { leOpen(e.target.dataset.lyrics); return; }
const id = e.target.dataset.del;
if (!id || !confirm('Delete this upload? The file is removed from the server.')) return;
const j = await api(`/api/admin/uploads/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!j.ok) alert(j.error || 'failed');
loadUploads();
});
/* ========================================================================
* Lyric editor — the song plays here, so every line can be timed against
* what you actually hear. The rows are built ONCE per load and then only
* their classes/values change: rebuilding them would steal focus from the
* field being typed in and reset the scroll position.
* ====================================================================== */
const LE = {
id: '', rev: 0, lines: [], tags: [], offset: 0, dur: 0,
audio: null, peaks: null, rows: [], tap: false, dirty: false, raw: false,
};
const clamp2 = (n) => Math.round(n * 100) / 100;
function fmtT(t) {
if (t == null) return '';
const m = Math.floor(t / 60);
return `${m}:${(t - m * 60).toFixed(2).padStart(5, '0')}`;
}
function parseT(s) {
const v = String(s || '').trim();
if (!v) return null;
const m = v.match(/^(\d+):(\d{1,2}(?:\.\d+)?)$/);
const n = m ? Number(m[1]) * 60 + Number(m[2]) : Number(v);
return Number.isFinite(n) && n >= 0 ? clamp2(n) : null;
}
function leDirty(on) {
LE.dirty = on;
$('leDirty').textContent = on ? 'unsaved changes' : '';
$('leDirty').className = on ? 'muted' : 'muted';
}
function leSay(text, cls) { const m = $('leMsg'); m.className = 'msg' + (cls ? ' ' + cls : ''); m.textContent = text; }
async function leLoad(id, keepAudio) {
leSay('Loading…');
const [notes, streams] = await Promise.all([
api(`/api/notes/${id}`),
fetch(`/api/streams?v=${encodeURIComponent(id)}`, { credentials: 'same-origin' }).then((r) => r.json()).catch(() => ({})),
]);
const doc = (notes.lyrics && notes.lyrics.data) || { lines: [], tags: [], offset: 0 };
LE.id = id;
LE.rev = (notes.lyrics && notes.lyrics.rev) || 0;
LE.lines = (doc.lines || []).map((l) => ({ t: l.t == null ? null : clamp2(l.t), text: String(l.text || ''), kind: l.kind || 'line' }));
LE.tags = doc.tags || [];
LE.offset = Number(doc.offset) || 0;
const meta = (streams.data && streams.data.meta) || {};
LE.dur = Number(meta.duration) || 0;
$('leTitle').textContent = meta.title || id;
$('leMeta').textContent = ` ${meta.channel || ''} · ${LE.lines.length} lines · rev ${LE.rev}`;
$('leTags').value = LE.tags.join(', ');
$('leState').textContent = LE.lines.length ? `${LE.lines.filter((l) => l.t != null).length}/${LE.lines.length} timed` : 'no lyrics yet';
$('leState').className = 'pill' + (LE.lines.length && LE.lines.every((l) => l.t != null) ? ' on' : '');
$('leWrap').classList.remove('hidden');
leRender();
leDirty(false);
leSay(`Loaded ${id}`, 'ok');
if (!keepAudio) {
const url = (streams.data && streams.data.audioUrl)
|| ((streams.data && streams.data.qualities || []).find((q) => q.hasAudio) || {}).url || '';
if (LE.audio) { LE.audio.pause(); LE.audio.src = ''; }
LE.audio = null;
if (url) {
const a = new Audio(url);
a.preload = 'auto';
a.addEventListener('timeupdate', leTick);
a.addEventListener('seeked', leTick);
a.addEventListener('loadedmetadata', () => { if (a.duration) { LE.dur = a.duration; $('leDur').textContent = fmtT(a.duration).slice(0, -3); } leDraw(); });
a.addEventListener('play', () => { $('lePlay').textContent = '❚❚ Pause'; });
a.addEventListener('pause', () => { $('lePlay').textContent = '▶︎ Play'; });
a.addEventListener('error', () => leSay('No playable audio for this song on the server — you can still edit the text.', 'err'));
LE.audio = a;
} else {
leSay('This song is not cached on the server, so there is no audio to time against.', 'err');
}
$('leDur').textContent = fmtT(LE.dur).slice(0, -3);
LE.peaks = null;
fetch(`/api/media/${id}/peaks`, { credentials: 'same-origin' })
.then((r) => r.json()).then((j) => { if (j.ok) { LE.peaks = j.peaks; if (j.duration) LE.dur = j.duration; leDraw(); } })
.catch(() => { /* peaks are a nicety */ });
}
leDraw();
}
function leRow(line, i) {
const row = document.createElement('div');
row.className = 'le-line' + (line.t == null ? ' untimed' : '') + (line.kind !== 'line' ? ' ' + line.kind : '');
row.dataset.i = String(i);
row.innerHTML = `
<input class="le-t" value="${esc(fmtT(line.t))}" placeholder="—" title="Line time — type it, or use Set" />
<button class="le-mini" data-act="set" title="Stamp the playhead here (S)">Set</button>
<button class="le-mini" data-act="seek" title="Play from here">▶︎</button>
<button class="le-mini" data-act="nudge" data-d="-0.2" title="0.2 s earlier">−</button>
<button class="le-mini" data-act="nudge" data-d="0.2" title="0.2 s later">+</button>
<input class="le-text" value="${esc(line.text)}" />
<select class="le-kind" title="Line type">
<option value="line">line</option><option value="section">section</option><option value="cue">cue</option>
</select>
<button class="le-mini" data-act="add" title="Insert a line below">⏎</button>
<button class="le-mini danger" data-act="del" title="Delete this line">✕</button>`;
row.querySelector('.le-kind').value = line.kind;
return row;
}
function leRender() {
const box = $('leLines');
box.innerHTML = '';
LE.rows = LE.lines.map((l, i) => { const r = leRow(l, i); box.appendChild(r); return r; });
if (!LE.lines.length) box.innerHTML = '<div class="le-line muted" style="padding:12px">No lines yet — “+ Line”, paste them in ✎ Text, or pull them from LRCLIB.</div>';
$('leMeta').textContent = $('leMeta').textContent.replace(/\d+ lines/, `${LE.lines.length} lines`);
}
// Which line is sung at time t (doc offset included, as the app plays it).
function leIndexAt(t) {
let hit = -1;
for (let i = 0; i < LE.lines.length; i++) {
const lt = LE.lines[i].t;
if (lt != null && lt + LE.offset <= t) hit = i; else if (lt != null) break;
}
return hit;
}
let leLastIdx = -2;
function leTick() {
const t = LE.audio ? LE.audio.currentTime : 0;
$('leCur').textContent = fmtT(t);
const i = LE.tap ? leTapIdx() : leIndexAt(t);
if (i !== leLastIdx) {
// Clear every row, not just the previous pair: in tap mode (and after a
// seek) the index jumps, and a stale highlight would be left behind.
for (const r of LE.rows) r.classList.remove('now', 'next');
if (LE.rows[i]) LE.rows[i].classList.add('now');
if (LE.rows[i + 1]) LE.rows[i + 1].classList.add('next');
if ($('leFollow').checked && LE.rows[i]) LE.rows[i].scrollIntoView({ block: 'center', behavior: 'smooth' });
leLastIdx = i;
}
leDraw();
}
function leDraw() {
const c = $('leWave');
if (!c) return;
const w = c.clientWidth || 600, h = c.height;
const dpr = window.devicePixelRatio || 1;
if (c.width !== Math.round(w * dpr)) { c.width = Math.round(w * dpr); }
const g = c.getContext('2d');
g.setTransform(dpr, 0, 0, dpr, 0, 0);
const css = getComputedStyle(document.body);
g.clearRect(0, 0, w, h);
const dur = LE.dur || 1;
const cur = LE.audio ? LE.audio.currentTime : 0;
if (LE.peaks && LE.peaks.length) {
const n = LE.peaks.length, bw = w / n;
for (let i = 0; i < n; i++) {
const bh = Math.max(1, (LE.peaks[i] / 100) * (h - 8));
g.fillStyle = (i * dur) / n <= cur ? css.getPropertyValue('--accent') : css.getPropertyValue('--line');
g.fillRect(i * bw, (h - bh) / 2, Math.max(1, bw - 0.5), bh);
}
} else {
g.fillStyle = css.getPropertyValue('--line');
g.fillRect(0, h / 2 - 1, w, 2);
g.fillStyle = css.getPropertyValue('--accent');
g.fillRect(0, h / 2 - 1, (cur / dur) * w, 2);
}
// line markers
g.fillStyle = css.getPropertyValue('--text-2');
for (const l of LE.lines) {
if (l.t == null) continue;
const x = ((l.t + LE.offset) / dur) * w;
g.fillRect(x, 0, 1, 6);
g.fillRect(x, h - 6, 1, 6);
}
g.fillStyle = css.getPropertyValue('--text');
g.fillRect((cur / dur) * w - 1, 0, 2, h);
}
function leSeek(t) {
if (!LE.audio) return;
LE.audio.currentTime = Math.max(0, Math.min(LE.dur || 1e9, t));
leTick();
}
// Tap mode: the next line without a time (or the one after the playhead).
function leTapIdx() {
const i = LE.lines.findIndex((l) => l.t == null);
if (i >= 0) return i;
return Math.min(LE.lines.length - 1, leIndexAt(LE.audio ? LE.audio.currentTime : 0) + 1);
}
function leStamp(i) {
if (!LE.audio || i < 0 || i >= LE.lines.length) return;
LE.lines[i].t = clamp2(LE.audio.currentTime);
const row = LE.rows[i];
if (row) { row.querySelector('.le-t').value = fmtT(LE.lines[i].t); row.classList.remove('untimed'); }
leDirty(true);
leLastIdx = -2;
leTick();
}
$('leLoadForm').addEventListener('submit', (e) => {
e.preventDefault();
const id = videoIdFrom($('leId').value);
if (!id) { leSay('Not a YouTube URL, video id or upl_ id', 'err'); return; }
if (LE.dirty && !confirm('Discard the unsaved changes to the song you have open?')) return;
leLoad(id);
});
$('leFromWeb').addEventListener('click', async () => {
const id = LE.id || videoIdFrom($('leId').value);
if (!id) { leSay('Load a song first', 'err'); return; }
leSay('Asking LRCLIB…');
const j = await api(`/api/notes/${id}/lyrics/web`, { method: 'POST', body: { overwrite: true } });
if (!j.ok) { leSay(j.error || 'LRCLIB has nothing for this song', 'err'); return; }
await leLoad(id, true);
leSay(`LRCLIB: ${j.lines} ${j.synced ? 'synced' : 'plain'} lines saved as rev ${j.rev} — the previous version is restorable below.`, 'ok');
loadRevs();
});
$('leLines').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-act]');
if (!btn) return;
const i = Number(btn.closest('.le-line').dataset.i);
const act = btn.dataset.act;
if (act === 'set') leStamp(i);
else if (act === 'seek') { leSeek((LE.lines[i].t || 0) + LE.offset); if (LE.audio && LE.audio.paused) LE.audio.play().catch(() => {}); }
else if (act === 'nudge') {
if (LE.lines[i].t == null) return;
LE.lines[i].t = clamp2(Math.max(0, LE.lines[i].t + Number(btn.dataset.d)));
btn.closest('.le-line').querySelector('.le-t').value = fmtT(LE.lines[i].t);
leDirty(true); leDraw();
} else if (act === 'add') {
LE.lines.splice(i + 1, 0, { t: null, text: '', kind: 'line' });
leRender(); leDirty(true);
const next = LE.rows[i + 1]; if (next) next.querySelector('.le-text').focus();
} else if (act === 'del') {
LE.lines.splice(i, 1);
leRender(); leDirty(true); leDraw();
}
});
$('leLines').addEventListener('input', (e) => {
const row = e.target.closest('.le-line');
if (!row) return;
const i = Number(row.dataset.i);
if (e.target.classList.contains('le-text')) { LE.lines[i].text = e.target.value; leDirty(true); }
});
$('leLines').addEventListener('change', (e) => {
const row = e.target.closest('.le-line');
if (!row) return;
const i = Number(row.dataset.i);
if (e.target.classList.contains('le-t')) {
LE.lines[i].t = parseT(e.target.value);
e.target.value = fmtT(LE.lines[i].t);
row.classList.toggle('untimed', LE.lines[i].t == null);
leDirty(true); leDraw();
} else if (e.target.classList.contains('le-kind')) {
LE.lines[i].kind = e.target.value;
row.classList.remove('section', 'cue');
if (e.target.value !== 'line') row.classList.add(e.target.value);
leDirty(true);
}
});
// Enter in a line = insert the next one below (writing lyrics by ear).
$('leLines').addEventListener('keydown', (e) => {
if (e.key !== 'Enter' || !e.target.classList.contains('le-text')) return;
e.preventDefault();
const i = Number(e.target.closest('.le-line').dataset.i);
LE.lines.splice(i + 1, 0, { t: null, text: '', kind: LE.lines[i].kind === 'section' ? 'line' : LE.lines[i].kind });
leRender(); leDirty(true);
if (LE.rows[i + 1]) LE.rows[i + 1].querySelector('.le-text').focus();
});
$('lePlay').addEventListener('click', () => {
if (!LE.audio) return;
if (LE.audio.paused) LE.audio.play().catch((err) => leSay('Playback failed: ' + err.message, 'err'));
else LE.audio.pause();
});
$('leBack').addEventListener('click', () => leSeek((LE.audio ? LE.audio.currentTime : 0) - 5));
$('leFwd').addEventListener('click', () => leSeek((LE.audio ? LE.audio.currentTime : 0) + 5));
$('leRate').addEventListener('change', () => { if (LE.audio) LE.audio.playbackRate = Number($('leRate').value); });
$('leTap').addEventListener('click', () => {
LE.tap = !LE.tap;
$('leTap').classList.toggle('primary', LE.tap);
leSay(LE.tap ? 'Tap mode: press Space (or S) on the beat to stamp the highlighted line; it then moves to the next one.' : '', LE.tap ? 'ok' : '');
leLastIdx = -2; leTick();
});
$('leWave').addEventListener('pointerdown', (e) => {
const r = e.currentTarget.getBoundingClientRect();
leSeek(((e.clientX - r.left) / r.width) * (LE.dur || 0));
});
window.addEventListener('resize', leDraw);
document.addEventListener('keydown', (e) => {
if ($('leWrap').classList.contains('hidden')) return;
const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
if (e.code === 'Space' && LE.tap && !typing) { e.preventDefault(); leStamp(leTapIdx()); return; }
if ((e.key === 's' || e.key === 'S') && !typing && !e.metaKey && !e.ctrlKey) { e.preventDefault(); leStamp(LE.tap ? leTapIdx() : leIndexAt(LE.audio ? LE.audio.currentTime : 0)); return; }
if (e.key === 'k' && !typing) { e.preventDefault(); $('lePlay').click(); }
});
$('leShiftBtn').addEventListener('click', () => {
const d = Number($('leShift').value);
if (!Number.isFinite(d) || !d) return;
for (const l of LE.lines) if (l.t != null) l.t = clamp2(Math.max(0, l.t + d));
leRender(); leDirty(true); leDraw();
leSay(`Shifted every timed line by ${d > 0 ? '+' : ''}${d}s`, 'ok');
});
$('leAddBtn').addEventListener('click', () => {
LE.lines.push({ t: LE.audio ? clamp2(LE.audio.currentTime) : null, text: '', kind: 'line' });
leRender(); leDirty(true);
const last = LE.rows[LE.rows.length - 1]; if (last) last.querySelector('.le-text').focus();
});
$('leTags').addEventListener('input', () => { LE.tags = $('leTags').value.split(',').map((s) => s.trim()).filter(Boolean); leDirty(true); });
// ✎ Text — the same LRC superset the app's own editor uses.
$('leRawBtn').addEventListener('click', () => {
LE.raw = !LE.raw;
$('leRaw').classList.toggle('hidden', !LE.raw);
$('leLines').classList.toggle('hidden', LE.raw);
$('leRawBtn').textContent = LE.raw ? '≡ Lines' : '✎ Text';
if (LE.raw) {
$('leRaw').value = LE.lines.map((l) => (l.t == null ? '' : `[${fmtT(l.t)}] `) + (l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : '') + l.text).join('\n');
} else {
LE.lines = $('leRaw').value.split('\n').map((raw) => {
let rest = raw.trim(), t = null;
const m = rest.match(/^\[(\d{1,3}):(\d{1,2}(?:[.:]\d{1,3})?)\]\s*/);
if (m) { t = clamp2(Number(m[1]) * 60 + Number(m[2].replace(':', '.'))); rest = rest.slice(m[0].length); }
let kind = 'line';
if (rest.startsWith('# ')) { kind = 'section'; rest = rest.slice(2); }
else if (rest.startsWith('! ')) { kind = 'cue'; rest = rest.slice(2); }
return { t, text: rest, kind };
}).filter((l) => l.text);
leRender(); leDirty(true); leDraw();
}
});
$('leReload').addEventListener('click', () => { if (LE.id && confirm('Throw away the unsaved changes and reload from the server?')) leLoad(LE.id, true); });
$('leSave').addEventListener('click', async () => {
if (!LE.id) return;
const lines = LE.lines.filter((l) => l.text.trim()).map((l) => ({ t: l.t, text: l.text.trim(), kind: l.kind }));
if (!lines.length) { leSay('Nothing to save', 'err'); return; }
const data = { lines, tags: LE.tags, offset: LE.offset };
leSay('Saving…');
let j = await api(`/api/notes/${LE.id}/lyrics`, { method: 'PUT', body: { data, baseRev: LE.rev } });
if (!j.ok && j.current) {
if (!confirm(`Someone else saved this song while you were editing (their rev ${j.current.rev}).\n\nOK = keep YOUR version, Cancel = load theirs.`)) {
await leLoad(LE.id, true);
return;
}
j = await api(`/api/notes/${LE.id}/lyrics`, { method: 'PUT', body: { data, force: true } });
}
if (!j.ok) { leSay(j.error || 'save failed', 'err'); return; }
LE.rev = j.rev;
leDirty(false);
const timed = lines.filter((l) => l.t != null).length;
$('leState').textContent = `${timed}/${lines.length} timed`;
$('leState').className = 'pill' + (timed === lines.length ? ' on' : '');
leSay(`Saved ${lines.length} lines as rev ${j.rev}.`, 'ok');
loadRevs();
});
window.addEventListener('beforeunload', (e) => { if (LE.dirty) { e.preventDefault(); e.returnValue = ''; } });
// "Edit" from the revisions feed and ?v= in the URL open a song here.
function leOpen(id) {
$('leId').value = id;
leLoad(id);
$('leWrap').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
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": ""}]}}'
# Upload media (admin cookie or token; multipart)
curl -X POST ${o}/api/admin/uploads -H "Authorization: Bearer $YTP_TOKEN" \\
-F file=@song.mp3 -F art=@cover.jpg -F 'title=My song' -F 'artist=Some artist'
curl ${o}/api/uploads?q=worship # public list / search
# Lyrics from LRCLIB (synced when available; profile, token or admin)
curl -X POST ${o}/api/notes/VIDEO_ID/lyrics/web \\
-H "Authorization: Bearer $YTP_TOKEN" -H "Content-Type: application/json" -d '{"overwrite": false}'
# Lyric editor deep link (admin): /admin?v=VIDEO_ID opens that song in the editor
# 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>