Add resumable bulk lyrics operations

This commit is contained in:
Jonathan Sykes
2026-10-14 02:36:30 +08:00
parent 555027a61c
commit 703aef617b
25 changed files with 547 additions and 55 deletions

View File

@@ -16,29 +16,61 @@
<style> <style>
:root { :root {
color-scheme: dark; color-scheme: dark;
--bg: #0a0c12; --bg-1: #10131b; --bg-2: #161a25; --bg-3: #1d2230; --bg-4: #262c3d; --bg: #121315; --bg-1: #18191c; --bg-2: #202226; --bg-3: #2b2e33; --bg-4: #35383d;
--line: rgba(255,255,255,.08); --line-2: rgba(255,255,255,.14); --line: #42464d; --line-2: #42464d;
--text: #f2f4fa; --text-2: #b5bacb; --text-dim: #7b8299; --text: #f3f1ed; --text-2: #c3c4c7; --text-dim: #a1a5ac;
--a1: #7c5cff; --a2: #22d3ee; /* signature gradient */ --a1: #f06b53; --a2: #f06b53; --action: #b53625; --on-action: #fff; --grad: #b53625;
--grad: linear-gradient(135deg, var(--a1), var(--a2)); --ok: #4ade80; --on-ok: #06210f; --warn: #ffcf70; --bad: #ff8585;
--ok: #4ade80; --warn: #ffb454; --bad: #ff6b6b;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace; --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
--ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; --ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--nav-h: 62px; --nav-h: 62px;
--safe: env(safe-area-inset-bottom, 0px); --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; } * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html { scroll-padding-top: 150px; } html { scroll-padding-top: 150px; }
body { body {
margin: 0; min-height: 100dvh; color: var(--text); font: 15px/1.5 var(--ui); margin: 0; min-height: 100dvh; color: var(--text); font: 15px/1.5 var(--ui);
background: background: var(--bg);
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;
} }
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, input, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; touch-action: manipulation; } 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; } .hidden { display: none !important; }
.muted { color: var(--text-dim); } .muted { color: var(--text-dim); }
code { font-family: var(--mono); font-size: 12.5px; } code { font-family: var(--mono); font-size: 12.5px; }
@@ -47,28 +79,27 @@
/* ---------- chrome ---------- */ /* ---------- chrome ---------- */
.top { .top {
position: sticky; top: 0; z-index: 40; display: flex; align-items: center; gap: 14px; position: sticky; top: 0; z-index: 40; display: flex; align-items: center; gap: 14px;
padding: 10px 18px; background: rgba(10,12,18,.78); padding: 10px 18px; background: var(--bg-1);
-webkit-backdrop-filter: blur(16px) saturate(150%); backdrop-filter: blur(16px) saturate(150%);
border-bottom: 1px solid var(--line); 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 { 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; } .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 { .tab {
position: relative; display: flex; align-items: center; gap: 8px; height: 38px; padding: 0 14px; border: 0; border-radius: 11px; 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; transition: background .15s, color .15s; 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 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: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 { .badge {
min-width: 19px; height: 19px; padding: 0 6px; border-radius: 99px; display: inline-grid; place-items: center; 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); background: var(--warn); color: #1a1206; font: 800 11px/1 var(--ui);
} }
.grow { flex: 1; } .grow { flex: 1; }
.ghost { .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; 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); } .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:hover { background: rgba(255,255,255,.1); }
.btn:active { transform: scale(.97); } .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.pri:hover { filter: brightness(1.1); }
.btn.ok { border: 0; background: var(--ok); color: #06210f; } .btn.ok { border: 0; background: var(--ok); color: var(--on-ok); }
.btn.dng { color: var(--bad); border-color: rgba(255,107,107,.35); background: rgba(255,107,107,.07); } .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.sm { height: 34px; padding: 0 12px; font-size: 13px; border-radius: 10px; }
.btn[disabled] { opacity: .45; pointer-events: none; } .btn[disabled] { opacity: .45; pointer-events: none; }
.msg { min-height: 1.3em; margin-top: 10px; font-size: 13.5px; color: var(--text-2); } .msg { min-height: 1.3em; margin-top: 10px; font-size: 13.5px; color: var(--text-2); }
@@ -129,9 +160,9 @@
border: 1.5px dashed var(--line-2); border-radius: 16px; background: rgba(0,0,0,.22); cursor: pointer; border: 1.5px dashed var(--line-2); border-radius: 16px; background: rgba(0,0,0,.22); cursor: pointer;
transition: border-color .15s, background .15s; 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: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); .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; } fill: none; stroke: var(--a2); stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.drop-text { flex: 1 1 auto; min-width: 0; display: grid; gap: 2px; } .drop-text { flex: 1 1 auto; min-width: 0; display: grid; gap: 2px; }
@@ -169,7 +200,7 @@
#p-editor { #p-editor {
--ed-bg: #121315; --ed-surface: #18191c; --ed-raised: #202226; --ed-selected: #2b2e33; --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-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; --ed-wave-played: #f06b53; --ed-wave-unplayed: #8f9399; --ed-wave-mark: #c3c4c7; --ed-wave-cursor: #fff;
--ok: #4ade80; --warn: #ffcf70; --bad: #ff8585; --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); --bg: var(--ed-bg); --bg-1: var(--ed-surface); --bg-2: var(--ed-raised); --bg-3: var(--ed-selected);
@@ -180,7 +211,7 @@
html[data-editor-theme="light"] #p-editor { html[data-editor-theme="light"] #p-editor {
--ed-bg: #f4f2ee; --ed-surface: #fff; --ed-raised: #efece6; --ed-selected: #e5e0d8; --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-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; --ed-wave-played: #b43623; --ed-wave-unplayed: #716c65; --ed-wave-mark: #4a4843; --ed-wave-cursor: #1b1a18;
--ok: #166534; --warn: #754500; --bad: #a32121; --ok: #166534; --warn: #754500; --bad: #a32121;
color-scheme: light; color-scheme: light;
@@ -188,7 +219,7 @@
html[data-editor-theme="contrast"] #p-editor { html[data-editor-theme="contrast"] #p-editor {
--ed-bg: #000; --ed-surface: #000; --ed-raised: #0a0a0a; --ed-selected: #161616; --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-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; --ed-wave-played: #ff5a3c; --ed-wave-unplayed: #fff; --ed-wave-mark: #fff; --ed-wave-cursor: #fff;
--ok: #00ff70; --warn: #ffff00; --bad: #ff7777; --ok: #00ff70; --warn: #ffff00; --bad: #ff7777;
color-scheme: dark; color-scheme: dark;
@@ -329,7 +360,7 @@
.line-code { color: var(--ed-dim); font: 600 11px var(--mono); font-variant-numeric: tabular-nums; } .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 { 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::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); } #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; } .analytics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin: 20px 0; }
@@ -341,6 +372,13 @@
.analytics-form .query { flex: 1 1 250px; } .analytics-form .query { flex: 1 1 250px; }
.analytics-form .field { width: 100%; min-width: 0; } .analytics-form .field { width: 100%; min-width: 0; }
.analytics-form button { min-height: 44px; } .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 { overflow-x: auto; margin-top: 12px; }
.analytics-table table { width: 100%; } .analytics-table table { width: 100%; }
.collection-job { padding: 14px 0; border-bottom: 1px solid var(--line-2); overflow-wrap: anywhere; } .collection-job { padding: 14px 0; border-bottom: 1px solid var(--line-2); overflow-wrap: anywhere; }
@@ -365,11 +403,12 @@
.tabs, .top .ghost.desk { display: none; } .tabs, .top .ghost.desk { display: none; }
.top { padding: 10px 14px; } .top { padding: 10px 14px; }
.nav { .nav {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 50; display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; 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); background: rgba(12,14,21,.92); height: calc(var(--nav-h) + var(--safe)); padding: 6px 6px var(--safe); overflow-x: auto; overflow-y: hidden;
-webkit-backdrop-filter: blur(18px); backdrop-filter: blur(18px); border-top: 1px solid var(--line-2); 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%; } .nav .tab .badge { position: absolute; top: 2px; left: 55%; }
.steps { grid-template-columns: 1fr; } .steps { grid-template-columns: 1fr; }
.ln-main { grid-template-columns: 82px minmax(0, 1fr) 44px; gap: 6px; padding: 7px; } .ln-main { grid-template-columns: 82px minmax(0, 1fr) 44px; gap: 6px; padding: 7px; }
@@ -628,6 +667,14 @@
</form> </form>
<div id="injectMsg" class="msg"></div> <div id="injectMsg" class="msg"></div>
</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> </section>
<!-- ================= PEER-TO-PEER ================= --> <!-- ================= PEER-TO-PEER ================= -->
@@ -820,7 +867,7 @@
if (name === 'history') loadRevs(); if (name === 'history') loadRevs();
if (name === 'backups') loadBackups(); if (name === 'backups') loadBackups();
if (name === 'peers') loadP2p(); if (name === 'peers') loadP2p();
if (name === 'library') loadUploads(); if (name === 'library') { loadUploads(); loadBulkLibrary(); loadBulkJobs(); }
if (name === 'access') loadTokens(); if (name === 'access') loadTokens();
if (name === 'analytics') loadAnalytics(); if (name === 'analytics') loadAnalytics();
if (name === 'deep-search') loadDeepSearch(); if (name === 'deep-search') loadDeepSearch();
@@ -1053,9 +1100,13 @@
try { try {
const j = await api(`/api/admin/lyrics/quality?category=${encodeURIComponent(category)}&minLines=${minLines}&limit=50&offset=${qualityOffset}`); 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.'); if (!j.ok) throw Error(j.error || 'Could not load lyrics queue.');
$('qualityCount').textContent = `${j.total.toLocaleString()} songs · ${j.songs.length ? qualityOffset + 1 : 0}–${qualityOffset + j.songs.length}`; $('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; $('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 => `<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(song.reasons.join(' · '))}${song.openReports ? ` · ${song.openReports} reports` : ''}</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>'; 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>`; } } catch (error) { table.innerHTML = `<tr><td class="err">${esc(error.message)}</td></tr>`; }
} }
$('qualityFilterForm').addEventListener('submit', event => { event.preventDefault(); qualityOffset = 0; loadLyricsQuality(); }); $('qualityFilterForm').addEventListener('submit', event => { event.preventDefault(); qualityOffset = 0; loadLyricsQuality(); });
@@ -1088,7 +1139,7 @@
try { try {
const result = await api('/api/admin/lyrics/search?' + lyricsSearchQuery()); if (!result.ok) throw Error(result.error || 'Lyrics search failed.'); 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>'; 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()} matches · ${result.results.length ? lyricsSearchOffset + 1 : 0}–${lyricsSearchOffset + result.results.length}`; $('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; $('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.'; $('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>`; } } catch (error) { table.innerHTML = `<tr><td class="err">${esc(error.message)}</td></tr>`; }
@@ -1333,6 +1384,67 @@
if (!j.ok) alert(j.error || 'failed'); if (!j.ok) alert(j.error || 'failed');
loadUploads(); loadRecent(); 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() { async function loadRecent() {
const j = await api('/api/uploads?limit=10'); 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(''); $('recentChips').innerHTML = (j.uploads || []).map((u) => `<button type="button" class="chip" data-open="${esc(u.id)}">♪ ${esc(u.title)}</button>`).join('');

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

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

View File

@@ -1,12 +1,31 @@
import { audit } from './audit-log.js'; import { audit } from './audit-log.js';
import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
import { join } from 'node:path'; import { basename, join } from 'node:path';
const clientOf = db => typeof db?.execute === 'function' ? db : db?.db; const clientOf = db => typeof db?.execute === 'function' ? db : db?.db;
const parse = value => { try { return JSON.parse(value || '{}'); } catch { return {}; } }; const parse = value => { try { return JSON.parse(value || '{}'); } catch { return {}; } };
const number = value => Number(value) || 0; 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 }) { export function registerAdminToolsRoutes(app, { db, adminAuth, media = null, backupDir = null, backfillStep = null, pullLyrics = null, queueWhisper = null, autoStartJobs = true }) {
const sql = clientOf(db); const sql = clientOf(db);
const backupFile = date => join(backupDir || '.', `notes-${date}.json`); const backupFile = date => join(backupDir || '.', `notes-${date}.json`);
@@ -83,8 +102,9 @@ export function registerAdminToolsRoutes(app, { db, adminAuth, media = null, bac
await sql.execute({ sql: 'DELETE FROM video_notes WHERE video_id=? AND kind=?', args: [note.videoId, note.kind] }); await sql.execute({ sql: 'DELETE FROM video_notes WHERE video_id=? AND kind=?', args: [note.videoId, note.kind] });
} }
} }
await audit(db, 'admin', 'backup.restore', date, { videoId: body.videoId || null, restored: selected.length, safetySnapshot: safety.file }); const safetySnapshot = basename(safety.file);
return c.json({ ok: true, restored: selected.length, safetySnapshot: 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 => { app.get('/api/admin/backups/export/sqlite-dump', adminAuth, async c => {
@@ -202,6 +222,148 @@ export function registerAdminToolsRoutes(app, { db, adminAuth, media = null, bac
return c.json({ ok: true, 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 => { app.get('/api/admin/cache/health', adminAuth, async c => {
const limit = Math.max(1, Math.min(100, Number(c.req.query('limit')) || 50)); 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 offset = Math.max(0, Math.min(1_000_000, Number(c.req.query('offset')) || 0));
@@ -297,4 +459,6 @@ export function registerAdminToolsRoutes(app, { db, adminAuth, media = null, bac
return c.json({ ok: true, removed, days }); return c.json({ ok: true, removed, days });
}); });
return { runBulkJob };
} }

View File

@@ -13,13 +13,15 @@ const { registerAdminToolsRoutes } = await import('./admin-tools.js');
const app = new Hono(); const app = new Hono();
const backupDir = join(root, 'backups'); const backupDir = join(root, 'backups');
const auth = async (c, next) => c.req.header('x-admin') === 'yes' ? next() : c.json({ ok: false }, 401); const auth = async (c, next) => c.req.header('x-admin') === 'yes' ? next() : c.json({ ok: false }, 401);
let tools;
beforeAll(async () => { beforeAll(async () => {
await db.initDb(); await db.initDb();
registerAdminToolsRoutes(app, { db, adminAuth: auth, backupDir, backfillStep: async () => 3 }); 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: ['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.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.db.execute({ sql: "INSERT INTO video_notes(video_id,kind,data,rev,source) VALUES(?, 'lyrics', ?, 1, 'user')", args: ['Untimely001', JSON.stringify({ lines: [{ t: null, text: 'A line' }, { t: 4, text: 'Next' }] })] }); 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 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(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_meta_sources(video_id,source,last_seen) VALUES('IndexSong001','deep',?)", args: [Date.now()] });
@@ -88,7 +90,7 @@ describe('lyrics quality queue', () => {
const restored = await (await app.request(`/api/admin/backups/${created.date}/restore`, { method: 'POST', headers, body: JSON.stringify({ videoId: 'BackupSong01' }) })).json(); 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(restored.ok).toBe(true);
expect((await db.getNotes('BackupSong01')).lyrics.data.lines[0].text).toBe('Before'); expect((await db.getNotes('BackupSong01')).lyrics.data.lines[0].text).toBe('Before');
expect(existsSync(restored.safetySnapshot)).toBe(true); expect(existsSync(join(backupDir, 'snapshots', restored.safetySnapshot))).toBe(true);
const dump = await app.request('/api/admin/backups/export/sqlite-dump', { headers: { 'x-admin': 'yes' } }); const dump = await app.request('/api/admin/backups/export/sqlite-dump', { headers: { 'x-admin': 'yes' } });
expect(dump.status).toBe(200); expect(dump.status).toBe(200);
expect(await dump.text()).toContain('CREATE TABLE'); expect(await dump.text()).toContain('CREATE TABLE');
@@ -112,4 +114,44 @@ describe('lyrics quality queue', () => {
expect(rebuilt.ok).toBe(true); expect(rebuilt.ok).toBe(true);
expect(rebuilt.indexed).toBeGreaterThanOrEqual(1); 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');
});
}); });

View File

@@ -258,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_video ON lyric_flags (video_id, status);
CREATE INDEX IF NOT EXISTS idx_flags_status ON lyric_flags (status, created_at DESC); 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 -- 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. -- token is stored; the plaintext is shown once when it is created.
CREATE TABLE IF NOT EXISTS api_tokens ( CREATE TABLE IF NOT EXISTS api_tokens (

View File

@@ -683,7 +683,7 @@ export function registerNoteRoutes(app, deps) {
if (deps.analytics) registerAnalyticsRoutes(app, { adminAuth: requireAdminOrToken, runYtdlp, ...deps.analytics }); if (deps.analytics) registerAnalyticsRoutes(app, { adminAuth: requireAdminOrToken, runYtdlp, ...deps.analytics });
registerTranscriptionRoutes(app, { const transcriptionService = registerTranscriptionRoutes(app, {
db, adminAuth: requireAdminOrToken, sanitizeLyrics, db, adminAuth: requireAdminOrToken, sanitizeLyrics,
workerEnabled: !!workerToken && workerToken.length >= 24, workerEnabled: !!workerToken && workerToken.length >= 24,
workerAuth: async (c, next) => { workerAuth: async (c, next) => {
@@ -827,8 +827,36 @@ export function registerNoteRoutes(app, deps) {
console.warn('[notes] backup failed:', err.message); 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 { return {
requireAdminOrToken, requireAdminOrToken,
pullLyricsForAdmin,
queueWhisperForAdmin: transcriptionService.enqueue,
startBackups() { startBackups() {
setTimeout(writeBackup, 60_000); setTimeout(writeBackup, 60_000);
setInterval(writeBackup, 24 * 3600_000).unref?.(); setInterval(writeBackup, 24 * 3600_000).unref?.();

View File

@@ -285,6 +285,25 @@ test('admin song list includes metadata-only phone songs for lyrics lookup', asy
expect((await app.request('/api/admin/media/phoneSong01/redownload', { method: 'POST' })).status).toBe(401); 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 () => { test('report triage filters aging flags and resolves every open report for one song', async () => {
const id = VID; const id = VID;
const a = await dbmod.upsertFlag({ videoId: id, index: 0, text: 'Flagged line', rev: 1, reason: 'words', note: '', reporter: 'listener-a' }); const a = await dbmod.upsertFlag({ videoId: id, index: 0, text: 'Flagged line', rev: 1, reason: 'words', note: '', reporter: 'listener-a' });

View File

@@ -2319,7 +2319,8 @@ const deepSearch = registerDeepSearchRoutes(app, { db: notesDb, adminAuth: notes
searchDeep: innertube.searchDeep, related: innertube.related, browseUploads: innertube.browseUploads, searchDeep: innertube.searchDeep, related: innertube.related, browseUploads: innertube.browseUploads,
ingest: collectVideoMetadata, putSearch: searchCacheDb.put }); ingest: collectVideoMetadata, putSearch: searchCacheDb.put });
registerAdminToolsRoutes(app, { db: notesDb, adminAuth: notes.requireAdminOrToken, media, 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 }); 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) // Uploads — the server's own searchable media library (see uploads.js)

View File

@@ -17,26 +17,30 @@ export function registerTranscriptionRoutes(app, { db, adminAuth, workerAuth, wo
if (id.startsWith('upl_')) return db.getUpload(id); if (id.startsWith('upl_')) return db.getUpload(id);
const row = await db.getMedia(id); return row?.status === 'ready' ? row : null; const row = await db.getMedia(id); return row?.status === 'ready' ? row : null;
} }
app.post('/api/admin/transcriptions/:video', adminAuth, async c => { async function enqueue(videoId) {
const id = c.req.param('video'); const id = String(videoId || '');
if (!ID.test(id)) return c.json({ ok: false, error: 'invalid video id' }, 400); if (!ID.test(id)) return { status: 400, body: { ok: false, error: 'invalid video id' } };
if (!workerEnabled) return c.json({ ok: false, error: 'Whisper is unavailable: the lyrics worker is not configured.' }, 503); if (!workerEnabled) return { status: 503, body: { ok: false, error: 'Whisper is unavailable: the lyrics worker is not configured.' } };
const audio = await source(id); 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 (!audio) return { status: 409, body: { ok: false, error: 'Save this video on the server before transcribing its audio.' } };
if (Number(audio.duration) > 3600) return c.json({ ok: false, error: 'Whisper requests are limited to one hour of audio.' }, 422); 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); const notes = await db.getNotes(id);
// One active job per song and a bounded queue, including across restarts. // 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]; 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); 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(); const time = now();
await sql.execute({ sql: `INSERT OR IGNORE INTO lyric_transcriptions (id,video_id,base_rev,created_at,updated_at) 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`, SELECT ?,?,?,?,? WHERE (SELECT COUNT(*) FROM lyric_transcriptions WHERE status IN ('queued','running')) < 25`,
args: [randomUUID(), id, notes.lyrics?.rev || 0, time, time] }); 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]; const row = (await sql.execute({ sql: "SELECT * FROM lyric_transcriptions WHERE video_id=? AND status IN ('queued','running')", args: [id] })).rows[0];
if (row) await audit(db, 'admin', 'lyrics.whisper.enqueue', id, { jobId: row.id }); if (row) await audit(db, 'admin', 'lyrics.whisper.enqueue', id, { jobId: row.id });
return row ? c.json({ ok: true, job: publicJob(row) }, 202) : c.json({ ok: false, error: 'The transcription queue is full.' }, 429); 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 => { app.get('/api/admin/transcriptions/:video', adminAuth, async c => {
const id = c.req.param('video'); const id = c.req.param('video');
@@ -78,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] }); 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 c.json({ ok: true });
}); });
return { enqueue };
} }

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 });
});

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`) });
}
}
});