diff --git a/webui/index.html b/webui/index.html index 70e93d3f..e776eb23 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2191,7 +2191,6 @@
Recent History
-
@@ -2202,24 +2201,6 @@
- - @@ -7591,6 +7572,9 @@ +
diff --git a/webui/static/helper.js b/webui/static/helper.js index 3f3d91f5..48ed3473 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -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/`, `POST //approve`, `POST //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' }, diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index d3fefb30..c3bea000 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 = '
Loading...
'; + 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 = '

Loading…

'; 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 = `

${_quarantineEsc(data.error || 'Failed to load')}

`; + list.innerHTML = `
Error: ${escapeHtml(data.error || 'Failed to load')}
`; return; } - const entries = data.entries || []; if (entries.length === 0) { - container.innerHTML = '

No quarantined files. Nice and clean.

'; + list.innerHTML = '
🛡️

No quarantined files. Nice and clean.
'; return; } - const rows = entries.map(e => { - const approveBtn = e.has_full_context - ? `` - : ``; - return ` - - ${_quarantineEsc(e.original_filename)} - ${_quarantineEsc(e.expected_track || '—')} - ${_quarantineEsc(e.expected_artist || '—')} - ${_quarantineEsc(e.reason)} - ${_quarantineEsc(_quarantineFormatTime(e.timestamp))} - ${_quarantineFormatBytes(e.size_bytes)} - - ${approveBtn} - - - - `; - }).join(''); - container.innerHTML = ` -

- ${entries.length} quarantined file${entries.length !== 1 ? 's' : ''}. - Approve re-runs the post-process pipeline with the failing check skipped. - Recover (legacy entries only) drops the file into Staging for manual import. - Delete removes the file permanently. -

-
- - - - - - - - - - - - - ${rows} -
FileExpected TrackExpected ArtistReasonWhenSizeActions
-
- `; + list.innerHTML = entries.map(renderQuarantineEntry).join(''); } catch (err) { - container.innerHTML = `

${_quarantineEsc(err.message || 'Network error')}

`; + console.error('Error loading quarantine entries:', err); + list.innerHTML = '
Error loading quarantine
'; } } +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 = `${escapeHtml(triggerLabel)}`; + const reasonDetail = `
Reason: ${escapeHtml(entry.reason || 'Unknown')}
`; + + return `
+
🛡️
+
+
+
+
${escapeHtml(entry.expected_track || entry.original_filename || 'Unknown')}
+ +
+
${triggerBadge}
+
${formatHistoryTime(entry.timestamp)}
+ + + +
+
+ ${reasonDetail} +
+
+
`; +} + 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;