Merge branch 'codex/admin-tools' into codex/admin-ops

This commit is contained in:
Jonathan Sykes
2026-10-14 02:38:40 +08:00
39 changed files with 1691 additions and 59 deletions

View File

@@ -16,29 +16,61 @@
<style>
:root {
color-scheme: dark;
--bg: #0a0c12; --bg-1: #10131b; --bg-2: #161a25; --bg-3: #1d2230; --bg-4: #262c3d;
--line: rgba(255,255,255,.08); --line-2: rgba(255,255,255,.14);
--text: #f2f4fa; --text-2: #b5bacb; --text-dim: #7b8299;
--a1: #7c5cff; --a2: #22d3ee; /* signature gradient */
--grad: linear-gradient(135deg, var(--a1), var(--a2));
--ok: #4ade80; --warn: #ffb454; --bad: #ff6b6b;
--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: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-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:
radial-gradient(900px 500px at 8% -10%, rgba(124,92,255,.16), transparent 60%),
radial-gradient(800px 480px at 100% 0%, rgba(34,211,238,.10), transparent 55%),
var(--bg);
background-attachment: fixed;
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; }
@@ -47,28 +79,27 @@
/* ---------- chrome ---------- */
.top {
position: sticky; top: 0; z-index: 40; display: flex; align-items: center; gap: 14px;
padding: 10px 18px; background: rgba(10,12,18,.78);
-webkit-backdrop-filter: blur(16px) saturate(150%); backdrop-filter: blur(16px) saturate(150%);
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; box-shadow: 0 6px 20px -6px var(--a1); }
.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; gap: 2px; margin-left: 14px; }
.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; align-items: center; gap: 8px; height: 38px; padding: 0 14px; border: 0; border-radius: 11px;
background: transparent; color: var(--text-2); font-weight: 650; font-size: 14px; transition: background .15s, color .15s;
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: #fff; background: rgba(124,92,255,.22); box-shadow: inset 0 0 0 1px rgba(124,92,255,.5); }
.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 {
height: 36px; padding: 0 13px; border-radius: 10px; border: 1px solid var(--line-2); background: rgba(255,255,255,.04);
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;
}
.ghost:hover { color: var(--text); border-color: rgba(255,255,255,.28); }
@@ -99,10 +130,10 @@
}
.btn:hover { background: rgba(255,255,255,.1); }
.btn:active { transform: scale(.97); }
.btn.pri { border: 0; background: #6143cf; color: #fff; box-shadow: none; }
.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: #06210f; }
.btn.dng { color: var(--bad); border-color: rgba(255,107,107,.35); background: rgba(255,107,107,.07); }
.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); }
@@ -115,6 +146,11 @@
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); }
@@ -124,9 +160,9 @@
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: rgba(124,92,255,.08); }
.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: rgba(34,211,238,.45); background: rgba(34,211,238,.06); }
.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; }
@@ -164,7 +200,7 @@
#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-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);
@@ -175,7 +211,7 @@
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-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;
@@ -183,7 +219,7 @@
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-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;
@@ -324,7 +360,7 @@
.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-text); }
#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; }
@@ -336,6 +372,13 @@
.analytics-form .query { flex: 1 1 250px; }
.analytics-form .field { width: 100%; min-width: 0; }
.analytics-form button { min-height: 44px; }
.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%; }
.collection-job { padding: 14px 0; border-bottom: 1px solid var(--line-2); overflow-wrap: anywhere; }
@@ -360,11 +403,12 @@
.tabs, .top .ghost.desk { display: none; }
.top { padding: 10px 14px; }
.nav {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
height: calc(var(--nav-h) + var(--safe)); padding: 6px 6px var(--safe); background: rgba(12,14,21,.92);
-webkit-backdrop-filter: blur(18px); backdrop-filter: blur(18px); border-top: 1px solid var(--line-2);
position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; display: flex; gap: 4px;
height: calc(var(--nav-h) + var(--safe)); padding: 6px 6px var(--safe); overflow-x: auto; overflow-y: hidden;
overscroll-behavior-x: contain; -webkit-overflow-scrolling: touch; scrollbar-width: thin;
background: var(--bg-1); border-top: 1px solid var(--line-2);
}
.nav .tab { flex-direction: column; justify-content: center; gap: 3px; height: auto; padding: 4px 0; font-size: 10.5px; border-radius: 12px; }
.nav .tab { flex: 0 0 68px; min-width: 68px; min-height: 44px; flex-direction: column; justify-content: center; gap: 3px; height: auto; padding: 4px 3px; font-size: 10.5px; border-radius: 12px; }
.nav .tab .badge { position: absolute; top: 2px; left: 55%; }
.steps { grid-template-columns: 1fr; }
.ln-main { grid-template-columns: 82px minmax(0, 1fr) 44px; gap: 6px; padding: 7px; }
@@ -527,6 +571,7 @@
<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>
@@ -537,6 +582,48 @@
<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>
@@ -580,6 +667,14 @@
</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 ================= -->
@@ -606,6 +701,15 @@
</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>
@@ -659,6 +763,24 @@
<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>
@@ -710,14 +832,20 @@
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"/>',
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"/>',
'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'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['analytics', 'Stats'], ['deep-search', 'Deep search'], ['access', 'API']];
const TABS = [['editor', 'Editor'], ['quality', 'Lyrics queue'], ['lyric-search', 'Lyrics search'], ['cache', 'Cache'], ['index', 'Search index'], ['reports', 'Reports'], ['library', 'Library'], ['peers', 'P2P'], ['history', 'History'], ['backups', 'Backups'], ['analytics', 'Stats'], ['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('');
@@ -732,12 +860,18 @@
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();
if (name === 'library') { loadUploads(); loadBulkLibrary(); loadBulkJobs(); }
if (name === 'access') loadTokens();
if (name === 'analytics') loadAnalytics();
if (name === 'deep-search') loadDeepSearch();
if (name === 'audit') loadAudit();
window.scrollTo({ top: 0 });
}
function setBadge(n) {
@@ -889,6 +1023,209 @@
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();
@@ -1047,6 +1384,67 @@
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)}">♪ ${esc(u.title)}</button>`).join('');
@@ -1121,7 +1519,8 @@
async function loadReports() {
const box = $('repList');
box.innerHTML = '<div class="empty">Loading…</div>';
const j = await api('/api/admin/flags?status=' + repStatus);
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) {
@@ -1142,6 +1541,7 @@
<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">
@@ -1163,6 +1563,12 @@
$('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' } }));
@@ -1176,6 +1582,7 @@
}
$('fOpen').addEventListener('click', () => setFilter('open'));
$('fRes').addEventListener('click', () => setFilter('resolved'));
$('reportAge').addEventListener('change', loadReports);
$('repRefresh').addEventListener('click', loadReports);
/* ========================================================================

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

View File

@@ -0,0 +1,3 @@
const { defineConfig } = require('@playwright/test');
const classic = require('./playwright.classic.config');
module.exports = defineConfig({ ...classic, testMatch: /admin-(audit|quality|cache|index|reports|backups|lyrics-search|bulk-lyrics|tools-shots)\.spec\.js/ });

464
server/admin-tools.js Normal file
View File

@@ -0,0 +1,464 @@
import { audit } from './audit-log.js';
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
const clientOf = db => typeof db?.execute === 'function' ? db : db?.db;
const parse = value => { try { return JSON.parse(value || '{}'); } catch { return {}; } };
const number = value => Number(value) || 0;
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
const zipCrcTable = Uint32Array.from({ length: 256 }, (_, value) => {
let crc = value; for (let bit = 0; bit < 8; bit++) crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
return crc >>> 0;
});
function zipStore(files) {
const local = [], central = []; let offset = 0;
for (const file of files) {
const name = Buffer.from(file.name), data = Buffer.from(file.content);
let crc = 0xffffffff; for (const byte of data) crc = zipCrcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); crc = (crc ^ 0xffffffff) >>> 0;
const header = Buffer.alloc(30); header.writeUInt32LE(0x04034b50, 0); header.writeUInt16LE(20, 4); header.writeUInt16LE(0x0800, 6); header.writeUInt32LE(crc, 14); header.writeUInt32LE(data.length, 18); header.writeUInt32LE(data.length, 22); header.writeUInt16LE(name.length, 26);
local.push(header, name, data);
const directory = Buffer.alloc(46); directory.writeUInt32LE(0x02014b50, 0); directory.writeUInt16LE(20, 4); directory.writeUInt16LE(20, 6); directory.writeUInt16LE(0x0800, 8); directory.writeUInt32LE(crc, 16); directory.writeUInt32LE(data.length, 20); directory.writeUInt32LE(data.length, 24); directory.writeUInt16LE(name.length, 28); directory.writeUInt32LE(offset, 42);
central.push(directory, name); offset += header.length + name.length + data.length;
}
const centralBytes = Buffer.concat(central), localBytes = Buffer.concat(local), end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(files.length, 8); end.writeUInt16LE(files.length, 10); end.writeUInt32LE(centralBytes.length, 12); end.writeUInt32LE(localBytes.length, 16);
return Buffer.concat([localBytes, centralBytes, end]);
}
export function registerAdminToolsRoutes(app, { db, adminAuth, media = null, backupDir = null, backfillStep = null, pullLyrics = null, queueWhisper = null, autoStartJobs = true }) {
const sql = clientOf(db);
const backupFile = date => join(backupDir || '.', `notes-${date}.json`);
const backupDate = value => /^\d{4}-\d{2}-\d{2}$/.test(value || '') && !Number.isNaN(Date.parse(`${value}T00:00:00Z`));
async function readBackup(date) {
if (!backupDate(date)) return null;
try {
const data = JSON.parse(await readFile(backupFile(date), 'utf8'));
if (!Array.isArray(data.notes) || data.notes.some(note => !note?.videoId || !['lyrics', 'chapters'].includes(note.kind) || !note.data)) return null;
return data;
} catch { return null; }
}
async function createBackup({ safety = false } = {}) {
if (!backupDir || typeof db.allNotes !== 'function') throw new Error('Backup storage unavailable');
await mkdir(safety ? join(backupDir, 'snapshots') : backupDir, { recursive: true });
const now = new Date();
const date = now.toISOString().slice(0, 10);
const file = safety ? join(backupDir, 'snapshots', `notes-${date}-${now.toISOString().slice(11, 19).replaceAll(':', '-')}-safety.json`) : backupFile(date);
const payload = { exportedAt: now.toISOString(), notes: await db.allNotes() };
await writeFile(file, JSON.stringify(payload));
return { file, date, notes: payload.notes.length };
}
app.get('/api/admin/backups', adminAuth, async c => {
if (!backupDir) return c.json({ ok: false, error: 'Backup storage unavailable' }, 503);
let names = []; try { names = await readdir(backupDir); } catch {}
const backups = [];
for (const name of names.filter(value => /^notes-\d{4}-\d{2}-\d{2}\.json$/.test(value)).sort().reverse()) {
const date = name.slice(6, 16); const data = await readBackup(date); const info = await stat(backupFile(date)).catch(() => null);
backups.push({ date, bytes: info?.size || 0, exportedAt: data?.exportedAt || null, notes: data?.notes.length || 0, valid: !!data });
}
return c.json({ ok: true, backups }, 200, { 'Cache-Control': 'no-store' });
});
app.get('/api/admin/backups/:date', adminAuth, async c => {
const data = await readBackup(c.req.param('date'));
if (!data) return c.json({ ok: false, error: 'Backup is missing or invalid' }, 404);
const live = await db.allNotes();
const index = rows => new Map(rows.map(row => [`${row.videoId}:${row.kind}`, JSON.stringify(row.data)]));
const old = index(data.notes), current = index(live);
const added = [...old.keys()].filter(key => !current.has(key));
const removed = [...current.keys()].filter(key => !old.has(key));
const changed = [...old.keys()].filter(key => current.has(key) && old.get(key) !== current.get(key));
return c.json({ ok: true, date: c.req.param('date'), exportedAt: data.exportedAt, total: data.notes.length,
diff: { added: added.length, removed: removed.length, changed: changed.length, preview: [...changed.slice(0, 30).map(key => ({ key, change: 'changed' })), ...added.slice(0, 15).map(key => ({ key, change: 'only in backup' })), ...removed.slice(0, 15).map(key => ({ key, change: 'only live' }))] } });
});
app.get('/api/admin/backups/:date/download', adminAuth, async c => {
const data = await readBackup(c.req.param('date'));
if (!data) return c.json({ ok: false, error: 'Backup is missing or invalid' }, 404);
c.header('Content-Disposition', `attachment; filename="notes-${c.req.param('date')}.json"`);
return c.json(data);
});
app.post('/api/admin/backups', adminAuth, async c => {
try {
const result = await createBackup();
await audit(db, 'admin', 'backup.create', result.date, { notes: result.notes });
return c.json({ ok: true, ...result });
} catch (error) { return c.json({ ok: false, error: error.message }, 503); }
});
app.post('/api/admin/backups/:date/restore', adminAuth, async c => {
const date = c.req.param('date'); const data = await readBackup(date);
if (!data) return c.json({ ok: false, error: 'Backup is missing or invalid' }, 404);
let body = {}; try { body = await c.req.json(); } catch {}
const selected = body.videoId ? data.notes.filter(note => note.videoId === body.videoId) : data.notes;
if (body.videoId && !selected.length) return c.json({ ok: false, error: 'Song is not present in this backup' }, 404);
const safety = await createBackup({ safety: true });
for (const note of selected) await db.saveNote({ videoId: note.videoId, kind: note.kind, data: note.data, source: 'restore', updatedBy: `admin (backup ${date})`, force: true });
if (!body.videoId) {
const keep = new Set(data.notes.map(note => `${note.videoId}:${note.kind}`));
for (const note of await db.allNotes()) if (!keep.has(`${note.videoId}:${note.kind}`)) {
await sql.execute({ sql: 'DELETE FROM video_notes WHERE video_id=? AND kind=?', args: [note.videoId, note.kind] });
}
}
const safetySnapshot = basename(safety.file);
await audit(db, 'admin', 'backup.restore', date, { videoId: body.videoId || null, restored: selected.length, safetySnapshot });
return c.json({ ok: true, restored: selected.length, safetySnapshot });
});
app.get('/api/admin/backups/export/sqlite-dump', adminAuth, async c => {
const schema = await sql.execute("SELECT type,name,sql FROM sqlite_master WHERE type IN ('table','index','trigger') AND name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END,name");
const quote = value => {
if (value == null) return 'NULL';
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'NULL';
if (typeof value === 'bigint') return String(value);
if (value instanceof Uint8Array) return `X'${Buffer.from(value).toString('hex')}'`;
return `'${String(value).replaceAll("'", "''")}'`;
};
const statements = ['PRAGMA foreign_keys=OFF;', 'BEGIN TRANSACTION;'];
for (const table of schema.rows.filter(row => row.type === 'table')) {
statements.push(`${table.sql};`);
const rows = await sql.execute(`SELECT * FROM "${table.name.replaceAll('"', '""')}"`);
for (const row of rows.rows) {
const columns = Object.keys(row);
if (!columns.length) continue;
statements.push(`INSERT INTO "${table.name}" (${columns.map(name => `"${name.replaceAll('"', '""')}"`).join(',')}) VALUES (${columns.map(name => quote(row[name])).join(',')});`);
}
}
for (const object of schema.rows.filter(row => row.type !== 'table')) statements.push(`${object.sql};`);
statements.push('COMMIT;');
c.header('Content-Disposition', 'attachment; filename="ytplayer-notes-dump.sql"');
return c.text(statements.join('\n'), 200, { 'Content-Type': 'application/sql; charset=utf-8' });
});
app.get('/api/admin/lyrics/quality', adminAuth, async c => {
const minLines = Math.max(1, Math.min(200, Number(c.req.query('minLines')) || 8));
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
const limit = Math.max(1, Math.min(100, Number(c.req.query('limit')) || 50));
const category = ['all', 'missing', 'short', 'automatic', 'untimed', 'reports'].includes(c.req.query('category')) ? c.req.query('category') : 'all';
const conditions = {
missing: 'n.video_id IS NULL',
short: 'n.video_id IS NOT NULL AND COALESCE(json_array_length(json_extract(n.data,\'$.lines\')),0) < ?',
automatic: "n.source='auto'",
untimed: "EXISTS (SELECT 1 FROM json_each(n.data,'$.lines') line WHERE json_extract(line.value,'$.t') IS NULL)",
reports: "EXISTS (SELECT 1 FROM lyric_flags f WHERE f.video_id=m.id AND f.status='open')",
};
const parts = category === 'all'
? [conditions.missing, conditions.short, conditions.automatic, conditions.untimed, conditions.reports]
: [conditions[category]];
const where = `WHERE (${parts.join(' OR ')})`;
const args = category === 'short' ? [minLines] : category === 'all' ? [minLines] : [];
const query = `SELECT m.id,m.card,m.updated_at,n.data AS lyrics,n.source AS lyricSource,n.updated_at AS lyricUpdated,
COALESCE(json_array_length(json_extract(n.data,'$.lines')),0) AS lineCount,
EXISTS(SELECT 1 FROM json_each(n.data,'$.lines') line WHERE json_extract(line.value,'$.t') IS NULL) AS untimed,
(SELECT COUNT(*) FROM lyric_flags f WHERE f.video_id=m.id AND f.status='open') AS openReports
FROM video_meta m LEFT JOIN video_notes n ON n.video_id=m.id AND n.kind='lyrics'
${where} ORDER BY openReports DESC,lyricUpdated ASC,m.updated_at DESC LIMIT ? OFFSET ?`;
const rows = await sql.execute({ sql: query, args: [...args, limit, offset] });
const count = await sql.execute({ sql: `SELECT COUNT(*) AS total FROM video_meta m LEFT JOIN video_notes n ON n.video_id=m.id AND n.kind='lyrics' ${where}`, args });
const songs = rows.rows.map(row => {
const card = parse(row.card);
let untimed = !!row.untimed;
let lines = number(row.lineCount);
if (row.lyrics) {
const data = parse(row.lyrics); lines ||= Array.isArray(data.lines) ? data.lines.length : 0;
if (Array.isArray(data.lines)) untimed ||= data.lines.some(line => line.t == null);
}
const reasons = [];
if (!row.lyrics) reasons.push('missing');
else if (lines < minLines) reasons.push('short');
if (row.lyricSource === 'auto') reasons.push('automatic');
if (untimed) reasons.push('untimed');
if (number(row.openReports)) reasons.push('reports');
return { id: row.id, title: card.title || row.id, channel: card.channel || '', lines, source: row.lyricSource || null, untimed, openReports: number(row.openReports), reasons, updatedAt: number(row.lyricUpdated) };
});
return c.json({ ok: true, minLines, category, offset, limit, total: number(count.rows[0]?.total), songs }, 200, { 'Cache-Control': 'no-store' });
});
app.get('/api/admin/lyrics/search', adminAuth, async c => {
const query = String(c.req.query('q') || '').trim().slice(0, 120);
const hasLyrics = ['yes', 'no', 'all'].includes(c.req.query('hasLyrics')) ? c.req.query('hasLyrics') : 'yes';
const hasChapters = ['yes', 'no', 'all'].includes(c.req.query('hasChapters')) ? c.req.query('hasChapters') : 'all';
const limit = Math.max(1, Math.min(50, Number(c.req.query('limit')) || 30));
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
let hits = [], total = 0;
if (query && hasLyrics !== 'no') {
const chapterOptions = { hasChapters: hasChapters === 'yes', withoutChapters: hasChapters === 'no' };
hits = await db.searchNoteLyrics(query, { limit, offset, ...chapterOptions });
total = await db.countNoteLyrics(query, chapterOptions);
}
const terms = query.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [];
if (!query || hasLyrics === 'no') {
const parts = [];
const args = [];
if (hasLyrics === 'yes') parts.push("l.video_id IS NOT NULL AND l.data<>'{}'");
if (hasLyrics === 'no') parts.push('l.video_id IS NULL');
if (hasChapters === 'yes') parts.push("EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=m.id AND ch.kind='chapters')");
if (hasChapters === 'no') parts.push("NOT EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=m.id AND ch.kind='chapters')");
for (const term of terms) { parts.push('lower(m.hay) LIKE ?'); args.push(`%${term}%`); }
const where = parts.length ? `WHERE ${parts.join(' AND ')}` : '';
const count = await sql.execute({ sql: `SELECT COUNT(*) AS n FROM video_meta m LEFT JOIN video_notes l ON l.video_id=m.id AND l.kind='lyrics' ${where}`, args });
const rows = await sql.execute({ sql: `SELECT m.id,m.card,l.data AS lyrics,l.rev,EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=m.id AND ch.kind='chapters') AS chapters FROM video_meta m LEFT JOIN video_notes l ON l.video_id=m.id AND l.kind='lyrics' ${where} ORDER BY m.updated_at DESC LIMIT ? OFFSET ?`, args: [...args, limit, offset] });
total = number(count.rows[0]?.n);
hits = rows.rows.map(row => ({ videoId: row.id, snippet: parse(row.lyrics).lines?.slice(0, 3).map(line => line.text).join(' … ') || parse(row.card).title || row.id, rev: number(row.rev), hasChapters: !!row.chapters }));
}
if (hits.length && query && hasLyrics !== 'no') {
const ids = hits.map(item => item.videoId);
const rows = await sql.execute({ sql: `SELECT n.video_id,n.data,n.rev,m.card,EXISTS(SELECT 1 FROM video_notes ch WHERE ch.video_id=n.video_id AND ch.kind='chapters') AS chapters FROM video_notes n LEFT JOIN video_meta m ON m.id=n.video_id WHERE n.kind='lyrics' AND n.video_id IN (${ids.map(() => '?').join(',')})`, args: ids });
const byId = new Map(rows.rows.map(row => [row.video_id, row]));
hits = hits.map(hit => {
const row = byId.get(hit.videoId), card = parse(row?.card);
return { ...hit, title: card.title || hit.videoId, channel: card.channel || '', rev: number(row?.rev), hasChapters: !!row?.chapters };
});
}
return c.json({ ok: true, query, hasLyrics, hasChapters, total, limit, offset, results: hits }, 200, { 'Cache-Control': 'no-store' });
});
app.post('/api/admin/lyrics/search/rebuild', adminAuth, async c => {
if (typeof db.rebuildLyricsSearch !== 'function') return c.json({ ok: false, error: 'Lyrics search rebuild is unavailable.' }, 503);
const indexed = await db.rebuildLyricsSearch();
await audit(db, 'admin', 'lyrics.search.rebuild', '', { indexed });
return c.json({ ok: true, indexed });
});
const librarySelect = `SELECT id,title,channel,hay,lyrics,rev,cacheStatus FROM (
SELECT m.id,COALESCE(json_extract(m.card,'$.title'),m.id) AS title,COALESCE(json_extract(m.card,'$.channel'),'') AS channel,m.hay,
n.data AS lyrics,n.rev AS rev,c.status AS cacheStatus FROM video_meta m
LEFT JOIN video_notes n ON n.video_id=m.id AND n.kind='lyrics' LEFT JOIN media_cache c ON c.video_id=m.id
UNION ALL
SELECT u.id,u.title,COALESCE(u.artist,''),lower(u.title||' '||COALESCE(u.artist,'')),n.data,n.rev,NULL
FROM uploads u LEFT JOIN video_notes n ON n.video_id=u.id AND n.kind='lyrics'
)`;
function lyricsFilter(q) {
const words = String(q || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().match(/[\p{L}\p{N}_-]+/gu) || [];
return { where: words.length ? `WHERE ${words.map(() => 'lower(hay) LIKE ?').join(' AND ')}` : '', args: words.map(word => `%${word}%`) };
}
app.get('/api/admin/lyrics/bulk/library', adminAuth, async c => {
const limit = Math.max(1, Math.min(100, Number(c.req.query('limit')) || 50));
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
const q = String(c.req.query('q') || '').slice(0, 120), filter = lyricsFilter(q);
const [count, rows] = await Promise.all([
sql.execute({ sql: `SELECT COUNT(*) AS total FROM (${librarySelect}) library ${filter.where}`, args: filter.args }),
sql.execute({ sql: `${librarySelect} ${filter.where} ORDER BY title COLLATE NOCASE,id LIMIT ? OFFSET ?`, args: [...filter.args, limit, offset] }),
]);
return c.json({ ok: true, q, total: number(count.rows[0]?.total), limit, offset, songs: rows.rows.map(row => {
const lyrics = parse(row.lyrics), lines = Array.isArray(lyrics.lines) ? lyrics.lines.length : 0;
return { id: row.id, title: row.title || row.id, channel: row.channel || '', lines, rev: number(row.rev), cacheStatus: row.cacheStatus || null };
}) }, 200, { 'Cache-Control': 'no-store' });
});
app.post('/api/admin/lyrics/jobs', adminAuth, async c => {
let body = {}; try { body = await c.req.json(); } catch {}
const action = ['lrclib', 'whisper', 'export', 'clear'].includes(body.action) ? body.action : '';
if (!action) return c.json({ ok: false, error: 'Choose LRCLIB, Whisper, export, or clear.' }, 400);
let ids = Array.isArray(body.ids) ? body.ids : [];
if (body.allFiltered === true) {
const filter = lyricsFilter(String(body.query || '').slice(0, 120));
const rows = await sql.execute({ sql: `${librarySelect} ${filter.where} ORDER BY title COLLATE NOCASE,id LIMIT 500`, args: filter.args });
ids = rows.rows.map(row => row.id);
}
ids = [...new Set(ids.map(String).filter(id => /^[\w-]{11}$/.test(id) || /^upl_[a-f0-9]{12}$/.test(id)))].slice(0, 500);
if (!ids.length) return c.json({ ok: false, error: 'Select at least one song.' }, 400);
if (action === 'clear' && body.confirmClear !== true) return c.json({ ok: false, error: 'Clearing lyrics requires explicit confirmation.' }, 400);
const id = crypto.randomUUID(), now = Math.floor(Date.now() / 1000);
const state = { done: 0, total: ids.length, results: [], logs: [], fileReady: false };
await sql.execute({ sql: 'INSERT INTO admin_lyrics_jobs(id,action,status,ids,state,created_at,updated_at) VALUES(?,?,\'queued\',?,?,?,?)', args: [id, action, JSON.stringify(ids), JSON.stringify(state), now, now] });
await audit(db, 'admin', 'lyrics.bulk.start', id, { action, count: ids.length });
return c.json({ ok: true, job: { id, action, status: 'queued', state } }, 202);
});
app.get('/api/admin/lyrics/jobs', adminAuth, async c => {
const rows = await sql.execute('SELECT * FROM admin_lyrics_jobs ORDER BY created_at DESC LIMIT 30');
return c.json({ ok: true, jobs: rows.rows.map(row => ({ id: row.id, action: row.action, status: row.status, state: parse(row.state), error: row.error, createdAt: number(row.created_at), updatedAt: number(row.updated_at) })) }, 200, { 'Cache-Control': 'no-store' });
});
app.get('/api/admin/lyrics/jobs/:id', adminAuth, async c => {
const row = (await sql.execute({ sql: 'SELECT * FROM admin_lyrics_jobs WHERE id=?', args: [c.req.param('id')] })).rows[0];
return row ? c.json({ ok: true, job: { id: row.id, action: row.action, status: row.status, state: parse(row.state), error: row.error, createdAt: number(row.created_at), updatedAt: number(row.updated_at) } }) : c.json({ ok: false, error: 'Job not found.' }, 404);
});
app.post('/api/admin/lyrics/jobs/:id/:action', adminAuth, async c => {
const id = c.req.param('id'), action = c.req.param('action');
if (!['pause', 'resume', 'cancel'].includes(action)) return c.json({ ok: false, error: 'Unknown job action.' }, 400);
const row = (await sql.execute({ sql: 'SELECT status FROM admin_lyrics_jobs WHERE id=?', args: [id] })).rows[0];
if (!row) return c.json({ ok: false, error: 'Job not found.' }, 404);
const next = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'cancelled';
if (action === 'pause' && !['queued', 'running'].includes(row.status) || action === 'resume' && row.status !== 'paused' || action === 'cancel' && !['queued', 'running', 'paused'].includes(row.status)) return c.json({ ok: false, error: `Cannot ${action} a ${row.status} job.` }, 409);
await sql.execute({ sql: 'UPDATE admin_lyrics_jobs SET status=?,updated_at=? WHERE id=?', args: [next, Math.floor(Date.now() / 1000), id] });
await audit(db, 'admin', `lyrics.bulk.${action}`, id, { from: row.status, to: next });
return c.json({ ok: true, status: next });
});
app.get('/api/admin/lyrics/jobs/:id/export', adminAuth, async c => {
const row = (await sql.execute({ sql: 'SELECT action,status,state FROM admin_lyrics_jobs WHERE id=?', args: [c.req.param('id')] })).rows[0];
const state = parse(row?.state);
if (!row || row.action !== 'export' || row.status !== 'complete' || !state.fileReady || !backupDir) return c.json({ ok: false, error: 'Export is not ready.' }, 404);
try { const data = await readFile(join(backupDir, 'bulk-lyrics', `${c.req.param('id')}.zip`)); return new Response(data, { headers: { 'Content-Type': 'application/zip', 'Content-Disposition': `attachment; filename="lyrics-${c.req.param('id')}.zip"` } }); }
catch { return c.json({ ok: false, error: 'Export file is unavailable.' }, 404); }
});
let workerBusy = false;
let activeJobId = null;
async function runBulkJob() {
if (workerBusy) return;
workerBusy = true;
try {
const row = (await sql.execute("SELECT * FROM admin_lyrics_jobs WHERE status IN ('queued','running') ORDER BY created_at,id LIMIT 1")).rows[0];
if (!row) return;
activeJobId = row.id;
await sql.execute({ sql: "UPDATE admin_lyrics_jobs SET status='running',updated_at=? WHERE id=? AND status='queued'", args: [Math.floor(Date.now() / 1000), row.id] });
const ids = parse(row.ids), state = parse(row.state); state.total = ids.length; state.results ||= []; state.logs ||= [];
for (let index = number(state.done); index < ids.length; index++) {
const current = (await sql.execute({ sql: 'SELECT status FROM admin_lyrics_jobs WHERE id=?', args: [row.id] })).rows[0]?.status;
if (current !== 'running') return;
const videoId = ids[index]; let result;
try {
if (row.action === 'lrclib') result = pullLyrics ? await pullLyrics(videoId) : { ok: false, error: 'LRCLIB worker is unavailable.' };
else if (row.action === 'whisper') {
const queued = queueWhisper ? await queueWhisper(videoId) : { status: 503, body: { ok: false, error: 'Whisper worker is unavailable.' } };
result = { ...(queued.body || {}), ok: queued.status < 400 && queued.body?.ok, httpStatus: queued.status };
} else if (row.action === 'clear') {
const saved = await db.saveNote({ videoId, kind: 'lyrics', data: { lines: [], tags: [], offset: 0 }, source: 'admin-bulk-clear', updatedBy: 'admin bulk clear', force: true });
const resolved = await db.autoResolveFlags(videoId, new Set());
await audit(db, 'admin', 'lyrics.clear', videoId, { rev: saved.rev, resolvedReports: resolved, undoViaRevision: saved.rev - 1 });
result = { ok: true, rev: saved.rev, resolvedReports: resolved };
} else {
const note = (await db.getNotes(videoId)).lyrics;
result = note ? { ok: true, lines: note.data.lines?.length || 0 } : { ok: false, error: 'No saved lyrics to export.' };
}
} catch (error) { result = { ok: false, error: String(error.message || error).slice(0, 300) }; }
const item = { id: videoId, ok: !!result?.ok, message: result?.ok ? (result.lines != null ? `${result.lines} lines` : 'Done') : result?.error || 'Action failed.' };
state.done = index + 1; state.results.push(item); state.logs.push({ at: Date.now(), ...item }); if (state.logs.length > 80) state.logs.splice(0, state.logs.length - 80);
await sql.execute({ sql: 'UPDATE admin_lyrics_jobs SET state=?,updated_at=? WHERE id=?', args: [JSON.stringify(state), Math.floor(Date.now() / 1000), row.id] });
if (row.action === 'lrclib') await sleep(1200);
}
if (row.action === 'export') {
const files = [];
for (const videoId of ids) {
const note = (await db.getNotes(videoId)).lyrics; if (!note?.data?.lines?.length) continue;
let title = videoId;
if (videoId.startsWith('upl_')) title = (await db.getUpload(videoId))?.title || videoId;
else {
const card = (await sql.execute({ sql: 'SELECT card FROM video_meta WHERE id=?', args: [videoId] })).rows[0]; title = parse(card?.card).title || videoId;
}
const safe = String(title).normalize('NFKC').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 100) || videoId;
const lrc = note.data.lines.map(line => line.t == null ? line.text : `[${String(Math.floor(line.t / 60)).padStart(2, '0')}:${(line.t % 60).toFixed(2).padStart(5, '0')}]${line.text}`).join('\n') + '\n';
files.push({ name: `${safe}__${videoId}.lrc`, content: lrc });
}
if (!backupDir) throw Error('Export storage is unavailable.');
await mkdir(join(backupDir, 'bulk-lyrics'), { recursive: true });
await writeFile(join(backupDir, 'bulk-lyrics', `${row.id}.zip`), zipStore(files));
state.fileReady = true; state.exportedFiles = files.length;
}
await sql.execute({ sql: "UPDATE admin_lyrics_jobs SET status='complete',state=?,updated_at=? WHERE id=? AND status='running'", args: [JSON.stringify(state), Math.floor(Date.now() / 1000), row.id] });
} catch (error) {
console.error('[admin-tools] lyrics job failed:', error.message);
if (activeJobId) {
try {
await sql.execute({ sql: "UPDATE admin_lyrics_jobs SET status='failed',error=?,updated_at=? WHERE id=? AND status='running'", args: [String(error.message || error).slice(0, 300), Math.floor(Date.now() / 1000), activeJobId] });
await audit(db, 'admin', 'lyrics.bulk.failed', activeJobId, { error: String(error.message || error).slice(0, 300) });
} catch (persistError) { console.warn('[admin-tools] could not persist lyrics job failure:', persistError.message); }
}
} finally { activeJobId = null; workerBusy = false; }
}
if (autoStartJobs) {
sql.execute("UPDATE admin_lyrics_jobs SET status='queued',updated_at=unixepoch() WHERE status='running'").then(() => runBulkJob()).catch(error => console.warn('[admin-tools] could not resume lyrics jobs:', error.message));
const timer = setInterval(runBulkJob, 1000); timer.unref?.();
}
app.get('/api/admin/cache/health', adminAuth, async c => {
const limit = Math.max(1, Math.min(100, Number(c.req.query('limit')) || 50));
const offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
const rows = await sql.execute({ sql: "SELECT video_id,status,size,last_access,updated_at,gen,pinned FROM media_cache ORDER BY CASE WHEN status='ready' THEN 0 ELSE 1 END,last_access ASC LIMIT ? OFFSET ?", args: [limit, offset] });
const total = await sql.execute('SELECT COUNT(*) AS total FROM media_cache');
const grouped = await sql.execute('SELECT status,COUNT(*) AS count,COALESCE(SUM(size),0) AS bytes,COALESCE(SUM(CASE WHEN pinned=1 THEN size ELSE 0 END),0) AS pinnedBytes FROM media_cache GROUP BY status');
const bytes = grouped.rows.reduce((sum, row) => sum + number(row.bytes), 0);
const pinnedBytes = grouped.rows.reduce((sum, row) => sum + number(row.pinnedBytes), 0);
const budget = Math.max(0, Number(process.env.MEDIA_CACHE_MAX_BYTES) || 10 * 1024 ** 3);
const pinnedBudget = Math.max(0, Number(process.env.MEDIA_CACHE_PINNED_MAX_BYTES) || 2 * 1024 ** 3);
return c.json({ ok: true, budget, pinnedBudget, bytes, pinnedBytes, utilization: budget ? bytes / budget : 0,
statuses: grouped.rows.map(row => ({ status: row.status, count: number(row.count), bytes: number(row.bytes) })),
total: number(total.rows[0]?.total), limit, offset,
media: rows.rows.map(row => ({ id: row.video_id, status: row.status, bytes: number(row.size), lastAccess: number(row.last_access), updatedAt: number(row.updated_at), gen: number(row.gen), pinned: !!row.pinned })) }, 200, { 'Cache-Control': 'no-store' });
});
app.post('/api/admin/cache/:id/evict', adminAuth, async c => {
const id = c.req.param('id');
if (!/^[A-Za-z0-9_-]{6,64}$/.test(id)) return c.json({ ok: false, error: 'invalid media id' }, 400);
const row = await db.getMedia(id);
if (!row) return c.json({ ok: false, error: 'media not found' }, 404);
if (row.pinned) return c.json({ ok: false, error: 'Unpin this copy before evicting it.' }, 409);
if (!media?.evictNow) return c.json({ ok: false, error: 'media cache unavailable' }, 503);
await media.evictNow(id);
await audit(db, 'admin', 'cache.evict', id, { bytes: number(row.size) });
return c.json({ ok: true });
});
app.post('/api/admin/cache/:id/pin', adminAuth, async c => {
const id = c.req.param('id');
let body = {}; try { body = await c.req.json(); } catch {}
if (body.pinned !== true && body.pinned !== false) return c.json({ ok: false, error: 'pinned must be boolean' }, 400);
const target = await sql.execute({ sql: "SELECT size,pinned,status FROM media_cache WHERE video_id=?", args: [id] });
const row = target.rows[0];
if (!row) return c.json({ ok: false, error: 'media not found' }, 404);
if (body.pinned && row.status !== 'ready') return c.json({ ok: false, error: 'Only ready cache copies can be pinned.' }, 409);
const pinnedBudget = Math.max(0, Number(process.env.MEDIA_CACHE_PINNED_MAX_BYTES) || 2 * 1024 ** 3);
if (body.pinned && !row.pinned) {
const totals = await sql.execute('SELECT COALESCE(SUM(size),0) AS bytes FROM media_cache WHERE pinned=1');
if (number(totals.rows[0]?.bytes) + number(row.size) > pinnedBudget) return c.json({ ok: false, error: 'Pinned cache byte cap exceeded.' }, 409);
}
await sql.execute({ sql: 'UPDATE media_cache SET pinned=? WHERE video_id=?', args: [body.pinned ? 1 : 0, id] });
await audit(db, 'admin', body.pinned ? 'cache.pin' : 'cache.unpin', id, { bytes: number(row.size) });
return c.json({ ok: true, pinned: body.pinned });
});
app.get('/api/admin/index/health', adminAuth, async c => {
const [stats, sources, latest, backfill] = await Promise.all([
sql.execute(`SELECT COUNT(*) AS total,SUM(CASE WHEN url IS NOT NULL AND url<>'' THEN 1 ELSE 0 END) AS urls,
SUM(CASE WHEN EXISTS(SELECT 1 FROM video_details d WHERE d.video_id=video_meta.id) THEN 1 ELSE 0 END) AS details,
SUM(CASE WHEN EXISTS(SELECT 1 FROM video_thumbnails t WHERE t.video_id=video_meta.id AND t.data IS NOT NULL) THEN 1 ELSE 0 END) AS thumbnails FROM video_meta`),
sql.execute('SELECT source,COUNT(*) AS videos,SUM(discoveries) AS discoveries,MAX(last_seen) AS lastSeen FROM video_meta_sources GROUP BY source ORDER BY videos DESC'),
sql.execute('SELECT id,status,created_at,updated_at,state,error FROM deep_search_jobs ORDER BY created_at DESC LIMIT 1'),
sql.execute('SELECT source,cursor,ceiling FROM catalog_backfill ORDER BY source'),
]);
const row = stats.rows[0] || {}, total = number(row.total);
const last = latest.rows[0];
return c.json({ ok: true, total, urlCount: number(row.urls), detailCount: number(row.details), thumbnailCount: number(row.thumbnails),
urlCoverage: total ? number(row.urls) / total : 0, detailCoverage: total ? number(row.details) / total : 0, thumbnailCoverage: total ? number(row.thumbnails) / total : 0,
sources: sources.rows.map(x => ({ source: x.source, videos: number(x.videos), discoveries: number(x.discoveries), lastSeen: number(x.lastSeen) })),
lastRun: last ? { id: last.id, status: last.status, createdAt: number(last.created_at), updatedAt: number(last.updated_at), error: last.error || null, progress: parse(last.state) } : null,
backfill: backfill.rows.map(x => ({ source: x.source, cursor: number(x.cursor), ceiling: number(x.ceiling), done: number(x.cursor) >= number(x.ceiling) })) }, 200, { 'Cache-Control': 'no-store' });
});
app.post('/api/admin/index/backfill', adminAuth, async c => {
if (!backfillStep) return c.json({ ok: false, error: 'Catalog backfill is unavailable.' }, 503);
const processed = await backfillStep(100);
const state = (await sql.execute('SELECT source,cursor,ceiling FROM catalog_backfill ORDER BY source')).rows;
await audit(db, 'admin', 'index.backfill', '', { processed });
return c.json({ ok: true, processed, backfill: state.map(x => ({ source: x.source, cursor: number(x.cursor), ceiling: number(x.ceiling), done: number(x.cursor) >= number(x.ceiling) })) });
});
app.post('/api/admin/index/cleanup', adminAuth, async c => {
let body = {}; try { body = await c.req.json(); } catch {}
const days = Math.max(180, Math.min(3650, Number(body.days) || 365));
const cutoff = Date.now() - days * 86400_000;
const candidates = await sql.execute({ sql: `SELECT COUNT(*) AS n FROM video_meta m WHERE m.updated_at<?
AND NOT EXISTS(SELECT 1 FROM video_meta_sources s WHERE s.video_id=m.id)
AND NOT EXISTS(SELECT 1 FROM media_cache c WHERE c.video_id=m.id)
AND NOT EXISTS(SELECT 1 FROM listening_daily l WHERE l.video_id=m.id)`, args: [cutoff] });
const removed = number(candidates.rows[0]?.n);
await sql.execute({ sql: `DELETE FROM video_meta WHERE updated_at<?
AND NOT EXISTS(SELECT 1 FROM video_meta_sources s WHERE s.video_id=video_meta.id)
AND NOT EXISTS(SELECT 1 FROM media_cache c WHERE c.video_id=video_meta.id)
AND NOT EXISTS(SELECT 1 FROM listening_daily l WHERE l.video_id=video_meta.id)`, args: [cutoff] });
await sql.batch([
'DELETE FROM video_meta_sources WHERE video_id NOT IN (SELECT id FROM video_meta)',
'DELETE FROM video_thumbnails WHERE video_id NOT IN (SELECT id FROM video_meta)',
'DELETE FROM video_channels WHERE video_id NOT IN (SELECT id FROM video_meta)',
'DELETE FROM video_details WHERE video_id NOT IN (SELECT id FROM video_meta)',
], 'write');
await audit(db, 'admin', 'index.cleanup', '', { days, removed });
return c.json({ ok: true, removed, days });
});
return { runBulkJob };
}

157
server/admin-tools.test.js Normal file
View File

@@ -0,0 +1,157 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Hono } from 'hono';
const root = mkdtempSync(join(tmpdir(), 'ytp-admin-tools-'));
process.env.DB_PATH = join(root, 'admin.db');
process.env.MEDIA_CACHE_PINNED_MAX_BYTES = '1000';
const db = await import('./db.js');
const { registerAdminToolsRoutes } = await import('./admin-tools.js');
const app = new Hono();
const backupDir = join(root, 'backups');
const auth = async (c, next) => c.req.header('x-admin') === 'yes' ? next() : c.json({ ok: false }, 401);
let tools;
beforeAll(async () => {
await db.initDb();
tools = registerAdminToolsRoutes(app, { db, adminAuth: auth, backupDir, backfillStep: async () => 3,
pullLyrics: async () => ({ ok: true, lines: 2 }), queueWhisper: async () => ({ status: 202, body: { ok: true, job: { status: 'queued' } } }), autoStartJobs: false });
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: ['QualMiss001', JSON.stringify({ title: 'Missing lyric sample', channel: 'Choir' }), 'missing lyric sample choir', 1, Date.now()] });
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: ['Untimely001', JSON.stringify({ title: 'Untimed sample', channel: 'Band' }), 'untimed sample band', 1, Date.now()] });
await db.saveNote({ videoId: 'Untimely001', kind: 'lyrics', data: { lines: [{ t: null, text: 'A line' }, { t: 4, text: 'Next' }] }, source: 'user', force: true });
await db.db.execute({ sql: "INSERT INTO media_cache(video_id,status,size,gen) VALUES('CacheItem001','ready',700,1),('CacheItem002','ready',400,1)" });
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at,url) VALUES(?,?,?,?,?,?)', args: ['IndexSong001', JSON.stringify({ title: 'Indexed song' }), 'indexed song', 1, Date.now(), 'https://www.youtube.com/watch?v=IndexSong001'] });
await db.db.execute({ sql: "INSERT INTO video_meta_sources(video_id,source,last_seen) VALUES('IndexSong001','deep',?)", args: [Date.now()] });
await db.db.execute({ sql: "INSERT INTO video_details(video_id,metadata,updated_at) VALUES('IndexSong001','{}',?)", args: [Date.now()] });
await db.db.execute({ sql: "INSERT INTO video_thumbnails(video_id,url,data,mime,size,fetched_at) VALUES('IndexSong001','https://i.ytimg.com/vi/IndexSong001/hqdefault.jpg',x'01','image/jpeg',1,?)", args: [Date.now()] });
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,1)', args: ['OldOrphan01', JSON.stringify({ title: 'Old orphan' }), 'old orphan', 1] });
await db.db.execute({ sql: "INSERT INTO deep_search_jobs(id,status,plan,options,state,created_at,updated_at) VALUES('job-1','complete','[]','{}','{\"queriesDone\":2,\"newVideos\":5}',1,2)" });
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
describe('lyrics quality queue', () => {
test('requires admin and returns missing, untimed and short-lyrics reasons in pages', async () => {
expect((await app.request('/api/admin/lyrics/quality')).status).toBe(401);
const headers = { 'x-admin': 'yes' };
const missing = await (await app.request('/api/admin/lyrics/quality?category=missing', { headers })).json();
expect(missing.total).toBeGreaterThanOrEqual(1);
expect(missing.songs.find(song => song.id === 'QualMiss001')).toMatchObject({ id: 'QualMiss001', reasons: ['missing'] });
const untimed = await (await app.request('/api/admin/lyrics/quality?category=untimed', { headers })).json();
expect(untimed.songs[0]).toMatchObject({ id: 'Untimely001', lines: 2, untimed: true });
const short = await (await app.request('/api/admin/lyrics/quality?category=short&minLines=3', { headers })).json();
expect(short.songs.find(song => song.id === 'Untimely001').reasons).toContain('short');
const page = await (await app.request('/api/admin/lyrics/quality?limit=1&offset=1', { headers })).json();
expect(page.songs).toHaveLength(1);
});
test('pinning is authorized and enforces the configured pinned-byte cap', async () => {
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
expect((await app.request('/api/admin/cache/CacheItem001/pin', { method: 'POST', headers, body: JSON.stringify({ pinned: true }) })).status).toBe(200);
const over = await app.request('/api/admin/cache/CacheItem002/pin', { method: 'POST', headers, body: JSON.stringify({ pinned: true }) });
expect(over.status).toBe(409);
expect((await over.json()).error).toContain('byte cap');
const refused = await app.request('/api/admin/cache/CacheItem001/evict', { method: 'POST', headers, body: '{}' });
expect(refused.status).toBe(409);
const health = await (await app.request('/api/admin/cache/health', { headers: { 'x-admin': 'yes' } })).json();
expect(health.pinnedBytes).toBe(700);
});
test('index health reports coverage, source and deep-search data; repair actions are audited', async () => {
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
const health = await (await app.request('/api/admin/index/health', { headers })).json();
expect(health.total).toBeGreaterThanOrEqual(1);
expect(health.urlCount).toBeGreaterThanOrEqual(1);
expect(health.detailCount).toBe(1);
expect(health.thumbnailCount).toBe(1);
expect(health.sources[0].source).toBe('deep');
expect(health.lastRun.progress.queriesDone).toBe(2);
const step = await (await app.request('/api/admin/index/backfill', { method: 'POST', headers, body: '{}' })).json();
expect(step.processed).toBe(3);
const clean = await (await app.request('/api/admin/index/cleanup', { method: 'POST', headers, body: JSON.stringify({ days: 180 }) })).json();
expect(clean.removed).toBeGreaterThanOrEqual(1);
expect((await db.db.execute({ sql: "SELECT COUNT(*) AS n FROM video_meta WHERE id='OldOrphan01'" })).rows[0].n).toBe(0);
});
test('backup list verifies, previews, downloads, safely restores and exports a SQLite dump', async () => {
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
await db.saveNote({ videoId: 'BackupSong01', kind: 'lyrics', data: { lines: [{ t: 1, text: 'Before' }] }, force: true, source: 'user' });
const created = await (await app.request('/api/admin/backups', { method: 'POST', headers, body: '{}' })).json();
expect(created.ok).toBe(true);
await db.saveNote({ videoId: 'BackupSong01', kind: 'lyrics', data: { lines: [{ t: 1, text: 'After' }] }, force: true, source: 'user' });
const list = await (await app.request('/api/admin/backups', { headers })).json();
expect(list.backups.find(item => item.date === created.date)).toMatchObject({ valid: true, notes: created.notes });
const preview = await (await app.request(`/api/admin/backups/${created.date}`, { headers })).json();
expect(preview.diff.changed).toBe(1);
const download = await app.request(`/api/admin/backups/${created.date}/download`, { headers });
expect(download.status).toBe(200);
const restored = await (await app.request(`/api/admin/backups/${created.date}/restore`, { method: 'POST', headers, body: JSON.stringify({ videoId: 'BackupSong01' }) })).json();
expect(restored.ok).toBe(true);
expect((await db.getNotes('BackupSong01')).lyrics.data.lines[0].text).toBe('Before');
expect(existsSync(join(backupDir, 'snapshots', restored.safetySnapshot))).toBe(true);
const dump = await app.request('/api/admin/backups/export/sqlite-dump', { headers: { 'x-admin': 'yes' } });
expect(dump.status).toBe(200);
expect(await dump.text()).toContain('CREATE TABLE');
});
test('lyrics search is indexed on save, filters chapter state, and rebuilds under admin auth', async () => {
const id = 'SearchSong01';
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: [id, JSON.stringify({ title: 'Amazing grace sample', channel: 'Choir' }), 'amazing grace sample choir', 1, Date.now()] });
await db.saveNote({ videoId: id, kind: 'lyrics', data: { lines: [{ t: 1, text: 'Amazing grace will lead me home' }] }, source: 'user', force: true });
expect((await app.request('/api/admin/lyrics/search?q=amazing')).status).toBe(401);
const headers = { 'x-admin': 'yes' };
let result = await (await app.request('/api/admin/lyrics/search?q=amazing+grace&hasChapters=no', { headers })).json();
expect(result.results[0]).toMatchObject({ videoId: id, title: 'Amazing grace sample', hasChapters: false });
expect(result.results[0].snippet).toContain('\u0001');
await db.saveNote({ videoId: id, kind: 'chapters', data: { items: [{ t: 1, title: 'Verse' }] }, source: 'user', force: true });
result = await (await app.request('/api/admin/lyrics/search?q=amazing&hasChapters=yes', { headers })).json();
expect(result.results[0].videoId).toBe(id);
await db.saveNote({ videoId: id, kind: 'lyrics', data: { lines: [{ t: 1, text: 'Bright morning star' }] }, source: 'user', force: true });
expect((await (await app.request('/api/admin/lyrics/search?q=amazing', { headers })).json()).total).toBe(0);
const rebuilt = await (await app.request('/api/admin/lyrics/search/rebuild', { method: 'POST', headers: { ...headers, 'content-type': 'application/json' }, body: '{}' })).json();
expect(rebuilt.ok).toBe(true);
expect(rebuilt.indexed).toBeGreaterThanOrEqual(1);
});
test('bulk lyrics jobs page the library, resume, clear with revision undo, export ZIP, and cancel', async () => {
const headers = { 'x-admin': 'yes', 'content-type': 'application/json' };
expect((await app.request('/api/admin/lyrics/bulk/library')).status).toBe(401);
const library = await (await app.request('/api/admin/lyrics/bulk/library?q=untimed', { headers })).json();
expect(library.songs[0]).toMatchObject({ id: 'Untimely001', title: 'Untimed sample' });
const create = async body => (await app.request('/api/admin/lyrics/jobs', { method: 'POST', headers, body: JSON.stringify(body) })).json();
const pull = (await create({ action: 'lrclib', ids: ['Untimely001'] })).job;
expect((await app.request(`/api/admin/lyrics/jobs/${pull.id}/pause`, { method: 'POST', headers, body: '{}' })).status).toBe(200);
expect((await app.request(`/api/admin/lyrics/jobs/${pull.id}/resume`, { method: 'POST', headers, body: '{}' })).status).toBe(200);
await tools.runBulkJob();
let current = await (await app.request(`/api/admin/lyrics/jobs/${pull.id}`, { headers: { 'x-admin': 'yes' } })).json();
expect(current.job).toMatchObject({ status: 'complete', state: { done: 1, results: [{ ok: true, id: 'Untimely001' }] } });
const clear = (await create({ action: 'clear', ids: ['Untimely001'], confirmClear: true })).job;
await tools.runBulkJob();
expect((await db.getNotes('Untimely001')).lyrics.data.lines).toEqual([]);
expect((await db.getNoteRev('Untimely001', 'lyrics', 1)).data.lines[0].text).toBe('A line');
await db.db.execute({ sql: 'INSERT INTO video_meta(id,card,hay,seen,updated_at) VALUES(?,?,?,?,?)', args: ['ZipSong0001', JSON.stringify({ title: 'Zip Song' }), 'zip song', 1, Date.now()] });
await db.saveNote({ videoId: 'ZipSong0001', kind: 'lyrics', data: { lines: [{ t: 1.2, text: 'Saved line' }] }, source: 'user', force: true });
const exportJob = (await create({ action: 'export', ids: ['ZipSong0001'] })).job;
await tools.runBulkJob();
const archive = await app.request(`/api/admin/lyrics/jobs/${exportJob.id}/export`, { headers: { 'x-admin': 'yes' } });
expect(archive.status).toBe(200);
expect([...new Uint8Array(await archive.arrayBuffer())].slice(0, 2)).toEqual([0x50, 0x4b]);
const failingApp = new Hono();
const failingTools = registerAdminToolsRoutes(failingApp, { db, adminAuth: auth, backupDir: null, autoStartJobs: false });
const failingExport = (await (await failingApp.request('/api/admin/lyrics/jobs', { method: 'POST', headers, body: JSON.stringify({ action: 'export', ids: ['ZipSong0001'] }) })).json()).job;
await failingTools.runBulkJob();
const failed = await (await failingApp.request(`/api/admin/lyrics/jobs/${failingExport.id}`, { headers: { 'x-admin': 'yes' } })).json();
expect(failed.job).toMatchObject({ status: 'failed', error: 'Export storage is unavailable.' });
const cancelled = (await create({ action: 'whisper', ids: ['Untimely001'] })).job;
await app.request(`/api/admin/lyrics/jobs/${cancelled.id}/cancel`, { method: 'POST', headers, body: '{}' });
current = await (await app.request(`/api/admin/lyrics/jobs/${cancelled.id}`, { headers: { 'x-admin': 'yes' } })).json();
expect(current.job.status).toBe('cancelled');
});
});

53
server/audit-log.js Normal file
View File

@@ -0,0 +1,53 @@
const asJson = value => {
try { return JSON.stringify(value ?? {}); } catch { return '{}'; }
}
export async function audit(db, actor, action, target = '', detail = {}) {
const client = typeof db?.execute === 'function' ? db : db?.db;
if (!client?.execute) throw new TypeError('audit database client is unavailable');
await client.execute({
sql: 'INSERT INTO audit_log (ts,actor,action,target,detail) VALUES (unixepoch(),?,?,?,?)',
args: [String(actor || 'admin').slice(0, 120), String(action).slice(0, 120), String(target || '').slice(0, 300), asJson(detail).slice(0, 8000)],
});
}
export function registerAuditRoutes(app, { db, adminAuth }) {
const client = typeof db?.execute === 'function' ? db : db?.db;
app.get('/api/admin/audit', adminAuth, async c => {
const format = c.req.query('format');
const limit = Math.max(1, Math.min(format ? 10000 : 200, Number(c.req.query('limit')) || (format ? 10000 : 50)));
const offset = format ? 0 : Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
const filters = [];
const args = [];
for (const key of ['actor', 'action']) {
const value = String(c.req.query(key) || '').trim().slice(0, 120);
if (value) { filters.push(`${key} LIKE ?`); args.push(`%${value.replace(/[\\%_]/g, '\\$&')}%`); }
}
const search = String(c.req.query('q') || '').trim().slice(0, 160);
if (search) { filters.push('(actor LIKE ? OR action LIKE ? OR target LIKE ? OR detail LIKE ?)'); args.push(...Array(4).fill(`%${search.replace(/[\\%_]/g, '\\$&')}%`)); }
const from = Number(c.req.query('from'));
const to = Number(c.req.query('to'));
if (Number.isFinite(from) && from > 0) { filters.push('ts >= ?'); args.push(Math.floor(from)); }
if (Number.isFinite(to) && to > 0) { filters.push('ts <= ?'); args.push(Math.floor(to)); }
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
const [rows, count] = await Promise.all([
client.execute({ sql: `SELECT id,ts,actor,action,target,detail FROM audit_log ${where} ORDER BY ts DESC,id DESC LIMIT ? OFFSET ?`, args: [...args, limit, offset] }),
client.execute({ sql: `SELECT COUNT(*) AS total FROM audit_log ${where}`, args }),
]);
const events = rows.rows.map(row => ({
id: Number(row.id), ts: Number(row.ts), actor: row.actor, action: row.action, target: row.target,
detail: (() => { try { return JSON.parse(row.detail || '{}'); } catch { return {}; } })(),
}));
if (format === 'json') {
c.header('Content-Disposition', 'attachment; filename="ytplayer-audit.json"');
return c.json({ events, total: Number(count.rows[0]?.total) || 0 });
}
if (format === 'csv') {
const quote = value => `"${String(value ?? '').replaceAll('"', '""')}"`;
const body = ['id,ts,actor,action,target,detail', ...events.map(row => [row.id, new Date(row.ts * 1000).toISOString(), row.actor, row.action, row.target, JSON.stringify(row.detail)].map(quote).join(','))].join('\n');
c.header('Content-Disposition', 'attachment; filename="ytplayer-audit.csv"');
return c.text(body, 200, { 'Content-Type': 'text/csv; charset=utf-8' });
}
return c.json({ ok: true, events, total: Number(count.rows[0]?.total) || 0, limit, offset }, 200, { 'Cache-Control': 'no-store' });
});
}

35
server/audit-log.test.js Normal file
View File

@@ -0,0 +1,35 @@
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Hono } from 'hono';
const root = mkdtempSync(join(tmpdir(), 'ytp-audit-test-'));
process.env.DB_PATH = join(root, 'audit.db');
const db = await import('./db.js');
const { audit, registerAuditRoutes } = await import('./audit-log.js');
const app = new Hono();
const auth = async (c, next) => c.req.header('x-admin') === 'yes' ? next() : c.json({ ok: false }, 401);
beforeAll(async () => {
await db.initDb();
registerAuditRoutes(app, { db, adminAuth: auth });
await audit(db, 'admin', 'notes.save.lyrics', 'video_1', { rev: 2 });
await audit(db, 'editor@example.test', 'report.resolve', '42', { batch: true });
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
describe('admin audit log', () => {
test('is authenticated, filtered, paged and exports CSV/JSON', async () => {
expect((await app.request('/api/admin/audit')).status).toBe(401);
const headers = { 'x-admin': 'yes' };
const filtered = await (await app.request('/api/admin/audit?actor=editor&action=resolve', { headers })).json();
expect(filtered.total).toBe(1);
expect(filtered.events[0]).toMatchObject({ actor: 'editor@example.test', action: 'report.resolve', target: '42', detail: { batch: true } });
const csv = await app.request('/api/admin/audit?format=csv', { headers });
expect(csv.headers.get('content-type')).toContain('text/csv');
expect(await csv.text()).toContain('notes.save.lyrics');
const json = await (await app.request('/api/admin/audit?format=json', { headers })).json();
expect(json.events).toHaveLength(2);
});
});

View File

@@ -20,6 +20,7 @@ const DB_PATH = process.env.DB_PATH || join(process.cwd(), 'data', 'ytplayer.db'
export const db = createClient({
url: 'file:' + DB_PATH,
});
let lyricsFtsAvailable = false;
// ---- Schema ----------------------------------------------------------------
@@ -106,7 +107,8 @@ export async function initDb() {
created_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL DEFAULT 0,
last_access INTEGER NOT NULL DEFAULT 0,
hits INTEGER NOT NULL DEFAULT 0
hits INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_media_lru
@@ -256,6 +258,13 @@ export async function initDb() {
CREATE INDEX IF NOT EXISTS idx_flags_video ON lyric_flags (video_id, status);
CREATE INDEX IF NOT EXISTS idx_flags_status ON lyric_flags (status, created_at DESC);
CREATE TABLE IF NOT EXISTS admin_lyrics_jobs (
id TEXT PRIMARY KEY, action TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'queued',
ids TEXT NOT NULL, state TEXT NOT NULL DEFAULT '{}', error TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()), updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_admin_lyrics_jobs ON admin_lyrics_jobs(status,created_at);
-- API tokens for scripts (lyrics injection etc.). Only a SHA-256 of the
-- token is stored; the plaintext is shown once when it is created.
CREATE TABLE IF NOT EXISTS api_tokens (
@@ -265,8 +274,21 @@ export async function initDb() {
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
last_used_at INTEGER
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL DEFAULT (unixepoch()),
actor TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL DEFAULT '',
detail TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log (ts DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_audit_actor_action ON audit_log (actor, action, ts DESC);
`);
await migrateVideoMeta();
await initLyricsSearch();
try { await db.execute('ALTER TABLE media_cache ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0'); } catch { /* already migrated */ }
// Uploads shared from devices (PWA share target): who sent it, and whether it
// is listed in everyone's search (admin uploads) or reachable only by link.
for (const col of ['owner TEXT', 'listed INTEGER NOT NULL DEFAULT 1']) {
@@ -294,6 +316,75 @@ export async function initDb() {
}
}
function lyricsSearchContent(value) {
try {
const data = typeof value === 'string' ? JSON.parse(value) : value;
return [...(data?.lines || []).flatMap(line => [line?.text, line?.secondary, line?.phonetic]), ...(data?.tags || [])]
.filter(Boolean).join(' ').slice(0, 500_000);
} catch { return ''; }
}
async function initLyricsSearch() {
await db.execute('CREATE TABLE IF NOT EXISTS lyrics_search_state (id INTEGER PRIMARY KEY CHECK(id=1), indexed_at INTEGER NOT NULL)');
try {
await db.execute("CREATE VIRTUAL TABLE IF NOT EXISTS lyrics_search_fts USING fts5(video_id UNINDEXED, content, tokenize='unicode61 remove_diacritics 2')");
lyricsFtsAvailable = true;
} catch {
await db.execute('CREATE TABLE IF NOT EXISTS lyrics_search_fts (video_id TEXT PRIMARY KEY, content TEXT NOT NULL)');
await db.execute('CREATE INDEX IF NOT EXISTS idx_lyrics_search_id ON lyrics_search_fts(video_id)');
lyricsFtsAvailable = false;
}
const state = await db.execute('SELECT id FROM lyrics_search_state WHERE id=1');
if (state.rows.length) return;
await db.execute('DELETE FROM lyrics_search_fts');
const rows = await db.execute("SELECT video_id,data FROM video_notes WHERE kind='lyrics'");
for (let i = 0; i < rows.rows.length; i += 100) {
const batch = rows.rows.slice(i, i + 100).map(row => ({
sql: lyricsFtsAvailable ? 'INSERT INTO lyrics_search_fts(video_id,content) VALUES(?,?)' : 'INSERT OR REPLACE INTO lyrics_search_fts(video_id,content) VALUES(?,?)',
args: [row.video_id, lyricsSearchContent(row.data)],
}));
if (batch.length) await db.batch(batch, 'write');
}
await db.execute('INSERT INTO lyrics_search_state(id,indexed_at) VALUES(1,unixepoch())');
}
export async function searchNoteLyrics(query, { limit = 30, offset = 0, hasChapters = false, withoutChapters = false } = {}) {
const words = String(query || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').match(/[\p{L}\p{N}_-]+/gu) || [];
if (!words.length) return [];
const boundedLimit = Math.max(1, Math.min(100, Number(limit) || 30));
const boundedOffset = Math.max(0, Math.min(1_000_000, Number(offset) || 0));
const chapterFilter = hasChapters ? "AND EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : withoutChapters ? "AND NOT EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : '';
if (lyricsFtsAvailable) {
const match = words.map(word => `"${word.replaceAll('"', '""')}"*`).join(' AND ');
const result = await db.execute({ sql: `SELECT video_id, snippet(lyrics_search_fts,1,char(1),char(2),' … ',18) AS snippet
FROM lyrics_search_fts WHERE lyrics_search_fts MATCH ? ${chapterFilter} ORDER BY bm25(lyrics_search_fts) LIMIT ? OFFSET ?`, args: [match, boundedLimit, boundedOffset] });
return result.rows.map(row => ({ videoId: row.video_id, snippet: row.snippet }));
}
const filters = words.map(() => 'content LIKE ?').join(' AND ');
const result = await db.execute({ sql: `SELECT video_id,substr(content,1,220) AS snippet FROM lyrics_search_fts WHERE ${filters} ORDER BY video_id LIMIT ? OFFSET ?`, args: [...words.map(word => `%${word}%`), boundedLimit, boundedOffset] });
return result.rows.map(row => ({ videoId: row.video_id, snippet: row.snippet }));
}
export async function countNoteLyrics(query, { hasChapters = false, withoutChapters = false } = {}) {
const words = String(query || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').match(/[\p{L}\p{N}_-]+/gu) || [];
if (!words.length) return 0;
const chapterFilter = hasChapters ? "AND EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : withoutChapters ? "AND NOT EXISTS (SELECT 1 FROM video_notes chapter WHERE chapter.video_id=lyrics_search_fts.video_id AND chapter.kind='chapters')" : '';
if (lyricsFtsAvailable) {
const match = words.map(word => `"${word.replaceAll('"', '""')}"*`).join(' AND ');
return Number((await db.execute({ sql: `SELECT COUNT(*) AS n FROM lyrics_search_fts WHERE lyrics_search_fts MATCH ? ${chapterFilter}`, args: [match] })).rows[0]?.n) || 0;
}
const filters = words.map(() => 'content LIKE ?').join(' AND ');
const result = await db.execute({ sql: `SELECT COUNT(*) AS n FROM lyrics_search_fts WHERE ${filters} ${chapterFilter}`, args: words.map(word => `%${word}%`) });
return Number(result.rows[0]?.n) || 0;
}
export async function rebuildLyricsSearch() {
await db.execute('DELETE FROM lyrics_search_fts');
await db.execute('DELETE FROM lyrics_search_state');
await initLyricsSearch();
return Number((await db.execute("SELECT COUNT(*) AS n FROM video_notes WHERE kind='lyrics'")).rows[0]?.n) || 0;
}
const YOUTUBE_ID = /^[\w-]{11}$/;
const folded = value => String(value || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
@@ -382,6 +473,11 @@ export async function saveNote({ videoId, kind, data, baseRev, source, updatedBy
VALUES (?, ?, ?, ?, ?, ?, unixepoch())`,
args: [videoId, kind, rev, json, source, updatedBy || null],
});
if (kind === 'lyrics') {
await tx.execute({ sql: 'DELETE FROM lyrics_search_fts WHERE video_id=?', args: [videoId] });
const content = lyricsSearchContent(data);
if (content) await tx.execute({ sql: 'INSERT INTO lyrics_search_fts(video_id,content) VALUES(?,?)', args: [videoId, content] });
}
await tx.commit();
return { ok: true, rev };
} catch (err) {
@@ -480,11 +576,15 @@ export async function listFlagsForVideo(videoId, status = 'open') {
}
// Newest first across every video; status 'all' returns both.
export async function listAllFlags({ status = 'open', limit = 200 } = {}) {
const where = status === 'all' ? '' : 'WHERE status = ?';
export async function listAllFlags({ status = 'open', limit = 200, olderThan = 0 } = {}) {
const filters = [];
const args = [];
if (status !== 'all') { filters.push('status = ?'); args.push(status); }
if (Number(olderThan) > 0) { filters.push('created_at <= ?'); args.push(Math.floor(Number(olderThan))); }
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
const r = await db.execute({
sql: `SELECT * FROM lyric_flags ${where} ORDER BY created_at DESC, id DESC LIMIT ?`,
args: status === 'all' ? [limit] : [status, limit],
args: [...args, limit],
});
return r.rows.map(flagRow);
}
@@ -570,6 +670,7 @@ const MEDIA_COLS = new Set([
'status', 'gen', 'size', 'height', 'vcodec', 'acodec', 'duration', 'optimized',
'meta', 'priority', 'auto', 'attempts', 'error', 'retry_at', 'created_at',
'updated_at', 'last_access', 'hits', 'sha256',
'pinned',
]);
function rowToObj(res, row) {
@@ -607,7 +708,7 @@ export async function listMedia() {
// Ready rows, least-recently-played first — the eviction order.
export async function listMediaLru() {
const r = await db.execute(
"SELECT video_id, size, last_access FROM media_cache WHERE status = 'ready' ORDER BY last_access ASC",
"SELECT video_id, size, last_access, pinned FROM media_cache WHERE status = 'ready' ORDER BY last_access ASC",
);
return r.rows.map((row) => rowToObj(r, row));
}

View File

@@ -3,6 +3,7 @@ import { db as defaultDb } from './db.js';
import * as catalog from './video-catalog.js';
import * as innertube from './innertube.js';
import * as searchCache from './search-cache.js';
import { audit } from './audit-log.js';
const MAX_SEEDS = 200, MAX_PLAN = 500, MAX_ERRORS = 50;
const clean = value => String(value || '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 200);
@@ -191,6 +192,7 @@ export function createDeepSearchService({ db = defaultDb, searchDeep = innertube
const options = { resultsPerQuery: Math.max(1, Math.min(200, Number(body.resultsPerQuery) || 50)), relatedDepth: Math.max(0, Math.min(2, Number(body.relatedDepth) || 0)), relatedTop: Math.max(1, Math.min(10, Number(body.relatedTop) || 3)), delayMs: Math.max(1000, Math.min(300000, (Number(body.delaySeconds) || 10) * 1000)), jitterMs: Math.max(0, Math.min(300000, (Number(body.jitterSeconds) || 3) * 1000)), dailyCap: Math.max(1, Math.min(10000, Number(body.dailyCap) || 1000)), channels: (Array.isArray(body.channels) ? body.channels : []).map(clean).filter(Boolean).slice(0, 25), playlists: (Array.isArray(body.playlists) ? body.playlists : []).map(clean).filter(Boolean).slice(0, 25) };
const id = randomUUID(), time = now();
await db.execute({ sql: "INSERT INTO deep_search_jobs(id,status,plan,options,state,created_at,updated_at) VALUES(?,'queued',?,?,?,?,?)", args: [id, JSON.stringify(plan), JSON.stringify(options), JSON.stringify({ queriesDone: 0, newVideos: 0, errors: [], log: [] }), time, time] });
await audit(db, 'admin', 'deep-search.start', id, { queries: plan.length });
if (autoStart) kick(); return c.json({ ok: true, id }, 202);
});
for (const action of ['pause', 'resume', 'cancel']) app.post(`/api/admin/deep-search/:id/${action}`, adminAuth, async c => {
@@ -199,6 +201,7 @@ export function createDeepSearchService({ db = defaultDb, searchDeep = innertube
const target = action === 'pause' ? 'paused' : action === 'resume' ? 'queued' : 'cancelled';
const marks = allowed.map(() => '?').join(',');
const r = await db.execute({ sql: `UPDATE deep_search_jobs SET status=?,updated_at=? WHERE id=? AND status IN (${marks})`, args: [target, now(), id, ...allowed] });
if (r.rowsAffected) await audit(db, 'admin', `deep-search.${action}`, id);
if (action === 'resume' && r.rowsAffected && autoStart) kick();
return c.json({ ok: !!r.rowsAffected, status: r.rowsAffected ? target : undefined }, r.rowsAffected ? 200 : 409);
});

View File

@@ -242,7 +242,7 @@ export function createMediaCache({
const order = db.listMediaEvictionOrder ? await db.listMediaEvictionOrder() : await db.listMediaLru();
for (const r of order) {
if (bytes + est <= maxBytes) break;
if (r.video_id === id || r.last_access > cutoff || jobs.has(r.video_id) || optActive === r.video_id) continue;
if (r.video_id === id || r.pinned || r.last_access > cutoff || jobs.has(r.video_id) || optActive === r.video_id) continue;
await evict(r.video_id, 'budget');
bytes -= r.size || 0;
}
@@ -735,5 +735,6 @@ export function createMediaCache({
return n;
}
return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId, backfillHashes, adoptFile, available };
return { init, ensureCached, redownload, verify, getReady, filePath, touch, status, stats, isMediaId, backfillHashes, adoptFile, available,
evictNow: id => evict(id, 'admin') };
}

View File

@@ -271,6 +271,21 @@ describe('media cache jobs', () => {
expect(await cache.getReady('lruAAAAAAA3')).not.toBeNull();
});
test('pinned copies are skipped while automatic LRU eviction selects another row', async () => {
await clearDb();
const size = readFileSync(fx('good.mp4')).length;
const { cache } = makeCache({ maxBytes: size * 2 + 60 * 250 * 1024 + 50_000, duration: 10 });
await cache.init();
await cache.ensureCached('pinAAAAAA01', { priority: HIGH });
await cache.ensureCached('pinAAAAAA02', { priority: HIGH });
await dbmod.upsertMedia('pinAAAAAA01', { last_access: 1000, pinned: 1 });
await dbmod.upsertMedia('pinAAAAAA02', { last_access: 2000 });
await cache.ensureCached('pinAAAAAA03', { priority: HIGH });
expect((await dbmod.getMedia('pinAAAAAA01')).pinned).toBe(1);
expect(await dbmod.getMedia('pinAAAAAA02')).toBeNull();
expect(await cache.getReady('pinAAAAAA03')).not.toBeNull();
});
test('eviction follows listMediaEvictionOrder when the db provides it', async () => {
await clearDb();
const size = readFileSync(fx('good.mp4')).length;

View File

@@ -44,6 +44,7 @@ import { join } from 'node:path';
import { getCookie, setCookie, deleteCookie } from 'hono/cookie';
import { registerAnalyticsRoutes } from './admin-analytics.js';
import { registerTranscriptionRoutes } from './transcriptions.js';
import { audit, registerAuditRoutes } from './audit-log.js';
export const NOTE_KINDS = new Set(['lyrics', 'chapters']);
// A YouTube id or one of the server's own uploads (see uploads.js).
@@ -507,6 +508,7 @@ export function registerNoteRoutes(app, deps) {
if (!r.lines.length) return c.json({ ok: false, error: 'this video has no usable captions' }, 404);
const data = sanitizeLyrics({ lines: r.lines.map((l) => ({ ...l, kind: 'line' })), tags: [], offset: 0 });
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: who.by, force: true });
await audit(db, who.by, 'lyrics.save.auto-captions', id, { rev: saved.rev, lines: data.lines.length });
return c.json({ ok: true, rev: saved.rev, lines: data.lines.length, lang: r.lang, auto: r.auto });
} catch (err) {
return c.json({ ok: false, error: err.message }, 502);
@@ -551,6 +553,7 @@ export function registerNoteRoutes(app, deps) {
offset: 0,
});
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: who.by, force: true });
await audit(db, who.by, 'lyrics.save.lrclib', id, { rev: saved.rev, lines: data.lines.length });
return c.json({ ok: true, rev: saved.rev, lines: data.lines.length, synced: hit.synced, match: { track: hit.meta.trackName, artist: hit.meta.artistName, duration: hit.meta.duration } });
} catch (err) {
return c.json({ ok: false, error: err.message }, 502);
@@ -578,6 +581,7 @@ export function registerNoteRoutes(app, deps) {
});
if (!r.ok) return c.json({ ok: false, error: 'someone else saved a newer version', conflict: true, current: r.current }, 409);
if (kind === 'lyrics') autoResolve(id, data);
await audit(db, who.by, `notes.save.${kind}`, id, { rev: r.rev, source: who.via });
return c.json({ ok: true, rev: r.rev, data });
} catch (err) {
return c.json({ ok: false, error: err.message }, 500);
@@ -658,11 +662,13 @@ export function registerNoteRoutes(app, deps) {
const token = newApiToken();
const id = randomBytes(6).toString('hex');
await db.createApiToken({ id, label, tokenHash: hashToken(token) });
await audit(db, 'admin', 'token.create', id, { label });
return c.json({ ok: true, id, label, token });
});
app.delete('/api/admin/tokens/:tid', requireAdmin, async (c) => {
const ok = await db.deleteApiToken(c.req.param('tid'));
if (ok) await audit(db, 'admin', 'token.revoke', c.req.param('tid'));
return ok ? c.json({ ok: true }) : c.json({ ok: false, error: 'not found' }, 404);
});
@@ -673,9 +679,11 @@ export function registerNoteRoutes(app, deps) {
await next();
};
registerAuditRoutes(app, { db, adminAuth: requireAdminOrToken });
if (deps.analytics) registerAnalyticsRoutes(app, { adminAuth: requireAdminOrToken, runYtdlp, ...deps.analytics });
registerTranscriptionRoutes(app, {
const transcriptionService = registerTranscriptionRoutes(app, {
db, adminAuth: requireAdminOrToken, sanitizeLyrics,
workerEnabled: !!workerToken && workerToken.length >= 24,
workerAuth: async (c, next) => {
@@ -707,7 +715,11 @@ export function registerNoteRoutes(app, deps) {
app.post('/api/admin/media/:id/redownload', requireAdminOrToken, async c => {
if (!media) return c.json({ ok: false, error: 'media cache unavailable' }, 503);
try { return c.json({ ok: true, ...(await media.redownload(c.req.param('id'))) }, 202); }
try {
const result = await media.redownload(c.req.param('id'));
await audit(db, 'admin', 'media.redownload', c.req.param('id'), result);
return c.json({ ok: true, ...result }, 202);
}
catch (err) { return c.json({ ok: false, error: err.message }, 400); }
});
@@ -727,6 +739,7 @@ export function registerNoteRoutes(app, deps) {
updatedBy: `admin (rev ${old.rev})`, force: true,
});
if (kind === 'lyrics') autoResolve(id, restored);
await audit(db, 'admin', `notes.restore.${kind}`, id, { fromRev: old.rev, newRev: r.rev });
return c.json({ ok: true, rev: r.rev });
});
@@ -749,12 +762,24 @@ export function registerNoteRoutes(app, deps) {
app.get('/api/admin/flags', requireAdminOrToken, async (c) => {
const status = ['open', 'resolved', 'all'].includes(c.req.query('status')) ? c.req.query('status') : 'open';
const olderThanDays = Math.max(0, Math.min(3650, Number(c.req.query('olderThanDays')) || 0));
const olderThan = olderThanDays ? Math.floor(Date.now() / 1000) - olderThanDays * 86400 : 0;
const cache = new Map();
const flags = [];
for (const f of await db.listAllFlags({ status, limit: 300 })) {
for (const f of await db.listAllFlags({ status, limit: 300, olderThan })) {
flags.push({ ...f, ...(await songLabel(f.videoId, cache)) });
}
return c.json({ ok: true, open: await db.countOpenFlags(), flags });
return c.json({ ok: true, open: await db.countOpenFlags(), flags, olderThanDays });
});
app.post('/api/admin/flags/song/:videoId/resolve', requireAdmin, async (c) => {
const videoId = c.req.param('videoId');
if (badId(videoId)) return c.json({ ok: false, error: 'invalid video id' }, 400);
const flags = await db.listFlagsForVideo(videoId, 'open');
let resolved = 0;
for (const flag of flags) if (await db.setFlagStatus(flag.id, 'resolved', 'admin')) resolved++;
if (resolved) await audit(db, 'admin', 'report.song.resolve', videoId, { resolved });
return c.json({ ok: true, resolved, open: await db.countOpenFlags() });
});
app.post('/api/admin/flags/:fid', requireAdmin, async (c) => {
@@ -763,11 +788,14 @@ export function registerNoteRoutes(app, deps) {
try { body = await c.req.json(); } catch { /* status required below */ }
if (!Number.isInteger(fid) || !['open', 'resolved'].includes(body.status)) return c.json({ ok: false, error: 'bad request' }, 400);
const ok = await db.setFlagStatus(fid, body.status, 'admin');
if (ok) await audit(db, 'admin', `report.${body.status}`, String(fid));
return ok ? c.json({ ok: true, open: await db.countOpenFlags() }) : c.json({ ok: false, error: 'not found' }, 404);
});
app.delete('/api/admin/flags/:fid', requireAdmin, async (c) => {
const ok = await db.deleteFlag(Number(c.req.param('fid')));
const id = Number(c.req.param('fid'));
const ok = await db.deleteFlag(id);
if (ok) await audit(db, 'admin', 'report.delete', String(id));
return ok ? c.json({ ok: true, open: await db.countOpenFlags() }) : c.json({ ok: false, error: 'not found' }, 404);
});
@@ -799,8 +827,36 @@ export function registerNoteRoutes(app, deps) {
console.warn('[notes] backup failed:', err.message);
}
}
async function pullLyricsForAdmin(id) {
if (badId(id)) return { ok: false, error: 'invalid video id' };
const existing = (await db.getNotes(id)).lyrics;
if (existing?.data?.lines?.length) return { ok: false, error: 'lyrics already exist; clear or edit them before pulling LRCLIB', rev: existing.rev };
let meta = {}, row = null;
if (id.startsWith('upl_')) {
const upload = await db.getUpload(id);
if (upload) meta = { title: upload.title, channel: upload.artist, album: upload.album, duration: upload.duration };
} else {
row = await db.getMedia(id);
try { meta = JSON.parse(row?.meta || '{}'); } catch {}
try {
const catalogRow = (await (db.db || db).execute({ sql: 'SELECT card FROM video_meta WHERE id=?', args: [id] })).rows[0];
meta = { ...JSON.parse(catalogRow?.card || '{}'), ...meta };
} catch {}
}
const title = cleanTitle(meta.title || ''), artist = cleanArtist(meta.channel || meta.artist || meta.uploader || ''), duration = Number(row?.duration || meta.duration || 0) || 0;
if (!title) return { ok: false, error: 'no title known for this video' };
const hit = await lrclibLookup({ title, artist, album: meta.album, duration });
if (!hit) return { ok: false, error: `no lyrics on LRCLIB for “${title}”${artist ? ` by ${artist}` : ''}` };
const data = sanitizeLyrics({ lines: hit.lines, tags: [hit.synced ? 'from LRCLIB (synced)' : 'from LRCLIB (plain text)'], offset: 0 });
const saved = await db.saveNote({ videoId: id, kind: 'lyrics', data, source: 'auto', updatedBy: 'admin bulk LRCLIB', force: true });
await db.autoResolveFlags(id, new Set(data.lines.map(line => line.text)));
await audit(db, 'admin', 'lyrics.save.lrclib', id, { rev: saved.rev, lines: data.lines.length, bulk: true });
return { ok: true, rev: saved.rev, lines: data.lines.length, synced: hit.synced };
}
return {
requireAdminOrToken,
pullLyricsForAdmin,
queueWhisperForAdmin: transcriptionService.enqueue,
startBackups() {
setTimeout(writeBackup, 60_000);
setInterval(writeBackup, 24 * 3600_000).unref?.();

View File

@@ -284,3 +284,37 @@ test('admin song list includes metadata-only phone songs for lyrics lookup', asy
expect((await app.request(`/api/admin/media/${id}/redownload`, { method: 'POST', headers: { Cookie: cookie } })).status).toBe(202);
expect((await app.request('/api/admin/media/phoneSong01/redownload', { method: 'POST' })).status).toBe(401);
});
test('Whisper enqueue reports a full queue without depending on request context', async () => {
await dbmod.upsertMedia(VID, { status: 'ready', duration: 60 });
const activeIds = Array.from({ length: 25 }, (_, index) => `QF${String(index).padStart(9, '0')}`);
const now = Math.floor(Date.now() / 1000);
await dbmod.db.execute({
sql: `INSERT INTO lyric_transcriptions(id,video_id,status,base_rev,created_at,updated_at) VALUES ${activeIds.map(() => '(?,? ,\'queued\',0,?,?)').join(',')}`,
args: activeIds.flatMap((id, index) => [String(index), id, now, now]),
});
try {
const cookie = await adminCookie();
const response = await app.request(`/api/admin/transcriptions/${VID}`, json('POST', {}, { Cookie: cookie }));
expect(response.status).toBe(429);
expect(await response.json()).toMatchObject({ ok: false, error: 'The transcription queue is full. Try again after a job finishes.' });
} finally {
await dbmod.db.execute({ sql: `DELETE FROM lyric_transcriptions WHERE video_id IN (${activeIds.map(() => '?').join(',')})`, args: activeIds });
await dbmod.deleteMedia(VID);
}
});
test('report triage filters aging flags and resolves every open report for one song', async () => {
const id = VID;
const a = await dbmod.upsertFlag({ videoId: id, index: 0, text: 'Flagged line', rev: 1, reason: 'words', note: '', reporter: 'listener-a' });
const b = await dbmod.upsertFlag({ videoId: id, index: 0, text: 'Flagged line', rev: 1, reason: 'typo', note: '', reporter: 'listener-b' });
await dbmod.db.execute({ sql: 'UPDATE lyric_flags SET created_at=? WHERE id IN (?,?)', args: [Math.floor(Date.now() / 1000) - 10 * 86400, a.id, b.id] });
const cookie = await adminCookie();
const aged = await (await app.request('/api/admin/flags?status=open&olderThanDays=7', { headers: { Cookie: cookie } })).json();
expect(aged.flags.filter(flag => flag.videoId === id)).toHaveLength(2);
const unauthorized = await app.request(`/api/admin/flags/song/${id}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
expect(unauthorized.status).toBe(401);
const response = await app.request(`/api/admin/flags/song/${id}/resolve`, json('POST', {}, { Cookie: cookie }));
expect(await response.json()).toMatchObject({ ok: true, resolved: 2 });
expect((await dbmod.listFlagsForVideo(id, 'open'))).toHaveLength(0);
});

View File

@@ -243,7 +243,7 @@ export async function p2pStats() {
// only then the qualifying ones, least recently played first.
export async function listMediaEvictionOrder({ now, keepMinViews, keepDays, keepRecentDays }) {
const r = await db.execute({
sql: `SELECT m.video_id, m.size, m.last_access,
sql: `SELECT m.video_id, m.size, m.last_access, m.pinned,
COALESCE((SELECT SUM(v.n) FROM video_views v
WHERE v.video_id = m.video_id AND v.day >= ?), 0) AS views
FROM media_cache m WHERE m.status = 'ready'`,

View File

@@ -6,7 +6,7 @@
"scripts": {
"start": "bun server.js",
"dev": "bun --hot server.js",
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./asset-manifest.test.js && bun test ./static-delivery.test.js && bun test ./video-catalog.test.js && bun test ./search-cache.test.js && bun test --timeout 60000 ./deep-search.test.js"
"test": "bun test ./recommendations.test.js && bun test --timeout 60000 ./media-cache.test.js && bun test ./notes.test.js && bun test ./audit-log.test.js && bun test ./admin-tools.test.js && bun test ./transcriptions.test.js && bun test ./admin-analytics.test.js && bun test ./remote.test.js && bun test ./party.test.js && bun test ./uploads.test.js && bun test ./innertube.test.js && bun test ./related.test.js && bun test ./ytdlp-pool.test.js && bun test ./warm-queue.test.js && bun test ./p2p-db.test.js && bun test ./p2p-admit.test.js && bun test ./p2p-retention.test.js && bun test ./p2p-routes.test.js && bun test ./p2p-hub.test.js && bun test --timeout 60000 ./p2p-intake.test.js && bun test ./flags.test.js && bun test ./asset-manifest.test.js && bun test ./static-delivery.test.js && bun test ./video-catalog.test.js && bun test ./search-cache.test.js && bun test --timeout 60000 ./deep-search.test.js"
},
"dependencies": {
"@hono/node-server": "^1.14.0",

View File

@@ -47,6 +47,7 @@ import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getP
import { createMediaCache, HIGH, LOW, validateMedia, MediaSkip } from './media-cache.js';
import * as notesDb from './db.js';
import { registerNoteRoutes, parseLrc, sanitizeLyrics } from './notes.js';
import { registerAdminToolsRoutes } from './admin-tools.js';
import { createRemoteHub } from './remote.js';
import { createPartyHub } from './party.js';
import { registerMediaMetaRoutes } from './media-meta.js';
@@ -63,7 +64,7 @@ import { sha256Range } from './hash.js';
import * as innertube from './innertube.js';
import { createRelatedService, registerRelatedRoute } from './related.js';
import * as searchCacheDb from './search-cache.js';
import { ingest as collectVideoMetadata, syncListening, linkListening, startThumbnails } from './video-catalog.js';
import { ingest as collectVideoMetadata, syncListening, linkListening, startThumbnails, backfillStep } from './video-catalog.js';
import { registerCatalogRoutes } from './recommendations.js';
import QRCode from 'qrcode';
import { createYtdlpPool } from './ytdlp-pool.js';
@@ -2317,6 +2318,9 @@ const notes = registerNoteRoutes(app, {
const deepSearch = registerDeepSearchRoutes(app, { db: notesDb, adminAuth: notes.requireAdminOrToken, autoStart: false,
searchDeep: innertube.searchDeep, related: innertube.related, browseUploads: innertube.browseUploads,
ingest: collectVideoMetadata, putSearch: searchCacheDb.put });
registerAdminToolsRoutes(app, { db: notesDb, adminAuth: notes.requireAdminOrToken, media,
backupDir: process.env.UPLOAD_BACKUP_DIR || pathJoin(dirname(process.env.DB_PATH || './data/ytplayer.db'), 'backups'), backfillStep,
pullLyrics: notes.pullLyricsForAdmin, queueWhisper: notes.queueWhisperForAdmin });
// ============================================================================
// Uploads — the server's own searchable media library (see uploads.js)

View File

@@ -1,6 +1,7 @@
// Explicit Whisper requests produce reviewable drafts, never published notes.
import { randomUUID } from 'node:crypto';
import { db as sql } from './db.js';
import { audit } from './audit-log.js';
const ID = /^([\w-]{11}|upl_[a-f0-9]{12})$/;
const LEASE_MS = 120000;
function publicJob(row) {
@@ -16,25 +17,30 @@ export function registerTranscriptionRoutes(app, { db, adminAuth, workerAuth, wo
if (id.startsWith('upl_')) return db.getUpload(id);
const row = await db.getMedia(id); return row?.status === 'ready' ? row : null;
}
app.post('/api/admin/transcriptions/:video', adminAuth, async c => {
const id = c.req.param('video');
if (!ID.test(id)) return c.json({ ok: false, error: 'invalid video id' }, 400);
if (!workerEnabled) return c.json({ ok: false, error: 'Whisper is unavailable: the lyrics worker is not configured.' }, 503);
async function enqueue(videoId) {
const id = String(videoId || '');
if (!ID.test(id)) return { status: 400, body: { ok: false, error: 'invalid video id' } };
if (!workerEnabled) return { status: 503, body: { ok: false, error: 'Whisper is unavailable: the lyrics worker is not configured.' } };
const audio = await source(id);
if (!audio) return c.json({ ok: false, error: 'Save this video on the server before transcribing its audio.' }, 409);
if (Number(audio.duration) > 3600) return c.json({ ok: false, error: 'Whisper requests are limited to one hour of audio.' }, 422);
if (!audio) return { status: 409, body: { ok: false, error: 'Save this video on the server before transcribing its audio.' } };
if (Number(audio.duration) > 3600) return { status: 422, body: { ok: false, error: 'Whisper requests are limited to one hour of audio.' } };
const notes = await db.getNotes(id);
// One active job per song and a bounded queue, including across restarts.
const existing = (await sql.execute({ sql: "SELECT * FROM lyric_transcriptions WHERE video_id=? AND status IN ('queued','running')", args: [id] })).rows[0];
if (existing) return c.json({ ok: true, job: publicJob(existing) });
if (existing) return { status: 200, body: { ok: true, job: publicJob(existing) } };
const active = Number((await sql.execute("SELECT COUNT(*) AS n FROM lyric_transcriptions WHERE status IN ('queued','running')")).rows[0].n);
if (active >= 25) return c.json({ ok: false, error: 'The transcription queue is full. Try again after a job finishes.' }, 429);
if (active >= 25) return { status: 429, body: { ok: false, error: 'The transcription queue is full. Try again after a job finishes.' } };
const time = now();
await sql.execute({ sql: `INSERT OR IGNORE INTO lyric_transcriptions (id,video_id,base_rev,created_at,updated_at)
SELECT ?,?,?,?,? WHERE (SELECT COUNT(*) FROM lyric_transcriptions WHERE status IN ('queued','running')) < 25`,
args: [randomUUID(), id, notes.lyrics?.rev || 0, time, time] });
const row = (await sql.execute({ sql: "SELECT * FROM lyric_transcriptions WHERE video_id=? AND status IN ('queued','running')", args: [id] })).rows[0];
return row ? c.json({ ok: true, job: publicJob(row) }, 202) : c.json({ ok: false, error: 'The transcription queue is full.' }, 429);
if (row) await audit(db, 'admin', 'lyrics.whisper.enqueue', id, { jobId: row.id });
return row ? { status: 202, body: { ok: true, job: publicJob(row) } } : { status: 429, body: { ok: false, error: 'The transcription queue is full.' } };
}
app.post('/api/admin/transcriptions/:video', adminAuth, async c => {
const result = await enqueue(c.req.param('video'));
return c.json(result.body, result.status);
});
app.get('/api/admin/transcriptions/:video', adminAuth, async c => {
const id = c.req.param('video');
@@ -76,4 +82,5 @@ export function registerTranscriptionRoutes(app, { db, adminAuth, workerAuth, wo
await sql.execute({ sql: "DELETE FROM lyric_transcriptions WHERE status IN ('complete','failed') AND updated_at<?", args: [time - 30 * 86400000] });
return c.json({ ok: true });
});
return { enqueue };
}

27
tests/admin-audit.spec.js Normal file
View File

@@ -0,0 +1,27 @@
const { test, expect } = require('@playwright/test');
test('audit tab filters, pages and links filtered exports', async ({ page }) => {
const requests = [];
await page.route('**/api/**', async route => {
const url = new URL(route.request().url()); requests.push(url.pathname + url.search);
let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
if (url.pathname === '/api/admin/audit') body = {
ok: true, total: 62, offset: Number(url.searchParams.get('offset')) || 0,
events: [{ id: 9, ts: 1791900000, actor: 'admin', action: 'notes.save.lyrics', target: 'dQw4w9WgXcQ', detail: { rev: 4 } }],
};
if (url.pathname === '/api/admin/flags') body = { ok: true, open: 0, flags: [] };
if (url.pathname === '/api/admin/media') body = { ok: true, media: [] };
if (url.pathname === '/api/uploads') body = { ok: true, uploads: [] };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.goto('/admin.html#audit');
await expect(page.locator('#p-audit')).toBeVisible();
await expect(page.locator('#auditTable')).toContainText('notes.save.lyrics');
await page.locator('#auditActor').fill('admin'); await page.locator('#auditAction').fill('lyrics');
await page.locator('#auditFilterForm button[type=submit]').click();
await expect.poll(() => requests.filter(x => x.startsWith('/api/admin/audit?')).length).toBeGreaterThan(1);
await expect(page.locator('#auditJson')).toHaveAttribute('href', /actor=admin.*action=lyrics.*format=json/);
await page.locator('#auditNext').click();
await expect(page.locator('#auditCount')).toContainText('51–');
});

View File

@@ -0,0 +1,25 @@
const { test, expect } = require('@playwright/test');
test('backups lists integrity, previews diff, creates a snapshot and confirms restore', async ({ page }) => {
const calls = [];
await page.route('**/api/**', async route => {
const request = route.request(), url = new URL(request.url()); calls.push({ path: url.pathname, method: request.method() });
let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
if (url.pathname === '/api/admin/backups' && request.method() === 'GET') body = { ok: true, backups: [{ date: '2026-10-14', bytes: 1024, notes: 12, valid: true }] };
if (url.pathname === '/api/admin/backups/2026-10-14') body = { ok: true, diff: { changed: 2, added: 1, removed: 0, preview: [{ key: 'song:lyrics', change: 'changed' }] } };
if (url.pathname === '/api/admin/backups' && request.method() === 'POST') body = { ok: true, date: '2026-10-14', notes: 12 };
if (url.pathname.endsWith('/restore')) body = { ok: true, restored: 12, safetySnapshot: '/data/backups/snapshots/safety.json' };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
page.on('dialog', dialog => dialog.accept());
await page.goto('/admin.html#backups');
await expect(page.locator('#backupTable')).toContainText('Verified');
await expect(page.locator('#backupDiff')).toContainText('2 changed');
await expect(page.locator('a[href="/api/admin/backups/export/sqlite-dump"]')).toBeVisible();
await page.locator('#backupNow').click();
await expect(page.locator('#backupStatus')).toContainText('Saved 12 notes');
await page.locator('#backupRestore').click();
await expect.poll(() => calls.some(call => call.path.endsWith('/restore') && call.method === 'POST')).toBe(true);
await expect(page.locator('#backupStatus')).toContainText('Safety snapshot');
});

View File

@@ -0,0 +1,43 @@
const { test, expect } = require('@playwright/test');
test('library selection queues LRCLIB, Whisper, LRC export and revision-backed clear jobs', async ({ page }) => {
const calls = []; let latestJob = null;
await page.route('**/api/**', async route => {
const request = route.request(), url = new URL(request.url());
let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
if (url.pathname === '/api/uploads') body = { ok: true, uploads: [] };
if (url.pathname === '/api/admin/lyrics/bulk/library') body = { ok: true, total: 2, limit: 50, offset: 0, songs: [
{ id: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', lines: 4, cacheStatus: 'ready' },
{ id: 'jNQXAC9IVRw', title: 'Saved song', channel: 'Band', lines: 0, cacheStatus: 'failed' },
] };
if (url.pathname === '/api/admin/lyrics/jobs' && request.method() === 'GET') body = { ok: true, jobs: latestJob ? [latestJob] : [] };
if (url.pathname === '/api/admin/lyrics/jobs' && request.method() === 'POST') {
const payload = request.postDataJSON(); calls.push(payload);
latestJob = { id: `job-${calls.length}`, action: payload.action, status: 'complete', state: { done: payload.ids?.length || 2, total: payload.ids?.length || 2, results: [{ id: 'dQw4w9WgXcQ', ok: true, message: 'Done' }], fileReady: payload.action === 'export' } };
body = { ok: true, job: latestJob };
}
if (url.pathname === '/api/admin/lyrics/jobs/job-1/pause' || url.pathname.endsWith('/resume') || url.pathname.endsWith('/cancel')) body = { ok: true, status: 'paused' };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
page.on('dialog', dialog => dialog.accept());
await page.goto('/admin.html#library');
await expect(page.locator('#bulkLibraryTable')).toContainText('Amazing grace');
await page.locator('#bulkSelectAll').check(); await page.locator('#bulkAction').selectOption('lrclib'); await page.locator('#bulkStart').click();
await expect.poll(() => calls.length).toBe(1);
expect(calls[0]).toMatchObject({ action: 'lrclib', allFiltered: true, query: '' });
await page.locator('#bulkLibraryTable [data-bulk-song="dQw4w9WgXcQ"]').check();
await page.locator('#bulkAction').selectOption('whisper'); await page.locator('#bulkStart').click();
await expect.poll(() => calls.length).toBe(2); expect(calls[1].action).toBe('whisper');
await page.locator('#bulkLibraryTable [data-bulk-song="jNQXAC9IVRw"]').check();
await page.locator('#bulkAction').selectOption('export'); await page.locator('#bulkStart').click();
await expect.poll(() => calls.length).toBe(3); expect(calls[2].action).toBe('export');
await expect(page.locator('#bulkJobs a[href*="/export"]')).toBeVisible();
await page.locator('#bulkLibraryTable [data-bulk-song="dQw4w9WgXcQ"]').check();
await page.locator('#bulkAction').selectOption('clear'); await page.locator('#bulkStart').click();
await expect.poll(() => calls.length).toBe(4); expect(calls[3]).toMatchObject({ action: 'clear', confirmClear: true });
});

25
tests/admin-cache.spec.js Normal file
View File

@@ -0,0 +1,25 @@
const { test, expect } = require('@playwright/test');
test('cache actions pin, unpin, confirm eviction and request a source re-download', async ({ page }) => {
const state = { pinned: false, evicted: false, redownloaded: false };
await page.route('**/api/**', async route => {
const req = route.request(), url = new URL(req.url()); let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { enabled: true, admin: true };
else if (url.pathname === '/api/admin/cache/health') body = { ok: true, total: 1, offset: 0, budget: 1000, pinnedBudget: 900, bytes: 950, pinnedBytes: state.pinned ? 700 : 0, utilization: .95, statuses: [{ status: 'ready', count: 1, bytes: 700 }, { status: 'queued', count: 2, bytes: 100 }, { status: 'downloading', count: 3, bytes: 150 }, { status: 'failed', count: 1, bytes: 0 }], media: [{ id: 'CacheItem001', status: 'ready', bytes: 700, lastAccess: 1791900000000, gen: 4, pinned: state.pinned }] };
else if (url.pathname === '/api/admin/cache/CacheItem001/pin' && req.method() === 'POST') { state.pinned = req.postDataJSON().pinned; body.pinned = state.pinned; }
else if (url.pathname === '/api/admin/cache/CacheItem001/evict' && req.method() === 'POST') state.evicted = true;
else if (url.pathname === '/api/admin/media/CacheItem001/redownload' && req.method() === 'POST') state.redownloaded = true;
else if (url.pathname === '/api/admin/media') body.media = [];
else if (url.pathname === '/api/uploads') body.uploads = [];
else if (url.pathname === '/api/admin/flags') body.flags = [];
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.goto('/admin.html#cache');
await expect(page.locator('#cacheTable')).toContainText('CacheItem001');
await expect(page.locator('#cacheMetrics')).toContainText('5 / 1');
await page.locator('[data-cache-pin]').click(); await expect(page.locator('[data-cache-pin]')).toHaveText('Unpin');
await page.locator('[data-cache-pin]').click(); await expect(page.locator('[data-cache-pin]')).toHaveText('Pin');
page.once('dialog', dialog => dialog.accept()); await page.locator('[data-cache-evict]').click();
page.once('dialog', dialog => dialog.accept()); await page.locator('[data-cache-redownload]').click();
expect(state).toEqual({ pinned: false, evicted: true, redownloaded: true });
});

22
tests/admin-index.spec.js Normal file
View File

@@ -0,0 +1,22 @@
const { test, expect } = require('@playwright/test');
test('search index tab shows source coverage and runs bounded repair actions', async ({ page }) => {
const actions = [];
await page.route('**/api/**', async route => {
const req = route.request(), url = new URL(req.url()); let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { enabled: true, admin: true };
else if (url.pathname === '/api/admin/index/health') body = { ok: true, total: 100, urlCount: 90, detailCount: 40, thumbnailCount: 75, urlCoverage: .9, detailCoverage: .4, thumbnailCoverage: .75, sources: [{ source: 'deep', videos: 70, discoveries: 300, lastSeen: 1791900000000 }], lastRun: { status: 'complete', createdAt: 1791900000000, progress: { queriesDone: 20, newVideos: 400 } }, backfill: [{ source: 'media_cache', cursor: 10, ceiling: 20, done: false }] };
else if (url.pathname === '/api/admin/index/backfill' && req.method() === 'POST') { actions.push('backfill'); body.processed = 3; }
else if (url.pathname === '/api/admin/index/cleanup' && req.method() === 'POST') { actions.push('cleanup'); body.removed = 2; }
else if (url.pathname === '/api/admin/media') body.media = [];
else if (url.pathname === '/api/uploads') body.uploads = [];
else if (url.pathname === '/api/admin/flags') body.flags = [];
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.goto('/admin.html#index');
await expect(page.locator('#indexMetrics')).toContainText('90%');
await expect(page.locator('#indexSources')).toContainText('deep');
await page.locator('#indexBackfill').click(); await expect(page.locator('#indexStatus')).toContainText('Processed 3');
page.once('dialog', dialog => dialog.accept()); await page.locator('#indexCleanup').click(); await expect(page.locator('#indexStatus')).toContainText('Removed 2');
expect(actions).toEqual(['backfill', 'cleanup']);
});

View File

@@ -0,0 +1,28 @@
const { test, expect } = require('@playwright/test');
test('lyrics full-text search highlights matches, filters, rebuilds and opens the editor', async ({ page }) => {
const requests = [];
await page.route('**/api/**', async route => {
const request = route.request(), url = new URL(request.url()); requests.push({ path: url.pathname, query: url.search, method: request.method() });
let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
if (url.pathname === '/api/admin/lyrics/search' && request.method() === 'GET') body = { ok: true, query: url.searchParams.get('q') || '', hasLyrics: 'yes', hasChapters: 'all', total: 1, limit: 30, offset: 0, results: [{ videoId: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', snippet: '\u0001Amazing grace\u0002 will lead me home', hasChapters: true, rev: 2 }] };
if (url.pathname === '/api/admin/lyrics/search/rebuild') body = { ok: true, indexed: 42 };
if (url.pathname === '/api/notes/dQw4w9WgXcQ') body = { ok: true, lyrics: { rev: 2, data: { lines: [{ t: 1, text: 'Amazing grace', kind: 'line' }] } }, chapters: null };
if (url.pathname === '/api/streams') body = { ok: true, data: { meta: { title: 'Amazing grace', duration: 30 }, qualities: [] } };
if (url.pathname === '/api/admin/flags') body = { ok: true, open: 0, flags: [] };
if (url.pathname.startsWith('/api/admin/transcriptions/')) body = { ok: true, job: null };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.goto('/admin.html#lyric-search');
await expect(page.locator('#lyricsSearchTable')).toContainText('Amazing grace');
await expect(page.locator('#lyricsSearchTable mark')).toHaveText('Amazing grace');
await page.locator('#lyricsSearchQuery').fill('amazing grace'); await page.locator('#lyricsSearchHasChapters').selectOption('yes');
await page.locator('#lyricsSearchForm button[type=submit]').click();
await expect.poll(() => requests.some(item => item.path === '/api/admin/lyrics/search' && item.query.includes('hasChapters=yes'))).toBe(true);
await page.locator('#lyricsSearchRebuild').click();
await expect.poll(() => requests.some(item => item.path === '/api/admin/lyrics/search/rebuild' && item.method === 'POST')).toBe(true);
await expect(page.locator('#lyricsSearchTable')).toContainText('Amazing grace');
await page.locator('[data-lyrics-search-open="dQw4w9WgXcQ"]').click();
await expect(page.locator('#songTitle')).toHaveText('Amazing grace');
});

View File

@@ -0,0 +1,26 @@
const { test, expect } = require('@playwright/test');
test('lyrics queue exposes issue filters and reuses LRCLIB and Whisper routes', async ({ page }) => {
const calls = [];
await page.route('**/api/**', async route => {
const req = route.request(), url = new URL(req.url()); calls.push({ path: url.pathname, method: req.method() });
let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { enabled: true, admin: true };
if (url.pathname === '/api/admin/lyrics/quality') body = { ok: true, total: 1, offset: 0, songs: [{ id: 'QualMiss001', title: 'Missing lyric sample', channel: 'Choir', lines: 0, reasons: ['missing'], openReports: 0 }] };
if (url.pathname === '/api/notes/QualMiss001/lyrics/web') body = { ok: true, rev: 2, lines: 12 };
if (url.pathname === '/api/admin/transcriptions/QualMiss001') body = { ok: true, job: { status: 'queued' } };
if (url.pathname === '/api/admin/media') body = { ok: true, media: [] };
if (url.pathname === '/api/uploads') body = { ok: true, uploads: [] };
if (url.pathname === '/api/admin/flags') body = { ok: true, open: 0, flags: [] };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.goto('/admin.html#quality');
await expect(page.locator('#p-quality')).toBeVisible();
await expect(page.locator('#qualityTable')).toContainText('Missing lyric sample');
await page.locator('#qualityCategory').selectOption('untimed'); await page.locator('#qualityFilterForm button').click();
await expect(page.locator('#qualityTable')).toContainText('Pull LRCLIB');
await page.locator('[data-quality-pull]').click(); await expect(page.locator('#qualityStatus')).toContainText('revision 2');
await page.locator('[data-quality-whisper]').click(); await expect(page.locator('#qualityStatus')).toContainText('Whisper queued');
expect(calls.some(x => x.path.endsWith('/lyrics/web') && x.method === 'POST')).toBe(true);
expect(calls.some(x => x.path.endsWith('/transcriptions/QualMiss001') && x.method === 'POST')).toBe(true);
});

View File

@@ -0,0 +1,25 @@
const { test, expect } = require('@playwright/test');
test('report triage filters age and resolves a song batch with confirmation', async ({ page }) => {
const requests = [];
await page.route('**/api/**', async route => {
const request = route.request(); const url = new URL(request.url()); requests.push({ path: url.pathname, search: url.search, method: request.method() });
let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
if (url.pathname === '/api/admin/flags' && request.method() === 'GET') body = { ok: true, open: 2, flags: [
{ id: 1, videoId: 'dQw4w9WgXcQ', title: 'Sample song', channel: 'Choir', text: 'Flagged line', reporter: 'Listener', reason: 'words', status: 'open', createdAt: 1791300000, note: 'Wrong lyric' },
{ id: 2, videoId: 'dQw4w9WgXcQ', title: 'Sample song', channel: 'Choir', text: 'Flagged line', reporter: 'Second listener', reason: 'typo', status: 'open', createdAt: 1791300000, note: '' },
] };
if (url.pathname.endsWith('/resolve') && request.method() === 'POST') body = { ok: true, resolved: 2, open: 0 };
if (url.pathname === '/api/notes/dQw4w9WgXcQ') body = { ok: true, lyrics: null, chapters: null };
if (url.pathname === '/api/admin/notes/recent') body = { ok: true, notes: [] };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
page.on('dialog', dialog => dialog.accept());
await page.goto('/admin.html#reports');
await expect(page.locator('#repList')).toContainText('Second listener');
await page.locator('#reportAge').selectOption('30');
await expect.poll(() => requests.some(r => r.path === '/api/admin/flags' && r.search.includes('olderThanDays=30'))).toBe(true);
await page.locator('[data-resolve-song="dQw4w9WgXcQ"]').click();
await expect.poll(() => requests.some(r => r.path === '/api/admin/flags/song/dQw4w9WgXcQ/resolve' && r.method === 'POST')).toBe(true);
});

View File

@@ -0,0 +1,71 @@
const { test, expect } = require('@playwright/test');
const { mkdirSync } = require('node:fs');
const path = require('node:path');
function contrast(foreground, background) {
const luminance = color => {
const channels = color.match(/[\d.]+/g).slice(0, 3).map(Number).map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2);
return channels[0] * .2126 + channels[1] * .7152 + channels[2] * .0722;
};
const a = luminance(foreground), b = luminance(background);
return (Math.max(a, b) + .05) / (Math.min(a, b) + .05);
}
test('capture the new admin tools screens for visual review', async ({ page }) => {
const dir = path.resolve('plans/admin-tools-shots'); mkdirSync(dir, { recursive: true });
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.route('**/api/**', async route => {
const request = route.request(), url = new URL(request.url()); let body = { ok: true };
if (url.pathname === '/api/admin/me') body = { ok: true, enabled: true, admin: true };
if (url.pathname === '/api/uploads') body.uploads = [];
if (url.pathname === '/api/admin/media') body.media = [];
if (url.pathname === '/api/admin/flags') body = { ok: true, open: 1, flags: [{ id: 1, videoId: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', text: 'Grace will lead me home', reporter: 'listener', reason: 'words', status: 'open', createdAt: 1791300000, note: 'One word is wrong' }] };
if (url.pathname === '/api/admin/lyrics/quality') body = { ok: true, total: 1, offset: 0, limit: 50, songs: [{ id: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', lines: 4, source: 'auto', untimed: true, openReports: 1, reasons: ['automatic', 'untimed', 'reports'] }] };
if (url.pathname === '/api/admin/lyrics/search') body = { ok: true, query: '', hasLyrics: 'yes', hasChapters: 'all', total: 1, limit: 30, offset: 0, results: [{ videoId: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', snippet: '\u0001Grace will lead\u0002 me home', hasChapters: true, rev: 2 }] };
if (url.pathname === '/api/admin/cache/health') body = { ok: true, total: 2, offset: 0, budget: 10_000_000, pinnedBudget: 2_000_000, bytes: 4_800_000, pinnedBytes: 1_200_000, utilization: .48, statuses: [{ status: 'ready', count: 1, bytes: 4_000_000 }, { status: 'queued', count: 1, bytes: 800_000 }, { status: 'failed', count: 1, bytes: 0 }], media: [{ id: 'dQw4w9WgXcQ', status: 'ready', bytes: 4_000_000, lastAccess: 1791900000000, gen: 4, pinned: true }] };
if (url.pathname === '/api/admin/index/health') body = { ok: true, total: 1200, urlCount: 1190, detailCount: 980, thumbnailCount: 850, urlCoverage: .99, detailCoverage: .82, thumbnailCoverage: .71, sources: [{ source: 'deep', videos: 600, discoveries: 940, lastSeen: 1791900000 }, { source: 'search', videos: 500, discoveries: 1200, lastSeen: 1791800000 }], lastRun: { id: 'run-1', status: 'complete', createdAt: 1791800000, updatedAt: 1791900000, progress: { queriesDone: 160, newVideos: 340 } }, backfill: [{ source: 'history', cursor: 840, ceiling: 1000, done: false }] };
if (url.pathname === '/api/admin/backups') body = { ok: true, backups: [{ date: '2026-10-14', bytes: 20480, notes: 124, valid: true }] };
if (url.pathname === '/api/admin/backups/2026-10-14') body = { ok: true, date: '2026-10-14', total: 124, diff: { added: 2, removed: 1, changed: 3, preview: [{ key: 'dQw4w9WgXcQ:lyrics', change: 'changed' }] } };
if (url.pathname === '/api/admin/audit') body = { ok: true, total: 42, offset: 0, events: [{ id: 8, ts: 1791900000, actor: 'admin', action: 'lyrics.save', target: 'dQw4w9WgXcQ', detail: { rev: 4, lines: 22 } }] };
if (url.pathname === '/api/admin/lyrics/bulk/library') body = { ok: true, total: 1, limit: 50, offset: 0, songs: [{ id: 'dQw4w9WgXcQ', title: 'Amazing grace', channel: 'Choir', lines: 22, rev: 4, cacheStatus: 'ready' }] };
if (url.pathname === '/api/admin/lyrics/jobs') body = { ok: true, jobs: [{ id: 'bulk-1', action: 'lrclib', status: 'running', state: { done: 3, total: 12, results: [{ id: 'dQw4w9WgXcQ', ok: true, message: 'Saved revision 4' }], logs: [] } }] };
if (url.pathname === '/api/admin/notes/recent') body = { ok: true, revs: [] };
if (url.pathname === '/api/admin/analytics') body = { ok: true };
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) });
});
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/admin.html#editor');
for (const tab of ['quality', 'lyric-search', 'cache', 'index', 'reports', 'backups', 'audit', 'library']) {
await page.locator(`#tabsTop [data-tab="${tab}"]`).click();
await expect(page.locator(`#p-${tab}`)).toBeVisible();
await page.waitForTimeout(100);
await page.screenshot({ path: path.join(dir, `${tab}.png`), fullPage: true });
}
for (const theme of ['dark', 'light', 'contrast']) {
await page.evaluate(theme => { document.documentElement.dataset.editorTheme = theme; }, theme);
for (const width of [390, 1280]) {
await page.setViewportSize({ width, height: 844 });
if (width === 390) {
const nav = page.locator('#tabsBottom');
const dimensions = await nav.evaluate(node => ({ client: node.clientWidth, scroll: node.scrollWidth, document: document.documentElement.scrollWidth }));
expect(dimensions.scroll, 'phone tab strip should retain all admin destinations').toBeGreaterThan(dimensions.client);
expect(dimensions.document, 'phone tab strip must not overflow the page').toBeLessThanOrEqual(width);
const lastTab = nav.locator('[data-tab]').last();
await lastTab.scrollIntoViewIfNeeded();
await expect(lastTab).toBeInViewport();
}
const targetHeight = await page.locator('#bulkStart').evaluate(node => node.getBoundingClientRect().height);
expect(targetHeight, `bulk action target in ${theme} at ${width}px`).toBeGreaterThanOrEqual(44);
const colors = await page.locator('#bulkLyricsCard').evaluate(node => {
const card = getComputedStyle(node), muted = getComputedStyle(node.querySelector('.muted'));
const action = getComputedStyle(node.querySelector('#bulkStart'));
return { text: muted.color, surface: card.backgroundColor, actionText: action.color, actionSurface: action.backgroundColor };
});
expect(contrast(colors.text, colors.surface), `muted text contrast ${theme} ${width}px`).toBeGreaterThanOrEqual(4.5);
expect(contrast(colors.actionText, colors.actionSurface), `primary action contrast ${theme} ${width}px ${JSON.stringify(colors)}`).toBeGreaterThanOrEqual(4.5);
const overflow = await page.locator('#bulkLyricsCard').evaluate(node => node.scrollWidth > node.clientWidth);
expect(overflow, `bulk card overflow in ${theme} at ${width}px`).toBe(false);
await page.locator('#bulkLyricsCard').screenshot({ path: path.join(dir, `bulk-${theme}-${width}.png`) });
}
}
});