321 lines
14 KiB
HTML
321 lines
14 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); }
|
|
</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>
|