Polish quarantine UI — fold into Library History modal as third tab

Standalone Quarantine button + modal felt out of place — duplicated
the chrome of the existing Library History modal but with worse
styling and behavior. Folded the quarantine list into the existing
modal as a third tab next to Downloads + Server Imports.

UI changes:
- Removed the standalone Quarantine button on the Downloads page
  header and the standalone modal HTML
- Added third tab to library-history-tabs with a count badge
- loadLibraryHistory dispatches to loadQuarantineList when the
  quarantine tab is active
- Quarantine entries render as library-history-entry cards using
  the exact same class chrome as Downloads + Imports (thumb
  placeholder, title + meta, badge, relative time via
  formatHistoryTime, expandable details panel)
- Per-row actions styled as lh-audit-btn to match the existing
  Audit button look
- Approve / Recover / Delete now use the themed showConfirmDialog
  + showToast — no more native browser alert / confirm

Backend endpoints + pure helpers + tests unchanged from f4cff78f.
WHATS_NEW entry rewritten to reflect the actual final UX.
pull/591/head
Broque Thomas 2 weeks ago
parent f4cff78f13
commit d0d65946c8

@ -2191,7 +2191,6 @@
<div class="adl-batch-history-header" onclick="adlToggleBatchHistory()">
<span>Recent History</span>
<div class="adl-batch-history-header-actions">
<button class="library-history-btn" onclick="event.stopPropagation();openQuarantineModal()" title="Manage quarantined files">Quarantine</button>
<button class="library-history-btn" onclick="event.stopPropagation();openLibraryHistoryModal()" title="View full download + import history">Download History</button>
<svg class="adl-batch-history-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</div>
@ -2202,24 +2201,6 @@
</div>
</div>
<!-- Quarantine Management Modal -->
<div id="quarantine-modal" class="modal-overlay" style="display:none" onclick="if(event.target===this)closeQuarantineModal()">
<div class="modal-content" style="max-width:1100px;width:95%">
<div class="modal-header">
<h2>Quarantined Files</h2>
<button class="modal-close" onclick="closeQuarantineModal()">×</button>
</div>
<div class="modal-body">
<div id="quarantine-modal-content">
<p style="color:rgba(255,255,255,0.5);text-align:center;padding:40px">Loading…</p>
</div>
</div>
<div class="modal-footer">
<button class="modal-btn-secondary" onclick="loadQuarantineEntries()">Refresh</button>
<button class="modal-btn-secondary" onclick="closeQuarantineModal()">Close</button>
</div>
</div>
</div>
</div>
</div>
@ -7591,6 +7572,9 @@
<button class="library-history-tab" data-tab="import" onclick="switchHistoryTab('import')">
Server Imports <span class="library-history-tab-count" id="history-import-count">0</span>
</button>
<button class="library-history-tab" data-tab="quarantine" onclick="switchHistoryTab('quarantine')">
Quarantine <span class="library-history-tab-count" id="history-quarantine-count">0</span>
</button>
</div>
<div class="history-source-bar" id="history-source-bar" style="display:none"></div>
<div class="library-history-list" id="library-history-list"></div>

