897 lines
44 KiB
HTML
897 lines
44 KiB
HTML
<!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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|