Add resumable bulk lyrics operations
This commit is contained in:
@@ -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); }
|
||||
@@ -129,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; }
|
||||
@@ -169,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);
|
||||
@@ -180,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;
|
||||
@@ -188,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;
|
||||
@@ -329,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; }
|
||||
@@ -341,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; }
|
||||
@@ -365,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; }
|
||||
@@ -628,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 ================= -->
|
||||
@@ -820,7 +867,7 @@
|
||||
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();
|
||||
@@ -1053,9 +1100,13 @@
|
||||
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()} 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;
|
||||
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>`; }
|
||||
}
|
||||
$('qualityFilterForm').addEventListener('submit', event => { event.preventDefault(); qualityOffset = 0; loadLyricsQuality(); });
|
||||
@@ -1088,7 +1139,7 @@
|
||||
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()} 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;
|
||||
$('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>`; }
|
||||
@@ -1333,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('');
|
||||
|
||||
Reference in New Issue
Block a user