From 5f5bf4b24eee0dba00d5dc60d9574829e376728a Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 24 Jun 2026 15:14:54 +0200 Subject: [PATCH] feat(quarantine): consolidate quarantine view into downloads page filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the duplicate Quarantine tab from the Library History modal and brings the same-song grouping feature into the ⚠ Unverified/Quarantine filter on the Downloads page. Co-Authored-By: Claude Sonnet 4.6 --- webui/index.html | 3 - webui/static/pages-extra.js | 99 +++++++++----- webui/static/style.css | 8 ++ webui/static/wishlist-tools.js | 241 +-------------------------------- 4 files changed, 71 insertions(+), 280 deletions(-) diff --git a/webui/index.html b/webui/index.html index e22748f2..9992dfca 100644 --- a/webui/index.html +++ b/webui/index.html @@ -8158,9 +8158,6 @@ -
diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index d398c309..23e8756e 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -2668,47 +2668,72 @@ const _VERIF_QUAR_TRIGGERS = { bit_depth: ['BIT DEPTH FILTER', 'verif-rb-int'], }; +function _verifQuarRowHtml(q, idx) { + const title = _adlEsc(q.expected_track || q.original_filename || q.filename || 'Unknown file'); + const meta = [_adlEsc(q.expected_artist || ''), _adlEsc(q.original_filename || '')].filter(Boolean).join(' — '); + const [trigLabel, trigClass] = _VERIF_QUAR_TRIGGERS[q.trigger] || ['QUARANTINED', 'verif-rb-unv']; + const timeAgo = _verifTimeAgo(q.timestamp); + const approveBtn = q.has_full_context + ? `` + : ``; + const details = [ + q.reason ? `
Reason: ${_adlEsc(q.reason)}
` : '', + q.source_username ? `
Source uploader: ${_adlEsc(q.source_username)}
` : '', + q.source_filename ? `
Original Soulseek file: ${_adlEsc(q.source_filename)}
` : '', + q.timestamp ? `
Quarantined: ${_adlEsc(q.timestamp)}
` : '', + ].filter(Boolean).join(''); + const detailsOpen = _verifQuarOpenDetails.has(q.id); + const artHtml = q.thumb_url + ? `` + : '
'; + return `
+ ${artHtml} +
+
${title}
+ ${meta ? `
${meta}
` : ''} +
${details || 'No further details in the sidecar.'}
+
+
+ ${trigLabel} + ${q.quality ? `${_adlEsc(q.quality)}` : ''} + ${timeAgo ? `${timeAgo}` : ''} + + + + ${approveBtn} + +
+
`; +} + function _verifQuarRows() { if (!_verifQuarLoaded) return '
Loading quarantine…
'; if (!_verifQuarEntries.length) return ''; + const idxById = new Map(_verifQuarEntries.map((q, i) => [q.id, i])); + const groups = (typeof _groupQuarantineEntries === 'function') + ? _groupQuarantineEntries(_verifQuarEntries) + : _verifQuarEntries.map(q => ({ key: null, members: [q] })); let html = ''; - _verifQuarEntries.forEach((q, idx) => { - const title = _adlEsc(q.expected_track || q.original_filename || q.filename || 'Unknown file'); - const meta = [_adlEsc(q.expected_artist || ''), _adlEsc(q.original_filename || '')].filter(Boolean).join(' — '); - const [trigLabel, trigClass] = _VERIF_QUAR_TRIGGERS[q.trigger] || ['QUARANTINED', 'verif-rb-unv']; - const timeAgo = _verifTimeAgo(q.timestamp); - const approveBtn = q.has_full_context - ? `` - : ``; - const details = [ - q.reason ? `
Reason: ${_adlEsc(q.reason)}
` : '', - q.source_username ? `
Source uploader: ${_adlEsc(q.source_username)}
` : '', - q.source_filename ? `
Original Soulseek file: ${_adlEsc(q.source_filename)}
` : '', - q.timestamp ? `
Quarantined: ${_adlEsc(q.timestamp)}
` : '', - ].filter(Boolean).join(''); - const detailsOpen = _verifQuarOpenDetails.has(q.id); - const artHtml = q.thumb_url - ? `` - : '
'; - html += `
- ${artHtml} -
-
${title}
- ${meta ? `
${meta}
` : ''} -
${details || 'No further details in the sidecar.'}
-
-
- ${trigLabel} - ${q.quality ? `${_adlEsc(q.quality)}` : ''} - ${timeAgo ? `${timeAgo}` : ''} - - - - ${approveBtn} - -
-
`; - }); + for (const group of groups) { + if (group.members.length === 1) { + html += _verifQuarRowHtml(group.members[0], idxById.get(group.members[0].id)); + } else { + const first = group.members[0]; + const title = _adlEsc(first.expected_track || first.original_filename || 'Unknown'); + const artist = first.expected_artist ? ` — ${_adlEsc(first.expected_artist)}` : ''; + const n = group.members.length; + const groupId = `vqg-${_adlEsc(first.id)}`; + html += `
+
+ ${title}${artist} + ${n} alternatives ▾ +
+
+ ${group.members.map(m => _verifQuarRowHtml(m, idxById.get(m.id))).join('')} +
+
`; + } + } return html; } diff --git a/webui/static/style.css b/webui/static/style.css index d176e84d..74c2f02b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -68215,6 +68215,14 @@ body.em-scroll-lock { overflow: hidden; } .verif-banner-spacer { flex: 1; } .verif-bulk-danger { border-color: rgba(248,113,113,0.4) !important; color: #f87171 !important; } +.verif-quar-group { margin-bottom: 4px; } +.verif-quar-group-hdr { display: flex; align-items: center; gap: 8px; padding: 6px 12px; background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 6px; cursor: pointer; user-select: none; } +.verif-quar-group-hdr:hover { background: rgba(255,255,255,0.07); } +.verif-quar-group-lbl { flex: 1; font-size: 13px; font-weight: 500; color: rgba(255,255,255,0.85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.verif-quar-group-cnt { font-size: 12px; color: rgba(255,255,255,0.45); white-space: nowrap; } +.verif-quar-group-members { display: none; padding-left: 12px; padding-top: 2px; } +.verif-quar-group-members.vqg-open { display: block; } + /* ── Security settings: grouped sub-sections + dependency visuals ── */ .security-subgroup { border: 1px solid rgba(255, 255, 255, 0.07); diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index c0ad23c1..56cebab9 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -3253,61 +3253,10 @@ function openLibraryHistoryModal() { overlay.classList.remove('hidden'); _libraryHistoryState.page = 1; loadLibraryHistory(); - _refreshQuarantineTabCount(); // #876: count correct on open, not only after clicking the tab } } -// #876: keep the Quarantine tab badge accurate the moment the modal opens. The -// full count was previously only set by loadQuarantineList() (i.e. after the tab -// was clicked), so it showed a stale 0 until then. -async function _refreshQuarantineTabCount() { - const el = document.getElementById('history-quarantine-count'); - if (!el) return; - try { - const resp = await fetch('/api/quarantine/list'); - const data = await resp.json(); - el.textContent = (data.entries || []).length; - } catch (e) { /* leave the existing value on error */ } -} - -// ────────────────────────────────────────────────────────────────────── -// Quarantine tab — rendered inside the Library History modal as a third -// tab next to Downloads + Server Imports. Reuses the existing list + -// pagination chrome; provides per-row Approve / Recover / Delete actions. -// ────────────────────────────────────────────────────────────────────── - -async function loadQuarantineList() { - const list = document.getElementById('library-history-list'); - const pagination = document.getElementById('library-history-pagination'); - const sourceBar = document.getElementById('history-source-bar'); - if (!list) return; - list.innerHTML = '
Loading...
'; - if (pagination) pagination.innerHTML = ''; - if (sourceBar) sourceBar.style.display = 'none'; - - try { - const resp = await fetch('/api/quarantine/list'); - const data = await resp.json(); - const entries = data.entries || []; - const countEl = document.getElementById('history-quarantine-count'); - if (countEl) countEl.textContent = entries.length; - - if (!data.success) { - list.innerHTML = `
Error: ${escapeHtml(data.error || 'Failed to load')}
`; - return; - } - if (entries.length === 0) { - list.innerHTML = '
🛡️

No quarantined files. Nice and clean.
'; - return; - } - list.innerHTML = _groupQuarantineEntries(entries).map(renderQuarantineGroupOrEntry).join(''); - } catch (err) { - console.error('Error loading quarantine entries:', err); - list.innerHTML = '
Error loading quarantine
'; - } -} - -// #876: bucket entries that are alternatives for the SAME intended target +// Bucket entries that are alternatives for the SAME intended target // (same backend group_key — derived from expected_artist/expected_track, the // track SoulSync was trying to fetch, not the bad file's own tags). Entries // with a null group_key (legacy/orphan, ungroupable) each stand alone. @@ -3325,188 +3274,6 @@ function _groupQuarantineEntries(entries) { return groups; } -function renderQuarantineGroupOrEntry(group) { - if (group.members.length === 1) return renderQuarantineEntry(group.members[0]); - return renderQuarantineGroup(group); -} - -// A collapsible parent row for multiple alternatives of one song. Header shows -// the shared track/artist + an alternatives count; members reuse the standard -// entry markup so per-row Approve/Recover/Delete keep working unchanged. -function renderQuarantineGroup(group) { - const first = group.members[0] || {}; - const title = first.expected_track || first.original_filename || 'Unknown'; - const artist = first.expected_artist || ''; - const n = group.members.length; - const sub = artist ? escapeHtml(artist) : ''; - // Reuse the album art the sidecar context carried, when any member has it. - const thumb = (group.members.find(m => m.thumb_url) || {}).thumb_url || ''; - const thumbHtml = thumb - ? `` - : '
🛡️
'; - return `
-
- ${thumbHtml} -
-
${escapeHtml(title)}
- -
- ${n} alternatives - -
-
- ${group.members.map(renderQuarantineEntry).join('')} -
-
`; -} - -function renderQuarantineEntry(entry) { - const triggerLabels = { integrity: 'Duration / Integrity', acoustid: 'AcoustID Mismatch', bit_depth: 'Bit Depth Filter', unknown: 'Unknown' }; - const triggerColors = { integrity: '#facc15', acoustid: '#ef5350', bit_depth: '#fb923c', unknown: '#888' }; - const triggerLabel = triggerLabels[entry.trigger] || entry.trigger || 'Unknown'; - const triggerColor = triggerColors[entry.trigger] || '#888'; - - // Keep dynamic filenames/ids out of inline JS. Quarantine ids are derived - // from filenames, so quotes in the filename can break onclick attributes - // if they are interpolated as JS string literals. - const entryIdAttr = escapeHtml(String(entry.id || '')); - const approveLabel = entry.has_full_context ? 'Approve' : 'Recover'; - const approveTitle = entry.has_full_context - ? 'Re-run post-processing with quarantine checks skipped for this approved file' - : 'Legacy entry — move to Staging, finish via Import flow'; - const approveCall = entry.has_full_context - ? 'approveQuarantineEntryFromButton(this)' - : 'recoverQuarantineEntryFromButton(this)'; - - const meta = [entry.expected_artist, entry.original_filename].filter(Boolean).join(' — '); - const triggerBadge = `${escapeHtml(triggerLabel)}`; - const reasonDetail = `
Reason: ${escapeHtml(entry.reason || 'Unknown')}
`; - - // Issue #608 follow-up: show source uploader + original soulseek - // filename when the sidecar carried that context. Helps the user - // understand which uploader the bad file came from at a glance. - const sourceParts = []; - if (entry.source_username) { - sourceParts.push(`
Source: ${escapeHtml(entry.source_username)}
`); - } - if (entry.source_filename) { - sourceParts.push(`
Original filename: ${escapeHtml(entry.source_filename)}
`); - } - const sourceDetail = sourceParts.join(''); - - return `
-
🛡️
-
-
-
-
${escapeHtml(entry.expected_track || entry.original_filename || 'Unknown')}
- -
-
${triggerBadge}
-
${formatHistoryTime(entry.timestamp)}
- - - -
-
- ${reasonDetail} - ${sourceDetail} -
-
-
`; -} - -function getQuarantineEntryIdFromButton(button) { - return button?.dataset?.entryId || ''; -} - -function approveQuarantineEntryFromButton(button) { - return approveQuarantineEntry(getQuarantineEntryIdFromButton(button)); -} - -function recoverQuarantineEntryFromButton(button) { - return recoverQuarantineEntry(getQuarantineEntryIdFromButton(button)); -} - -function deleteQuarantineEntryFromButton(button) { - return deleteQuarantineEntry(getQuarantineEntryIdFromButton(button)); -} - -async function approveQuarantineEntry(entryId) { - const ok = await showConfirmDialog({ - title: 'Approve Quarantined File', - message: 'Re-run post-processing for this file with quarantine checks skipped for this approved pass. The file will be tagged, lyrics generated, and moved into your library.', - confirmText: 'Approve & Import', - cancelText: 'Cancel', - }); - if (!ok) return; - try { - const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - // #876: clear the other quarantined alternatives for this same song - // once one is accepted — they're redundant failed attempts now. - body: JSON.stringify({ remove_siblings: true }), - }); - const data = await r.json(); - if (!data.success) { - showToast(`Approve failed: ${data.error}`, 'error'); - } else { - const removed = (data.removed_siblings || []).length; - const extra = removed ? ` — removed ${removed} other option${removed === 1 ? '' : 's'}.` : ''; - showToast(`Approved — skipped ${data.trigger_bypassed} check, re-running pipeline.${extra}`, 'success'); - } - } catch (err) { - showToast(`Approve failed: ${err.message}`, 'error'); - } - loadQuarantineList(); -} - -async function recoverQuarantineEntry(entryId) { - const ok = await showConfirmDialog({ - title: 'Recover To Staging', - message: 'Legacy entry — no embedded context. The file will be moved to your Staging folder so you can finish via the Import page (manual match).', - confirmText: 'Move To Staging', - cancelText: 'Cancel', - }); - if (!ok) return; - try { - const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/recover`, { method: 'POST' }); - const data = await r.json(); - if (!data.success) { - showToast(`Recover failed: ${data.error}`, 'error'); - } else { - showToast('Moved to Staging — finish via the Import page.', 'success'); - } - } catch (err) { - showToast(`Recover failed: ${err.message}`, 'error'); - } - loadQuarantineList(); -} - -async function deleteQuarantineEntry(entryId) { - const ok = await showConfirmDialog({ - title: 'Delete Quarantined File', - message: 'This permanently removes the file and its metadata sidecar. Cannot be undone.', - confirmText: 'Delete', - cancelText: 'Cancel', - destructive: true, - }); - if (!ok) return; - try { - const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}`, { method: 'DELETE' }); - const data = await r.json(); - if (!data.success) { - showToast(`Delete failed: ${data.error}`, 'error'); - } else { - showToast('Quarantined file deleted.', 'success'); - } - } catch (err) { - showToast(`Delete failed: ${err.message}`, 'error'); - } - loadQuarantineList(); -} - function closeLibraryHistoryModal() { const overlay = document.getElementById('library-history-overlay'); if (overlay) overlay.classList.add('hidden'); @@ -3523,12 +3290,6 @@ function switchHistoryTab(tab) { async function loadLibraryHistory() { const { tab, page, limit } = _libraryHistoryState; - if (tab === 'quarantine') { - // Refresh the count for the other two tabs in the background so - // the badge stays accurate when the user switches over. - loadQuarantineList(); - return; - } const list = document.getElementById('library-history-list'); const pagination = document.getElementById('library-history-pagination'); if (!list) return;