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 @@
Server Imports 0
-
- Quarantine 0
-
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 '';
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 `
-
-
- ${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')}
-
${escapeHtml(meta)}
-
-
${triggerBadge}
-
${formatHistoryTime(entry.timestamp)}
-
${approveLabel}
-
Delete
-
▾
-
-
- ${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;