2483 lines
194 KiB
HTML
2483 lines
194 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||
<meta name="robots" content="noindex" />
|
||
<meta name="theme-color" content="#0a0c12" />
|
||
<title>Lyrics Studio</title>
|
||
<link rel="stylesheet" href="fonts/fonts.css" />
|
||
<link rel="stylesheet" href="download-actions.css" />
|
||
<script>
|
||
try {
|
||
const savedTheme = JSON.parse(localStorage.getItem('_ytpdata') || '{}').settings?.theme;
|
||
document.documentElement.dataset.editorTheme = ['light', 'contrast'].includes(savedTheme) ? savedTheme : 'dark';
|
||
} catch { document.documentElement.dataset.editorTheme = 'dark'; }
|
||
</script>
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
--bg: #121315; --bg-1: #18191c; --bg-2: #202226; --bg-3: #2b2e33; --bg-4: #35383d;
|
||
--line: #42464d; --line-2: #42464d;
|
||
--text: #f3f1ed; --text-2: #c3c4c7; --text-dim: #a1a5ac;
|
||
--a1: #f06b53; --a2: #f06b53; --action: #b53625; --on-action: #fff; --grad: #b53625;
|
||
--ok: #4ade80; --on-ok: #06210f; --warn: #ffcf70; --bad: #ff8585;
|
||
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
|
||
--ui: "Hanken Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||
--display: "Bricolage Grotesque", Georgia, serif;
|
||
--nav-h: 62px;
|
||
--safe: env(safe-area-inset-bottom, 0px);
|
||
}
|
||
html[data-editor-theme="light"] {
|
||
color-scheme: light;
|
||
--bg: #f4f2ee; --bg-1: #fff; --bg-2: #efece6; --bg-3: #e5e0d8; --bg-4: #d8d2c8;
|
||
--line: #d8d2c8; --line-2: #d8d2c8;
|
||
--text: #1b1a18; --text-2: #4a4843; --text-dim: #625e59;
|
||
--a1: #b43623; --a2: #b43623; --action: #b43623; --on-action: #fff; --grad: #b43623;
|
||
--ok: #166534; --on-ok: #fff; --warn: #754500; --bad: #a32121;
|
||
}
|
||
html[data-editor-theme="contrast"] {
|
||
color-scheme: dark;
|
||
--bg: #000; --bg-1: #000; --bg-2: #0a0a0a; --bg-3: #161616; --bg-4: #242424;
|
||
--line: #fff; --line-2: #fff;
|
||
--text: #fff; --text-2: #fff; --text-dim: #e5e5e5;
|
||
--a1: #ff8a6c; --a2: #ff8a6c; --action: #ff5a3c; --on-action: #000; --grad: #ff5a3c;
|
||
--ok: #00ff70; --on-ok: #000; --warn: #ffe66d; --bad: #ff7777;
|
||
}
|
||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||
html { scroll-padding-top: 150px; }
|
||
body {
|
||
margin: 0; min-height: 100dvh; color: var(--text); font: 15px/1.5 var(--ui);
|
||
background: var(--bg);
|
||
}
|
||
html[data-editor-theme="light"] body { background: var(--bg); }
|
||
html[data-editor-theme="contrast"] body { background: var(--bg); }
|
||
html[data-editor-theme="light"] .top { background: rgba(244,242,238,.96); }
|
||
html[data-editor-theme="contrast"] .top { background: #000; }
|
||
html[data-editor-theme="light"] .top .tab[aria-selected="true"] { color: var(--text); background: var(--bg-2); box-shadow: inset 0 0 0 1px var(--line-2); }
|
||
html[data-editor-theme="contrast"] .top .tab[aria-selected="true"] { color: var(--text); background: var(--bg-3); box-shadow: inset 0 0 0 2px var(--a1); }
|
||
html[data-editor-theme="light"] .card { background: var(--bg-1); }
|
||
html[data-editor-theme="contrast"] .card { background: var(--bg-1); }
|
||
html[data-editor-theme="light"] .field { background: #fff; }
|
||
html[data-editor-theme="contrast"] .field { background: #000; }
|
||
html[data-editor-theme="light"] .btn.pri, html[data-editor-theme="contrast"] .btn.pri { background: var(--action); color: var(--on-action); }
|
||
html[data-editor-theme="light"] .btn.ok, html[data-editor-theme="contrast"] .btn.ok { color: var(--on-ok); }
|
||
html[data-editor-theme="light"] .btn.dng { border-color: var(--bad); background: transparent; }
|
||
html[data-editor-theme="contrast"] .btn.dng { border-color: var(--bad); background: transparent; }
|
||
html[data-editor-theme="light"] .badge { background: #754500; color: #fff; }
|
||
html[data-editor-theme="contrast"] .badge { background: #ffe66d; color: #000; }
|
||
html[data-editor-theme="light"] .ghost { background: #fff; }
|
||
html[data-editor-theme="contrast"] .ghost { background: #000; border-color: #fff; }
|
||
html[data-editor-theme="light"] a { color: #075f70; }
|
||
html[data-editor-theme="contrast"] a { color: #63efff; }
|
||
button, input, select, textarea { font: inherit; color: inherit; }
|
||
button { cursor: pointer; touch-action: manipulation; }
|
||
:is(button, a, input, select, textarea, [tabindex]):focus-visible { outline: 2px solid var(--a2); outline-offset: 2px; }
|
||
.hidden { display: none !important; }
|
||
.muted { color: var(--text-dim); }
|
||
code { font-family: var(--mono); font-size: 12.5px; }
|
||
a { color: var(--a2); }
|
||
|
||
/* ---------- chrome ---------- */
|
||
.top {
|
||
position: sticky; top: 0; z-index: 40; display: flex; align-items: center; gap: 14px;
|
||
padding: 10px 18px; background: var(--bg-1);
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
.brand { display: flex; align-items: center; gap: 10px; font-weight: 800; letter-spacing: -.02em; font-size: 17px; white-space: nowrap; }
|
||
.brand i { width: 30px; height: 30px; border-radius: 10px; background: var(--grad); display: grid; place-items: center; }
|
||
.brand i svg { width: 17px; height: 17px; stroke: #fff; fill: none; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
|
||
.tabs { display: flex; flex: 0 1 auto; min-width: 0; max-width: calc(100vw - 430px); gap: 2px; margin-left: 14px; overflow-x: auto; scrollbar-width: thin; }
|
||
.tab {
|
||
position: relative; display: flex; flex: 0 0 auto; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border: 0; border-radius: 11px;
|
||
background: transparent; color: var(--text-2); font-weight: 650; font-size: 14px; white-space: nowrap; transition: background .15s, color .15s;
|
||
}
|
||
.tab svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; flex: none; }
|
||
.tab:hover { background: rgba(255,255,255,.05); color: var(--text); }
|
||
.tab[aria-selected="true"] { color: var(--text); background: var(--bg-3); box-shadow: inset 0 0 0 1px var(--a1); }
|
||
.badge {
|
||
min-width: 19px; height: 19px; padding: 0 6px; border-radius: 99px; display: inline-grid; place-items: center;
|
||
background: var(--warn); color: #1a1206; font: 800 11px/1 var(--ui);
|
||
}
|
||
.grow { flex: 1; }
|
||
.ghost {
|
||
min-height: 44px; padding: 0 13px; border-radius: 10px; border: 1px solid var(--line-2); background: rgba(255,255,255,.04);
|
||
color: var(--text-2); font-weight: 600; font-size: 13px; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; flex: none;
|
||
}
|
||
.ghost:hover { color: var(--text); border-color: rgba(255,255,255,.28); }
|
||
|
||
main { max-width: 1040px; margin: 0 auto; padding: 22px 18px calc(var(--nav-h) + var(--safe) + 110px); }
|
||
.panel { display: none; animation: rise .28s ease both; }
|
||
.panel.on { display: block; }
|
||
@keyframes rise { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
||
h1.page { font-size: clamp(24px, 4.4vw, 32px); letter-spacing: -.03em; margin: 4px 0 4px; }
|
||
p.lead { color: var(--text-2); margin: 0 0 20px; max-width: 62ch; }
|
||
|
||
.card {
|
||
background: linear-gradient(180deg, rgba(255,255,255,.045), rgba(255,255,255,.02)), var(--bg-1);
|
||
border: 1px solid var(--line); border-radius: 20px; padding: 18px; margin-bottom: 16px;
|
||
}
|
||
.card h2 { margin: 0 0 4px; font-size: 16px; letter-spacing: -.01em; }
|
||
.card > p.muted { margin: 0 0 14px; font-size: 13.5px; }
|
||
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||
.field {
|
||
flex: 1 1 200px; min-width: 0; height: 44px; padding: 0 14px; border-radius: 13px;
|
||
border: 1px solid var(--line-2); background: rgba(0,0,0,.28); font-size: 16px;
|
||
}
|
||
.field:focus, .ln-text:focus, .note:focus { outline: 2px solid var(--a1); outline-offset: 1px; }
|
||
.btn {
|
||
height: 44px; padding: 0 18px; border-radius: 13px; border: 1px solid var(--line-2); background: rgba(255,255,255,.06);
|
||
font-weight: 700; font-size: 14px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; text-decoration: none; color: var(--text);
|
||
transition: transform .12s, filter .15s, background .15s;
|
||
}
|
||
.btn:hover { background: rgba(255,255,255,.1); }
|
||
.btn:active { transform: scale(.97); }
|
||
.btn.pri { border: 0; background: var(--action); color: var(--on-action); box-shadow: none; }
|
||
.btn.pri:hover { filter: brightness(1.1); }
|
||
.btn.ok { border: 0; background: var(--ok); color: var(--on-ok); }
|
||
.btn.dng { color: var(--bad); border-color: var(--bad); background: transparent; }
|
||
.btn.sm { height: 34px; padding: 0 12px; font-size: 13px; border-radius: 10px; }
|
||
.btn[disabled] { opacity: .45; pointer-events: none; }
|
||
.msg { min-height: 1.3em; margin-top: 10px; font-size: 13.5px; color: var(--text-2); }
|
||
.msg.err { color: var(--bad); } .msg.ok { color: var(--ok); }
|
||
|
||
.tbl { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||
.tbl th { text-align: left; color: var(--text-dim); font: 700 11px/1 var(--ui); letter-spacing: .1em; text-transform: uppercase; padding: 8px 8px; border-bottom: 1px solid var(--line); }
|
||
.tbl td { padding: 10px 8px; border-bottom: 1px solid var(--line); vertical-align: middle; }
|
||
.scroll { overflow-x: auto; margin: 0 -4px; padding: 0 4px; }
|
||
pre { margin: 0; padding: 14px; border-radius: 14px; background: rgba(0,0,0,.35); border: 1px solid var(--line); overflow: auto; font: 12px/1.55 var(--mono); white-space: pre-wrap; word-break: break-word; max-height: 60vh; }
|
||
.newtoken { margin-top: 12px; padding: 12px; border: 1px solid var(--ok); border-radius: 12px; background: rgba(74,222,128,.07); word-break: break-all; }
|
||
.src { font: 11px var(--mono); color: var(--text-dim); } .src.api, .src.auto { color: var(--warn); } .src.restore { color: var(--ok); }
|
||
.audit-filters { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:10px; align-items:end; }
|
||
.audit-filters label { display:grid; gap:5px; color:var(--text-2); font-size:12px; }
|
||
.audit-tools { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||
.audit-detail { color:var(--text-2); font:12px/1.4 var(--mono); max-width:36ch; overflow-wrap:anywhere; }
|
||
@media(max-width:720px) { .audit-filters { grid-template-columns:repeat(2,minmax(0,1fr)); } }
|
||
|
||
/* ---------- upload card ---------- */
|
||
.sr-file { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; clip: rect(0 0 0 0); }
|
||
.up-form { display: grid; gap: 14px; }
|
||
.drop {
|
||
position: relative; display: flex; align-items: center; gap: 14px; min-height: 76px; padding: 14px 16px;
|
||
border: 1.5px dashed var(--line-2); border-radius: 16px; background: rgba(0,0,0,.22); cursor: pointer;
|
||
transition: border-color .15s, background .15s;
|
||
}
|
||
.drop:hover, .drop.over { border-color: var(--a1); background: var(--bg-2); }
|
||
.drop:focus-within { outline: 2px solid var(--a1); outline-offset: 2px; }
|
||
.drop.has { border-style: solid; border-color: var(--a2); background: var(--bg-2); }
|
||
.drop-ico { flex: none; width: 40px; height: 40px; padding: 9px; border-radius: 12px; background: rgba(255,255,255,.06);
|
||
fill: none; stroke: var(--a2); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||
.drop-text { flex: 1 1 auto; min-width: 0; display: grid; gap: 2px; }
|
||
.drop-text strong { font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.drop-text span { font-size: 13px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.drop-btn { flex: none; height: 36px; padding: 0 14px; display: inline-flex; align-items: center; border-radius: 11px;
|
||
border: 1px solid var(--line-2); background: rgba(255,255,255,.06); font-weight: 700; font-size: 13px; }
|
||
.up-meta { display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; align-items: end; }
|
||
.fl { display: grid; gap: 6px; min-width: 0; }
|
||
.fl > span:first-child { font: 700 11px/1 var(--ui); letter-spacing: .08em; text-transform: uppercase; color: var(--text-dim); }
|
||
.fl .field { width: 100%; }
|
||
.cover-pick { cursor: pointer; }
|
||
.cover-box { height: 44px; display: inline-flex; align-items: center; gap: 8px; padding: 0 12px 0 6px; border-radius: 13px;
|
||
border: 1px solid var(--line-2); background: rgba(0,0,0,.28); max-width: 220px; }
|
||
.cover-box svg { flex: none; width: 32px; height: 32px; padding: 5px; border-radius: 9px; background: rgba(255,255,255,.06);
|
||
fill: none; stroke: var(--text-2); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
||
.cover-box img { flex: none; width: 32px; height: 32px; border-radius: 8px; object-fit: cover; }
|
||
.cover-box em { font-style: normal; font-size: 13.5px; color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.cover-pick:hover .cover-box { border-color: var(--a1); }
|
||
.cover-pick:focus-within .cover-box { outline: 2px solid var(--a1); outline-offset: 1px; }
|
||
.up-actions { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||
.up-actions .btn.pri { min-width: 132px; }
|
||
@media (max-width: 640px) {
|
||
.up-meta { grid-template-columns: 1fr; }
|
||
.cover-box { max-width: none; width: 100%; }
|
||
.drop-btn { display: none; }
|
||
}
|
||
|
||
/* ---------- login ---------- */
|
||
.gate { max-width: 420px; margin: 12vh auto 0; text-align: center; }
|
||
.gate .brand { justify-content: center; margin-bottom: 18px; font-size: 22px; }
|
||
|
||
/* ---------- editor: picker + empty state ---------- */
|
||
/* The editor follows the user's app appearance while the surrounding admin tools stay untouched. */
|
||
#p-editor {
|
||
--ed-bg: #121315; --ed-surface: #18191c; --ed-raised: #202226; --ed-selected: #2b2e33;
|
||
--ed-line: #42464d; --ed-text: #f3f1ed; --ed-muted: #c3c4c7; --ed-dim: #a1a5ac;
|
||
--ed-accent: #f06b53; --ed-action: #c6442f; --ed-on-action: #fff; --ed-on-accent: #171719;
|
||
--ed-wave-played: #f06b53; --ed-wave-unplayed: #8f9399; --ed-wave-mark: #c3c4c7; --ed-wave-cursor: #fff;
|
||
--ok: #4ade80; --warn: #ffcf70; --bad: #ff8585;
|
||
--bg: var(--ed-bg); --bg-1: var(--ed-surface); --bg-2: var(--ed-raised); --bg-3: var(--ed-selected);
|
||
--line: var(--ed-line); --line-2: var(--ed-line); --text: var(--ed-text); --text-2: var(--ed-muted); --text-dim: var(--ed-dim);
|
||
--a1: var(--ed-accent); --a2: var(--ed-accent); --grad: var(--ed-action);
|
||
color: var(--ed-text); color-scheme: dark; background: var(--ed-bg); border: 1px solid var(--ed-line); border-radius: 14px; padding: 16px;
|
||
}
|
||
html[data-editor-theme="light"] #p-editor {
|
||
--ed-bg: #f4f2ee; --ed-surface: #fff; --ed-raised: #efece6; --ed-selected: #e5e0d8;
|
||
--ed-line: #d8d2c8; --ed-text: #1b1a18; --ed-muted: #4a4843; --ed-dim: #625e59;
|
||
--ed-accent: #b43623; --ed-action: #b43623; --ed-on-action: #fff; --ed-on-accent: #fff;
|
||
--ed-wave-played: #b43623; --ed-wave-unplayed: #716c65; --ed-wave-mark: #4a4843; --ed-wave-cursor: #1b1a18;
|
||
--ok: #166534; --warn: #754500; --bad: #a32121;
|
||
color-scheme: light;
|
||
}
|
||
html[data-editor-theme="contrast"] #p-editor {
|
||
--ed-bg: #000; --ed-surface: #000; --ed-raised: #0a0a0a; --ed-selected: #161616;
|
||
--ed-line: #fff; --ed-text: #fff; --ed-muted: #fff; --ed-dim: #d8d8d8;
|
||
--ed-accent: #ff8a6c; --ed-action: #ff5a3c; --ed-on-action: #000; --ed-on-accent: #000;
|
||
--ed-wave-played: #ff5a3c; --ed-wave-unplayed: #fff; --ed-wave-mark: #fff; --ed-wave-cursor: #fff;
|
||
--ok: #00ff70; --warn: #ffff00; --bad: #ff7777;
|
||
color-scheme: dark;
|
||
}
|
||
#p-editor .card { background: var(--ed-surface); border-color: var(--ed-line); }
|
||
#p-editor .field, #p-editor select.mini, #p-editor .ln-tools input.tin { background: var(--ed-bg); border-color: var(--ed-line); color: var(--ed-text); }
|
||
#p-editor .deck { background: var(--ed-surface); border-color: var(--ed-line); box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||
#p-editor #wave { background: var(--ed-bg); }
|
||
#p-editor #raw { background: var(--ed-surface); border-color: var(--ed-line); color: var(--ed-text); }
|
||
#p-editor .btn { min-height: 44px; }
|
||
#p-editor .btn.pri { background: var(--ed-action); color: var(--ed-on-action); }
|
||
#p-editor .btn.ok { background: var(--ed-action); color: var(--ed-on-action); }
|
||
#p-editor .btn.dng { color: var(--ed-action); border-color: var(--ed-action); background: transparent; }
|
||
#p-editor :is(button, a, input, select, textarea, [tabindex]):focus-visible { outline: 3px solid var(--ed-accent); outline-offset: 2px; }
|
||
#p-editor ::selection { background: var(--ed-accent); color: var(--ed-bg); }
|
||
#p-editor :is(input, textarea) { caret-color: var(--ed-accent); }
|
||
#p-editor :is(input, textarea)::placeholder { color: var(--ed-dim); opacity: 1; }
|
||
|
||
/* ---------- editor home ---------- */
|
||
.brand, h1.page, .lib-head h2 { font-family: var(--display); }
|
||
.pick-head h1.page { font-size: clamp(32px, 5vw, 46px); line-height: 1.04; letter-spacing: -.035em; margin: 2px 0 10px; text-wrap: balance; }
|
||
.pick-head .lead { font-size: 16px; line-height: 1.6; max-width: 56ch; margin-bottom: 22px; }
|
||
.open-bar { display: flex; align-items: center; gap: 8px; padding: 6px 6px 6px 14px; border: 1px solid var(--line); border-radius: 16px; background: var(--bg-2); }
|
||
.open-bar:focus-within { border-color: var(--a1); }
|
||
#p-editor .open-bar .field { flex: 1 1 auto; height: 46px; padding: 0 4px; border: 0; background: transparent; outline: none; }
|
||
.open-bar .btn { flex: none; min-width: 92px; }
|
||
.open-ico { flex: none; width: 20px; height: 20px; fill: none; stroke: var(--text-dim); stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
|
||
#loadMsg:empty { min-height: 0; margin: 0; }
|
||
.chip { display: inline-flex; align-items: center; gap: 7px; }
|
||
.chip span { overflow: hidden; text-overflow: ellipsis; }
|
||
.chip-ico { flex: none; width: 15px; height: 15px; fill: none; stroke: var(--a1); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||
.library { margin-top: 30px; }
|
||
.lib-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||
.lib-head h2 { margin: 0; font-size: 24px; line-height: 1.15; letter-spacing: -.02em; }
|
||
.lib-count, .lib-filter span, .dur { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||
.lib-count { color: var(--text-dim); font-size: 13px; }
|
||
.lib-hint { margin: 6px 0 16px; color: var(--text-2); font-size: 14px; }
|
||
.coverage { margin: 0 0 18px; }
|
||
.cov-bar { height: 6px; border-radius: 99px; background: var(--bg-3); overflow: hidden; }
|
||
.cov-bar i { display: block; width: 0; height: 100%; border-radius: inherit; background: var(--ok); transition: width .7s cubic-bezier(.16, 1, .3, 1); }
|
||
.coverage p { margin: 8px 0 0; color: var(--text-2); font-size: 14px; }
|
||
.coverage b { color: var(--text); font-variant-numeric: tabular-nums; }
|
||
.lib-tools { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; margin-bottom: 14px; }
|
||
.lib-filter { display: flex; gap: 4px; padding: 4px; border-radius: 14px; background: var(--bg-2); overflow-x: auto; scrollbar-width: none; }
|
||
.lib-filter button { flex: 1 0 auto; min-height: 40px; padding: 0 13px; border: 0; border-radius: 10px; background: transparent; color: var(--text-2); font-weight: 650; font-size: 14px; white-space: nowrap; transition: background .15s, color .15s; }
|
||
.lib-filter button:hover { color: var(--text); }
|
||
.lib-filter button[aria-pressed="true"] { background: var(--bg-3); color: var(--text); box-shadow: inset 0 0 0 1px var(--line); }
|
||
.lib-filter span { margin-left: 3px; color: var(--text-dim); font-size: 12px; font-weight: 500; }
|
||
.lib-search { position: relative; display: flex; min-width: 0; }
|
||
.lib-search svg { position: absolute; left: 13px; top: 50%; width: 18px; height: 18px; transform: translateY(-50%); fill: none; stroke: var(--text-dim); stroke-width: 1.9; stroke-linecap: round; pointer-events: none; }
|
||
#p-editor .lib-search .field { width: 100%; height: 48px; padding-left: 40px; }
|
||
.song-art { position: relative; flex: none; display: grid; place-items: center; width: 88px; aspect-ratio: 16 / 9; border-radius: 6px; overflow: hidden; background: var(--bg-3); }
|
||
.song-art svg { width: 22px; height: 22px; fill: none; stroke: var(--text-dim); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||
.song-art img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||
.song-meta { display: flex; align-items: center; gap: 10px; margin-top: 6px; }
|
||
.dur { color: var(--text-dim); font-size: 12px; }
|
||
.song-row .st.none { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, transparent); }
|
||
.songs-empty { padding: 28px 16px; border: 1px dashed var(--line); border-radius: 14px; color: var(--text-2); text-align: center; }
|
||
.more-songs { width: 100%; margin-top: 12px; }
|
||
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 24px; margin: 34px 0 4px; padding: 0; list-style: none; }
|
||
.step { display: flex; gap: 12px; align-items: flex-start; padding: 16px 0 0; border-top: 1px solid var(--line); }
|
||
.step b { flex: none; display: grid; place-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--bg-3); color: var(--a1); font: 700 13px var(--mono); }
|
||
.step h3 { margin: 2px 0 2px; font-size: 15px; } .step p { margin: 0; font-size: 13.5px; color: var(--text-2); }
|
||
.songs { display: grid; gap: 8px; }
|
||
.song-row { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; padding: 10px 12px; border-radius: 14px; border: 1px solid var(--line); background: rgba(255,255,255,.03); }
|
||
.song-row:hover { border-color: var(--a1); }
|
||
.song-row b { display: block; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.song-row small { color: var(--text-dim); font-size: 12px; }
|
||
.song-row .st { flex: none; font: 700 11px var(--ui); padding: 3px 9px; border-radius: 99px; border: 1px solid var(--line-2); color: var(--text-dim); }
|
||
.song-row .st.has { color: var(--ok); border-color: rgba(74,222,128,.4); }
|
||
.song-open { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; padding: 0; border: 0; background: none; color: inherit; text-align: left; }
|
||
.whisper-request { min-height: 46px; flex: none; }
|
||
.whisper-draft { margin-block: 14px; }
|
||
.whisper-draft pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 180px; overflow: auto; font: 400 14px/1.55 var(--ui); color: var(--text); }
|
||
#whisperStatus { color: var(--text-dim); font-size: 14px; margin-block: 10px; }
|
||
.song { flex-wrap: wrap; }
|
||
@media (max-width: 480px) { .song-row { flex-wrap: wrap; } .song-open { flex-basis: 100%; min-height: 44px; } }
|
||
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; }
|
||
.chip { height: 34px; padding: 0 13px; border-radius: 99px; border: 1px solid var(--line-2); background: rgba(255,255,255,.04); font-size: 13px; font-weight: 600; color: var(--text-2); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.chip:hover { color: var(--text); border-color: var(--a1); }
|
||
|
||
/* ---------- editor: song header + deck ---------- */
|
||
.song { display: flex; align-items: center; gap: 14px; margin-bottom: 14px; }
|
||
.ring { --p: 0; width: 54px; height: 54px; border-radius: 50%; flex: none; display: grid; place-items: center;
|
||
background: conic-gradient(var(--a2) calc(var(--p) * 1%), rgba(255,255,255,.1) 0); position: relative; font: 700 12px var(--mono); }
|
||
.ring::before { content: ""; position: absolute; inset: 5px; border-radius: 50%; background: var(--bg-1); }
|
||
.ring span { position: relative; }
|
||
.song h1 { margin: 0; font-size: clamp(18px, 3.6vw, 24px); letter-spacing: -.02em; line-height: 1.2; overflow-wrap: anywhere; }
|
||
.song small { color: var(--text-dim); }
|
||
|
||
.deck {
|
||
position: sticky; top: 58px; z-index: 30; padding: 12px; margin: 0 0 14px; border-radius: 14px;
|
||
background: var(--ed-surface); border: 1px solid var(--ed-line);
|
||
}
|
||
#wave { width: 100%; height: 64px; display: block; border-radius: 8px; background: var(--ed-bg); cursor: pointer; touch-action: none; }
|
||
.transport { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
|
||
.round { width: 44px; min-width: 44px; height: 44px; border-radius: 10px; border: 1px solid var(--ed-line); background: var(--ed-raised); color: var(--ed-text); display: grid; place-items: center; font-size: 13px; font-weight: 800; }
|
||
.round.big { width: 52px; height: 52px; border: 0; border-radius: 50%; background: var(--ed-action); color: var(--ed-on-action); box-shadow: none; }
|
||
.round svg { width: 20px; height: 20px; }
|
||
.clock { font: 700 17px var(--mono); letter-spacing: -.02em; min-width: 108px; text-align: center; font-variant-numeric: tabular-nums; }
|
||
.clock b { color: var(--ed-accent); } .clock span { color: var(--ed-dim); font-size: 13px; }
|
||
.seg { display: inline-flex; padding: 3px; border-radius: 10px; background: var(--ed-bg); border: 1px solid var(--ed-line); }
|
||
.seg button { min-width: 76px; min-height: 44px; padding: 0 12px; border: 0; border-radius: 7px; background: transparent; color: var(--ed-muted); font-weight: 700; font-size: 13px; }
|
||
.seg button[aria-pressed="true"] { background: var(--ed-selected); color: var(--ed-text); box-shadow: inset 0 0 0 1px var(--ed-line); }
|
||
select.mini { min-height: 44px; border-radius: 8px; border: 1px solid var(--ed-line); background: var(--ed-bg); color: var(--ed-text); padding: 0 10px; font-size: 13px; }
|
||
.iconbtn { width: 44px; height: 44px; border-radius: 9px; border: 1px solid var(--ed-line); background: var(--ed-raised); color: var(--ed-text); display: grid; place-items: center; }
|
||
.iconbtn svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
|
||
.iconbtn[disabled] { opacity: .5; cursor: not-allowed; }
|
||
.iconbtn[aria-pressed="true"] { background: var(--ed-selected); border-color: var(--ed-accent); }
|
||
#p-editor :is(.btn, .round, .iconbtn, .seg button, .more, .tc):hover:not(:disabled) { border-color: var(--ed-accent); }
|
||
#p-editor :is(.btn, .round, .iconbtn, .seg button, .more, .tc):active:not(:disabled) { transform: translateY(1px); }
|
||
|
||
.drawer { display: grid; gap: 10px; padding: 14px; margin-bottom: 14px; border-radius: 18px; border: 1px dashed var(--line-2); background: rgba(255,255,255,.025); }
|
||
.drawer .row label { font-size: 13px; color: var(--text-2); display: flex; align-items: center; gap: 8px; }
|
||
|
||
/* ---------- editor: lines ---------- */
|
||
.lines { display: grid; gap: 8px; }
|
||
.ln { position: relative; border-radius: 10px; border: 1px solid var(--ed-line); background: var(--ed-surface); transition: background .16s, border-color .16s, box-shadow .16s; scroll-margin: 200px; }
|
||
.ln-main { display: grid; grid-template-columns: 88px minmax(0, 1fr) 44px; align-items: center; gap: 8px; padding: 8px; }
|
||
.tc {
|
||
flex: none; width: 88px; min-height: 44px; border-radius: 8px; border: 1px solid var(--ed-line); background: var(--ed-bg);
|
||
font: 700 13px var(--mono); color: var(--ed-muted); font-variant-numeric: tabular-nums;
|
||
}
|
||
.tc:hover { border-color: var(--ed-accent); color: var(--ed-text); }
|
||
.ln.untimed .tc { color: var(--ed-accent); border-style: dashed; }
|
||
.ln-text { width: 100%; min-width: 0; min-height: 44px; max-height: 8rem; height: auto; padding: 8px 10px; resize: vertical; overflow: auto; line-height: 1.4; border: 1px solid transparent; border-radius: 7px; background: transparent; color: var(--ed-text); font-size: 16px; }
|
||
.ln.section .ln-text { font-weight: 800; text-transform: uppercase; letter-spacing: .06em; font-size: 13px; color: var(--ed-accent); }
|
||
.ln.cue .ln-text { font-style: italic; color: var(--ed-muted); }
|
||
.ln.now { background: var(--ed-selected); border-color: var(--ed-line); }
|
||
.ln.now .tc { color: var(--ed-on-action); background: var(--ed-action); border-color: var(--ed-action); }
|
||
.ln.next { background: var(--ed-bg); }
|
||
.ln.sel { outline: 2px solid var(--ed-accent); outline-offset: 2px; }
|
||
.ln.sel .ln-text { border-color: var(--ed-line); background: var(--ed-bg); }
|
||
.ln.flash { animation: flash 1.6s ease; }
|
||
@keyframes flash { 0%,60% { box-shadow: 0 0 0 3px var(--warn); } 100% { box-shadow: 0 0 0 0 transparent; } }
|
||
.flagchip { flex: none; display: inline-flex; align-items: center; gap: 5px; height: 28px; padding: 0 9px; border-radius: 99px; border: 1px solid var(--warn); background: var(--ed-bg); color: var(--warn); font: 800 12px var(--ui); }
|
||
.flagchip svg { width: 12px; height: 12px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
|
||
.ln.flagged { border-color: var(--warn); }
|
||
.more { width: 44px; height: 44px; border-radius: 8px; border: 1px solid transparent; background: transparent; color: var(--ed-dim); font-size: 20px; line-height: 1; }
|
||
.more:hover { background: var(--ed-raised); color: var(--ed-text); }
|
||
.ln-tools { display: none; flex-wrap: wrap; gap: 8px; align-items: center; padding: 0 8px 8px; }
|
||
.ln.sel .ln-tools { display: flex; }
|
||
.ln-tools .btn { min-height: 44px; padding: 0 12px; font-size: 13px; border-radius: 8px; }
|
||
.ln-tools input.tin { width: 88px; height: 44px; text-align: center; font: 600 13px var(--mono); border-radius: 8px; border: 1px solid var(--ed-line); background: var(--ed-bg); }
|
||
.flagbox { display: grid; gap: 8px; margin: 0 10px 10px; padding: 12px; border-radius: 13px; background: var(--ed-bg); border: 1px solid var(--warn); }
|
||
.rep { display: flex; gap: 10px; align-items: flex-start; font-size: 13.5px; }
|
||
.rep .who { font-weight: 800; color: var(--warn); }
|
||
.rep .why { display: inline-block; margin-left: 6px; padding: 1px 8px; border-radius: 99px; background: rgba(255,255,255,.08); font-size: 11.5px; color: var(--text-2); }
|
||
.rep .txt { color: var(--text-2); overflow-wrap: anywhere; }
|
||
.empty { padding: 34px 14px; text-align: center; color: var(--text-dim); border: 1px dashed var(--line-2); border-radius: 18px; }
|
||
#raw { width: 100%; min-height: 46vh; padding: 14px; border-radius: 16px; border: 1px solid var(--line-2); background: rgba(0,0,0,.35); font: 13px/1.6 var(--mono); }
|
||
|
||
/* ---------- bars ---------- */
|
||
.savebar {
|
||
position: fixed; left: 0; right: 0; bottom: calc(var(--nav-h) + var(--safe)); z-index: 45; display: flex; justify-content: center; padding: 0 12px 10px; pointer-events: none;
|
||
}
|
||
.savebar > div {
|
||
pointer-events: auto; display: flex; align-items: center; gap: 12px; width: min(760px, 100%); padding: 10px 10px 10px 16px; border-radius: 20px;
|
||
background: rgba(20,23,34,.92); border: 1px solid var(--line-2); box-shadow: 0 20px 50px -14px #000;
|
||
-webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px);
|
||
}
|
||
#p-editor .savebar > div { background: var(--ed-surface); border-color: var(--ed-line); box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; }
|
||
#p-editor .dot, #p-editor .dirty .dot { box-shadow: none; }
|
||
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--ok); flex: none; box-shadow: 0 0 10px var(--ok); }
|
||
.dirty .dot { background: var(--warn); box-shadow: 0 0 10px var(--warn); animation: pulse 1.4s infinite; }
|
||
@keyframes pulse { 50% { opacity: .35; } }
|
||
.savebar .stat { font-size: 13px; color: var(--text-2); flex: 1; min-width: 0; }
|
||
.tap {
|
||
flex: none; min-width: 76px; min-height: 44px; height: 44px; padding: 0 12px; border-radius: 8px; border: 1px solid var(--ed-line);
|
||
background: var(--ed-action); color: var(--ed-on-action); font: 800 14px var(--ui); letter-spacing: .04em; box-shadow: none;
|
||
display: grid; place-items: center; text-align: center; line-height: 1.15;
|
||
}
|
||
.tap small { display: none; }
|
||
.tap:active { transform: translateY(1px); }
|
||
.keyboard-hint { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 8px 2px 0; min-width: 0; color: var(--ed-muted); font-size: 12px; }
|
||
#p-editor .ghost { min-height: 44px; }
|
||
.keyboard-hint kbd { min-width: 24px; padding: 2px 5px; border: 1px solid var(--ed-line); border-radius: 4px; background: var(--ed-bg); color: var(--ed-text); font: 600 11px var(--mono); text-align: center; }
|
||
.editor-footer { display: flex; align-items: center; justify-content: flex-start; gap: 12px; margin-top: 14px; }
|
||
.line-code { color: var(--ed-dim); font: 600 11px var(--mono); font-variant-numeric: tabular-nums; }
|
||
#p-editor .ring { background: conic-gradient(var(--ed-accent) calc(var(--p) * 1%), var(--ed-raised) 0); }
|
||
#p-editor .ring::before { background: var(--ed-surface); }
|
||
#p-editor .ring span { color: var(--ed-on-accent); }
|
||
#p-editor .flagchip, #p-editor .flagbox { color: var(--warn); }
|
||
|
||
.analytics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin: 20px 0; }
|
||
.metric { padding: 18px; background: var(--bg-2); border: 1px solid var(--line-2); border-radius: 14px; }
|
||
.metric b { display: block; font-size: 26px; margin: 4px 0; }
|
||
.metric small, .metric span { color: var(--text-2); }
|
||
.analytics-form { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; }
|
||
.analytics-form label { display: grid; gap: 5px; min-width: 0; }
|
||
.analytics-form .query { flex: 1 1 250px; }
|
||
.analytics-form .field { width: 100%; min-width: 0; }
|
||
.analytics-form button { min-height: 44px; }
|
||
#p-ops .btn, #p-devices .btn, #p-uploads .btn, #p-uploads .field, #p-devices .field { min-height: 44px; }
|
||
#p-uploads .tbl td { vertical-align: top; }
|
||
#p-uploads .tbl td .field { min-width: 130px; margin-bottom: 5px; }
|
||
#p-uploads .tbl label.btn { display: inline-flex; align-items: center; cursor: pointer; }
|
||
#p-uploads input[type="checkbox"], #p-devices input[type="checkbox"] { width: 20px; height: 20px; accent-color: var(--a1); }
|
||
#p-uploads input[type="file"]::file-selector-button { min-height: 36px; margin: 2px 8px 2px 0; padding: 0 10px; border: 1px solid var(--line-2); border-radius: 7px; background: var(--bg-3); color: var(--text); font: 650 12px var(--ui); }
|
||
.bulk-selection { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; margin: 12px 0; }
|
||
.bulk-selection label { display: inline-flex; align-items: center; gap: 9px; min-height: 44px; color: var(--text); }
|
||
.bulk-selection input[type="checkbox"] { width: 20px; height: 20px; accent-color: var(--a1); }
|
||
#bulkStart { min-width: 180px; }
|
||
#bulkJobs .card { margin-top: 10px; }
|
||
#bulkJobs small { display: block; margin-top: 8px; color: var(--text-2); overflow-wrap: anywhere; }
|
||
#bulkJobs .btn.sm { min-height: 44px; height: auto; }
|
||
.analytics-table { overflow-x: auto; margin-top: 12px; }
|
||
.analytics-table table { width: 100%; }
|
||
#opsCards { grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); }
|
||
#opsRoutes { min-width: 680px; }
|
||
#opsRoutes th:first-child, #opsRoutes td:first-child { position: sticky; left: 0; z-index: 1; background: var(--bg-1); }
|
||
#p-ops .scroll { overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: thin; }
|
||
#opsRoutes th:not(:first-child), #opsRoutes td:not(:first-child) { white-space: nowrap; }
|
||
.route-scroll-hint { display: none; }
|
||
.collection-job { padding: 14px 0; border-bottom: 1px solid var(--line-2); overflow-wrap: anywhere; }
|
||
.collection-job p { margin: 4px 0; color: var(--text-2); }
|
||
#metadataDetail { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 400px; overflow: auto; font-size: 13px; }
|
||
@media (max-width: 760px) { .route-scroll-hint { display: block; margin: 2px 0 8px; font-size: 12px; } }
|
||
@media (max-width: 480px) { .analytics-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .metric { padding: 12px; } .metric b { font-size: 22px; } #opsCards { grid-template-columns: repeat(2,minmax(0,1fr)); } }
|
||
.deep-grid { display: grid; grid-template-columns: repeat(auto-fit,minmax(min(100%,240px),1fr)); gap: 12px; }
|
||
.deep-grid label { display: grid; gap: 5px; min-width: 0; color: var(--text); font-size: 13px; }
|
||
.deep-search .muted { color: var(--text-2); }
|
||
.deep-grid .field { width: 100%; flex: initial; }
|
||
.deep-grid textarea.field { height: auto; min-height: 110px; padding: 10px 12px; resize: vertical; }
|
||
.deep-job { padding: 14px 0; border-bottom: 1px solid var(--line-2); overflow-wrap: anywhere; }
|
||
.deep-job progress { width: 100%; height: 8px; accent-color: var(--a1); }
|
||
.deep-log { max-height: 240px; overflow: auto; white-space: pre-wrap; font: 12px/1.5 var(--mono); color: var(--text-2); }
|
||
@media (min-width: 761px) and (max-width: 1100px) {
|
||
.top { flex-wrap: wrap; }
|
||
.tabs { order: 2; width: 100%; margin-left: 0; overflow-x: auto; }
|
||
.tabs .tab { flex: none; }
|
||
}
|
||
.navbar { display: none; }
|
||
.song-info { min-width: 0; flex: 1; }
|
||
.song-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||
.song-edit { display: none; }
|
||
.m-only { display: none; }
|
||
@media (max-width: 760px) {
|
||
.tabs, .top .desk-only { display: none; }
|
||
.top .brand { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||
.top .back-app, .top #logoutBtn { padding-inline: 10px; flex: none; }
|
||
.top { padding: calc(10px + env(safe-area-inset-top, 0px)) calc(14px + env(safe-area-inset-right, 0px)) 10px calc(14px + env(safe-area-inset-left, 0px)); }
|
||
.deck { top: calc(54px + env(safe-area-inset-top, 0px)); }
|
||
|
||
/* Song cards: what it is on top, every action full width beneath it. */
|
||
#songList { display: grid; gap: 12px; }
|
||
.song-row { display: grid; gap: 14px; padding: 14px; border-radius: 16px; border: 1px solid var(--bg-3); background: var(--bg-1); }
|
||
.song-row:hover { border-color: transparent; }
|
||
.song-open { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 12px; align-items: start; min-height: 0; flex-basis: auto; }
|
||
.song-art { width: 112px; }
|
||
.song-row b { font-size: 16px; line-height: 1.3; white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||
.song-row small { display: block; margin-top: 2px; font-size: 13.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.song-row .st { justify-self: start; font-size: 12px; padding: 4px 10px; }
|
||
.song-actions { display: grid; grid-template-columns: 1fr; gap: 8px; }
|
||
.song-actions .btn { width: 100%; min-height: 46px; justify-content: center; font-size: 15px; margin: 0; }
|
||
.song-actions .btn:not(.pri) { border-color: transparent; background: var(--bg-3); }
|
||
.pick-head h1.page { font-size: 34px; }
|
||
.pick-head .lead { font-size: 15.5px; margin-bottom: 18px; }
|
||
.lib-tools { grid-template-columns: minmax(0, 1fr); }
|
||
.lib-filter button { min-height: 44px; }
|
||
.steps { grid-template-columns: 1fr; gap: 0; }
|
||
.step { padding: 14px 0; }
|
||
.song-edit { display: inline-flex; }
|
||
.m-only { display: inline; } .d-only { display: none; }
|
||
|
||
.navbar {
|
||
position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; display: block;
|
||
padding: 0 env(safe-area-inset-right, 0px) var(--safe) env(safe-area-inset-left, 0px);
|
||
background: var(--bg-1); border-top: 1px solid var(--line-2);
|
||
}
|
||
.nav-rail { position: absolute; top: -1px; left: env(safe-area-inset-left, 0px); right: env(safe-area-inset-right, 0px); height: 2px; pointer-events: none; }
|
||
.nav-rail i { display: block; width: 0; height: 100%; border-radius: 2px; background: var(--a1); }
|
||
/* No side padding on the scroller: tabs are exactly 1/n of its width and
|
||
snapping is mandatory, so it always rests on whole tabs. */
|
||
.nav {
|
||
display: flex; height: var(--nav-h); padding: 0; gap: 0; overflow-x: auto; overflow-y: hidden;
|
||
overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch; scrollbar-width: none;
|
||
scroll-snap-type: x mandatory;
|
||
}
|
||
.nav::-webkit-scrollbar { display: none; }
|
||
.nav .tab {
|
||
flex: 0 0 var(--tab-w, 20%); width: var(--tab-w, 20%); min-width: 0; scroll-snap-align: start;
|
||
flex-direction: column; justify-content: center; gap: 3px; height: auto; min-height: 44px;
|
||
padding: 2px; border: solid transparent; border-width: 6px 3px; background-clip: padding-box;
|
||
font-size: 10.5px; line-height: 1.05; text-align: center; white-space: normal; border-radius: 15px;
|
||
}
|
||
.nav .tab span:not(.badge) { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||
.nav .tab .badge { position: absolute; top: 2px; left: 56%; }
|
||
.steps { grid-template-columns: 1fr; }
|
||
.ln-main { grid-template-columns: 82px minmax(0, 1fr) 44px; gap: 6px; padding: 7px; }
|
||
.ln-text { grid-column: 2; }
|
||
.tc { width: 82px; }
|
||
.deck { top: 54px; padding: 8px; margin: 0 0 10px; border-radius: 12px; }
|
||
#wave { height: 48px; }
|
||
.transport { display: grid; grid-template-columns: 44px 52px 44px minmax(72px, 1fr) 74px; gap: 6px; margin-top: 8px; }
|
||
#bBack { grid-column: 1; } #bPlay { grid-column: 2; } #bFwd { grid-column: 3; } .clock { grid-column: 4; } #rate { grid-column: 5; }
|
||
.transport .seg { grid-column: 1 / span 2; }
|
||
#bUndo { grid-column: 3; } #bRedo { grid-column: 4; } #bTools { grid-column: 5; }
|
||
.round { width: 44px; height: 44px; } .round.big { width: 52px; height: 52px; }
|
||
.clock { font-size: 15px; }
|
||
.transport .grow { display: none; }
|
||
.seg button { min-width: 0; min-height: 44px; padding: 0 5px; }
|
||
.iconbtn { width: 44px; height: 44px; }
|
||
.song { margin-bottom: 10px; } .ring { width: 44px; height: 44px; }
|
||
.clock { min-width: 0; font-size: 14px; }
|
||
main { padding-top: 16px; }
|
||
#tapPad { grid-column: 1 / -1; width: 100%; min-height: 52px; height: 52px; }
|
||
.editor-footer { align-items: flex-start; }
|
||
#p-editor { padding: 0; border: 0; border-radius: 0; }
|
||
}
|
||
@media (max-width: 370px) {
|
||
.transport { grid-template-columns: 44px 52px 44px minmax(64px, 1fr); }
|
||
#rate { grid-column: 4; }
|
||
#bUndo { grid-column: 1; } #bRedo { grid-column: 2; } #bTools { grid-column: 3; }
|
||
.transport .seg { grid-column: 1 / span 3; }
|
||
.clock { font-size: 12px; }
|
||
}
|
||
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
|
||
.media-shape { display:none; } .media-shape-classic { display:inline; }
|
||
.media-icon { width:18px; height:18px; vertical-align:middle; fill:none; stroke:currentColor; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ============ sign-in ============ -->
|
||
<div id="loginBox" class="gate hidden">
|
||
<a class="brand" href="/admin#editor" data-home style="text-decoration:none;color:inherit"><i><svg viewBox="0 0 24 24"><path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/></svg></i>Lyrics Studio</a>
|
||
<div class="card">
|
||
<h2>Sign in</h2>
|
||
<p class="muted">Admin password</p>
|
||
<form id="loginForm" class="row">
|
||
<span style="position:relative;flex:1;display:flex;min-width:0">
|
||
<input class="field" type="password" id="pw" placeholder="Password" autocomplete="current-password" autocapitalize="off" autocorrect="off" spellcheck="false" style="flex:1;padding-right:48px" />
|
||
<button type="button" id="pwShow" aria-label="Show password" aria-pressed="false" style="position:absolute;right:4px;top:50%;transform:translateY(-50%);width:40px;height:40px;border:0;background:none;color:inherit;opacity:.7;cursor:pointer"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||
</span>
|
||
<button class="btn pri" type="submit">Sign in</button>
|
||
</form>
|
||
<div id="loginMsg" class="msg"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="disabledBox" class="gate hidden">
|
||
<div class="card"><h2>Admin disabled</h2>
|
||
<p class="muted">Set <code>ADMIN_PASSWORD</code> in the server environment (Dokploy → Environment) and redeploy.</p></div>
|
||
</div>
|
||
|
||
<div id="app" class="hidden">
|
||
<header class="top">
|
||
<a class="brand" href="/admin#editor" data-home style="text-decoration:none;color:inherit"><i><svg viewBox="0 0 24 24"><path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/></svg></i>Lyrics Studio</a>
|
||
<nav class="tabs" role="tablist" id="tabsTop"></nav>
|
||
<span class="grow"></span>
|
||
<a class="ghost back-app" href="/">← <span class="desk-only">Back to </span>App</a>
|
||
<button id="logoutBtn" class="ghost">Sign out</button>
|
||
</header>
|
||
|
||
<main>
|
||
<!-- ================= EDITOR ================= -->
|
||
<section id="p-editor" class="panel">
|
||
<div id="pickBox">
|
||
<div class="pick-head">
|
||
<h1 class="page">Time & fix lyrics</h1>
|
||
<p class="lead">Play a song right here and stamp every line against what you actually hear. Everything is undoable and every save keeps the previous version.</p>
|
||
</div>
|
||
<form id="loadForm" class="open-bar">
|
||
<svg class="open-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 14a4.5 4.5 0 006.4 0l3-3a4.5 4.5 0 00-6.4-6.4l-1 1"/><path d="M14 10a4.5 4.5 0 00-6.4 0l-3 3a4.5 4.5 0 006.4 6.4l1-1"/></svg>
|
||
<input class="field" type="text" id="songId" placeholder="Paste a YouTube link, video id or upl_… id" aria-label="YouTube link, video id or upload id" autocomplete="off" />
|
||
<button class="btn pri" type="submit">Open</button>
|
||
</form>
|
||
<div id="loadMsg" class="msg"></div>
|
||
<div id="recentChips" class="chips"></div>
|
||
|
||
<section class="library" aria-labelledby="libTitle">
|
||
<div class="lib-head">
|
||
<h2 id="libTitle">Songs on the server</h2>
|
||
<span class="lib-count" id="songCount"></span>
|
||
</div>
|
||
<p class="lib-hint">Downloaded videos and uploads. Songs that already have lyrics can be corrected here too.</p>
|
||
<div class="coverage" id="coverage" hidden>
|
||
<div class="cov-bar"><i id="covFill"></i></div>
|
||
<p id="covText"></p>
|
||
</div>
|
||
<div class="lib-tools">
|
||
<div class="lib-filter" role="group" aria-label="Show songs">
|
||
<button type="button" data-view="all" aria-pressed="true">All <span data-n="all"></span></button>
|
||
<button type="button" data-view="need" aria-pressed="false">Need lyrics <span data-n="need"></span></button>
|
||
<button type="button" data-view="has" aria-pressed="false">Have lyrics <span data-n="has"></span></button>
|
||
</div>
|
||
<label class="lib-search"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="6.5"/><path d="m16 16 4.5 4.5"/></svg><input class="field" id="songFilter" type="search" placeholder="Filter by title or artist" aria-label="Filter songs" /></label>
|
||
</div>
|
||
<div id="songList" class="songs"><div class="muted" style="padding:10px">Loading…</div></div>
|
||
<button type="button" class="btn more-songs hidden" id="songMore">Show more songs</button>
|
||
</section>
|
||
|
||
<ol class="steps" aria-label="How it works">
|
||
<li class="step"><b>1</b><div><h3>Open a song</h3><p>Paste a link or pick one from your library.</p></div></li>
|
||
<li class="step"><b>2</b><div><h3>Play & tap</h3><p>Switch to Tap-sync and hit the big button on each line as it is sung.</p></div></li>
|
||
<li class="step"><b>3</b><div><h3>Save</h3><p>Everyone gets the new timing instantly.</p></div></li>
|
||
</ol>
|
||
</div>
|
||
|
||
<div id="studio" class="hidden">
|
||
<div class="song">
|
||
<div class="ring" id="ring"><span id="ringTxt">0/0</span></div>
|
||
<div style="min-width:0"><h1 id="songTitle"></h1><small id="songMeta"></small></div>
|
||
<span class="grow"></span>
|
||
<button class="btn sm whisper-request" id="whisperBtn" type="button">Transcribe with Whisper</button>
|
||
<button class="ghost" id="closeSong" type="button">Change song</button>
|
||
</div>
|
||
|
||
<div class="deck">
|
||
<canvas id="wave" height="64"></canvas>
|
||
<div class="transport">
|
||
<button class="round" id="bBack" type="button" title="Back 5 s" aria-label="Back 5 seconds">−5</button>
|
||
<button class="round big" id="bPlay" type="button" aria-label="Play or pause"><svg id="playIcon" class="transport-icon" data-transport-icon="play" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true"><use class="transport-shape transport-shape-classic" href="#transport-play"/><use class="transport-shape transport-shape-f7-swipe" href="#transport-play-f7-swipe"/><use class="transport-shape transport-shape-glass-stage" href="#transport-play-glass-stage"/><use class="transport-shape transport-shape-bento-hub" href="#transport-play-bento-hub"/></svg></button>
|
||
<button class="round" id="bFwd" type="button" title="Forward 5 s" aria-label="Forward 5 seconds">+5</button>
|
||
<div class="clock"><b id="cur">0:00.00</b> <span>/ <span id="dur">0:00</span></span></div>
|
||
<select class="mini" id="rate" title="Playback speed" aria-label="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>
|
||
<span class="grow"></span>
|
||
<div class="seg" role="group" aria-label="Mode">
|
||
<button id="mEdit" type="button" aria-pressed="true">Edit</button>
|
||
<button id="mTap" type="button" aria-pressed="false">Tap-sync</button>
|
||
</div>
|
||
<button class="iconbtn" id="bUndo" type="button" title="Undo (Ctrl/⌘ Z)" aria-label="Undo" disabled><svg viewBox="0 0 24 24"><path d="M9 14L4 9l5-5"/><path d="M4 9h10a6 6 0 010 12h-3"/></svg></button>
|
||
<button class="iconbtn" id="bRedo" type="button" title="Redo (Ctrl/⌘ Shift Z)" aria-label="Redo" disabled><svg viewBox="0 0 24 24"><path d="M15 14l5-5-5-5"/><path d="M20 9H10a6 6 0 000 12h3"/></svg></button>
|
||
<button class="iconbtn" id="bTools" type="button" title="More tools" aria-label="More tools" aria-pressed="false"><svg viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.2"/><circle cx="12" cy="12" r="1.2"/><circle cx="19" cy="12" r="1.2"/></svg></button>
|
||
<button id="tapPad" class="tap hidden" type="button" aria-label="Stamp the next line">TAP<small id="tapNext">next line</small></button>
|
||
</div>
|
||
<div class="keyboard-hint" aria-label="Keyboard shortcuts"><strong>Shortcuts</strong> <kbd>Space</kbd> stamp in Tap-sync · <kbd>↑</kbd><kbd>↓</kbd> select · <kbd>S</kbd> stamp selected · <kbd>K</kbd> play · <kbd>⌘/Ctrl S</kbd> save</div>
|
||
</div>
|
||
|
||
<div id="drawer" class="drawer hidden">
|
||
<div class="row">
|
||
<input class="field" id="tags" type="text" placeholder="Tags — e.g. Key G, 70 BPM" />
|
||
</div>
|
||
<div class="row">
|
||
<label>Shift every timed line by <input class="field" id="shiftBy" type="text" value="0.0" style="flex:0 0 84px;height:36px;text-align:center" /> s</label>
|
||
<button class="btn sm" id="shiftBtn" type="button">Apply</button>
|
||
<span class="grow"></span>
|
||
<button class="btn sm" id="webBtn" type="button" title="Fetch lyrics from LRCLIB"><svg class="media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> Pull from LRCLIB</button>
|
||
<button class="btn sm" id="rawBtn" type="button">✎ Edit as text</button>
|
||
<label><input type="checkbox" id="follow" checked /> Follow playback</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="msg" class="msg" role="status" aria-live="polite"></div>
|
||
<p id="whisperStatus" role="status" aria-live="polite"></p>
|
||
<div id="whisperResult" class="card whisper-draft hidden">
|
||
<h2 style="font-size:18px;margin:0 0 8px">Whisper transcript ready</h2>
|
||
<p class="muted">Saved lyrics stay unchanged until you review and save this draft.</p>
|
||
<pre id="whisperPreview"></pre>
|
||
<button id="whisperUse" class="btn" type="button">Use transcript in editor</button>
|
||
</div>
|
||
<div id="lines" class="lines"></div>
|
||
<textarea id="raw" aria-label="Lyrics as text; prefix continuation lines with vertical bar and space" class="hidden" spellcheck="false"></textarea>
|
||
<div class="editor-footer">
|
||
<button class="btn sm" id="addLine" type="button">+ Add line</button>
|
||
</div>
|
||
<div id="saveBar" class="savebar hidden"><div id="saveInner">
|
||
<span class="dot"></span><span class="stat" id="saveStat">All changes saved</span>
|
||
<button class="btn sm" id="discard" type="button">Discard</button>
|
||
<button class="btn pri" id="save" type="button">Save lyrics</button>
|
||
</div></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ================= REPORTS ================= -->
|
||
<section id="p-reports" class="panel">
|
||
<h1 class="page">Reported lines</h1>
|
||
<p class="lead">Listeners flag lines that look wrong. Fix one in the editor and its report closes itself; or mark it resolved if it was fine.</p>
|
||
<div class="row" style="margin-bottom:14px">
|
||
<label>Age<select class="field" id="reportAge"><option value="0">Any age</option><option value="7">7+ days</option><option value="30">30+ days</option><option value="90">90+ days</option></select></label>
|
||
<div class="seg" role="group" aria-label="Status">
|
||
<button id="fOpen" type="button" aria-pressed="true">Open</button>
|
||
<button id="fRes" type="button" aria-pressed="false">Resolved</button>
|
||
</div>
|
||
<span class="grow"></span>
|
||
<button class="btn sm" id="repRefresh" type="button">↻ Refresh</button>
|
||
</div>
|
||
<div id="repList"></div>
|
||
</section>
|
||
|
||
<section id="p-quality" class="panel">
|
||
<h1 class="page">Lyrics quality</h1>
|
||
<p class="lead">Work the songs that need attention: missing, short, machine-drafted, untimed, or reported lyrics.</p>
|
||
<div class="card">
|
||
<form id="qualityFilterForm" class="row">
|
||
<label>Issue<select class="field" id="qualityCategory"><option value="all">All issues</option><option value="missing">No lyrics</option><option value="short">Fewer than minimum lines</option><option value="automatic">Auto-transcribed, unreviewed</option><option value="untimed">Untimed lines</option><option value="reports">Open reports</option></select></label>
|
||
<label>Minimum lines<input class="field" id="qualityMinLines" type="number" min="1" max="200" value="8" /></label>
|
||
<button class="btn pri" type="submit">Update queue</button><span class="grow"></span><span id="qualityCount" class="muted" role="status" aria-live="polite"></span>
|
||
</form>
|
||
<p id="qualityStatus" class="msg" role="status" aria-live="polite"></p>
|
||
<div class="scroll"><table class="tbl" id="qualityTable"></table></div>
|
||
<div class="row"><button class="btn" id="qualityPrev" type="button">Previous</button><button class="btn" id="qualityNext" type="button">Next</button><span class="grow"></span><span class="muted">50 songs per page</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="p-lyric-search" class="panel">
|
||
<h1 class="page">Lyrics search</h1>
|
||
<p class="lead">Search the saved lyric text, then open a matching song directly in the editor.</p>
|
||
<div class="card"><form id="lyricsSearchForm" class="analytics-form"><label class="query">Words in lyrics<input class="field" id="lyricsSearchQuery" type="search" maxlength="120" placeholder="grace will lead me home" /></label><label>Lyrics<select class="field" id="lyricsSearchHasLyrics"><option value="yes">With lyrics</option><option value="no">Without lyrics · title search</option><option value="all">All</option></select></label><label>Chapters<select class="field" id="lyricsSearchHasChapters"><option value="all">Any</option><option value="yes">Has chapters</option><option value="no">No chapters</option></select></label><button class="btn pri" type="submit">Search</button><button class="btn" id="lyricsSearchRebuild" type="button">Rebuild text index</button></form>
|
||
<p id="lyricsSearchStatus" class="msg" role="status" aria-live="polite"></p><div class="scroll"><table class="tbl" id="lyricsSearchTable"></table></div><div class="row"><button class="btn" id="lyricsSearchPrev" type="button">Previous</button><button class="btn" id="lyricsSearchNext" type="button">Next</button><span class="grow"></span><span class="muted" id="lyricsSearchCount"></span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="p-cache" class="panel">
|
||
<h1 class="page">Cache health</h1>
|
||
<p class="lead">Review server copies by state and recency. Pinned copies stay out of automatic eviction.</p>
|
||
<div class="analytics-grid" id="cacheMetrics"></div>
|
||
<div class="card"><div class="row"><h2 style="margin:0">Server media</h2><span class="grow"></span><button class="btn sm" id="cacheRefresh" type="button">Refresh</button></div>
|
||
<p id="cacheStatus" class="msg" role="status" aria-live="polite"></p><div class="scroll"><table class="tbl" id="cacheTable"></table></div>
|
||
<div class="row"><button class="btn" id="cachePrev" type="button">Previous</button><button class="btn" id="cacheNext" type="button">Next</button><span class="grow"></span><span id="cacheCount" class="muted">50 rows per page</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="p-index" class="panel">
|
||
<h1 class="page">Search index</h1>
|
||
<p class="lead">Coverage and discovery sources for the local search catalogue. Backfill advances in bounded, resumable pages.</p>
|
||
<div class="analytics-grid" id="indexMetrics"></div>
|
||
<div class="card"><div class="row"><h2 style="margin:0">Discovery sources</h2><span class="grow"></span><button class="btn sm" id="indexRefresh" type="button">Refresh</button></div><div class="scroll"><table class="tbl" id="indexSources"></table></div></div>
|
||
<div class="card"><h2>Deep search</h2><p class="muted" id="indexLastRun">No deep-search run recorded.</p><div id="indexRunDetail" class="muted"></div></div>
|
||
<div class="card"><div class="row"><h2 style="margin:0">Catalog repair</h2><span class="grow"></span><button class="btn" id="indexBackfill" type="button">Backfill next page</button><button class="btn dng" id="indexCleanup" type="button">Clean stale entries</button></div><p class="muted">Backfill imports older metadata in one small page. Cleanup removes only old catalogue rows with no source, cache copy, or listening history, then prunes orphaned detail records.</p><p id="indexStatus" class="msg" role="status" aria-live="polite"></p><div class="scroll"><table class="tbl" id="indexBackfillTable"></table></div></div>
|
||
</section>
|
||
|
||
<!-- ================= LIBRARY ================= -->
|
||
<section id="p-library" class="panel">
|
||
<h1 class="page">Library</h1>
|
||
<p class="lead">Upload your own audio or video, or pull lyrics straight from a YouTube video's captions.</p>
|
||
<div class="card">
|
||
<h2>Upload video / audio</h2>
|
||
<p class="muted">Title, artist, duration, cover art and embedded lyrics are read from the file. A cover image or manual title/artist override them.</p>
|
||
<form id="upForm" class="up-form" novalidate>
|
||
<label class="drop" id="upDrop">
|
||
<input class="sr-file" type="file" id="upFile" accept="video/*,audio/*" />
|
||
<svg class="drop-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 15V4m-4.5 4.5L12 4l4.5 4.5M5 14v4.5A1.5 1.5 0 0 0 6.5 20h11a1.5 1.5 0 0 0 1.5-1.5V14"/></svg>
|
||
<span class="drop-text">
|
||
<strong id="upFileName">Choose a video or audio file</strong>
|
||
<span id="upFileMeta">or drop it here · mp4, mov, webm, mkv, mp3, m4a, flac, wav…</span>
|
||
</span>
|
||
<span class="drop-btn" aria-hidden="true">Browse</span>
|
||
</label>
|
||
<div class="up-meta">
|
||
<label class="fl"><span>Title</span><input class="field" type="text" id="upTitle" placeholder="From the file" autocomplete="off" /></label>
|
||
<label class="fl"><span>Artist</span><input class="field" type="text" id="upArtist" placeholder="From the file" autocomplete="off" /></label>
|
||
<label class="fl cover-pick" title="Cover image (optional)">
|
||
<span>Cover</span>
|
||
<input class="sr-file" type="file" id="upArt" accept="image/*" />
|
||
<span class="cover-box" id="upArtBox"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="17" height="17" rx="3"/><circle cx="9" cy="9.5" r="1.6"/><path d="m20 15-4.5-4.5L6 20"/></svg><em id="upArtName">Add image</em></span>
|
||
</label>
|
||
</div>
|
||
<div class="up-actions">
|
||
<button class="btn pri" type="submit" id="upSubmit">Upload</button>
|
||
</div>
|
||
</form>
|
||
<div id="upProgress" class="msg"></div>
|
||
<div class="scroll"><table class="tbl" id="upTable"></table></div>
|
||
</div>
|
||
<div class="card">
|
||
<h2>Inject lyrics from captions</h2>
|
||
<p class="muted">Human subtitles first, else the auto captions in the spoken language, saved as the shared lyrics.</p>
|
||
<form id="injectForm" class="row">
|
||
<input class="field" type="text" id="injectId" placeholder="YouTube URL or video id" />
|
||
<label class="muted" style="font-size:13px"><input type="checkbox" id="injectOverwrite" /> Replace existing</label>
|
||
<button class="btn pri" type="submit">Inject</button>
|
||
</form>
|
||
<div id="injectMsg" class="msg"></div>
|
||
</div>
|
||
<div class="card" id="bulkLyricsCard">
|
||
<h2>Bulk lyrics operations</h2><p class="muted">Select songs from the indexed library. Clear keeps the previous text in revisions, so you can restore it from History.</p>
|
||
<form id="bulkLibraryFilter" class="analytics-form"><label class="query">Filter songs<input id="bulkLibraryQuery" class="field" type="search" maxlength="120" placeholder="Title or artist" /></label><button class="btn" type="submit">Filter</button></form>
|
||
<div class="bulk-selection" aria-label="Song selection"><label><input type="checkbox" id="bulkSelectPage" /> Select this page</label><label><input type="checkbox" id="bulkSelectAll" /> All filtered (up to 500)</label><span class="muted">Changing the filter clears selected rows.</span></div>
|
||
<p class="muted" id="bulkLibraryCount"></p><div class="scroll"><table class="tbl" id="bulkLibraryTable"></table></div><div class="row"><button class="btn" id="bulkLibraryPrev" type="button">Previous</button><button class="btn" id="bulkLibraryNext" type="button">Next</button><span class="grow"></span><span id="bulkSelectedCount" class="muted">0 selected</span></div>
|
||
<div class="row"><label>Operation<select class="field" id="bulkAction"><option value="lrclib">Pull LRCLIB</option><option value="whisper">Queue Whisper</option><option value="export">Export selected LRC as ZIP</option><option value="clear">Clear lyrics</option></select></label><button class="btn pri" id="bulkStart" type="button">Start job</button></div>
|
||
<p id="bulkJobStatus" class="msg" role="status" aria-live="polite"></p><div id="bulkJobs"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ================= PEER-TO-PEER ================= -->
|
||
<section id="p-peers" class="panel">
|
||
<h1 class="page">Peer-to-peer</h1>
|
||
<p class="lead">Devices share verified copies with each other. A file's hash is added only after the server itself hashed and validated it. Holders are never expired — stale ones are only marked.</p>
|
||
<div class="card">
|
||
<div class="row"><h2 style="margin:0">Overview</h2><span class="grow"></span><button class="btn sm" id="p2pRefreshBtn" type="button">↻ Refresh</button></div>
|
||
<div id="p2pSummary" class="msg"></div>
|
||
<div class="scroll"><table class="tbl" id="p2pTable"></table></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- ================= HISTORY ================= -->
|
||
<section id="p-history" class="panel">
|
||
<h1 class="page">History</h1>
|
||
<p class="lead">Every save is 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.</p>
|
||
<div class="card">
|
||
<div class="row" style="margin-bottom:8px"><h2 style="margin:0">Recent edits</h2><span class="grow"></span>
|
||
<button class="btn sm" id="refreshBtn" type="button">↻ Refresh</button>
|
||
<a class="btn sm" href="/api/admin/notes/export"><svg class="media-icon" data-media-icon="download" viewBox="0 0 24 24" aria-hidden="true"><use href="#media-download"/></svg> Export all (JSON)</a></div>
|
||
<div class="scroll"><table class="tbl" id="revTable"></table></div>
|
||
<pre id="revView" class="hidden" style="margin-top:12px"></pre>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="p-backups" class="panel">
|
||
<h1 class="page">Backups</h1>
|
||
<p class="lead">Daily note snapshots can be inspected, downloaded, and restored. Every restore writes a separate safety snapshot first.</p>
|
||
<div class="card"><div class="row"><h2 style="margin:0">Notes snapshots</h2><span class="grow"></span><button class="btn pri" id="backupNow" type="button">Create backup now</button><a class="btn sm" href="/api/admin/backups/export/sqlite-dump">Export SQLite dump</a></div>
|
||
<p id="backupStatus" class="msg" role="status" aria-live="polite"></p><div class="scroll"><table class="tbl" id="backupTable"></table></div>
|
||
</div>
|
||
<div class="card"><h2>Preview and restore</h2><div class="row"><label>Backup date<select class="field" id="backupDate"></select></label><label>Optional song ID<input class="field" id="backupSong" placeholder="Restore one song only" /></label><button class="btn" id="backupPreview" type="button">Preview diff</button><button class="btn dng" id="backupRestore" type="button">Restore</button></div><div id="backupDiff" class="muted"></div></div>
|
||
</section>
|
||
|
||
<!-- ================= ACCESS ================= -->
|
||
<section id="p-analytics" class="panel">
|
||
<h1 class="page">Server analytics</h1>
|
||
<p class="lead">Video storage, listening activity and the metadata library.</p>
|
||
<button class="btn" type="button" id="analyticsRefresh">Refresh analytics</button>
|
||
<p id="analyticsStatus" role="status" aria-live="polite"></p>
|
||
<div class="analytics-grid" id="analyticsMetrics"></div>
|
||
<div class="card"><h2>Remaining disk space</h2><div id="analyticsVolumes"></div><p class="muted">Volumes on the same device share the reported free space. Stored media totals exclude temporary files and filesystem overhead.</p></div>
|
||
<div class="card"><h2>Metadata collector</h2>
|
||
<p class="muted">Search and save descriptive video metadata and thumbnails. Video and audio files are not downloaded.</p>
|
||
<form id="collectorForm" class="analytics-form">
|
||
<label class="query">Search<input id="collectorQuery" class="field" type="search" maxlength="200" required placeholder="Cathedral of Praise Worship" /></label>
|
||
<label>Total video limit<input id="collectorLimit" class="field" type="number" min="1" max="500" value="30" required /></label>
|
||
<label>Related-search depth<select id="collectorDepth" class="field"><option value="0">0 · Search only</option><option value="1">1 · Channels and topics</option><option value="2">2 · Two related rounds</option><option value="3">3 · Three related rounds</option></select></label>
|
||
<button class="btn pri" type="submit" id="collectorStart">Collect metadata</button>
|
||
</form>
|
||
<p class="muted">The limit applies across every round. Depth follows channels and tags found in video metadata, with at most 24 searches. A collection can return fewer videos when results repeat or sources are unavailable. Thumbnails follow the server’s storage budget.</p>
|
||
<p id="collectorStatus" role="status" aria-live="polite"></p><div id="collectionJobs"></div>
|
||
</div>
|
||
<div class="card"><h2>Metadata library</h2>
|
||
<form id="metadataFilterForm" class="analytics-form"><label class="query">Filter saved metadata<input id="metadataFilter" class="field" type="search" maxlength="200" placeholder="Title, channel or tag" /></label><button class="btn" type="submit">Filter</button></form>
|
||
<p id="metadataCount" class="muted"></p>
|
||
<div class="analytics-table"><table id="metadataTable"></table></div>
|
||
<div class="row"><button id="metadataPrev" class="btn" type="button">Previous</button><button id="metadataNext" class="btn" type="button">Next</button></div>
|
||
<details id="metadataDetails" class="hidden"><summary>Video metadata</summary><pre id="metadataDetail"></pre></details>
|
||
</div>
|
||
<div class="card"><h2>Discovery sources</h2><div class="analytics-table"><table id="analyticsSources"></table></div></div>
|
||
<div class="card"><h2>Most played</h2><div id="analyticsTop"></div></div>
|
||
</section>
|
||
|
||
<section id="p-ops" class="panel">
|
||
<div class="row"><div><h1 class="page">Performance</h1><p class="lead">Route latency, service queues, process health and storage headroom.</p></div><span class="grow"></span><button class="btn" id="opsRefresh" type="button">Refresh</button></div>
|
||
<p id="opsStatus" class="msg" role="status" aria-live="polite"></p>
|
||
<div class="analytics-grid" id="opsCards"></div>
|
||
<div class="card"><h2>Route health</h2><p class="muted route-scroll-hint">Swipe the table horizontally to compare every route metric.</p><div class="scroll"><table class="tbl" id="opsRoutes"></table></div></div>
|
||
<div class="card"><h2>Storage volumes</h2><div id="opsVolumes"></div></div>
|
||
<div class="card"><h2>24-hour p95 history</h2><div id="opsHistory" class="analytics-grid"></div></div>
|
||
<div id="opsAlerts" aria-live="polite"></div>
|
||
</section>
|
||
<section id="p-devices" class="panel">
|
||
<div class="row"><div><h1 class="page">P2P devices</h1><p class="lead">Presence, stored holdings and device-level sharing controls.</p></div><span class="grow"></span><button class="btn" id="devicesRefresh" type="button">Refresh</button></div>
|
||
<div class="analytics-grid" id="deviceHealth"></div><p id="devicesStatus" class="msg" role="status" aria-live="polite"></p>
|
||
<div class="card"><h2>Network health</h2><svg id="deviceNetworkGraph" role="img" aria-label="P2P network health summary" viewBox="0 0 100 40" preserveAspectRatio="none" style="width:100%;height:72px;stroke:var(--a1);fill:none;stroke-width:2"></svg><p class="muted">Online devices · verified holdings · active intake · open intake, left to right.</p></div>
|
||
<div class="card"><div class="scroll"><table class="tbl" id="deviceTable"></table></div><div class="row"><button class="btn" id="devicesPrev" type="button">Previous</button><span id="devicesPage" class="muted"></span><button class="btn" id="devicesNext" type="button">Next</button></div></div>
|
||
</section>
|
||
<section id="p-uploads" class="panel">
|
||
<h1 class="page">Upload library</h1><p class="lead">Edit metadata and visibility, replace files without changing IDs, and review storage by owner.</p>
|
||
<form class="card analytics-form" id="uploadCreateForm"><label class="query">Audio or video file<input class="field" type="file" id="uploadCreateFile" accept="audio/*,video/*" required /></label><label>Title<input class="field" id="uploadCreateTitle" maxlength="300" placeholder="Use embedded title" /></label><label>Artist<input class="field" id="uploadCreateArtist" maxlength="200" /></label><label>Album<input class="field" id="uploadCreateAlbum" maxlength="200" /></label><button class="btn pri" type="submit">Add upload</button></form>
|
||
<div class="card"><h2>Storage by owner</h2><div id="uploadOwners" class="analytics-grid"></div></div>
|
||
<div class="row"><input class="field" id="uploadManageFilter" type="search" placeholder="Filter uploads" /><button class="btn" id="uploadManageRefresh" type="button">Refresh</button><span class="grow"></span><select class="field" id="uploadBulkAction"><option value="list">List selected</option><option value="unlist">Unlist selected</option><option value="delete">Delete selected</option></select><button class="btn dng" id="uploadBulkApply" type="button">Apply</button></div>
|
||
<p id="uploadManageStatus" class="msg" role="status" aria-live="polite"></p><div class="card"><div class="scroll"><table class="tbl" id="uploadManageTable"></table></div><div class="row"><button class="btn" id="uploadManagePrev" type="button">Previous</button><span id="uploadManagePage" class="muted"></span><button class="btn" id="uploadManageNext" type="button">Next</button></div></div>
|
||
<div class="card"><h2>Orphan files</h2><p class="muted">Files in upload storage without a database row. Review before removing them from disk.</p><div id="uploadOrphans"></div></div>
|
||
</section>
|
||
|
||
<section id="p-deep-search" class="panel deep-search">
|
||
<h1 class="page">Deep search</h1>
|
||
<p class="lead">Build the local video index from YouTube searches. Runs are owned by the server and continue if this page closes.</p>
|
||
<div class="card"><h2>Query plan</h2><p class="muted">One seed per line. Presets include worship terms and artists in English, Tagalog, Cebuano and Spanish.</p>
|
||
<form id="deepSearchForm">
|
||
<div class="deep-grid">
|
||
<label>Seed terms<textarea class="field" id="deepSeeds" required></textarea></label>
|
||
<label>Modifiers, one per line<textarea class="field" id="deepModifiers"></textarea></label>
|
||
<label>Maximum queries<input class="field" id="deepMaxQueries" type="number" min="1" max="500" value="160" /></label>
|
||
<label>Results per query<input class="field" id="deepResults" type="number" min="1" max="200" value="50" /></label>
|
||
<label>Related videos<select class="field" id="deepDepth"><option value="0">Off</option><option value="1">Top hits · depth 1</option><option value="2">Top hits · depth 2</option></select></label>
|
||
<label>Related results per query<input class="field" id="deepRelatedTop" type="number" min="1" max="10" value="3" /></label>
|
||
<label>Channel IDs or URLs<textarea class="field" id="deepChannels" placeholder="UC… or https://www.youtube.com/channel/UC…"></textarea></label>
|
||
<label>Playlist IDs or URLs<textarea class="field" id="deepPlaylists" placeholder="PL… or https://www.youtube.com/playlist?list=PL…"></textarea></label>
|
||
<label>Politeness delay (seconds)<input class="field" id="deepDelay" type="number" min="1" max="300" value="10" /></label>
|
||
<label>Daily query cap<input class="field" id="deepDailyCap" type="number" min="1" max="10000" value="1000" /></label>
|
||
</div>
|
||
<div class="row" style="margin-top:12px"><button class="btn" type="button" id="deepEstimate">Estimate plan</button><button class="btn pri" type="submit" id="deepStart">Start deep search</button><span class="muted" id="deepEstimateText" role="status" aria-live="polite"></span></div>
|
||
</form>
|
||
</div>
|
||
<div class="card"><div class="row"><h2 style="margin:0">Runs</h2><span class="grow"></span><button class="btn sm" type="button" id="deepRefresh">Refresh</button></div><p class="muted" id="deepIndexStatus"></p><div id="deepJobs"></div></div>
|
||
</section>
|
||
|
||
<section id="p-audit" class="panel">
|
||
<h1 class="page">Audit log</h1>
|
||
<p class="lead">A searchable record of admin changes, with the actor and target kept alongside each action.</p>
|
||
<div class="card">
|
||
<form id="auditFilterForm" class="audit-filters">
|
||
<label>Search<input class="field" id="auditSearch" type="search" maxlength="160" placeholder="target or detail" /></label>
|
||
<label>Actor<input class="field" id="auditActor" type="search" maxlength="120" placeholder="admin" /></label>
|
||
<label>Action<input class="field" id="auditAction" type="search" maxlength="120" placeholder="lyrics.save" /></label>
|
||
<label>From<input class="field" id="auditFrom" type="date" /></label>
|
||
<label>Through<input class="field" id="auditTo" type="date" /></label>
|
||
<div class="audit-tools"><button class="btn pri" type="submit">Filter</button><a class="btn sm" id="auditJson" href="/api/admin/audit?format=json">Export JSON</a><a class="btn sm" id="auditCsv" href="/api/admin/audit?format=csv">Export CSV</a></div>
|
||
<span class="grow"></span><span class="muted" id="auditCount" role="status" aria-live="polite"></span>
|
||
</form>
|
||
<div class="scroll"><table class="tbl" id="auditTable"></table></div>
|
||
<div class="row"><button class="btn" id="auditPrev" type="button">Previous</button><button class="btn" id="auditNext" type="button">Next</button><span class="grow"></span><span class="muted">50 events per page</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="p-access" class="panel">
|
||
<h1 class="page">API access</h1>
|
||
<p class="lead">Tokens let scripts write shared lyrics and chapters. A token is shown once — store it in your secrets vault.</p>
|
||
<div class="card">
|
||
<h2>Tokens</h2>
|
||
<form id="tokenForm" class="row" style="margin-top:10px">
|
||
<input class="field" type="text" id="tokenLabel" maxlength="60" placeholder="Label, e.g. lyrics-bot" />
|
||
<button class="btn pri" type="submit">Create token</button>
|
||
</form>
|
||
<div id="newToken" class="newtoken hidden"></div>
|
||
<div class="scroll" style="margin-top:8px"><table class="tbl" id="tokenTable"></table></div>
|
||
</div>
|
||
<div class="card"><h2 style="margin-bottom:10px">API reference</h2><pre id="apiDocs"></pre></div>
|
||
</section>
|
||
</main>
|
||
|
||
<div class="navbar"><span class="nav-rail" aria-hidden="true"><i id="navRail"></i></span><nav class="nav" id="tabsBottom" role="tablist"></nav></div>
|
||
</div>
|
||
|
||
<script src="opfs.js"></script>
|
||
<script src="download-actions.js"></script>
|
||
<script src="lyrics-core.js"></script>
|
||
<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;
|
||
}
|
||
|
||
/* ---------------- tabs ---------------- */
|
||
const ICONS = {
|
||
editor: '<path d="M4 20h4L19 9a2.8 2.8 0 00-4-4L4 16v4z"/><path d="M13.5 6.5l4 4"/>',
|
||
reports: '<path d="M5 21V4M5 4h12l-2.5 4L17 12H5"/>',
|
||
quality: '<path d="M4 5h16M4 12h16M4 19h10"/><path d="m17 18 2 2 4-5"/>',
|
||
'lyric-search': '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5M8 10.5h5M10.5 8v5"/>',
|
||
cache: '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/>',
|
||
index: '<path d="M4 19V5M4 19h16M8 15l3-4 3 2 5-7"/><circle cx="19" cy="6" r="1.5"/>',
|
||
library: '<path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/>',
|
||
peers: '<circle cx="6" cy="12" r="2.5"/><circle cx="18" cy="6" r="2.5"/><circle cx="18" cy="18" r="2.5"/><path d="M8.3 10.9l7.4-3.8M8.3 13.1l7.4 3.8"/>',
|
||
devices: '<rect x="6" y="3" width="12" height="18" rx="2"/><path d="M10 17h4"/>',
|
||
uploads: '<path d="M12 16V4m0 0L7 9m5-5 5 5M5 14v5h14v-5"/>',
|
||
history: '<path d="M3 12a9 9 0 109-9 9 9 0 00-6.4 2.6L3 8"/><path d="M3 3v5h5M12 7v5l3 2"/>',
|
||
backups: '<path d="M4 5h16v15H4zM8 5V3h8v2M8 11h8M8 15h5"/>',
|
||
analytics: '<path d="M4 20h16M7 16V9M12 16V4M17 16v-5"/>',
|
||
ops: '<path d="M4 19V5m0 14h16M7 15l3-4 3 2 5-7"/><circle cx="18" cy="6" r="1.5"/>',
|
||
'deep-search': '<circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5M8 10.5h5M10.5 8v5"/>',
|
||
audit: '<path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/>',
|
||
access: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M16 7l3 3M14 9l2 2"/>',
|
||
};
|
||
const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['lyric-search', 'Lyrics search'], ['cache', 'Cache'], ['index', 'Search index'], ['reports', 'Reports'], ['library', 'Library'], ['uploads', 'Uploads'], ['peers', 'P2P'], ['devices', 'Devices'], ['history', 'History'], ['backups', 'Backups'], ['analytics', 'Stats'], ['ops', 'Performance'], ['deep-search', 'Deep search'], ['audit', 'Audit log'], ['access', 'API']];
|
||
let tab = 'editor';
|
||
function buildTabs() {
|
||
const html = TABS.map(([k, l]) => `<button class="tab" role="tab" data-tab="${k}" aria-selected="false"><svg viewBox="0 0 24 24">${ICONS[k]}</svg><span>${l}</span>${k === 'reports' ? '<span class="badge hidden" data-badge>0</span>' : ''}</button>`).join('');
|
||
$('tabsTop').innerHTML = html; $('tabsBottom').innerHTML = html;
|
||
document.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab)));
|
||
}
|
||
/* Phone tab strip: every tab is exactly 1/n of the strip and snapping is
|
||
mandatory, so wherever it comes to rest it shows n whole tabs, never a
|
||
sliver of the next one. */
|
||
const navEl = () => $('tabsBottom');
|
||
function sizeNav() {
|
||
const nav = navEl(), w = nav.clientWidth; if (!w) return;
|
||
const n = Math.max(4, Math.min(7, Math.floor(w / 78)));
|
||
nav.style.setProperty('--tab-w', `${w / n}px`);
|
||
nav.dataset.perPage = n; railNav();
|
||
}
|
||
function railNav() {
|
||
const nav = navEl(), total = nav.scrollWidth, w = nav.clientWidth, rail = $('navRail');
|
||
if (!rail || !total) return;
|
||
rail.style.width = `${(w / total) * 100}%`;
|
||
rail.style.transform = `translateX(${(nav.scrollLeft / w) * 100}%)`;
|
||
}
|
||
function revealTab(name) {
|
||
const nav = navEl(), w = parseFloat(nav.style.getPropertyValue('--tab-w')); if (!w) return;
|
||
const i = TABS.findIndex(([k]) => k === name), n = Number(nav.dataset.perPage) || 5;
|
||
const first = Math.round(nav.scrollLeft / w);
|
||
if (i < first) nav.scrollTo({ left: i * w });
|
||
else if (i >= first + n) nav.scrollTo({ left: (i - n + 1) * w });
|
||
}
|
||
addEventListener('resize', sizeNav);
|
||
navEl().addEventListener('scroll', railNav, { passive: true });
|
||
function showTab(name, push = true) {
|
||
if (!$('p-' + name)) name = 'editor';
|
||
tab = name;
|
||
document.querySelectorAll('.panel').forEach((p) => p.classList.toggle('on', p.id === 'p-' + name));
|
||
document.querySelectorAll('[data-tab]').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.tab === name)));
|
||
revealTab(name);
|
||
if (push) history.replaceState(null, '', location.pathname + location.search + '#' + name);
|
||
syncBars();
|
||
if (name === 'reports') loadReports();
|
||
if (name === 'quality') loadLyricsQuality();
|
||
if (name === 'lyric-search') loadLyricsSearch();
|
||
if (name === 'cache') loadCacheHealth();
|
||
if (name === 'index') loadIndexHealth();
|
||
if (name === 'history') loadRevs();
|
||
if (name === 'backups') loadBackups();
|
||
if (name === 'peers') loadP2p();
|
||
if (name === 'library') { loadUploads(); loadBulkLibrary(); loadBulkJobs(); }
|
||
if (name === 'access') loadTokens();
|
||
if (name === 'analytics') loadAnalytics();
|
||
if (name === 'ops') loadOpsMetrics(); else clearTimeout(opsTimer);
|
||
if (name === 'devices') loadDevices();
|
||
if (name === 'uploads') loadManagedUploads();
|
||
if (name === 'deep-search') loadDeepSearch();
|
||
if (name === 'audit') loadAudit();
|
||
window.scrollTo({ top: 0 });
|
||
}
|
||
function setBadge(n) {
|
||
document.querySelectorAll('[data-badge]').forEach((b) => { b.textContent = n > 99 ? '99+' : String(n); b.classList.toggle('hidden', !n); });
|
||
}
|
||
|
||
function showLogin() {
|
||
$('loginBox').classList.remove('hidden');
|
||
$('app').classList.add('hidden');
|
||
}
|
||
|
||
/* ---------------- boot / login ---------------- */
|
||
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');
|
||
if (!$('tabsTop').children.length) buildTabs();
|
||
sizeNav();
|
||
renderDocs();
|
||
loadRecent();
|
||
loadSongs();
|
||
refreshBadge();
|
||
const want = videoIdFrom(new URLSearchParams(location.search).get('v') || '');
|
||
const hash = location.hash.replace('#', '');
|
||
showTab(want ? 'editor' : (hash || 'editor'), false);
|
||
if (want) leOpen(want);
|
||
}
|
||
$('loginForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
$('loginMsg').className = 'msg'; $('loginMsg').textContent = 'Signing in…';
|
||
// Pasted from a chat or note: drop surrounding spaces and a wrapping pair of
|
||
// backticks or quotes, which are never part of the password.
|
||
const password = $('pw').value.trim().replace(/^([`'"\u2018\u201C])(.*)([`'"\u2019\u201D])$/s, '$2');
|
||
const j = await api('/api/admin/login', { method: 'POST', body: { password } });
|
||
if (!j.ok) { $('loginMsg').className = 'msg err'; $('loginMsg').textContent = j.error || 'failed'; return; }
|
||
$('pw').value = ''; $('loginMsg').textContent = '';
|
||
boot();
|
||
});
|
||
$('pwShow').addEventListener('click', () => {
|
||
const show = $('pw').type === 'password';
|
||
$('pw').type = show ? 'text' : 'password';
|
||
$('pwShow').setAttribute('aria-pressed', String(show));
|
||
$('pwShow').setAttribute('aria-label', show ? 'Hide password' : 'Show password');
|
||
});
|
||
$('logoutBtn').addEventListener('click', async () => { await api('/api/admin/logout', { method: 'POST' }); showLogin(); });
|
||
|
||
/* ---------------- server analytics / metadata collection ---------------- */
|
||
let analyticsTimer = null, metadataOffset = 0;
|
||
const bytes = value => { let size = Number(value) || 0, unit = 0; const units = ['B','KB','MB','GB','TB']; while (size >= 1024 && unit < 4) { size /= 1024; unit++; } return `${size.toFixed(unit ? 1 : 0)} ${units[unit]}`; };
|
||
async function loadAnalytics() {
|
||
$('analyticsStatus').textContent = 'Loading server analytics…';
|
||
try {
|
||
const j = await api('/api/admin/analytics');
|
||
if (!j.ok) throw Error(j.error || 'Analytics unavailable.');
|
||
const ready = j.media.find(row => row.status === 'ready') || {}, uploadBytes = j.uploads.reduce((total, row) => total + Number(row.bytes), 0);
|
||
const metrics = [
|
||
['Stored media', bytes(Number(ready.bytes || 0) + uploadBytes), `${ready.count || 0} cached videos · ${j.uploads.reduce((sum, row) => sum + Number(row.count), 0)} uploads`],
|
||
['Metadata videos', Number(j.catalog.videos).toLocaleString(), `${j.details.videos} with detailed extractor metadata`],
|
||
['Saved thumbnails', Number(j.thumbnails.saved || 0).toLocaleString(), `${bytes(j.thumbnails.bytes)} · ${j.thumbnails.pending || 0} awaiting download`],
|
||
['Recorded plays', Number(j.listening.plays).toLocaleString(), `${j.listening.videos} different videos`],
|
||
['Cached video size', bytes(ready.bytes), `${Math.round(Number(ready.seconds || 0) / 3600)} hours of audio/video`],
|
||
['Upload size', bytes(uploadBytes), 'Server media library'],
|
||
['Metadata size', bytes(Number(j.catalog.bytes) + Number(j.details.bytes)), 'JSON payloads; database indexes excluded'],
|
||
['Cache queue', j.media.filter(row => row.status !== 'ready').reduce((sum, row) => sum + Number(row.count), 0), j.media.filter(row => row.status !== 'ready').map(row => `${row.status}: ${row.count}`).join(' · ') || 'No pending downloads'],
|
||
];
|
||
$('analyticsMetrics').innerHTML = metrics.map(([label, value, detail]) => `<div class="metric"><span>${esc(label)}</span><b>${esc(value)}</b><small>${esc(detail)}</small></div>`).join('');
|
||
$('analyticsVolumes').innerHTML = j.volumes.map(v => `<p><b>${esc(v.label)}</b> · ${v.unavailable ? 'Storage unavailable' : `${bytes(v.free)} available of ${bytes(v.total)} · device ${esc(v.device)}`}</p>`).join('');
|
||
$('analyticsSources').innerHTML = '<tr><th>Source</th><th>Videos</th><th>Discoveries</th></tr>' + j.sources.map(row => `<tr><td>${esc(row.source)}</td><td>${esc(row.videos)}</td><td>${esc(row.discoveries)}</td></tr>`).join('');
|
||
$('analyticsTop').innerHTML = j.topPlayed.length ? j.topPlayed.map(row => `<p>${esc(row.title)} · <b>${esc(row.plays)} plays</b></p>`).join('') : '<p class="muted">No listening activity recorded yet.</p>';
|
||
$('analyticsStatus').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||
await Promise.all([loadCollections(), loadMetadata()]);
|
||
} catch (error) { $('analyticsStatus').textContent = error.message || 'Could not load server analytics.'; }
|
||
}
|
||
let opsTimer = null;
|
||
async function loadOpsMetrics() {
|
||
const status = $('opsStatus'); if (!status) return;
|
||
status.textContent = 'Sampling service health…';
|
||
try {
|
||
const { current, history = [] } = await api('/api/admin/ops/metrics');
|
||
const cards = [
|
||
['In flight', current.inflight], ['Memory RSS', bytes(current.rssBytes)], ['CPU', `${Number(current.cpuPercent || 0).toFixed(1)}%`],
|
||
['yt-dlp pool', current.ytdlp ? `${current.ytdlp.busy}/${current.ytdlp.workers} busy · ${current.ytdlp.queued} queued` : 'Unavailable'],
|
||
['Media queue', current.media ? `${current.media.queued} queued · ${current.media.active ? '1 active' : 'idle'}` : 'Unavailable'],
|
||
['Search cache', current.cache ? `${current.cache.hits} hits · ${current.cache.misses} misses` : 'Unavailable'],
|
||
['Lyrics queue', current.transcription ? `${current.transcription.queued} queued · ${current.transcription.active} active` : 'Unavailable'],
|
||
];
|
||
$('opsCards').innerHTML = cards.map(([name,value]) => `<div class="metric"><span>${esc(name)}</span><b>${esc(value)}</b></div>`).join('');
|
||
const rows = Object.entries(current.routes || {}).sort((a,b)=>b[1].requests-a[1].requests);
|
||
$('opsRoutes').innerHTML = `<tr><th>Route</th><th>Requests</th><th>p50</th><th>p95</th><th>p99</th><th>Errors</th></tr>` + (rows.map(([path,r])=>`<tr><td><code>${esc(path)}</code></td><td>${r.requests}</td><td>${r.p50} ms</td><td>${r.p95} ms</td><td>${r.p99} ms</td><td>${r.errors} (${(r.errorRate*100).toFixed(1)}%)</td></tr>`).join('') || '<tr><td colspan="6" class="muted">No requests recorded yet.</td></tr>');
|
||
$('opsVolumes').innerHTML = (current.volumes || []).map(v=>`<p><b>${esc(v.name)}</b> · ${v.unavailable?'Unavailable':`${bytes(v.freeBytes)} free of ${bytes(v.totalBytes)}`}</p>`).join('');
|
||
const points = history.slice(-24).map(s=>Math.max(0,...Object.values(s.routes||{}).map(r=>r.p95)));
|
||
const maxPoint = Math.max(1,...points), poly = points.map((v,i)=>`${points.length < 2 ? 0 : i*100/(points.length-1)},${36-v/maxPoint*32}`).join(' ');
|
||
$('opsHistory').innerHTML = `<div class="metric" style="grid-column:1/-1"><span>Peak route p95 · last ${points.length} snapshots</span><b>${maxPoint} ms</b><svg role="img" aria-label="Peak p95 latency sparkline" viewBox="0 0 100 40" preserveAspectRatio="none" style="width:100%;height:64px;stroke:var(--a1);fill:none;stroke-width:2"><polyline points="${poly}" vector-effect="non-scaling-stroke"/></svg></div>`;
|
||
const alerts = [];
|
||
for (const [path,r] of rows) if (r.p95 > 5000 || r.errorRate > .1) alerts.push(`<div class="card" role="status"><b>Attention:</b> ${esc(path)} p95 ${r.p95} ms · errors ${(r.errorRate*100).toFixed(1)}%</div>`);
|
||
if (current.ytdlp && current.ytdlp.queued > Math.max(4,current.ytdlp.workers*3)) alerts.push('<div class="card" role="status"><b>Attention:</b> yt-dlp queue is saturated.</div>');
|
||
if (current.media?.queued > 50) alerts.push('<div class="card" role="status"><b>Attention:</b> media cache queue is backed up.</div>');
|
||
if ((current.volumes||[]).some(v=>!v.unavailable&&v.freeBytes<1024**3)) alerts.push('<div class="card" role="status"><b>Attention:</b> a monitored volume has less than 1 GB free.</div>');
|
||
$('opsAlerts').innerHTML = alerts.join(''); status.textContent = `Updated ${new Date(current.at).toLocaleTimeString()}`;
|
||
clearTimeout(opsTimer); if (tab === 'ops') opsTimer = setTimeout(loadOpsMetrics, 10000);
|
||
} catch (error) { status.textContent = error.message || 'Monitoring unavailable.'; }
|
||
}
|
||
$('opsRefresh')?.addEventListener('click', loadOpsMetrics);
|
||
let devicesOffset = 0;
|
||
async function loadDevices() {
|
||
$('devicesStatus').textContent = 'Loading registered devices…';
|
||
try {
|
||
const j = await api(`/api/admin/p2p/devices?offset=${devicesOffset}`);
|
||
$('deviceHealth').innerHTML = [['Online now',j.network.online],['Verified content',j.network.content],['Intake active',j.network.intakeActive],['Open intake',j.network.intakeOpen]].map(([a,b])=>`<div class="metric"><span>${esc(a)}</span><b>${esc(b)}</b></div>`).join('');
|
||
const bars=[j.network.online,j.network.content,j.network.intakeActive,j.network.intakeOpen].map(Number), max=Math.max(1,...bars);
|
||
$('deviceNetworkGraph').innerHTML=`<title>Online devices, verified items, active intake and open intake</title><path d="M4 36H98" stroke="var(--line-2)"/><polyline points="${bars.map((v,i)=>`${8+i*28},${34-v/max*28}`).join(' ')}"/><circle cx="8" cy="${34-bars[0]/max*28}" r="2"/><circle cx="36" cy="${34-bars[1]/max*28}" r="2"/><circle cx="64" cy="${34-bars[2]/max*28}" r="2"/><circle cx="92" cy="${34-bars[3]/max*28}" r="2"/>`;
|
||
$('deviceTable').innerHTML = '<tr><th>Device</th><th>Presence</th><th>Holdings</th><th>Traffic</th><th>Controls</th></tr>' + (j.devices.map(d=>`<tr><td><b>${esc(d.fingerprintShort)}</b><br><small>${esc(d.profile)} · ${esc(d.userAgent)}</small></td><td>${d.banned?'Banned':d.online?'Online':'Last seen '+new Date(d.lastSeenAt).toLocaleString()}</td><td>${d.holdings} · ${bytes(d.holdingBytes)}</td><td>Sent ${bytes(d.bytesUp)} · received ${bytes(d.bytesDown)}</td><td><div class="row"><button class="btn sm" data-device-sync="${esc(d.id)}" ${!d.online?'disabled':''}>Re-announce</button><button class="btn sm" data-device-holdings="${esc(d.id)}">Revoke holdings</button><button class="btn sm dng" data-device-ban="${esc(d.id)}" data-banned="${d.banned}">${d.banned?'Unban':'Ban'}</button></div></td></tr>`).join('') || '<tr><td colspan="5" class="muted">No devices registered.</td></tr>');
|
||
$('devicesPage').textContent = `${j.devices.length ? devicesOffset + 1 : 0}–${devicesOffset + j.devices.length}${j.hasMore ? '+' : ''} devices`;
|
||
$('devicesPrev').disabled = devicesOffset <= 0; $('devicesNext').disabled = !j.hasMore;
|
||
$('devicesStatus').textContent = `${j.devices.length} devices shown`;
|
||
} catch (error) { $('devicesStatus').textContent = error.message || 'Could not load devices.'; }
|
||
}
|
||
$('devicesPrev')?.addEventListener('click',()=>{devicesOffset=Math.max(0,devicesOffset-100);loadDevices();});
|
||
$('devicesNext')?.addEventListener('click',()=>{devicesOffset+=100;loadDevices();});
|
||
$('devicesRefresh')?.addEventListener('click', loadDevices);
|
||
$('deviceTable')?.addEventListener('click', async e => {
|
||
const b=e.target.closest('button'); if(!b)return;
|
||
try {
|
||
if (b.dataset.deviceSync) await api(`/api/admin/p2p/devices/${b.dataset.deviceSync}/reannounce`,{method:'POST',body:{}});
|
||
if (b.dataset.deviceHoldings && confirm('Remove all reported holdings for this device?')) await api(`/api/admin/p2p/devices/${b.dataset.deviceHoldings}/holdings`,{method:'DELETE'});
|
||
if (b.dataset.deviceBan) await api(`/api/admin/p2p/devices/${b.dataset.deviceBan}/ban`,{method:'POST',body:{banned:b.dataset.banned!=='true'}});
|
||
await loadDevices();
|
||
} catch(error) { $('devicesStatus').textContent=error.message || 'Device action failed.'; }
|
||
});
|
||
let uploadManageOffset = 0;
|
||
async function loadManagedUploads() {
|
||
$('uploadManageStatus').textContent = 'Loading uploads…';
|
||
try {
|
||
const j = await api(`/api/admin/uploads/manage?q=${encodeURIComponent($('uploadManageFilter').value)}&offset=${uploadManageOffset}`);
|
||
if (!j.ok) throw Error(j.error || 'Could not load uploads.');
|
||
const quotaByOwner=new Map((j.quotas||[]).map(q=>[q.owner,q.maxBytes]));
|
||
$('uploadOwners').innerHTML = j.owners.map(o=>`<div class="metric"><span>${esc(o.owner)}</span><b>${o.count} files</b><small>${bytes(o.bytes)} · quota ${quotaByOwner.has(o.owner)?bytes(quotaByOwner.get(o.owner)):'unlimited'}</small>${o.owner!=='(admin)'?`<div class="row"><input class="field" type="number" min="0" step="64" data-quota-owner="${esc(o.owner)}" placeholder="MB; 0 unlimited" value="${quotaByOwner.has(o.owner)?Math.round(quotaByOwner.get(o.owner)/1048576):''}"><button class="btn sm" data-quota-save="${esc(o.owner)}">Set quota</button></div>`:''}</div>`).join('') || '<p class="muted">No uploads.</p>';
|
||
$('uploadManageTable').innerHTML = '<tr><th><input type="checkbox" id="uploadSelectPage" aria-label="Select page"></th><th>File</th><th>Metadata</th><th>Visibility</th><th>Replace / art</th></tr>' + (j.uploads.map(u=>`<tr data-upload-row="${esc(u.id)}"><td><input type="checkbox" data-upload-select="${esc(u.id)}" aria-label="Select ${esc(u.title)}"></td><td><b>${esc(u.title)}</b><br><small>${esc(u.id)} · gen ${u.gen} · ${bytes(u.size)}</small></td><td><input class="field" data-title value="${esc(u.title)}" aria-label="Title"><input class="field" data-artist value="${esc(u.artist)}" aria-label="Artist"><input class="field" data-album value="${esc(u.album)}" aria-label="Album"><button class="btn sm" data-upload-save="${esc(u.id)}">Save details</button></td><td><label><input type="checkbox" data-listed ${u.listed?'checked':''}> Listed in search</label></td><td><label class="btn sm">Replace audio/video<input type="file" data-replace="${esc(u.id)}" accept="audio/*,video/*" hidden></label><label class="btn sm">Replace art<input type="file" data-art="${esc(u.id)}" accept="image/*" hidden></label><button class="btn sm dng" data-upload-delete="${esc(u.id)}">Delete</button></td></tr>`).join('') || '<tr><td colspan="5" class="muted">No uploads found.</td></tr>');
|
||
$('uploadSelectPage').addEventListener('change', e=>document.querySelectorAll('[data-upload-select]').forEach(c=>c.checked=e.target.checked));
|
||
$('uploadOrphans').innerHTML = j.orphans.map(o=>`<p><code>${esc(o.name)}</code> · untracked file</p>`).join('') || '<p class="muted">No orphan files detected.</p>';
|
||
$('uploadManagePage').textContent = `${j.total ? uploadManageOffset + 1 : 0}–${uploadManageOffset + j.uploads.length} of ${j.total}`;
|
||
$('uploadManagePrev').disabled = uploadManageOffset <= 0; $('uploadManageNext').disabled = uploadManageOffset + j.uploads.length >= j.total;
|
||
$('uploadManageStatus').textContent = `${j.uploads.length} uploads loaded. Transcode queue is not enabled in this build.`;
|
||
} catch(error) { $('uploadManageStatus').textContent = error.message || 'Could not load uploads.'; }
|
||
}
|
||
async function uploadRaw(path, method, file) {
|
||
const res = await fetch(path,{method,body:file,credentials:'same-origin'}); const j=await res.json().catch(()=>({ok:false,error:`HTTP ${res.status}`})); if(!res.ok||!j.ok) throw Error(j.error||`HTTP ${res.status}`); return j;
|
||
}
|
||
$('uploadManageRefresh')?.addEventListener('click',()=>{uploadManageOffset=0;loadManagedUploads();});
|
||
$('uploadManageFilter')?.addEventListener('input',()=>{clearTimeout(window.__uploadSearchTimer);window.__uploadSearchTimer=setTimeout(()=>{uploadManageOffset=0;loadManagedUploads();},250);});
|
||
$('uploadManagePrev')?.addEventListener('click',()=>{uploadManageOffset=Math.max(0,uploadManageOffset-100);loadManagedUploads();});
|
||
$('uploadManageNext')?.addEventListener('click',()=>{uploadManageOffset+=100;loadManagedUploads();});
|
||
$('uploadManageTable')?.addEventListener('click',async e=>{
|
||
const b=e.target.closest('button'); if(!b)return;
|
||
try {
|
||
const id=b.dataset.uploadSave||b.dataset.uploadDelete; if(!id)return;
|
||
if(b.dataset.uploadDelete && !confirm('Delete this uploaded file and its cover art? This cannot be undone.'))return;
|
||
const row=b.closest('[data-upload-row]');
|
||
if(b.dataset.uploadSave){const r=await api(`/api/admin/uploads/${id}`,{method:'PATCH',body:{title:row.querySelector('[data-title]').value,artist:row.querySelector('[data-artist]').value,album:row.querySelector('[data-album]').value,listed:row.querySelector('[data-listed]').checked}});if(!r.ok)throw Error(r.error||'Save failed.');}
|
||
else await api(`/api/admin/uploads/${id}`,{method:'DELETE'});
|
||
await loadManagedUploads();
|
||
} catch(error){$('uploadManageStatus').textContent=error.message||'Upload action failed.';}
|
||
});
|
||
$('uploadManageTable')?.addEventListener('change',async e=>{
|
||
const input=e.target.closest('input[type=file]'); if(!input?.files?.[0])return;
|
||
const id=input.dataset.replace||input.dataset.art, file=input.files[0];
|
||
try {
|
||
const url=input.dataset.replace?`/api/admin/uploads/${id}/replace?name=${encodeURIComponent(file.name)}`:`/api/admin/uploads/${id}/art`;
|
||
const result=await uploadRaw(url,'PUT',file); $('uploadManageStatus').textContent=input.dataset.replace?`Replaced file; generation is now ${result.upload.gen}.`:'Cover art updated.'; await loadManagedUploads();
|
||
} catch(error){$('uploadManageStatus').textContent=error.message||'Upload failed.';}
|
||
});
|
||
$('uploadOwners')?.addEventListener('click',async e=>{const b=e.target.closest('[data-quota-save]');if(!b)return;const input=b.parentElement.querySelector('[data-quota-owner]');try{const mb=Number(input.value)||0;const r=await api(`/api/admin/uploads/quotas/${encodeURIComponent(b.dataset.quotaSave)}`,{method:'PUT',body:{maxBytes:mb*1048576}});if(!r.ok)throw Error(r.error||'Quota update failed.');await loadManagedUploads();}catch(error){$('uploadManageStatus').textContent=error.message||'Quota update failed.';}});
|
||
$('uploadBulkApply')?.addEventListener('click',async()=>{
|
||
const ids=[...document.querySelectorAll('[data-upload-select]:checked')].map(x=>x.dataset.uploadSelect), action=$('uploadBulkAction').value;
|
||
if(!ids.length)return $('uploadManageStatus').textContent='Select at least one upload.';
|
||
if(action==='delete'&&!confirm(`Delete ${ids.length} selected uploads and their files?`))return;
|
||
try{const j=await api('/api/admin/uploads/bulk',{method:'POST',body:{ids,action}});if(!j.ok)throw Error(j.error||'Bulk action failed.');$('uploadManageStatus').textContent=`Updated ${j.changed} uploads.`;await loadManagedUploads();}catch(error){$('uploadManageStatus').textContent=error.message||'Bulk action failed.';}
|
||
});
|
||
$('uploadCreateForm')?.addEventListener('submit',async e=>{
|
||
e.preventDefault();const file=$('uploadCreateFile').files[0];if(!file)return;
|
||
try{const url=`/api/admin/uploads/stream?name=${encodeURIComponent(file.name)}&title=${encodeURIComponent($('uploadCreateTitle').value)}&artist=${encodeURIComponent($('uploadCreateArtist').value)}&album=${encodeURIComponent($('uploadCreateAlbum').value)}`;await uploadRaw(url,'PUT',file);e.target.reset();await loadManagedUploads();}catch(error){$('uploadManageStatus').textContent=error.message||'Upload failed.';}
|
||
});
|
||
async function loadCollections() {
|
||
try {
|
||
const j = await api('/api/admin/collections'); if (!j.ok) return;
|
||
$('collectionJobs').innerHTML = j.jobs.length ? j.jobs.map(job => `<div class="collection-job"><b>${esc(job.query)}</b> · ${esc(job.status)}<p>${job.collected}/${job.maxVideos} videos collected · ${job.enriched} enriched · ${job.failed} failed · ${job.searches} searches · depth ${job.depth}</p>${job.current ? `<p>Working on ${esc(job.current)}</p>` : ''}${job.error ? `<p>${esc(job.error)}</p>` : ''}${job.errors?.length ? `<details><summary>Extraction errors</summary>${job.errors.map(e => `<p>${esc(e.id)}: ${esc(e.error)}</p>`).join('')}</details>` : ''}${['queued','running'].includes(job.status) ? `<button class="btn" type="button" data-cancel-collection="${esc(job.id)}">Stop collection</button>` : ''}</div>`).join('') : '<p class="muted">No collections yet.</p>';
|
||
clearTimeout(analyticsTimer); analyticsTimer = null;
|
||
if (tab === 'analytics' && j.jobs.some(job => ['queued','running'].includes(job.status))) analyticsTimer = setTimeout(loadCollections, 4000);
|
||
} catch { $('collectorStatus').textContent = 'Connection interrupted. Collection continues on the server.'; }
|
||
}
|
||
async function loadMetadata() {
|
||
const j = await api(`/api/admin/metadata?q=${encodeURIComponent($('metadataFilter').value)}&offset=${metadataOffset}`); if (!j.ok) return;
|
||
$('metadataCount').textContent = `${j.total} saved videos · ${j.total ? metadataOffset + 1 : 0}–${metadataOffset + j.videos.length}`;
|
||
$('metadataTable').innerHTML = '<tr><th>Video</th><th>Metadata</th><th>Thumbnail</th></tr>' + j.videos.map(v => `<tr><td><button class="btn sm" type="button" data-metadata-id="${esc(v.id)}">${esc(v.title)}</button><br><small>${esc(v.channel)} · ${Number(v.duration) > 0 ? Math.round(Number(v.duration)) + 's' : 'Duration unavailable'}</small></td><td>${v.enriched ? 'Detailed' : 'Search card'}</td><td>${v.thumbnailBytes ? bytes(v.thumbnailBytes) : 'Pending / unavailable'}</td></tr>`).join('');
|
||
$('metadataPrev').disabled = metadataOffset === 0; $('metadataNext').disabled = metadataOffset + 50 >= j.total;
|
||
}
|
||
$('analyticsRefresh').addEventListener('click', loadAnalytics);
|
||
$('collectorForm').addEventListener('submit', async e => {
|
||
e.preventDefault(); $('collectorStart').disabled = true; $('collectorStatus').textContent = 'Queuing collection…';
|
||
try {
|
||
const j = await api('/api/admin/collections', { method: 'POST', body: { query: $('collectorQuery').value, maxVideos: Number($('collectorLimit').value), depth: Number($('collectorDepth').value) } });
|
||
$('collectorStatus').textContent = j.ok ? 'Collection queued. You can leave this page while it runs.' : j.error || 'Could not start collection.';
|
||
if (j.ok) await loadCollections();
|
||
} catch { $('collectorStatus').textContent = 'Could not reach the server.'; }
|
||
finally { $('collectorStart').disabled = false; }
|
||
});
|
||
$('collectionJobs').addEventListener('click', async e => { const btn = e.target.closest('[data-cancel-collection]'); if (!btn) return; await api('/api/admin/collections/' + btn.dataset.cancelCollection + '/cancel', { method: 'POST', body: {} }); await loadCollections(); });
|
||
$('metadataFilterForm').addEventListener('submit', e => { e.preventDefault(); metadataOffset = 0; loadMetadata(); });
|
||
$('metadataPrev').addEventListener('click', () => { metadataOffset = Math.max(0, metadataOffset - 50); loadMetadata(); });
|
||
$('metadataNext').addEventListener('click', () => { metadataOffset += 50; loadMetadata(); });
|
||
|
||
/* ---------------- durable deep search ---------------- */
|
||
let deepTimer = null;
|
||
const lines = id => $(id).value.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
|
||
function deepBody() {
|
||
return { seeds: lines('deepSeeds'), modifiers: lines('deepModifiers'), maxQueries: Number($('deepMaxQueries').value),
|
||
resultsPerQuery: Number($('deepResults').value), relatedDepth: Number($('deepDepth').value), relatedTop: Number($('deepRelatedTop').value),
|
||
channels: lines('deepChannels'), playlists: lines('deepPlaylists'), delaySeconds: Number($('deepDelay').value), dailyCap: Number($('deepDailyCap').value) };
|
||
}
|
||
if (!$('deepSeeds').value) $('deepSeeds').value = 'worship\npraise\nhymns\ngospel\npagsamba\npapuri\nmga awit ng papuri\nebanghelyo\ndayeg\npagdayeg\nmga alawiton\nmaayong balita\nadoración\nalabanza\nhimnos\nevangelio\nHillsong Worship\nElevation Worship\nBethel Music\nPlanetshakers\nDon Moen\nSandi Patty';
|
||
if (!$('deepModifiers').value) $('deepModifiers').value = 'lyrics\nlive\nacoustic\ninstrumental\nkaraoke';
|
||
$('deepEstimate').addEventListener('click', async () => {
|
||
const label = $('deepEstimateText'); label.textContent = 'Counting the plan…';
|
||
try { const j = await api('/api/admin/deep-search/estimate', { method: 'POST', body: deepBody() });
|
||
label.textContent = j.ok ? `${j.queries} unique queries · ${j.browseSources} browse sources · about ${j.estimatedMinutes} min · ${j.indexSize.toLocaleString()} indexed · ${j.dailyRemaining} daily requests remain` : j.error || 'Estimate unavailable';
|
||
} catch { label.textContent = 'Could not reach the server.'; }
|
||
});
|
||
$('deepSearchForm').addEventListener('submit', async e => {
|
||
e.preventDefault(); const button = $('deepStart'); button.disabled = true;
|
||
try { const j = await api('/api/admin/deep-search/start', { method: 'POST', body: deepBody() });
|
||
$('deepEstimateText').textContent = j.ok ? 'Job queued. It will continue if this page closes.' : j.error || 'Could not start job.';
|
||
await loadDeepSearch();
|
||
} catch { $('deepEstimateText').textContent = 'Could not reach the server.'; }
|
||
finally { button.disabled = false; }
|
||
});
|
||
async function loadDeepSearch() {
|
||
try {
|
||
const j = await api('/api/admin/deep-search/jobs'); if (!j.ok) throw Error(j.error || 'Could not load deep-search jobs.');
|
||
$('deepIndexStatus').textContent = `${Number(j.indexSize).toLocaleString()} videos in the local search index.`;
|
||
$('deepJobs').innerHTML = j.jobs.length ? j.jobs.map(job => {
|
||
const active = ['queued','running','paused'].includes(job.status), progress = job.queriesTotal ? Math.round(job.queriesDone / job.queriesTotal * 100) : 0;
|
||
const eta = job.etaMs == null ? 'estimating' : job.etaMs < 60000 ? `${Math.ceil(job.etaMs / 1000)} sec` : `${Math.ceil(job.etaMs / 60000)} min`;
|
||
const controls = job.status === 'running' || job.status === 'queued' ? `<button class="btn sm" data-deep-action="pause" data-id="${esc(job.id)}">Pause</button>` : job.status === 'paused' ? `<button class="btn sm" data-deep-action="resume" data-id="${esc(job.id)}">Resume</button>` : '';
|
||
return `<article class="deep-job"><div class="row"><b>${esc(job.status[0].toUpperCase() + job.status.slice(1))}</b><span class="muted">${job.queriesDone}/${job.queriesTotal} queries · ${job.newVideos} new videos · ETA ${eta}</span><span class="grow"></span>${controls}${active ? `<button class="btn sm dng" data-deep-action="cancel" data-id="${esc(job.id)}">Cancel</button>` : ''}</div><progress max="100" value="${progress}" aria-label="Deep search progress"></progress>${job.current ? `<p class="muted">Current: ${esc(job.current)}</p>` : ''}${job.errors?.length ? `<details><summary>${job.errors.length} errors</summary>${job.errors.map(x => `<p>${esc(x.query)}: ${esc(x.error)}</p>`).join('')}</details>` : ''}<details><summary>Live log (${job.log?.length || 0})</summary><div class="deep-log">${(job.log || []).map(x => `${new Date(x.at).toLocaleTimeString()} ${esc(x.message)}`).join('\n')}</div></details></article>`;
|
||
}).join('') : '<p class="muted">No deep-search runs yet.</p>';
|
||
clearTimeout(deepTimer); deepTimer = null;
|
||
if (tab === 'deep-search' && j.jobs.some(job => ['queued','running'].includes(job.status))) deepTimer = setTimeout(loadDeepSearch, 3000);
|
||
} catch (error) { $('deepIndexStatus').textContent = error.message || 'Could not load deep-search progress.'; }
|
||
}
|
||
$('deepRefresh').addEventListener('click', loadDeepSearch);
|
||
$('deepJobs').addEventListener('click', async e => {
|
||
const button = e.target.closest('[data-deep-action]'); if (!button) return;
|
||
button.disabled = true;
|
||
try { const j = await api(`/api/admin/deep-search/${encodeURIComponent(button.dataset.id)}/${button.dataset.deepAction}`, { method: 'POST', body: {} }); if (!j.ok) $('deepIndexStatus').textContent = j.error || `Could not ${button.dataset.deepAction} job.`; }
|
||
finally { await loadDeepSearch(); }
|
||
});
|
||
$('metadataTable').addEventListener('click', async e => {
|
||
const btn = e.target.closest('[data-metadata-id]'); if (!btn) return;
|
||
const j = await api('/api/admin/metadata/' + btn.dataset.metadataId); if (!j.ok) return;
|
||
$('metadataDetail').textContent = JSON.stringify(j.metadata, null, 2); $('metadataDetails').classList.remove('hidden'); $('metadataDetails').open = true;
|
||
});
|
||
|
||
/* ---------------- tokens / inject ---------------- */
|
||
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="btn sm dng" 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>';
|
||
}
|
||
|
||
/* ---------------- audit history ---------------- */
|
||
let auditOffset = 0;
|
||
function auditQuery(format = '') {
|
||
const p = new URLSearchParams(format ? {} : { limit: '50', offset: String(auditOffset) });
|
||
const search = $('auditSearch').value.trim(); if (search) p.set('q', search);
|
||
for (const [key, id] of [['actor', 'auditActor'], ['action', 'auditAction']]) {
|
||
const value = $(id).value.trim(); if (value) p.set(key, value);
|
||
}
|
||
for (const [key, id, end] of [['from', 'auditFrom', false], ['to', 'auditTo', true]]) {
|
||
const value = $(id).value;
|
||
if (value) p.set(key, String(Math.floor(new Date(`${value}T${end ? '23:59:59' : '00:00:00'}Z`).getTime() / 1000)));
|
||
}
|
||
if (format) p.set('format', format);
|
||
return p.toString();
|
||
}
|
||
async function loadAudit() {
|
||
const table = $('auditTable'); table.innerHTML = '<tr><td class="muted">Loading audit events…</td></tr>';
|
||
try {
|
||
const j = await api('/api/admin/audit?' + auditQuery());
|
||
if (!j.ok) throw Error(j.error || 'Audit log unavailable.');
|
||
const rows = j.events.map(event => `<tr><td>${when(event.ts)}</td><td>${esc(event.actor)}</td><td><code>${esc(event.action)}</code></td><td>${esc(event.target || '—')}</td><td class="audit-detail">${esc(JSON.stringify(event.detail || {}))}</td></tr>`).join('');
|
||
table.innerHTML = rows ? `<tr><th>When</th><th>Actor</th><th>Action</th><th>Target</th><th>Details</th></tr>${rows}` : '<tr><td class="muted">No matching events.</td></tr>';
|
||
$('auditCount').textContent = `${j.total.toLocaleString()} events · ${j.events.length ? auditOffset + 1 : 0}–${auditOffset + j.events.length}`;
|
||
$('auditPrev').disabled = auditOffset === 0; $('auditNext').disabled = auditOffset + j.events.length >= j.total;
|
||
$('auditJson').href = '/api/admin/audit?' + auditQuery('json'); $('auditCsv').href = '/api/admin/audit?' + auditQuery('csv');
|
||
} catch (error) { table.innerHTML = `<tr><td class="err">${esc(error.message)}</td></tr>`; }
|
||
}
|
||
$('auditFilterForm').addEventListener('submit', event => { event.preventDefault(); auditOffset = 0; loadAudit(); });
|
||
$('auditPrev').addEventListener('click', () => { auditOffset = Math.max(0, auditOffset - 50); loadAudit(); });
|
||
$('auditNext').addEventListener('click', () => { auditOffset += 50; loadAudit(); });
|
||
|
||
/* ---------------- note backups ---------------- */
|
||
async function loadBackups() {
|
||
const table = $('backupTable'); table.innerHTML = '<tr><td class="muted">Loading backups…</td></tr>';
|
||
try {
|
||
const result = await api('/api/admin/backups'); if (!result.ok) throw Error(result.error || 'Backup list unavailable.');
|
||
const valid = result.backups.filter(item => item.valid);
|
||
$('backupDate').innerHTML = valid.map(item => `<option value="${esc(item.date)}">${esc(item.date)} · ${item.notes} notes · ${bytes(item.bytes)}</option>`).join('');
|
||
table.innerHTML = result.backups.length ? `<tr><th>Date</th><th>Size</th><th>Notes</th><th>Integrity</th><th></th></tr>${result.backups.map(item => `<tr><td>${esc(item.date)}</td><td>${bytes(item.bytes)}</td><td>${item.notes}</td><td>${item.valid ? 'Verified' : 'Invalid JSON'}</td><td>${item.valid ? `<a class="btn sm" href="/api/admin/backups/${encodeURIComponent(item.date)}/download">Download</a><button class="btn sm" data-backup-preview="${esc(item.date)}">Preview</button>` : ''}</td></tr>`).join('')}` : '<tr><td class="muted">No daily backups found yet.</td></tr>';
|
||
$('backupPreview').disabled = $('backupRestore').disabled = !valid.length;
|
||
if (valid.length) await previewBackup();
|
||
} catch (error) { table.innerHTML = `<tr><td class="err">${esc(error.message)}</td></tr>`; }
|
||
}
|
||
async function previewBackup() {
|
||
const date = $('backupDate').value; if (!date) return;
|
||
const result = await api(`/api/admin/backups/${encodeURIComponent(date)}`);
|
||
if (!result.ok) { $('backupDiff').textContent = result.error || 'Could not preview backup.'; return; }
|
||
const diff = result.diff;
|
||
$('backupDiff').innerHTML = `<p>${esc(date)} · ${diff.changed} changed · ${diff.added} only in backup · ${diff.removed} only live</p><ul>${diff.preview.map(item => `<li><code>${esc(item.key)}</code> — ${esc(item.change)}</li>`).join('') || '<li>No differences</li>'}</ul>`;
|
||
}
|
||
$('backupNow').addEventListener('click', async () => {
|
||
$('backupStatus').textContent = 'Writing snapshot…';
|
||
const result = await api('/api/admin/backups', { method: 'POST', body: {} });
|
||
$('backupStatus').className = `msg ${result.ok ? 'ok' : 'err'}`; $('backupStatus').textContent = result.ok ? `Saved ${result.notes} notes to ${result.date}.` : result.error || 'Backup failed.';
|
||
await loadBackups();
|
||
});
|
||
$('backupDate').addEventListener('change', previewBackup); $('backupPreview').addEventListener('click', previewBackup);
|
||
$('backupTable').addEventListener('click', event => { const button = event.target.closest('[data-backup-preview]'); if (button) { $('backupDate').value = button.dataset.backupPreview; previewBackup(); } });
|
||
$('backupRestore').addEventListener('click', async () => {
|
||
const date = $('backupDate').value, videoId = $('backupSong').value.trim(); if (!date) return;
|
||
const scope = videoId ? `song ${videoId}` : 'all shared notes';
|
||
if (!confirm(`Restore ${scope} from ${date}? A safety snapshot will be created first.`)) return;
|
||
$('backupStatus').textContent = 'Creating safety snapshot and restoring…';
|
||
const result = await api(`/api/admin/backups/${encodeURIComponent(date)}/restore`, { method: 'POST', body: videoId ? { videoId } : {} });
|
||
$('backupStatus').className = `msg ${result.ok ? 'ok' : 'err'}`; $('backupStatus').textContent = result.ok ? `Restored ${result.restored} note documents. Safety snapshot: ${result.safetySnapshot}` : result.error || 'Restore failed.';
|
||
await loadBackups();
|
||
});
|
||
|
||
/* ---------------- lyrics quality queue ---------------- */
|
||
let qualityOffset = 0;
|
||
async function loadLyricsQuality() {
|
||
const table = $('qualityTable'); table.innerHTML = '<tr><td class="muted">Loading quality queue…</td></tr>';
|
||
const category = $('qualityCategory').value, minLines = Number($('qualityMinLines').value) || 8;
|
||
try {
|
||
const j = await api(`/api/admin/lyrics/quality?category=${encodeURIComponent(category)}&minLines=${minLines}&limit=50&offset=${qualityOffset}`);
|
||
if (!j.ok) throw Error(j.error || 'Could not load lyrics queue.');
|
||
$('qualityCount').textContent = `${j.total.toLocaleString()} ${j.total === 1 ? 'song' : 'songs'} · ${j.songs.length ? qualityOffset + 1 : 0}–${qualityOffset + j.songs.length}`;
|
||
$('qualityPrev').disabled = qualityOffset === 0; $('qualityNext').disabled = qualityOffset + j.songs.length >= j.total;
|
||
table.innerHTML = j.songs.length ? `<tr><th>Song</th><th>Lyrics</th><th>Why here</th><th>Actions</th></tr>${j.songs.map(song => {
|
||
const reasons = song.reasons.filter(reason => reason !== 'reports').map(reason => ({ missing: 'no lyrics', short: 'short lyrics', automatic: 'auto-transcribed', untimed: 'untimed' })[reason] || reason);
|
||
if (song.openReports) reasons.push(`${song.openReports} open ${song.openReports === 1 ? 'report' : 'reports'}`);
|
||
return `<tr><td><b>${esc(song.title)}</b><br><small>${esc(song.channel || song.id)}</small></td><td>${song.lines} lines${song.source === 'auto' ? ' · auto draft' : ''}${song.untimed ? ' · untimed' : ''}</td><td>${esc(reasons.join(' · '))}</td><td><div class="row"><button class="btn sm pri" data-quality-fix="${esc(song.id)}">Fix</button><button class="btn sm" data-quality-pull="${esc(song.id)}">Pull LRCLIB</button><button class="btn sm" data-quality-whisper="${esc(song.id)}">Queue Whisper</button></div></td></tr>`;
|
||
}).join('')}` : '<tr><td class="muted">No songs match this filter. Try All issues or reduce the minimum line count.</td></tr>';
|
||
} catch (error) { table.innerHTML = `<tr><td class="err">${esc(error.message)}</td></tr>`; }
|
||
}
|
||
$('qualityFilterForm').addEventListener('submit', event => { event.preventDefault(); qualityOffset = 0; loadLyricsQuality(); });
|
||
$('qualityPrev').addEventListener('click', () => { qualityOffset = Math.max(0, qualityOffset - 50); loadLyricsQuality(); });
|
||
$('qualityNext').addEventListener('click', () => { qualityOffset += 50; loadLyricsQuality(); });
|
||
$('qualityTable').addEventListener('click', async event => {
|
||
const button = event.target.closest('button'); if (!button) return;
|
||
const id = button.dataset.qualityFix || button.dataset.qualityPull || button.dataset.qualityWhisper;
|
||
if (button.dataset.qualityFix) { leOpen(id); return; }
|
||
$('qualityStatus').className = 'msg'; $('qualityStatus').textContent = button.dataset.qualityPull ? 'Looking up LRCLIB…' : 'Adding to the Whisper queue…';
|
||
try {
|
||
const result = button.dataset.qualityPull
|
||
? await api(`/api/notes/${encodeURIComponent(id)}/lyrics/web`, { method: 'POST', body: {} })
|
||
: await api(`/api/admin/transcriptions/${encodeURIComponent(id)}`, { method: 'POST', body: {} });
|
||
if (!result.ok) throw Error(result.error || 'Action failed.');
|
||
$('qualityStatus').className = 'msg ok'; $('qualityStatus').textContent = button.dataset.qualityPull ? `LRCLIB saved revision ${result.rev}.` : `Whisper ${result.job?.status || 'job queued'}.`;
|
||
loadLyricsQuality();
|
||
} catch (error) { $('qualityStatus').className = 'msg err'; $('qualityStatus').textContent = error.message; }
|
||
});
|
||
|
||
/* ---------------- full-text lyrics search ---------------- */
|
||
let lyricsSearchOffset = 0;
|
||
function lyricsSearchQuery() {
|
||
const params = new URLSearchParams({ q: $('lyricsSearchQuery').value.trim(), hasLyrics: $('lyricsSearchHasLyrics').value, hasChapters: $('lyricsSearchHasChapters').value, limit: '30', offset: String(lyricsSearchOffset) });
|
||
return params.toString();
|
||
}
|
||
const safeSnippet = value => esc(value || '').replaceAll('\u0001', '<mark>').replaceAll('\u0002', '</mark>');
|
||
async function loadLyricsSearch() {
|
||
const table = $('lyricsSearchTable'); table.innerHTML = '<tr><td class="muted">Searching saved lyrics…</td></tr>';
|
||
try {
|
||
const result = await api('/api/admin/lyrics/search?' + lyricsSearchQuery()); if (!result.ok) throw Error(result.error || 'Lyrics search failed.');
|
||
table.innerHTML = result.results.length ? `<tr><th>Song</th><th>Match</th><th>Chapters</th><th></th></tr>${result.results.map(row => `<tr><td><b>${esc(row.title || row.videoId)}</b><br><small>${esc(row.channel || row.videoId)}</small></td><td>${safeSnippet(row.snippet)}</td><td>${row.hasChapters ? 'Yes' : 'No'}</td><td><button class="btn sm pri" data-lyrics-search-open="${esc(row.videoId)}">Open editor</button></td></tr>`).join('')}` : '<tr><td class="muted">No matching notes found.</td></tr>';
|
||
$('lyricsSearchCount').textContent = `${result.total.toLocaleString()} ${result.total === 1 ? 'match' : 'matches'} · ${result.results.length ? lyricsSearchOffset + 1 : 0}–${lyricsSearchOffset + result.results.length}`;
|
||
$('lyricsSearchPrev').disabled = lyricsSearchOffset === 0; $('lyricsSearchNext').disabled = lyricsSearchOffset + result.results.length >= result.total;
|
||
$('lyricsSearchStatus').textContent = result.query ? `Searching “${result.query}” · ${result.hasLyrics} lyrics · ${result.hasChapters} chapters` : 'Showing saved lyrics. Enter words to search inside them.';
|
||
} catch (error) { table.innerHTML = `<tr><td class="err">${esc(error.message)}</td></tr>`; }
|
||
}
|
||
$('lyricsSearchForm').addEventListener('submit', event => { event.preventDefault(); lyricsSearchOffset = 0; loadLyricsSearch(); });
|
||
$('lyricsSearchPrev').addEventListener('click', () => { lyricsSearchOffset = Math.max(0, lyricsSearchOffset - 30); loadLyricsSearch(); });
|
||
$('lyricsSearchNext').addEventListener('click', () => { lyricsSearchOffset += 30; loadLyricsSearch(); });
|
||
$('lyricsSearchTable').addEventListener('click', event => { const button = event.target.closest('[data-lyrics-search-open]'); if (button) leOpen(button.dataset.lyricsSearchOpen); });
|
||
$('lyricsSearchRebuild').addEventListener('click', async () => {
|
||
$('lyricsSearchStatus').textContent = 'Rebuilding the lyric text index…';
|
||
const result = await api('/api/admin/lyrics/search/rebuild', { method: 'POST', body: {} });
|
||
$('lyricsSearchStatus').textContent = result.ok ? `Indexed ${result.indexed} saved lyric documents.` : result.error || 'Index rebuild failed.';
|
||
if (result.ok) loadLyricsSearch();
|
||
});
|
||
|
||
/* ---------------- cache health and pinning ---------------- */
|
||
let cacheOffset = 0;
|
||
async function loadCacheHealth() {
|
||
$('cacheTable').innerHTML = '<tr><td class="muted">Loading cache…</td></tr>';
|
||
try {
|
||
const j = await api(`/api/admin/cache/health?limit=50&offset=${cacheOffset}`);
|
||
if (!j.ok) throw Error(j.error || 'Cache health unavailable.');
|
||
const ready = j.statuses.find(x => x.status === 'ready') || {}, pending = j.statuses.filter(x => ['queued', 'downloading', 'validating'].includes(x.status)).reduce((sum, item) => ({ count: sum.count + Number(item.count || 0), bytes: sum.bytes + Number(item.bytes || 0) }), { count: 0, bytes: 0 }), failed = j.statuses.find(x => x.status === 'failed') || {};
|
||
$('cacheMetrics').innerHTML = [
|
||
['Cache used', `${bytes(j.bytes)} / ${bytes(j.budget)}`, `${Math.round(j.utilization * 100)}% of configured budget`],
|
||
['Ready', Number(ready.count || 0).toLocaleString(), bytes(ready.bytes || 0)],
|
||
['Pending / failed', `${Number(pending?.count || 0)} / ${Number(failed.count || 0)}`, 'Jobs in progress or needing retry'],
|
||
['Pinned', `${bytes(j.pinnedBytes)} / ${bytes(j.pinnedBudget)}`, j.pinnedBytes > j.pinnedBudget ? 'Over cap: unpin copies to restore automatic eviction' : 'Protected from automatic eviction'],
|
||
].map(([label, value, detail]) => `<div class="metric"><span>${esc(label)}</span><b>${esc(value)}</b><small>${esc(detail)}</small></div>`).join('');
|
||
$('cacheCount').textContent = `${j.total.toLocaleString()} copies · ${j.media.length ? cacheOffset + 1 : 0}–${cacheOffset + j.media.length}`;
|
||
$('cachePrev').disabled = cacheOffset === 0; $('cacheNext').disabled = cacheOffset + j.media.length >= j.total;
|
||
$('cacheTable').innerHTML = j.media.length ? `<tr><th>Video</th><th>Status</th><th>Size</th><th>Last access</th><th>Actions</th></tr>${j.media.map(item => `<tr><td><button class="btn sm" data-open-cache="${esc(item.id)}">${esc(item.id)}</button><br><small>Generation ${item.gen}</small></td><td>${esc(item.status)}${item.pinned ? ' · pinned' : ''}</td><td>${bytes(item.bytes)}</td><td>${when(Math.floor(item.lastAccess / 1000))}</td><td><div class="row">${item.status === 'ready' ? `<button class="btn sm" data-cache-pin="${esc(item.id)}" data-pinned="${item.pinned}">${item.pinned ? 'Unpin' : 'Pin'}</button><button class="btn sm dng" data-cache-evict="${esc(item.id)}" ${item.pinned ? 'disabled' : ''}>Evict now</button>` : ''}<button class="btn sm" data-cache-redownload="${esc(item.id)}">Re-download</button></div></td></tr>`).join('')}` : '<tr><td class="muted">No media copies are recorded yet.</td></tr>';
|
||
} catch (error) { $('cacheStatus').className = 'msg err'; $('cacheStatus').textContent = error.message; }
|
||
}
|
||
$('cacheRefresh').addEventListener('click', loadCacheHealth);
|
||
$('cachePrev').addEventListener('click', () => { cacheOffset = Math.max(0, cacheOffset - 50); loadCacheHealth(); });
|
||
$('cacheNext').addEventListener('click', () => { cacheOffset += 50; loadCacheHealth(); });
|
||
$('cacheTable').addEventListener('click', async event => {
|
||
const button = event.target.closest('button'); if (!button) return;
|
||
if (button.dataset.openCache) { $('songId').value = button.dataset.openCache; showTab('editor'); return; }
|
||
let path, body = {};
|
||
if (button.dataset.cachePin) { path = `/api/admin/cache/${encodeURIComponent(button.dataset.cachePin)}/pin`; body = { pinned: button.dataset.pinned !== 'true' }; }
|
||
else if (button.dataset.cacheEvict) {
|
||
if (!confirm('Evict this server copy now? Clients may need to stream or save it again.')) return;
|
||
path = `/api/admin/cache/${encodeURIComponent(button.dataset.cacheEvict)}/evict`;
|
||
} else if (button.dataset.cacheRedownload) {
|
||
if (!confirm('Re-download this media from its source? This replaces the server copy.')) return;
|
||
path = `/api/admin/media/${encodeURIComponent(button.dataset.cacheRedownload)}/redownload`;
|
||
} else return;
|
||
$('cacheStatus').className = 'msg'; $('cacheStatus').textContent = 'Applying change…';
|
||
try { const result = await api(path, { method: 'POST', body }); if (!result.ok) throw Error(result.error || 'Action failed.'); $('cacheStatus').className = 'msg ok'; $('cacheStatus').textContent = 'Cache updated.'; await loadCacheHealth(); }
|
||
catch (error) { $('cacheStatus').className = 'msg err'; $('cacheStatus').textContent = error.message; }
|
||
});
|
||
|
||
/* ---------------- search index health ---------------- */
|
||
async function loadIndexHealth() {
|
||
$('indexSources').innerHTML = '<tr><td class="muted">Loading source coverage…</td></tr>';
|
||
try {
|
||
const j = await api('/api/admin/index/health'); if (!j.ok) throw Error(j.error || 'Index health unavailable.');
|
||
const pct = value => `${Math.round(Number(value || 0) * 100)}%`;
|
||
$('indexMetrics').innerHTML = [
|
||
['Indexed videos', Number(j.total).toLocaleString(), 'Searchable catalogue rows'],
|
||
['Canonical URLs', pct(j.urlCoverage), `${Number(j.urlCount).toLocaleString()} of ${Number(j.total).toLocaleString()}`],
|
||
['Detailed metadata', pct(j.detailCoverage), `${Number(j.detailCount).toLocaleString()} enriched records`],
|
||
['Saved thumbnails', pct(j.thumbnailCoverage), `${Number(j.thumbnailCount).toLocaleString()} stored images`],
|
||
].map(([label, value, detail]) => `<div class="metric"><span>${esc(label)}</span><b>${esc(value)}</b><small>${esc(detail)}</small></div>`).join('');
|
||
$('indexSources').innerHTML = j.sources.length ? `<tr><th>Source</th><th>Videos</th><th>Discoveries</th><th>Last seen</th></tr>${j.sources.map(x => `<tr><td>${esc(x.source)}</td><td>${Number(x.videos).toLocaleString()}</td><td>${Number(x.discoveries).toLocaleString()}</td><td>${when(Math.floor(x.lastSeen / 1000))}</td></tr>`).join('')}` : '<tr><td class="muted">No source records yet.</td></tr>';
|
||
if (j.lastRun) {
|
||
$('indexLastRun').textContent = `${j.lastRun.status} · ${new Date(j.lastRun.createdAt).toLocaleString()}${j.lastRun.error ? ` · ${j.lastRun.error}` : ''}`;
|
||
$('indexRunDetail').textContent = `${Number(j.lastRun.progress.queriesDone || 0)} queries complete · ${Number(j.lastRun.progress.newVideos || 0)} new videos`;
|
||
} else { $('indexLastRun').textContent = 'No deep-search run recorded.'; $('indexRunDetail').textContent = ''; }
|
||
$('indexBackfillTable').innerHTML = j.backfill.length ? `<tr><th>Source table</th><th>Progress</th><th>Status</th></tr>${j.backfill.map(x => `<tr><td>${esc(x.source)}</td><td>${Number(x.cursor).toLocaleString()} / ${Number(x.ceiling).toLocaleString()}</td><td>${x.done ? 'Complete' : 'Ready for next page'}</td></tr>`).join('')}` : '<tr><td class="muted">Backfill has not started. Use “Backfill next page” to initialize it.</td></tr>';
|
||
} catch (error) { $('indexStatus').className = 'msg err'; $('indexStatus').textContent = error.message; }
|
||
}
|
||
$('indexRefresh').addEventListener('click', loadIndexHealth);
|
||
$('indexBackfill').addEventListener('click', async () => {
|
||
$('indexStatus').className = 'msg'; $('indexStatus').textContent = 'Backfilling one bounded page…';
|
||
try { const j = await api('/api/admin/index/backfill', { method: 'POST', body: {} }); if (!j.ok) throw Error(j.error || 'Backfill failed.'); $('indexStatus').className = 'msg ok'; $('indexStatus').textContent = `Processed ${j.processed} records. Run again to continue.`; await loadIndexHealth(); }
|
||
catch (error) { $('indexStatus').className = 'msg err'; $('indexStatus').textContent = error.message; }
|
||
});
|
||
$('indexCleanup').addEventListener('click', async () => {
|
||
if (!confirm('Remove only stale, unreferenced catalogue entries and orphaned details? This cannot be undone.')) return;
|
||
$('indexStatus').className = 'msg'; $('indexStatus').textContent = 'Cleaning stale entries…';
|
||
try { const j = await api('/api/admin/index/cleanup', { method: 'POST', body: { days: 365 } }); if (!j.ok) throw Error(j.error || 'Cleanup failed.'); $('indexStatus').className = 'msg ok'; $('indexStatus').textContent = `Removed ${j.removed} stale catalogue entries.`; await loadIndexHealth(); }
|
||
catch (error) { $('indexStatus').className = 'msg err'; $('indexStatus').textContent = error.message; }
|
||
});
|
||
$('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>${esc(j.token)}</code><div class="row" style="margin-top:10px"><button class="btn sm" id="copyTok" type="button">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 || !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}.`;
|
||
});
|
||
|
||
/* ---------------- history ---------------- */
|
||
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><div class="row" style="flex-wrap:nowrap">
|
||
<button class="btn sm" data-view="${esc(r.videoId)}|${esc(r.kind)}|${r.rev}">View</button>
|
||
${r.kind === 'lyrics' ? `<button class="btn sm" data-edit="${esc(r.videoId)}">Edit</button>` : ''}
|
||
<button class="btn sm" data-restore="${esc(r.videoId)}|${esc(r.kind)}|${r.rev}">Restore</button></div></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 b = e.target.closest('button'); if (!b) return;
|
||
if (b.dataset.edit) { leOpen(b.dataset.edit); return; }
|
||
if (b.dataset.view) {
|
||
const [id, kind, rev] = b.dataset.view.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 (b.dataset.restore) {
|
||
const [id, kind, rev] = b.dataset.restore.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);
|
||
|
||
/* ---------------- uploads ---------------- */
|
||
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:6px" />` : '<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><div class="row" style="flex-wrap:nowrap"><a class="btn sm" href="/?v=${esc(u.id)}" target="_blank" rel="noopener">Play</a>
|
||
<button class="btn sm" data-lyrics="${esc(u.id)}">Lyrics</button>
|
||
<button class="btn sm dng" data-del="${esc(u.id)}">Delete</button></div></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>';
|
||
return j.uploads || [];
|
||
}
|
||
// Upload card: show the chosen file / cover, accept drag-and-drop.
|
||
const fmtSize = (n) => n >= 1073741824 ? (n / 1073741824).toFixed(1) + ' GB' : n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB';
|
||
function paintUpFile() {
|
||
const f = $('upFile').files[0];
|
||
$('upDrop').classList.toggle('has', !!f);
|
||
$('upFileName').textContent = f ? f.name : 'Choose a video or audio file';
|
||
$('upFileMeta').textContent = f ? `${fmtSize(f.size)} · ${f.type || 'media file'} · click to change` : 'or drop it here · mp4, mov, webm, mkv, mp3, m4a, flac, wav…';
|
||
}
|
||
function paintUpArt() {
|
||
const f = $('upArt').files[0];
|
||
const box = $('upArtBox');
|
||
const old = box.querySelector('img'); if (old) { URL.revokeObjectURL(old.src); old.remove(); }
|
||
box.querySelector('svg').style.display = f ? 'none' : '';
|
||
$('upArtName').textContent = f ? f.name : 'Add image';
|
||
if (f) { const img = document.createElement('img'); img.alt = ''; img.src = URL.createObjectURL(f); box.prepend(img); }
|
||
}
|
||
$('upFile').addEventListener('change', paintUpFile);
|
||
$('upArt').addEventListener('change', paintUpArt);
|
||
$('upForm').addEventListener('reset', () => setTimeout(() => { paintUpFile(); paintUpArt(); }, 0));
|
||
['dragenter', 'dragover'].forEach((t) => $('upDrop').addEventListener(t, (e) => { e.preventDefault(); $('upDrop').classList.add('over'); }));
|
||
['dragleave', 'drop'].forEach((t) => $('upDrop').addEventListener(t, () => $('upDrop').classList.remove('over')));
|
||
$('upDrop').addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
|
||
if (!f) return;
|
||
const dt = new DataTransfer(); dt.items.add(f); $('upFile').files = dt.files; paintUpFile();
|
||
});
|
||
|
||
$('upForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
const f = $('upFile').files[0];
|
||
if (!f) { const m = $('upProgress'); m.className = 'msg err'; m.textContent = 'Choose a video or audio file first.'; $('upDrop').focus(); $('upFile').click(); return; }
|
||
const qs = new URLSearchParams({ name: f.name });
|
||
if ($('upTitle').value.trim()) qs.set('title', $('upTitle').value.trim());
|
||
if ($('upArtist').value.trim()) qs.set('artist', $('upArtist').value.trim());
|
||
const cover = $('upArt').files[0];
|
||
const msg = $('upProgress'); msg.className = 'msg'; msg.textContent = `Uploading ${f.name}…`;
|
||
// The raw file is the request body (streamed from disk by the browser, to
|
||
// disk by the server) so hour-long, multi-GB videos need no memory.
|
||
const xhr = new XMLHttpRequest();
|
||
xhr.open('PUT', '/api/admin/uploads/stream?' + qs); xhr.withCredentials = true;
|
||
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
|
||
const t0 = Date.now();
|
||
xhr.upload.onprogress = (ev) => {
|
||
if (!ev.lengthComputable) return;
|
||
const pct = Math.round((ev.loaded / ev.total) * 100), mbps = ev.loaded / 1048576 / Math.max(1, (Date.now() - t0) / 1000);
|
||
msg.textContent = `Uploading ${f.name}… ${pct}% · ${(ev.loaded / 1048576).toFixed(0)} of ${(ev.total / 1048576).toFixed(0)} MB · ${mbps.toFixed(1)} MB/s${pct >= 100 ? ' — processing…' : ''}`;
|
||
};
|
||
xhr.onload = async () => {
|
||
let j = {}; try { j = JSON.parse(xhr.responseText); } catch { /* non-JSON */ }
|
||
if (xhr.status === 200 && j.ok) {
|
||
let artNote = j.upload.art ? ', cover art' : '';
|
||
if (cover) {
|
||
const ar = await fetch(`/api/admin/uploads/${j.upload.id}/art`, { method: 'PUT', credentials: 'same-origin', body: cover }).then((r) => r.json()).catch(() => ({}));
|
||
artNote = ar.ok ? ', cover art' : ', cover image unusable';
|
||
}
|
||
msg.className = 'msg ok';
|
||
msg.textContent = `Added “${j.upload.title}” (${j.upload.kind}${artNote}${j.lyricLines ? `, ${j.lyricLines} lyric lines` : ''}).`;
|
||
$('upForm').reset(); loadUploads(); loadRecent(); loadSongs();
|
||
} else { msg.className = 'msg err'; msg.textContent = j.error || (xhr.status === 413 ? 'File is too large (limit 4 GB)' : `Upload failed (HTTP ${xhr.status})`); }
|
||
};
|
||
xhr.onerror = () => { msg.className = 'msg err'; msg.textContent = 'Upload failed — network error'; };
|
||
xhr.send(f);
|
||
});
|
||
$('upTable').addEventListener('click', async (e) => {
|
||
const b = e.target.closest('button'); if (!b) return;
|
||
if (b.dataset.lyrics) { leOpen(b.dataset.lyrics); return; }
|
||
if (!b.dataset.del || !confirm('Delete this upload? The file is removed from the server.')) return;
|
||
const j = await api(`/api/admin/uploads/${encodeURIComponent(b.dataset.del)}`, { method: 'DELETE' });
|
||
if (!j.ok) alert(j.error || 'failed');
|
||
loadUploads(); loadRecent();
|
||
});
|
||
/* ---------------- resumable bulk lyrics jobs ---------------- */
|
||
let bulkOffset = 0, bulkPollTimer = null, bulkAllFiltered = false;
|
||
const bulkSelected = new Set();
|
||
function paintBulkSelection() {
|
||
const scope = bulkAllFiltered ? 'First 500 matching songs selected' : `${bulkSelected.size} selected`;
|
||
$('bulkSelectedCount').textContent = scope;
|
||
$('bulkStart').textContent = bulkAllFiltered ? 'Start job · first 500' : `Start job · ${bulkSelected.size}`;
|
||
}
|
||
async function loadBulkLibrary() {
|
||
const table = $('bulkLibraryTable'); table.innerHTML = '<tr><td class="muted">Loading song library…</td></tr>';
|
||
try {
|
||
const q = encodeURIComponent($('bulkLibraryQuery').value.trim());
|
||
const result = await api(`/api/admin/lyrics/bulk/library?limit=50&offset=${bulkOffset}&q=${q}`);
|
||
if (!result.ok) throw Error(result.error || 'Could not load song library.');
|
||
$('bulkLibraryCount').textContent = `${result.total.toLocaleString()} ${result.total === 1 ? 'song' : 'songs'} · ${result.songs.length ? bulkOffset + 1 : 0}–${bulkOffset + result.songs.length}`;
|
||
$('bulkLibraryPrev').disabled = bulkOffset === 0; $('bulkLibraryNext').disabled = bulkOffset + result.songs.length >= result.total;
|
||
table.innerHTML = result.songs.length ? `<tr><th></th><th>Song</th><th>Lyrics</th><th>Audio</th></tr>${result.songs.map(song => `<tr><td><input type="checkbox" data-bulk-song="${esc(song.id)}" ${bulkAllFiltered || bulkSelected.has(song.id) ? 'checked' : ''} aria-label="Select ${esc(song.title)}" /></td><td><b>${esc(song.title)}</b><br><small>${esc(song.channel || song.id)}</small></td><td>${song.lines} lines</td><td>${esc(song.cacheStatus || 'not cached')}</td></tr>`).join('')}` : '<tr><td class="muted">No songs match this filter.</td></tr>';
|
||
if (bulkAllFiltered) $('bulkSelectAll').checked = true;
|
||
paintBulkSelection();
|
||
} catch (error) { table.innerHTML = `<tr><td class="err">${esc(error.message)}</td></tr>`; }
|
||
}
|
||
$('bulkLibraryFilter').addEventListener('submit', event => { event.preventDefault(); bulkOffset = 0; bulkAllFiltered = false; bulkSelected.clear(); $('bulkSelectAll').checked = false; loadBulkLibrary(); });
|
||
$('bulkLibraryPrev').addEventListener('click', () => { bulkOffset = Math.max(0, bulkOffset - 50); $('bulkSelectPage').checked = false; loadBulkLibrary(); });
|
||
$('bulkLibraryNext').addEventListener('click', () => { bulkOffset += 50; $('bulkSelectPage').checked = false; loadBulkLibrary(); });
|
||
$('bulkLibraryTable').addEventListener('change', event => {
|
||
const input = event.target.closest('[data-bulk-song]'); if (!input) return;
|
||
if (bulkAllFiltered) {
|
||
bulkSelected.clear();
|
||
$('bulkLibraryTable').querySelectorAll('[data-bulk-song]').forEach(item => { if (item.checked) bulkSelected.add(item.dataset.bulkSong); });
|
||
} else if (input.checked) bulkSelected.add(input.dataset.bulkSong); else bulkSelected.delete(input.dataset.bulkSong);
|
||
bulkAllFiltered = false; $('bulkSelectAll').checked = false; paintBulkSelection();
|
||
});
|
||
$('bulkSelectPage').addEventListener('change', event => {
|
||
$('bulkLibraryTable').querySelectorAll('[data-bulk-song]').forEach(input => { input.checked = event.target.checked; if (event.target.checked) bulkSelected.add(input.dataset.bulkSong); else bulkSelected.delete(input.dataset.bulkSong); });
|
||
bulkAllFiltered = false; $('bulkSelectAll').checked = false; paintBulkSelection();
|
||
});
|
||
$('bulkSelectAll').addEventListener('change', event => { bulkAllFiltered = event.target.checked; if (bulkAllFiltered) bulkSelected.clear(); paintBulkSelection(); loadBulkLibrary(); });
|
||
$('bulkStart').addEventListener('click', async () => {
|
||
const action = $('bulkAction').value;
|
||
if (action === 'clear' && !confirm(`Clear lyrics for ${bulkAllFiltered ? 'all filtered songs (up to 500)' : `${bulkSelected.size} selected songs`}? Previous versions remain in History.`)) return;
|
||
const result = await api('/api/admin/lyrics/jobs', { method: 'POST', body: { action, ids: [...bulkSelected], allFiltered: bulkAllFiltered, query: $('bulkLibraryQuery').value.trim(), confirmClear: action === 'clear' } });
|
||
if (!result.ok) { $('bulkJobStatus').className = 'msg err'; $('bulkJobStatus').textContent = result.error || 'Could not start bulk job.'; return; }
|
||
$('bulkJobStatus').className = 'msg ok'; $('bulkJobStatus').textContent = `Job ${result.job.id} queued (${result.job.state.total} songs). It continues if you leave this page.`;
|
||
bulkSelected.clear(); bulkAllFiltered = false; $('bulkSelectAll').checked = false; $('bulkSelectPage').checked = false; paintBulkSelection(); await loadBulkJobs();
|
||
});
|
||
async function loadBulkJobs() {
|
||
const result = await api('/api/admin/lyrics/jobs'); if (!result.ok) return;
|
||
$('bulkJobs').innerHTML = result.jobs.length ? result.jobs.slice(0, 10).map(job => {
|
||
const results = job.state.results || [], succeeded = results.filter(row => row.ok).length, failed = results.length - succeeded;
|
||
const summary = job.status === 'complete' ? ` · ${succeeded} succeeded${failed ? ` · ${failed} need review` : ''}` : '';
|
||
return `<div class="card" data-bulk-job="${esc(job.id)}"><div class="row"><b>${esc(job.action)} · ${esc(job.status)}${summary}</b><span class="grow"></span><span class="muted">${job.state.done || 0}/${job.state.total || 0}</span>${['queued','running'].includes(job.status) ? `<button class="btn sm" data-bulk-job-action="pause" data-id="${esc(job.id)}">Pause</button>` : job.status === 'paused' ? `<button class="btn sm" data-bulk-job-action="resume" data-id="${esc(job.id)}">Resume</button>` : ''}${['queued','running','paused'].includes(job.status) ? `<button class="btn sm dng" data-bulk-job-action="cancel" data-id="${esc(job.id)}">Cancel</button>` : ''}${job.action === 'export' && job.state.fileReady ? `<a class="btn sm" href="/api/admin/lyrics/jobs/${encodeURIComponent(job.id)}/export">Download ZIP</a>` : ''}</div>${job.error ? `<p class="msg err">${esc(job.error)}</p>` : ''}<small>${results.map(row => `${esc(row.id)}: ${esc(row.message)}`).join(' · ')}</small></div>`;
|
||
}).join('') : '<p class="muted">No bulk jobs yet.</p>';
|
||
clearTimeout(bulkPollTimer);
|
||
if (result.jobs.some(job => ['queued','running'].includes(job.status))) bulkPollTimer = setTimeout(loadBulkJobs, 1500);
|
||
}
|
||
$('bulkJobs').addEventListener('click', async event => {
|
||
const button = event.target.closest('[data-bulk-job-action]'); if (!button) return;
|
||
const result = await api(`/api/admin/lyrics/jobs/${encodeURIComponent(button.dataset.id)}/${button.dataset.bulkJobAction}`, { method: 'POST', body: {} });
|
||
if (!result.ok) $('bulkJobStatus').textContent = result.error || 'Job action failed.';
|
||
await loadBulkJobs();
|
||
});
|
||
async function loadRecent() {
|
||
const j = await api('/api/uploads?limit=10');
|
||
$('recentChips').innerHTML = (j.uploads || []).map((u) => `<button type="button" class="chip" data-open="${esc(u.id)}"><svg class="chip-ico" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/></svg><span>${esc(u.title)}</span></button>`).join('');
|
||
}
|
||
let SONGS = [];
|
||
async function loadSongs() {
|
||
const [m, u] = await Promise.all([api('/api/admin/media'), api('/api/uploads?limit=200')]);
|
||
const seen = new Set();
|
||
SONGS = [];
|
||
for (const r of (m.media || [])) { seen.add(r.id); SONGS.push({ id: r.id, title: r.title || r.id, sub: r.channel, lines: r.lyricsLines || 0, sourceUrl: r.sourceUrl, dur: r.duration || 0 }); }
|
||
for (const r of (u.uploads || [])) if (!seen.has(r.id)) SONGS.push({ id: r.id, title: r.title, sub: r.artist || 'Upload', lines: -1, dur: r.duration || 0 });
|
||
paintSongs();
|
||
}
|
||
let songView = 'all', songLimit = 30;
|
||
const SONG_ART = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 18V6l10-2v12"/><circle cx="6.5" cy="18" r="2.5"/><circle cx="16.5" cy="16" r="2.5"/></svg>';
|
||
const songArt = (id) => /^[A-Za-z0-9_-]{11}$/.test(id) ? `https://i.ytimg.com/vi/${id}/mqdefault.jpg` : `/api/uploads/${encodeURIComponent(id)}/art`;
|
||
const songDur = (s) => { s = Math.round(Number(s) || 0); return s > 0 ? `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` : ''; };
|
||
function paintSongs() {
|
||
const q = $('songFilter').value.trim().toLowerCase();
|
||
const has = SONGS.filter((r) => r.lines > 0).length, need = SONGS.filter((r) => r.lines === 0).length, known = has + need;
|
||
const counts = { all: SONGS.length, need, has };
|
||
document.querySelectorAll('[data-n]').forEach((el) => { el.textContent = counts[el.dataset.n].toLocaleString(); });
|
||
document.querySelectorAll('[data-view]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.view === songView)));
|
||
$('songCount').textContent = SONGS.length ? `${SONGS.length.toLocaleString()} songs` : '';
|
||
$('coverage').hidden = !known;
|
||
if (known) {
|
||
$('covFill').style.width = `${(has / known) * 100}%`;
|
||
$('covText').innerHTML = `<b>${has.toLocaleString()}</b> of ${known.toLocaleString()} downloaded songs have lyrics${need ? ` · <b>${need.toLocaleString()}</b> still need them` : ' · all done'}`;
|
||
}
|
||
const rows = SONGS.filter((r) => (songView === 'all' || (songView === 'has' ? r.lines > 0 : r.lines === 0))
|
||
&& (!q || (r.title + ' ' + (r.sub || '')).toLowerCase().includes(q)));
|
||
const shown = rows.slice(0, songLimit);
|
||
$('songList').innerHTML = shown.length ? shown.map((r) => `<div class="song-row"><button type="button" class="song-open" data-open="${esc(r.id)}">
|
||
<span class="song-art">${SONG_ART}<img src="${esc(songArt(r.id))}" alt="" loading="lazy" decoding="async" onerror="this.remove()"></span>
|
||
<span class="song-info"><b>${esc(r.title)}</b><small>${esc(r.sub || '')}</small>
|
||
<span class="song-meta"><span class="st ${r.lines > 0 ? 'has' : r.lines === 0 ? 'none' : ''}">${r.lines > 0 ? `${r.lines} lines` : r.lines === 0 ? 'no lyrics' : 'open'}</span>${songDur(r.dur) ? `<span class="dur">${songDur(r.dur)}</span>` : ''}</span></span></button>
|
||
<div class="song-actions">
|
||
<button type="button" class="btn sm pri song-edit" data-open="${esc(r.id)}">${r.lines > 0 ? 'Edit lyrics' : 'Add lyrics'}</button>
|
||
<button type="button" class="btn sm whisper-request" data-whisper="${esc(r.id)}" aria-label="Transcribe ${esc(r.title)} with Whisper">Whisper</button>
|
||
${r.sourceUrl ? `<button type="button" class="btn sm" data-redownload="${esc(r.id)}">Re-download from source</button><a class="btn sm" href="${esc(r.sourceUrl)}" target="_blank" rel="noopener"><span class="d-only">Source</span><span class="m-only">Open source page</span></a>` : ''}
|
||
</div></div>`).join('')
|
||
: `<div class="songs-empty">${SONGS.length ? 'Nothing matches. Paste a link above to open any YouTube song.' : 'No songs on the server yet. Paste a link above to open any YouTube song.'}</div>`;
|
||
const more = rows.length - shown.length;
|
||
$('songMore').classList.toggle('hidden', more <= 0);
|
||
$('songMore').textContent = `Show ${Math.min(more, 30)} more of ${more.toLocaleString()}`;
|
||
paintWhisper();
|
||
}
|
||
$('songMore').addEventListener('click', () => { songLimit += 30; paintSongs(); });
|
||
document.querySelector('.lib-filter').addEventListener('click', (e) => {
|
||
const b = e.target.closest('[data-view]'); if (!b) return;
|
||
songView = b.dataset.view; songLimit = 30; paintSongs();
|
||
});
|
||
$('songFilter').addEventListener('input', () => { songLimit = 30; paintSongs(); });
|
||
$('songList').addEventListener('click', (e) => {
|
||
const whisper = e.target.closest('[data-whisper]'); if (whisper) { startWhisper(whisper.dataset.whisper); return; }
|
||
const source = e.target.closest('[data-redownload]'); if (source) { startSourceRedownload(source); return; }
|
||
const b = e.target.closest('[data-open]'); if (b) leOpen(b.dataset.open);
|
||
});
|
||
async function startSourceRedownload(button) {
|
||
button.disabled = true; const original = button.textContent; button.textContent = 'Queueing…';
|
||
try { const result = await api(`/api/admin/media/${encodeURIComponent(button.dataset.redownload)}/redownload`, { method: 'POST', body: {} });
|
||
button.textContent = result.ok ? 'Queued' : 'Could not queue';
|
||
button.title = result.error || 'The server-owned download continues if this page closes.';
|
||
} catch { button.textContent = 'Connection failed'; }
|
||
setTimeout(() => { button.textContent = original; button.disabled = false; }, 2000);
|
||
}
|
||
$('recentChips').addEventListener('click', (e) => { const b = e.target.closest('[data-open]'); if (b) leOpen(b.dataset.open); });
|
||
|
||
/* ---------------- p2p ---------------- */
|
||
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 class="btn sm dng" 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();
|
||
});
|
||
|
||
/* ---------------- reports ---------------- */
|
||
let repStatus = 'open';
|
||
const REASON = { words: 'Wrong words', timing: 'Wrong timing', typo: 'Typo', other: 'Other' };
|
||
async function refreshBadge() { const j = await api('/api/admin/flags?status=open'); if (j.ok) setBadge(j.open); }
|
||
async function loadReports() {
|
||
const box = $('repList');
|
||
box.innerHTML = '<div class="empty">Loading…</div>';
|
||
const age = Number($('reportAge')?.value || 0);
|
||
const j = await api(`/api/admin/flags?status=${repStatus}&olderThanDays=${age}`);
|
||
if (!j.ok) { box.innerHTML = `<div class="empty">${esc(j.error || 'failed')}</div>`; return; }
|
||
setBadge(j.open);
|
||
if (!j.flags.length) {
|
||
box.innerHTML = `<div class="empty">${repStatus === 'open' ? '🎉 Nothing reported — every line is looking good.' : 'No resolved reports yet.'}</div>`;
|
||
return;
|
||
}
|
||
// Group per song, then per line text.
|
||
const bySong = new Map();
|
||
for (const f of j.flags) {
|
||
if (!bySong.has(f.videoId)) bySong.set(f.videoId, { title: f.title, channel: f.channel, lines: new Map() });
|
||
const s = bySong.get(f.videoId);
|
||
if (!s.lines.has(f.text)) s.lines.set(f.text, []);
|
||
s.lines.get(f.text).push(f);
|
||
}
|
||
box.innerHTML = [...bySong].map(([vid, s]) => `
|
||
<div class="card">
|
||
<div class="row" style="margin-bottom:10px">
|
||
<div style="min-width:0"><h2>${esc(s.title || vid)}</h2><span class="muted" style="font-size:12.5px">${esc(s.channel || '')} <code>${esc(vid)}</code></span></div>
|
||
<span class="grow"></span>
|
||
<button class="btn sm pri" data-fix="${esc(vid)}">Open in editor</button>
|
||
${repStatus === 'open' ? `<button class="btn sm" data-resolve-song="${esc(vid)}">Resolve all for song</button>` : ''}
|
||
</div>
|
||
<div class="lines">${[...s.lines].map(([text, fl]) => `
|
||
<div class="ln flagged" style="padding:12px">
|
||
<div style="font-size:17px;font-weight:700;margin-bottom:8px;overflow-wrap:anywhere">“${esc(text)}”</div>
|
||
<div class="flagbox" style="margin:0 0 10px">${fl.map((f) => `
|
||
<div class="rep"><div style="flex:1;min-width:0"><span class="who">${esc(f.reporter)}</span>${f.reason ? `<span class="why">${esc(REASON[f.reason] || f.reason)}</span>` : ''}
|
||
<span class="muted" style="font-size:12px"> · ${when(f.createdAt)}</span>
|
||
${f.note ? `<div class="txt">${esc(f.note)}</div>` : ''}</div></div>`).join('')}</div>
|
||
<div class="row">
|
||
<button class="btn sm pri" data-fix="${esc(vid)}" data-text="${esc(text)}">Fix this line</button>
|
||
${repStatus === 'open'
|
||
? `<button class="btn sm" data-resall="${fl.map((f) => f.id).join(',')}">Mark resolved</button>`
|
||
: `<button class="btn sm" data-reopen="${fl.map((f) => f.id).join(',')}">Reopen</button>`}
|
||
<button class="btn sm dng" data-delall="${fl.map((f) => f.id).join(',')}">Delete</button>
|
||
</div>
|
||
</div>`).join('')}</div>
|
||
</div>`).join('');
|
||
}
|
||
$('repList').addEventListener('click', async (e) => {
|
||
const b = e.target.closest('button'); if (!b) return;
|
||
if (b.dataset.fix) { leOpen(b.dataset.fix, b.dataset.text); return; }
|
||
if (b.dataset.resolveSong) {
|
||
if (!confirm(`Resolve every open report for ${b.dataset.resolveSong}?`)) return;
|
||
const result = await api(`/api/admin/flags/song/${encodeURIComponent(b.dataset.resolveSong)}/resolve`, { method: 'POST', body: {} });
|
||
if (!result.ok) alert(result.error || 'Could not resolve reports');
|
||
await loadReports(); leReloadFlagsOnly(); return;
|
||
}
|
||
const each = async (ids, fn) => { for (const id of ids.split(',')) await fn(id); await loadReports(); leReloadFlagsOnly(); };
|
||
if (b.dataset.resall) each(b.dataset.resall, (id) => api(`/api/admin/flags/${id}`, { method: 'POST', body: { status: 'resolved' } }));
|
||
else if (b.dataset.reopen) each(b.dataset.reopen, (id) => api(`/api/admin/flags/${id}`, { method: 'POST', body: { status: 'open' } }));
|
||
else if (b.dataset.delall && confirm('Delete these reports for good?')) each(b.dataset.delall, (id) => api(`/api/admin/flags/${id}`, { method: 'DELETE' }));
|
||
});
|
||
function setFilter(s) {
|
||
repStatus = s;
|
||
$('fOpen').setAttribute('aria-pressed', String(s === 'open'));
|
||
$('fRes').setAttribute('aria-pressed', String(s === 'resolved'));
|
||
loadReports();
|
||
}
|
||
$('fOpen').addEventListener('click', () => setFilter('open'));
|
||
$('fRes').addEventListener('click', () => setFilter('resolved'));
|
||
$('reportAge').addEventListener('change', loadReports);
|
||
$('repRefresh').addEventListener('click', loadReports);
|
||
|
||
/* ========================================================================
|
||
* Editor. Rows are built ONCE per (re)render and then only classes/values
|
||
* change — rebuilding on every tick would steal focus and reset scrolling.
|
||
* ====================================================================== */
|
||
const LE = {
|
||
id: '', rev: 0, lines: [], tags: [], offset: 0, dur: 0, audio: null, peaks: null, rows: [],
|
||
tap: false, dirty: false, raw: false, sel: -1, flags: new Map(), undo: [], redo: [], loaded: false,
|
||
};
|
||
const r2 = (n) => Math.round(n * 100) / 100;
|
||
const fmtT = (t) => (t == null ? '' : `${Math.floor(t / 60)}:${(t - Math.floor(t / 60) * 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 ? r2(n) : null;
|
||
}
|
||
const say = (t, c) => { const m = $('msg'); m.className = 'msg' + (c ? ' ' + c : ''); m.textContent = t; };
|
||
const cur = () => (LE.audio ? LE.audio.currentTime : 0);
|
||
|
||
function syncBars() {
|
||
const show = tab === 'editor' && LE.loaded;
|
||
$('saveBar').classList.toggle('hidden', !show);
|
||
$('tapPad').classList.toggle('hidden', !(show && LE.tap && !LE.raw));
|
||
}
|
||
function setDirty(on) {
|
||
LE.dirty = on;
|
||
$('saveInner').classList.toggle('dirty', on);
|
||
$('saveStat').textContent = on ? 'Unsaved changes' : 'All changes saved';
|
||
$('save').disabled = !on;
|
||
$('discard').disabled = !on;
|
||
}
|
||
|
||
const whisperJobs = new Map(), whisperStarting = new Set();
|
||
let whisperTimer = null;
|
||
const activeWhisper = job => job && (job.status === 'queued' || job.status === 'running');
|
||
function paintWhisper() {
|
||
document.querySelectorAll('[data-whisper]').forEach(btn => {
|
||
const job = whisperJobs.get(btn.dataset.whisper);
|
||
btn.disabled = whisperStarting.has(btn.dataset.whisper) || activeWhisper(job);
|
||
btn.textContent = job?.status === 'running' ? 'Transcribing…' : job?.status === 'queued' ? 'Queued' : (matchMedia('(max-width: 760px)').matches ? 'Transcribe with Whisper' : 'Whisper');
|
||
});
|
||
const job = whisperJobs.get(LE.id);
|
||
$('whisperBtn').disabled = whisperStarting.has(LE.id) || activeWhisper(job);
|
||
$('whisperBtn').textContent = activeWhisper(job) ? (job.status === 'queued' ? 'Whisper queued' : 'Whisper transcribing…') : 'Transcribe with Whisper';
|
||
const labels = { 'loading-model': 'Whisper is loading its model…', 'downloading-audio': 'Whisper is reading the song’s audio…', transcribing: 'Whisper is transcribing the vocals. This can take several minutes.' };
|
||
$('whisperStatus').textContent = job?.status === 'queued' ? 'Queued for Whisper. Waiting for the lyrics worker.'
|
||
: job?.status === 'running' ? labels[job.stage] || 'Whisper is working…'
|
||
: job?.status === 'failed' ? 'Whisper could not finish: ' + (job.error || 'Try again.') : '';
|
||
const ready = job?.status === 'complete' && job.result?.lines?.length;
|
||
$('whisperResult').classList.toggle('hidden', !ready);
|
||
$('whisperPreview').textContent = ready ? job.result.lines.map(l => l.text).join('\n') : '';
|
||
}
|
||
async function refreshWhisper(id) {
|
||
try {
|
||
const result = await api('/api/admin/transcriptions/' + encodeURIComponent(id));
|
||
if (result.ok) whisperJobs.set(id, result.job);
|
||
paintWhisper(); scheduleWhisper();
|
||
} catch { /* a reconnect or another poll can recover the durable job */ }
|
||
}
|
||
function scheduleWhisper() {
|
||
if (whisperTimer || ![...whisperJobs.values()].some(activeWhisper)) return;
|
||
whisperTimer = setTimeout(async () => {
|
||
whisperTimer = null;
|
||
for (const [id, job] of whisperJobs) if (activeWhisper(job)) await refreshWhisper(id);
|
||
scheduleWhisper();
|
||
}, 5000);
|
||
}
|
||
async function startWhisper(id) {
|
||
if (!id || whisperStarting.has(id) || activeWhisper(whisperJobs.get(id))) return;
|
||
whisperStarting.add(id); paintWhisper();
|
||
try {
|
||
if (LE.id !== id || !LE.loaded) { const opened = await leOpen(id); if (opened === false) return; }
|
||
const result = await api('/api/admin/transcriptions/' + encodeURIComponent(id), { method: 'POST', body: {} });
|
||
if (!result.ok) { say(result.error || 'Whisper could not start.', 'err'); return; }
|
||
whisperJobs.set(id, result.job); paintWhisper(); scheduleWhisper();
|
||
} catch { say('Could not reach Whisper. Try again when connected.', 'err'); }
|
||
finally { whisperStarting.delete(id); paintWhisper(); }
|
||
}
|
||
$('whisperBtn').addEventListener('click', () => startWhisper(LE.id));
|
||
$('whisperUse').addEventListener('click', () => {
|
||
const job = whisperJobs.get(LE.id); if (job?.status !== 'complete' || !job.result?.lines?.length) return;
|
||
if (LE.dirty && !confirm('Replace your unsaved editor text with the Whisper draft? Undo can restore it.')) return;
|
||
edit(() => {
|
||
LE.lines = job.result.lines.map(l => ({ ...l })); LE.offset = 0;
|
||
LE.tags = [...LE.tags.filter(t => !/^(from |auto-transcribed)/i.test(t)), ...job.result.tags];
|
||
LE.raw = false; $('raw').classList.add('hidden'); $('lines').classList.remove('hidden');
|
||
$('rawBtn').textContent = '✎ Edit as text'; $('tags').value = LE.tags.join(', ');
|
||
});
|
||
render(); draw(); syncBars();
|
||
$('whisperResult').classList.add('hidden');
|
||
say('Whisper draft loaded. Check the words and timing, then Save to publish; Undo restores the previous text.', 'ok');
|
||
});
|
||
function progress() {
|
||
const timed = LE.lines.filter((l) => l.kind === 'line' && l.t != null).length;
|
||
const all = LE.lines.filter((l) => l.kind === 'line').length;
|
||
$('ringTxt').textContent = `${timed}/${all}`;
|
||
$('ring').style.setProperty('--p', all ? Math.round((timed / all) * 100) : 0);
|
||
}
|
||
function tapLabel() {
|
||
const i = tapIdx();
|
||
$('tapNext').textContent = i >= 0 && LE.lines[i] ? LE.lines[i].text : 'done';
|
||
}
|
||
|
||
/* undo / redo — whole-document snapshots, typing is coalesced */
|
||
const snap = () => JSON.stringify({ l: LE.lines, t: LE.tags, o: LE.offset });
|
||
function pushUndo() { LE.undo.push(snap()); if (LE.undo.length > 100) LE.undo.shift(); LE.redo = []; undoBtns(); }
|
||
function undoBtns() { $('bUndo').disabled = !LE.undo.length; $('bRedo').disabled = !LE.redo.length; }
|
||
function restore(s) { const o = JSON.parse(s); LE.lines = o.l; LE.tags = o.t; LE.offset = o.o ?? LE.offset; $('tags').value = LE.tags.join(', '); render(); setDirty(true); undoBtns(); }
|
||
function doUndo() { if (!LE.undo.length) return; LE.redo.push(snap()); restore(LE.undo.pop()); say('Undone'); }
|
||
function doRedo() { if (!LE.redo.length) return; LE.undo.push(snap()); restore(LE.redo.pop()); say('Redone'); }
|
||
let typing = null;
|
||
function typed() { if (!typing) pushUndo(); clearTimeout(typing); typing = setTimeout(() => { typing = null; }, 900); }
|
||
const edit = (fn) => { pushUndo(); fn(); setDirty(true); };
|
||
|
||
async function loadFlags() {
|
||
const j = await api('/api/admin/flags?status=open');
|
||
LE.flags = new Map();
|
||
if (j.ok) for (const f of j.flags) if (f.videoId === LE.id) { if (!LE.flags.has(f.text)) LE.flags.set(f.text, []); LE.flags.get(f.text).push(f); }
|
||
if (j.ok) setBadge(j.open);
|
||
}
|
||
async function leReloadFlagsOnly() { if (LE.loaded) { await loadFlags(); paintFlags(); } }
|
||
|
||
async function leLoad(id, keepAudio, focusText) {
|
||
say('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 : r2(l.t), text: String(l.text || ''), kind: l.kind || 'line' }));
|
||
LE.tags = doc.tags || []; LE.offset = Number(doc.offset) || 0;
|
||
LE.undo = []; LE.redo = []; LE.sel = -1; LE.loaded = true; LE.raw = false;
|
||
const meta = (streams.data && streams.data.meta) || {};
|
||
LE.dur = Number(meta.duration) || 0;
|
||
$('songTitle').textContent = meta.title || id;
|
||
$('songMeta').textContent = `${meta.channel ? meta.channel + ' · ' : ''}${LE.lines.length} lines · rev ${LE.rev}`;
|
||
$('tags').value = LE.tags.join(', ');
|
||
$('pickBox').classList.add('hidden'); $('studio').classList.remove('hidden');
|
||
$('raw').classList.add('hidden'); $('lines').classList.remove('hidden'); $('rawBtn').textContent = '✎ Edit as text';
|
||
await loadFlags();
|
||
render(); setDirty(false); undoBtns(); syncBars();
|
||
paintWhisper(); refreshWhisper(id);
|
||
say('');
|
||
if (!keepAudio) initAudio(id, streams);
|
||
draw();
|
||
if (focusText) {
|
||
const i = LE.lines.findIndex((l) => l.text === focusText);
|
||
if (i >= 0) select(i, true, true);
|
||
}
|
||
}
|
||
|
||
/* The editor plays a COMPLETE local copy, never a live stream (a stream can
|
||
stall or expire mid-song). Order: the same saved video the player uses
|
||
(OPFS) → an audio-only copy kept for admin use (Cache Storage) → fetch the
|
||
audio-only file from the server once and keep it. */
|
||
const ADMIN_AUDIO_CACHE = 'ytp-admin-audio';
|
||
async function adminAudioBlobUrl(id, streams, onProgress) {
|
||
try {
|
||
if (window.OPFS && OPFS.isSupported() && await OPFS.hasVideo(id)) {
|
||
const u = await OPFS.getFileUrl(id);
|
||
if (u) return u;
|
||
}
|
||
} catch { /* fall through */ }
|
||
const key = `/admin-audio/${encodeURIComponent(id)}`;
|
||
let cache = null;
|
||
try { cache = await caches.open(ADMIN_AUDIO_CACHE); } catch { /* no Cache Storage */ }
|
||
if (cache) {
|
||
const hit = await cache.match(key).catch(() => null);
|
||
if (hit) return URL.createObjectURL(await hit.blob());
|
||
}
|
||
const d = (streams && streams.data) || {};
|
||
const sources = [`/api/media/${encodeURIComponent(id)}?a=1`,
|
||
d.audioUrl || ((d.qualities || []).find((q) => q.hasAudio) || {}).url || ''].filter(Boolean);
|
||
for (const src of sources) {
|
||
for (let attempt = 0; attempt < 2; attempt++) {
|
||
try {
|
||
const r = await fetch(src, { credentials: 'same-origin' });
|
||
if (!r.ok) break;
|
||
const total = Number(r.headers.get('content-length')) || 0;
|
||
const reader = r.body.getReader(); const parts = []; let got = 0;
|
||
for (;;) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
parts.push(value); got += value.length;
|
||
if (onProgress) onProgress(got, total);
|
||
}
|
||
if (total && got < total) throw new Error('short read');
|
||
const blob = new Blob(parts, { type: r.headers.get('content-type') || 'audio/mp4' });
|
||
if (blob.size < 1024) break;
|
||
if (cache) await cache.put(key, new Response(blob, { headers: { 'content-type': blob.type } })).catch(() => {});
|
||
return URL.createObjectURL(blob);
|
||
} catch { await new Promise((ok) => setTimeout(ok, 800)); }
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
|
||
// Audio status shares the #msg line with other messages: only write when it
|
||
// is empty or still showing our own text, and never clobber someone else's.
|
||
function audioSay(text, cls) {
|
||
const el = $('msg'), cur = el.textContent;
|
||
if (cur && cur !== LE.audioMsg) return;
|
||
LE.audioMsg = text; say(text, cls);
|
||
}
|
||
|
||
async function initAudio(id, streams) {
|
||
if (LE.audio) { LE.audio.pause(); LE.audio.src = ''; }
|
||
if (LE.audioUrl) { try { URL.revokeObjectURL(LE.audioUrl); } catch {} LE.audioUrl = ''; }
|
||
LE.audio = null;
|
||
const token = (LE.audioToken = (LE.audioToken || 0) + 1);
|
||
audioSay('Loading the full audio into the browser…');
|
||
const url = await adminAudioBlobUrl(id, streams, (got, total) => {
|
||
if (token === LE.audioToken) audioSay(total ? `Loading audio… ${Math.round(got / total * 100)}%` : `Loading audio… ${(got / 1048576).toFixed(1)} MB`);
|
||
});
|
||
if (token !== LE.audioToken || LE.id !== id) { if (url) try { URL.revokeObjectURL(url); } catch {} return; }
|
||
audioSay('');
|
||
if (url) {
|
||
LE.audioUrl = url;
|
||
const a = new Audio(url); a.preload = 'auto';
|
||
a.addEventListener('timeupdate', tick); a.addEventListener('seeked', tick);
|
||
a.addEventListener('loadedmetadata', () => { if (a.duration) { LE.dur = a.duration; $('dur').textContent = fmtT(a.duration).slice(0, -3); } draw(); });
|
||
a.addEventListener('play', () => { TransportIcons.set($('bPlay'),'pause'); });
|
||
a.addEventListener('pause', () => { TransportIcons.set($('bPlay'),'play'); });
|
||
a.addEventListener('error', () => audioSay('The saved audio could not be played — reload the song to fetch it again.', 'err'));
|
||
a.playbackRate = Number($('rate').value);
|
||
LE.audio = a;
|
||
} else audioSay('No audio could be saved for this song, so there is nothing to time against.', 'err');
|
||
$('dur').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; draw(); } }).catch(() => {});
|
||
}
|
||
|
||
/* ---- rows ---- */
|
||
function rowEl(line, i) {
|
||
const row = document.createElement('div');
|
||
row.className = 'ln'; row.dataset.i = String(i);
|
||
row.innerHTML = `
|
||
<div class="ln-main">
|
||
<button class="tc" data-act="chip" type="button"></button>
|
||
<textarea class="ln-text" rows="1" placeholder="Type the line… (Shift+Enter for a line break)" aria-label="Line text">${esc(line.text)}</textarea>
|
||
<span class="flagchip hidden" data-flag><svg viewBox="0 0 24 24"><path d="M5 21V4M5 4h11l-2 4 2 4H5"/></svg><b></b></span>
|
||
<button class="more" data-act="more" type="button" aria-label="Line options">⋯</button>
|
||
</div>
|
||
<div class="flagbox hidden" data-flagbox></div>
|
||
<div class="ln-tools">
|
||
<input class="tin" value="${esc(fmtT(line.t))}" placeholder="0:00.00" aria-label="Time" />
|
||
<button class="btn" data-act="set" type="button">◎ Stamp now</button>
|
||
<button class="btn" data-act="seek" type="button">${TransportIcons.svg('play')} Play here</button>
|
||
<button class="btn" data-act="nudge" data-d="-0.2" type="button">−0.2</button>
|
||
<button class="btn" data-act="nudge" data-d="0.2" type="button">+0.2</button>
|
||
<select class="mini" data-kind aria-label="Line type"><option value="line">Line</option><option value="section">Section</option><option value="cue">Cue</option></select>
|
||
<button class="btn" data-act="break" type="button" title="Same timestamp, both lines highlight together">↵ Line break</button>
|
||
<button class="btn" data-act="add" type="button">+ Below</button>
|
||
<button class="btn dng" data-act="del" type="button">Delete</button>
|
||
</div>`;
|
||
const text = row.querySelector('.ln-text');
|
||
text.rows = Math.max(1, line.text.split('\n').length);
|
||
row.querySelector('[data-kind]').value = line.kind;
|
||
return row;
|
||
}
|
||
function paintRow(i) {
|
||
const l = LE.lines[i], row = LE.rows[i]; if (!l || !row) return;
|
||
row.classList.toggle('untimed', l.t == null);
|
||
row.classList.toggle('section', l.kind === 'section');
|
||
row.classList.toggle('cue', l.kind === 'cue');
|
||
row.classList.toggle('sel', i === LE.sel);
|
||
row.querySelector('.tc').textContent = l.t == null ? 'Set' : fmtT(l.t);
|
||
row.querySelector('.tc').title = l.t == null ? 'Stamp the playhead on this line' : 'Play from this line';
|
||
const tin = row.querySelector('.tin'); if (document.activeElement !== tin) tin.value = fmtT(l.t);
|
||
}
|
||
function paintFlags() {
|
||
LE.rows.forEach((row, i) => {
|
||
const l = LE.lines[i]; const fl = (l && LE.flags.get(l.text)) || [];
|
||
row.classList.toggle('flagged', fl.length > 0);
|
||
const chip = row.querySelector('[data-flag]'); chip.classList.toggle('hidden', !fl.length); chip.querySelector('b').textContent = fl.length;
|
||
const box = row.querySelector('[data-flagbox]'); box.classList.toggle('hidden', !fl.length);
|
||
box.innerHTML = fl.length ? fl.map((f) => `<div class="rep"><div style="flex:1;min-width:0"><span class="who">${esc(f.reporter)}</span>${f.reason ? `<span class="why">${esc(REASON[f.reason] || f.reason)}</span>` : ''}${f.note ? `<div class="txt">${esc(f.note)}</div>` : '<div class="txt muted">No description</div>'}</div></div>`).join('')
|
||
+ `<div class="row"><span class="muted" style="font-size:12.5px;flex:1">Edit the text and save — the report closes itself.</span><button class="btn sm" data-act="resolve" data-ids="${fl.map((f) => f.id).join(',')}" type="button">It’s fine — dismiss</button></div>` : '';
|
||
});
|
||
}
|
||
function render() {
|
||
const box = $('lines'); box.innerHTML = '';
|
||
LE.rows = LE.lines.map((l, i) => { const r = rowEl(l, i); box.appendChild(r); return r; });
|
||
if (!LE.lines.length) box.innerHTML = '<div class="empty">No lines yet — press “+ Add line”, paste text with ✎ Edit as text, or pull them from LRCLIB (⋯ tools).</div>';
|
||
LE.lines.forEach((_, i) => paintRow(i));
|
||
const fields = LE.rows.map(row => row.querySelector('.ln-text'));
|
||
const heights = fields.map(field => field.scrollHeight);
|
||
fields.forEach((field, i) => { field.style.height = heights[i] + 'px'; });
|
||
paintFlags(); progress(); lastIdx = -2; tick(); tapLabel();
|
||
$('songMeta').textContent = $('songMeta').textContent.replace(/\d+ lines/, `${LE.lines.length} lines`);
|
||
}
|
||
function select(i, scroll, flash) {
|
||
const prev = LE.sel; LE.sel = i;
|
||
if (prev !== i && prev >= 0) paintRow(prev);
|
||
paintRow(i);
|
||
const row = LE.rows[i];
|
||
if (row && scroll) row.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
if (row && flash) { row.classList.remove('flash'); void row.offsetWidth; row.classList.add('flash'); }
|
||
}
|
||
|
||
const indexAt = (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; };
|
||
function tapIdx() {
|
||
const i = LE.lines.findIndex((l) => l.t == null && l.kind === 'line');
|
||
return i >= 0 ? i : -1;
|
||
}
|
||
let lastIdx = -2;
|
||
function tick() {
|
||
$('cur').textContent = fmtT(cur());
|
||
const i = LE.tap ? tapIdx() : indexAt(cur());
|
||
if (i !== lastIdx) {
|
||
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 ($('follow').checked && LE.rows[i] && LE.audio && !LE.audio.paused) LE.rows[i].scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||
lastIdx = i;
|
||
}
|
||
draw();
|
||
}
|
||
function draw() {
|
||
const c = $('wave'); if (!c || !LE.loaded) return;
|
||
const w = c.clientWidth || 600, h = c.height, 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); g.clearRect(0, 0, w, h);
|
||
const waveStyle = getComputedStyle($('p-editor'));
|
||
const played = waveStyle.getPropertyValue('--ed-wave-played').trim();
|
||
const unplayed = waveStyle.getPropertyValue('--ed-wave-unplayed').trim();
|
||
const marker = waveStyle.getPropertyValue('--ed-wave-mark').trim();
|
||
const cursor = waveStyle.getPropertyValue('--ed-wave-cursor').trim();
|
||
const dur = LE.dur || 1, t = cur();
|
||
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(2, (LE.peaks[i] / 100) * (h - 10));
|
||
g.fillStyle = (i * dur) / n <= t ? played : unplayed;
|
||
g.fillRect(i * bw, (h - bh) / 2, Math.max(1, bw - 1), bh);
|
||
}
|
||
} else {
|
||
g.fillStyle = unplayed; g.fillRect(0, h / 2 - 2, w, 4);
|
||
g.fillStyle = played; g.fillRect(0, h / 2 - 2, (t / dur) * w, 4);
|
||
}
|
||
g.fillStyle = marker;
|
||
for (const l of LE.lines) { if (l.t == null) continue; const x = ((l.t + LE.offset) / dur) * w; g.fillRect(x, 0, 1, 7); g.fillRect(x, h - 7, 1, 7); }
|
||
g.fillStyle = cursor; g.fillRect((t / dur) * w - 1, 0, 2, h);
|
||
}
|
||
function seek(t) { if (!LE.audio) return; LE.audio.currentTime = Math.max(0, Math.min(LE.dur || 1e9, t)); tick(); }
|
||
function stamp(i) {
|
||
if (i < 0 || i >= LE.lines.length) return;
|
||
if (!LE.audio) { say('The audio is still loading — try again in a moment.', 'err'); return; }
|
||
if (LE.sel !== i) select(i);
|
||
edit(() => { LE.lines[i].t = r2(cur()); });
|
||
paintRow(i); progress(); lastIdx = -2; tick(); tapLabel();
|
||
}
|
||
|
||
/* ---- events ---- */
|
||
$('lines').addEventListener('click', async (e) => {
|
||
const row = e.target.closest('.ln'); if (!row) return;
|
||
const i = Number(row.dataset.i);
|
||
if (LE.sel !== i || !row.classList.contains('sel')) select(i);
|
||
const btn = e.target.closest('button[data-act]'); if (!btn) return;
|
||
const act = btn.dataset.act;
|
||
if (act === 'chip') {
|
||
if (LE.lines[i].t == null) stamp(i);
|
||
else { seek(LE.lines[i].t + LE.offset); if (LE.audio && LE.audio.paused) LE.audio.play().catch(() => {}); }
|
||
} else if (act === 'more') { LE.rows[i].classList.toggle('sel'); if (!LE.rows[i].classList.contains('sel')) LE.sel = -1; }
|
||
else if (act === 'set') stamp(i);
|
||
else if (act === 'seek') { seek((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; edit(() => { LE.lines[i].t = r2(Math.max(0, LE.lines[i].t + Number(btn.dataset.d))); }); paintRow(i); draw(); }
|
||
else if (act === 'break') {
|
||
const text = LE.rows[i].querySelector('.ln-text');
|
||
const pos = text.selectionStart;
|
||
edit(() => { LE.lines[i].text = text.value.slice(0, pos) + '\n' + text.value.slice(text.selectionEnd); });
|
||
render(); select(i);
|
||
const target = LE.rows[i].querySelector('.ln-text'); target.focus(); target.setSelectionRange(pos + 1, pos + 1);
|
||
}
|
||
else if (act === 'add') { edit(() => LE.lines.splice(i + 1, 0, { t: null, text: '', kind: 'line' })); render(); select(i + 1); LE.rows[i + 1].querySelector('.ln-text').focus(); }
|
||
else if (act === 'del') { edit(() => LE.lines.splice(i, 1)); LE.sel = -1; render(); draw(); }
|
||
else if (act === 'resolve') {
|
||
for (const id of btn.dataset.ids.split(',')) await api(`/api/admin/flags/${id}`, { method: 'POST', body: { status: 'resolved' } });
|
||
await loadFlags(); paintFlags();
|
||
}
|
||
});
|
||
$('lines').addEventListener('input', (e) => {
|
||
const row = e.target.closest('.ln'); if (!row) return; const i = Number(row.dataset.i);
|
||
if (e.target.classList.contains('ln-text')) { typed(); LE.lines[i].text = e.target.value; e.target.rows = Math.max(1, e.target.value.split('\n').length); e.target.style.height = 'auto'; e.target.style.height = e.target.scrollHeight + 'px'; setDirty(true); tapLabel(); }
|
||
});
|
||
$('lines').addEventListener('change', (e) => {
|
||
const row = e.target.closest('.ln'); if (!row) return; const i = Number(row.dataset.i);
|
||
if (e.target.classList.contains('tin')) { edit(() => { LE.lines[i].t = parseT(e.target.value); }); paintRow(i); progress(); draw(); tapLabel(); }
|
||
else if (e.target.matches('[data-kind]')) { edit(() => { LE.lines[i].kind = e.target.value; }); paintRow(i); progress(); }
|
||
});
|
||
$('lines').addEventListener('focusin', (e) => { const row = e.target.closest('.ln'); if (row && Number(row.dataset.i) !== LE.sel) select(Number(row.dataset.i)); });
|
||
$('lines').addEventListener('keydown', (e) => {
|
||
if (e.key !== 'Enter' || !e.target.classList.contains('ln-text')) return;
|
||
if (e.shiftKey) return; // Native textarea break stays in the current timed cue.
|
||
e.preventDefault();
|
||
const i = Number(e.target.closest('.ln').dataset.i);
|
||
edit(() => LE.lines.splice(i + 1, 0, { t: null, text: '', kind: LE.lines[i].kind === 'section' ? 'line' : LE.lines[i].kind }));
|
||
render(); select(i + 1); LE.rows[i + 1].querySelector('.ln-text').focus();
|
||
});
|
||
|
||
function togglePlay() { if (!LE.audio) return; if (LE.audio.paused) LE.audio.play().catch((err) => say('Playback failed: ' + err.message, 'err')); else LE.audio.pause(); }
|
||
$('bPlay').addEventListener('click', togglePlay);
|
||
$('bBack').addEventListener('click', () => seek(cur() - 5));
|
||
$('bFwd').addEventListener('click', () => seek(cur() + 5));
|
||
$('rate').addEventListener('change', () => { if (LE.audio) LE.audio.playbackRate = Number($('rate').value); });
|
||
$('wave').addEventListener('pointerdown', (e) => { const r = e.currentTarget.getBoundingClientRect(); seek(((e.clientX - r.left) / r.width) * (LE.dur || 0)); });
|
||
window.addEventListener('resize', draw);
|
||
$('bUndo').addEventListener('click', doUndo);
|
||
$('bRedo').addEventListener('click', doRedo);
|
||
$('bTools').addEventListener('click', () => { const on = $('drawer').classList.toggle('hidden') === false; $('bTools').setAttribute('aria-pressed', String(on)); });
|
||
document.querySelectorAll('[data-home]').forEach((a) => a.addEventListener('click', (e) => {
|
||
e.preventDefault();
|
||
if (!$('app').classList.contains('hidden')) {
|
||
if (LE.loaded && !$('studio').classList.contains('hidden')) $('closeSong').click();
|
||
showTab('editor');
|
||
} else location.href = '/admin#editor';
|
||
}));
|
||
$('closeSong').addEventListener('click', () => {
|
||
if (LE.dirty && !confirm('Discard the unsaved changes to this song?')) return;
|
||
if (LE.audio) { LE.audio.pause(); LE.audio = null; }
|
||
LE.loaded = false; LE.dirty = false; setDirty(false);
|
||
$('studio').classList.add('hidden'); $('pickBox').classList.remove('hidden'); syncBars();
|
||
});
|
||
|
||
function setMode(tapOn) {
|
||
LE.tap = tapOn;
|
||
$('mTap').setAttribute('aria-pressed', String(tapOn)); $('mEdit').setAttribute('aria-pressed', String(!tapOn));
|
||
say(tapOn ? 'Tap-sync active. TAP or Space stamps the next untimed line.' : '', tapOn ? 'ok' : '');
|
||
lastIdx = -2; tick(); tapLabel(); syncBars();
|
||
}
|
||
$('mEdit').addEventListener('click', () => setMode(false));
|
||
$('mTap').addEventListener('click', () => { setMode(true); if (LE.audio && LE.audio.paused) LE.audio.play().catch(() => {}); });
|
||
$('tapPad').addEventListener('click', () => { const i = tapIdx(); if (i >= 0) { stamp(i); if (LE.rows[i] && $('follow').checked) LE.rows[i].scrollIntoView({ block: 'center', behavior: 'smooth' }); } });
|
||
|
||
document.addEventListener('keydown', (e) => {
|
||
if (tab !== 'editor' || !LE.loaded) return;
|
||
const inField = /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName);
|
||
const mod = e.metaKey || e.ctrlKey;
|
||
if (mod && e.key.toLowerCase() === 's') { e.preventDefault(); $('save').click(); return; }
|
||
if (mod && e.key.toLowerCase() === 'z' && !inField) { e.preventDefault(); e.shiftKey ? doRedo() : doUndo(); return; }
|
||
if (mod && e.key.toLowerCase() === 'y' && !inField) { e.preventDefault(); doRedo(); return; }
|
||
if (inField) return;
|
||
if (e.code === 'Space') { e.preventDefault(); if (LE.tap) { const i = tapIdx(); if (i >= 0) stamp(i); } else togglePlay(); return; }
|
||
if ((e.key === 's' || e.key === 'S') && !mod) { e.preventDefault(); stamp(LE.tap ? tapIdx() : (LE.sel >= 0 ? LE.sel : indexAt(cur()))); return; }
|
||
if (e.key === 'k') { e.preventDefault(); togglePlay(); return; }
|
||
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); const n = Math.max(0, Math.min(LE.lines.length - 1, (LE.sel < 0 ? -1 : LE.sel) + (e.key === 'ArrowDown' ? 1 : -1))); select(n, true); }
|
||
});
|
||
|
||
$('shiftBtn').addEventListener('click', () => {
|
||
const d = Number($('shiftBy').value); if (!Number.isFinite(d) || !d) return;
|
||
edit(() => { for (const l of LE.lines) if (l.t != null) l.t = r2(Math.max(0, l.t + d)); });
|
||
render(); draw(); say(`Shifted every timed line by ${d > 0 ? '+' : ''}${d}s`, 'ok');
|
||
});
|
||
$('addLine').addEventListener('click', () => {
|
||
edit(() => LE.lines.push({ t: LE.audio && !LE.audio.paused ? r2(cur()) : null, text: '', kind: 'line' }));
|
||
render(); select(LE.lines.length - 1, true); LE.rows[LE.rows.length - 1].querySelector('.ln-text').focus();
|
||
});
|
||
$('tags').addEventListener('input', () => { typed(); LE.tags = $('tags').value.split(',').map((s) => s.trim()).filter(Boolean); setDirty(true); });
|
||
$('webBtn').addEventListener('click', async () => {
|
||
if (!LE.id) return;
|
||
if (LE.dirty && !confirm('LRCLIB replaces the shared lyrics and your unsaved edits are lost. Continue?')) return;
|
||
say('Asking LRCLIB…');
|
||
const j = await api(`/api/notes/${LE.id}/lyrics/web`, { method: 'POST', body: { overwrite: true } });
|
||
if (!j.ok) { say(j.error || 'LRCLIB has nothing for this song', 'err'); return; }
|
||
await leLoad(LE.id, true);
|
||
say(`LRCLIB: ${j.lines} ${j.synced ? 'synced' : 'plain'} lines saved as rev ${j.rev} — the previous version is in History.`, 'ok');
|
||
});
|
||
$('rawBtn').addEventListener('click', () => {
|
||
LE.raw = !LE.raw;
|
||
$('raw').classList.toggle('hidden', !LE.raw); $('lines').classList.toggle('hidden', LE.raw);
|
||
$('rawBtn').textContent = LE.raw ? '≡ Back to lines' : '✎ Edit as text';
|
||
if (LE.raw) {
|
||
$('raw').value = LyricsCore.toText({ lines: LE.lines });
|
||
} else {
|
||
pushUndo();
|
||
LE.lines = LyricsCore.fromText($('raw').value).lines;
|
||
setDirty(true); render(); draw();
|
||
}
|
||
syncBars();
|
||
});
|
||
$('discard').addEventListener('click', () => { if (LE.id && confirm('Throw away the unsaved changes and reload from the server?')) leLoad(LE.id, true); });
|
||
$('save').addEventListener('click', async () => {
|
||
if (!LE.id) return;
|
||
if (LE.raw) $('rawBtn').click();
|
||
const lines = LE.lines.filter((l) => l.text.trim()).map((l) => ({ t: l.t, text: l.text.trim(), kind: l.kind }));
|
||
if (!lines.length) { say('Nothing to save', 'err'); return; }
|
||
const data = { lines, tags: LE.tags, offset: LE.offset };
|
||
$('save').disabled = true; $('saveStat').textContent = '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) { say(j.error || 'save failed', 'err'); setDirty(true); return; }
|
||
LE.rev = j.rev; setDirty(false);
|
||
say(`Saved ${lines.length} lines as rev ${j.rev}.`, 'ok');
|
||
setTimeout(async () => { await loadFlags(); paintFlags(); }, 400); // edited lines auto-close their reports
|
||
});
|
||
window.addEventListener('beforeunload', (e) => { if (LE.dirty) { e.preventDefault(); e.returnValue = ''; } });
|
||
|
||
$('loadForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
const id = videoIdFrom($('songId').value);
|
||
if (!id) { $('loadMsg').className = 'msg err'; $('loadMsg').textContent = 'That is not a YouTube URL, video id or upl_ id'; return; }
|
||
$('loadMsg').textContent = '';
|
||
leOpen(id);
|
||
});
|
||
function leOpen(id, focusText) {
|
||
if (LE.dirty && id !== LE.id && !confirm('Discard the unsaved changes to the song you have open?')) return false;
|
||
showTab('editor');
|
||
if (id === LE.id && LE.loaded && focusText) {
|
||
const i = LE.lines.findIndex((l) => l.text === focusText); if (i >= 0) select(i, true, true); return;
|
||
}
|
||
return leLoad(id, false, focusText);
|
||
}
|
||
|
||
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}'
|
||
|
||
# Reported lines (admin or token)
|
||
curl ${o}/api/admin/flags?status=open -H "Authorization: Bearer $YTP_TOKEN"
|
||
|
||
# Studio 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>
|