@ -3416,7 +3416,7 @@ const WHATS_NEW = {
'2.5.2': [
// --- May 13, 2026 — 2.5.2 release ---
{ date: 'May 13, 2026 — 2.5.2 release' },
{ title: 'Quarantine Management — See, Approve, Delete Files Without Touching The Filesystem', desc: 'github issue #584: quarantined files used to just sit in `ss_quarantine/` with a thin sidecar — no UI, no recovery, no way to see what got dropped or why. new "Quarantine" button on the downloads page header opens a modal with every quarantined file: filename, expected track + artist, full failure reason, when, size. three actions per row: **Approve** (one-click — restores the file, re-runs the post-process pipeline with ONLY the failing check skipped, lands in your library with full tags + lyrics + scan), **Recover** (legacy fallback for entries quarantined before this PR — moves to Staging so you finish via Import flow), **Delete** (permanent removal of file + sidecar). per-check bypass means approving a duration-mismatch file still runs AcoustID; approving an AcoustID failure still runs bit-depth — other quality gates stay live, you only override the specific trigger. sidecar now persists the full json-safe context so approve has everything the pipeline needs. download modal status text differentiates "🛡️ Quarantined" from "❌ Failed" so you can spot recoverable files at a glance. logic lifted to pure helpers in `core/imports/quarantine.py` (list / delete / approve / recover_to_staging / serialize_quarantine_context). 27 tests pin every shape: orphan files / orphan sidecars / corrupt sidecars / collision-safe filename restoration / full-context vs thin-sidecar dispatch / json round-trip safety. four new endpoints (`/api/quarantine/list`, `DELETE /api/quarantine/<id>`, `POST /<id>/approve`, `POST /<id>/recover`). pipeline change is three small per-check conditionals — no blanket bypass.', page: 'downloads' },
{ title: 'Quarantine Management — See, Approve, Delete Files Without Touching The Filesystem', desc: 'github issue #584: quarantined files used to just sit in `ss_quarantine/` with a thin sidecar — no UI, no recovery, no way to see what got dropped or why. new **Quarantine** tab on the existing Library History modal (downloads page → Download History button) lists every quarantined file with the same row chrome as the Downloads + Server Imports tabs: thumb placeholder, expected track + artist, original filename, trigger badge (Duration / AcoustID / Bit Depth), relative time, expandable details panel showing the full failure reason. three per-row actions: **Approve** (restores the file, re-runs post-processing with ONLY the failing check skipped, lands in your library with full tags + lyrics + scan), **Recover** (legacy fallback for entries quarantined before this PR with thin sidecars — moves to Staging so you finish via Import flow), **Delete** (permanent removal of file + sidecar). all three use the themed soulsync confirm modal + toast feedback (no native browser alert / confirm). per-check bypass means approving a duration-mismatch file still runs AcoustID; approving an AcoustID failure still runs bit-depth — other quality gates stay live so you can only override one trigger at a time. files that fail a different check after approval get re-quarantined with the new trigger label so you can decide again. sidecar now persists the full json-safe context so approve has everything the pipeline needs to re-process. download modal status differentiates "🛡️ Quarantined" from "❌ Failed" so recoverable files are visible at a glance. logic lifted to pure helpers in `core/imports/quarantine.py` (list / delete / approve / recover_to_staging / serialize_quarantine_context) with 27 boundary tests covering orphan files / orphan sidecars / corrupt sidecars / collision-safe filename restoration / full-context vs thin-sidecar dispatch / json round-trip safety. four new endpoints. pipeline change is per-check conditionals at the existing quarantine sites — no blanket skip-all flag.', page: 'downloads' },
{ title: 'Configurable Duration Tolerance For Quarantined Tracks', desc: 'discord question: tracks were quarantining when their actual length drifted by a few seconds from what spotify/musicbrainz reported (3s tolerance hardcoded, 5s for tracks >10min). live recordings, alternate masterings, and some legitimate uploads routinely drift more than that. new setting on settings → metadata → post-processing: "duration tolerance (seconds)". `0 = auto` (preserves the existing 3s/5s defaults). raise it to 10 / 15 / 20 if your library has a lot of drift-prone material. capped at 60s — past that the check is effectively off. applies to ALL matched downloads (soulseek / tidal / qobuz / hifi / youtube / deezer-direct) since they all flow through the same post-process integrity check. logic lifted to a pure helper `core/imports/file_integrity.py:resolve_duration_tolerance` that coerces the config value (none / empty / 0 / negative / unparseable / above-cap) to either a float override or `None` for the auto-scaled default. 12 tests pin every input shape.', page: 'settings' },
{ title: 'Soulseek Downloads: Multi-Artist Tags Now Get Written Properly', desc: 'discord report: tracks downloaded via soulseek were getting tagged with primary artist only (no collab artists), while the same track downloaded via deezer tagged everyone correctly. trace: the soulseek matched-download context constructed `original_search_result` with `artist` (singular string) but no `artists` (list), even though the full multi-artist list lived on `track_info` (the matched spotify track object). `core/metadata/source.py:extract_source_metadata` only read `original_search.artists`, so soulseek path always fell through to the single-artist branch. fix: lifted artist resolution into a pure helper `core/metadata/artist_resolution.py:resolve_track_artists` that walks `original_search.artists` → `track_info.artists` → `artist_dict.name` fallback chain. handles all three list-item shapes (spotify-style dicts, bare strings, anything else stringified). 13 tests pin the resolution order, fallback chain, mixed-shape normalization, whitespace stripping, empty/none handling. composes with the existing deezer per-track upgrade (still fires when single-artist + track_id available) and feat_in_title / artist_separator settings (still drive the joined ARTIST string downstream).', page: 'downloads' },
{ title: 'Download Missing Modal: Tracklist Got A Polish Pass', desc: 'visual tune-up only — column layout untouched. hairline row dividers, accent gradient + edge bar on hover, monospace track numbers (glow accent on row hover), monospace tabular duration. status text in both library-match + download-status columns picks up a leading colored dot with a soft halo (green found / amber missing / blue checking / orange downloading / red failed) and pulses while in-flight. artist column centered. soft scrollbar.', page: 'downloads' },

@ -3095,146 +3095,147 @@ function openLibraryHistoryModal() {
}
// ──────────────────────────────────────────────────────────────────────
// Quarantine management modal — list / delete / approve / recover
// 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.
// ──────────────────────────────────────────────────────────────────────
function openQuarantineModal() {
const modal = document.getElementById('quarantine-modal');
if (modal) {
modal.style.display = 'flex';
loadQuarantineEntries();
}
}
function closeQuarantineModal() {
const modal = document.getElementById('quarantine-modal');
if (modal) modal.style.display = 'none';
}
function _quarantineFormatBytes(n) {
if (!n) return '0 B';
const u = ['B', 'KB', 'MB', 'GB'];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${u[i]}`;
}
function _quarantineFormatTime(iso) {
if (!iso) return '';
try { return new Date(iso).toLocaleString(); } catch { return iso; }
}
function _quarantineEsc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
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 = '<div class="library-history-loading">Loading...</div>';
if (pagination) pagination.innerHTML = '';
if (sourceBar) sourceBar.style.display = 'none';
async function loadQuarantineEntries() {
const container = document.getElementById('quarantine-modal-content');
if (!container) return;
container.innerHTML = '<p style="color:rgba(255,255,255,0.5);text-align:center;padding:40px">Loading…</p>';
try {
const r = await fetch('/api/quarantine/list');
const data = await r.json();
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) {
container.innerHTML = `<p style="color:#f87171;text-align:center;padding:40px">${_quarantineEsc(data.error || 'Failed to load')}</p>`;
list.innerHTML = `<div class="library-history-empty">Error: ${escapeHtml(data.error || 'Failed to load')}</div>`;
return;
}
const entries = data.entries || [];
if (entries.length === 0) {
container.innerHTML = '<p style="color:rgba(255,255,255,0.5);text-align:center;padding:40px">No quarantined files. Nice and clean.</p>';
list.innerHTML = '<div class="library-history-empty">🛡️<br><br>No quarantined files. Nice and clean.</div>';
return;
}
const rows = entries.map(e => {
const approveBtn = e.has_full_context
? `<button class="modal-btn-primary" style="font-size:11px;padding:5px 10px" onclick="approveQuarantineEntry('${_quarantineEsc(e.id)}')">Approve</button>`
: `<button class="modal-btn-secondary" style="font-size:11px;padding:5px 10px" onclick="recoverQuarantineEntry('${_quarantineEsc(e.id)}')" title="Move to Staging — finish via Import flow">Recover</button>`;
return `
<tr>
<td style="padding:10px;border-bottom:1px solid rgba(255,255,255,0.05);font-weight:600;color:#fff">${_quarantineEsc(e.original_filename)}</td>
<td style="padding:10px;border-bottom:1px solid rgba(255,255,255,0.05);color:rgba(255,255,255,0.6);font-size:12px">${_quarantineEsc(e.expected_track || '—')}</td>
<td style="padding:10px;border-bottom:1px solid rgba(255,255,255,0.05);color:rgba(255,255,255,0.5);font-size:12px">${_quarantineEsc(e.expected_artist || '—')}</td>
<td style="padding:10px;border-bottom:1px solid rgba(255,255,255,0.05);color:#facc15;font-size:11.5px;max-width:260px">${_quarantineEsc(e.reason)}</td>
<td style="padding:10px;border-bottom:1px solid rgba(255,255,255,0.05);color:rgba(255,255,255,0.45);font-size:11px;font-family:monospace;white-space:nowrap">${_quarantineEsc(_quarantineFormatTime(e.timestamp))}</td>
<td style="padding:10px;border-bottom:1px solid rgba(255,255,255,0.05);color:rgba(255,255,255,0.4);font-size:11px;font-family:monospace">${_quarantineFormatBytes(e.size_bytes)}</td>
<td style="padding:10px;border-bottom:1px solid rgba(255,255,255,0.05);text-align:right;white-space:nowrap">
${approveBtn}
<button class="modal-btn-secondary" style="font-size:11px;padding:5px 10px;background:rgba(248,113,113,0.15);border-color:rgba(248,113,113,0.4);color:#f87171" onclick="deleteQuarantineEntry('${_quarantineEsc(e.id)}')">Delete</button>
</td>
</tr>
`;
}).join('');
container.innerHTML = `
<p style="color:rgba(255,255,255,0.5);font-size:12px;margin-bottom:12px">
${entries.length} quarantined file${entries.length !== 1 ? 's' : ''}.
<strong style="color:rgba(255,255,255,0.7)">Approve</strong> re-runs the post-process pipeline with the failing check skipped.
<strong style="color:rgba(255,255,255,0.7)">Recover</strong> (legacy entries only) drops the file into Staging for manual import.
<strong style="color:rgba(255,255,255,0.7)">Delete</strong> removes the file permanently.
</p>
<div style="overflow:auto;max-height:60vh">
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead>
<tr style="text-align:left;color:rgba(255,255,255,0.45);font-size:10.5px;text-transform:uppercase;letter-spacing:0.06em">
<th style="padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.1)">File</th>
<th style="padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.1)">Expected Track</th>
<th style="padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.1)">Expected Artist</th>
<th style="padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.1)">Reason</th>
<th style="padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.1)">When</th>
<th style="padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.1)">Size</th>
<th style="padding:8px 10px;border-bottom:1px solid rgba(255,255,255,0.1);text-align:right">Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
</div>
`;
list.innerHTML = entries.map(renderQuarantineEntry).join('');
} catch (err) {
container.innerHTML = `<p style="color:#f87171;text-align:center;padding:40px">${_quarantineEsc(err.message || 'Network error')}</p>`;
console.error('Error loading quarantine entries:', err);
list.innerHTML = '<div class="library-history-empty">Error loading quarantine</div>';
}
}
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';
const id = escapeHtml(entry.id);
const approveLabel = entry.has_full_context ? 'Approve' : 'Recover';
const approveTitle = entry.has_full_context
? 'Re-run post-processing with only the failing check skipped'
: 'Legacy entry — move to Staging, finish via Import flow';
const approveCall = entry.has_full_context
? `approveQuarantineEntry('${id}')`
: `recoverQuarantineEntry('${id}')`;
const meta = [entry.expected_artist, entry.original_filename].filter(Boolean).join(' — ');
const triggerBadge = `<span class="library-history-badge" style="border-color:${triggerColor};color:${triggerColor}">${escapeHtml(triggerLabel)}</span>`;
const reasonDetail = `<div class="library-history-entry-source"><span class="lh-prov-label">Reason:</span> ${escapeHtml(entry.reason || 'Unknown')}</div>`;
return `<div class="library-history-entry lh-expandable" onclick="this.classList.toggle('lh-expanded')">
<div class="library-history-thumb-placeholder">🛡</div>
<div class="library-history-entry-content">
<div class="library-history-entry-row1">
<div class="library-history-entry-text">
<div class="library-history-entry-title">${escapeHtml(entry.expected_track || entry.original_filename || 'Unknown')}</div>
<div class="library-history-entry-meta">${escapeHtml(meta)}</div>
</div>
<div class="library-history-entry-badges">${triggerBadge}</div>
<div class="library-history-entry-time">${formatHistoryTime(entry.timestamp)}</div>
<button class="lh-audit-btn" title="${approveTitle}" onclick="event.stopPropagation();${approveCall}">${approveLabel}</button>
<button class="lh-audit-btn" title="Delete permanently" style="border-color:rgba(248,113,113,0.4);color:#f87171" onclick="event.stopPropagation();deleteQuarantineEntry('${id}')">Delete</button>
<span class="lh-expand-btn">&#x25BE;</span>
</div>
<div class="library-history-entry-details">
${reasonDetail}
</div>
</div>
</div>`;
}
async function approveQuarantineEntry(entryId) {
if (!confirm('Approve this file? It will be re-processed with the failing check skipped, then moved into your library.')) return;
const ok = await showConfirmDialog({
title: 'Approve Quarantined File',
message: 'Re-run post-processing for this file with only the failing check skipped. The file will be tagged, lyrics generated, and moved into your library. Other quality gates (AcoustID + bit-depth) still run.',
confirmText: 'Approve & Import',
cancelText: 'Cancel',
});
if (!ok) return;
try {
const r = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, { method: 'POST' });
const data = await r.json();
if (!data.success) {
alert(`Approve failed: ${data.error}`);
} else if (typeof showToast === 'function') {
showToast(`Approved — bypassed ${data.trigger_bypassed} check. Re-running pipeline.`, 'success');
showToast(`Approve failed: ${data.error}`, 'error');
} else {
showToast(`Approved — skipped ${data.trigger_bypassed} check, re-running pipeline.`, 'success');
}
} catch (err) {
alert(`Approve failed: ${err.message}`);
showToast(`Approve failed: ${err.message}`, 'error');
}
loadQuarantineEntries();
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) {
alert(`Recover failed: ${data.error}`);
} else if (typeof showToast === 'function') {
showToast(`Recover failed: ${data.error}`, 'error');
} else {
showToast('Moved to Staging — finish via the Import page.', 'success');
}
} catch (err) {
alert(`Recover failed: ${err.message}`);
showToast(`Recover failed: ${err.message}`, 'error');
}
loadQuarantineEntries();
loadQuarantineList();
}
async function deleteQuarantineEntry(entryId) {
if (!confirm('Delete this quarantined file permanently?')) return;
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) {
alert(`Delete failed: ${data.error}`);
showToast(`Delete failed: ${data.error}`, 'error');
} else {
showToast('Quarantined file deleted.', 'success');
}
} catch (err) {
alert(`Delete failed: ${err.message}`);
showToast(`Delete failed: ${err.message}`, 'error');
}
loadQuarantineEntries();
loadQuarantineList();
}
function closeLibraryHistoryModal() {
@ -3253,6 +3254,12 @@ 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;

Loading…
Cancel
Save