Merge feature/quarantine-ui-consolidation: consolidate quarantine UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dev 2026-06-24 15:14:59 +02:00
commit bdbeff89d6
4 changed files with 71 additions and 280 deletions

View file

@ -8158,9 +8158,6 @@
<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>

View file

@ -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
? `<button class="verif-act verif-act-ok" onclick="verifQuarApprove(${idx}, this)" title="Approve: re-import this exact file into the library, marked human-verified">✔</button>`
: `<button class="verif-act verif-act-ok" onclick="verifQuarRecover(${idx}, this)" title="Recover to Staging for a manual import (legacy entry without embedded context)">⤴</button>`;
const details = [
q.reason ? `<div><span class="verif-detail-label">Reason:</span> ${_adlEsc(q.reason)}</div>` : '',
q.source_username ? `<div><span class="verif-detail-label">Source uploader:</span> ${_adlEsc(q.source_username)}</div>` : '',
q.source_filename ? `<div><span class="verif-detail-label">Original Soulseek file:</span> ${_adlEsc(q.source_filename)}</div>` : '',
q.timestamp ? `<div><span class="verif-detail-label">Quarantined:</span> ${_adlEsc(q.timestamp)}</div>` : '',
].filter(Boolean).join('');
const detailsOpen = _verifQuarOpenDetails.has(q.id);
const artHtml = q.thumb_url
? `<img class="adl-row-art" src="${_adlEsc(q.thumb_url)}" alt="" onerror="this.style.display='none'">`
: '<div class="adl-row-art adl-row-art-empty"></div>';
return `<div class="adl-row adl-row-failed verif-quar-row" data-quarantine-id="${_adlEsc(q.id)}" onclick="verifQuarInspect(${idx})" title="Click to show/hide details (reason, source uploader, original filename)">
${artHtml}
<div class="adl-row-info">
<div class="adl-row-title">${title}</div>
${meta ? `<div class="adl-row-meta">${meta}</div>` : ''}
<div class="verif-quar-details" id="verif-quar-details-${idx}" style="display:${detailsOpen ? '' : 'none'}">${details || 'No further details in the sidecar.'}</div>
</div>
<div class="verif-actions" onclick="event.stopPropagation()">
<span class="verif-reason-badge ${trigClass}" title="${_adlEsc(q.reason || '')}">${trigLabel}</span>
${q.quality ? `<span class="adl-quality-chip" title="Audio quality of the quarantined file (read from the file itself)">${_adlEsc(q.quality)}</span>` : ''}
${timeAgo ? `<span class="verif-time">${timeAgo}</span>` : ''}
<button class="verif-act verif-act-play" onclick="verifQuarPlay(${idx})" title="Play the quarantined file in the media player"></button>
<button class="verif-act" onclick="verifQuarCompare(${idx}, this)" title="Find the expected track on Soulseek/streaming sources and play it in the media player — compare against the quarantined file"></button>
<button class="verif-act" onclick="verifQuarAudit(${idx})" title="Open the audit trail for this quarantined file (details, embedded tags, lyrics)">🔍</button>
${approveBtn}
<button class="verif-act verif-act-del" onclick="verifQuarDelete(${idx}, this)" title="Delete the quarantined file permanently">🗑</button>
</div>
</div>`;
}
function _verifQuarRows() {
if (!_verifQuarLoaded) return '<div class="adl-section-header">Loading quarantine…</div>';
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
? `<button class="verif-act verif-act-ok" onclick="verifQuarApprove(${idx}, this)" title="Approve: re-import this exact file into the library, marked human-verified">✔</button>`
: `<button class="verif-act verif-act-ok" onclick="verifQuarRecover(${idx}, this)" title="Recover to Staging for a manual import (legacy entry without embedded context)">⤴</button>`;
const details = [
q.reason ? `<div><span class="verif-detail-label">Reason:</span> ${_adlEsc(q.reason)}</div>` : '',
q.source_username ? `<div><span class="verif-detail-label">Source uploader:</span> ${_adlEsc(q.source_username)}</div>` : '',
q.source_filename ? `<div><span class="verif-detail-label">Original Soulseek file:</span> ${_adlEsc(q.source_filename)}</div>` : '',
q.timestamp ? `<div><span class="verif-detail-label">Quarantined:</span> ${_adlEsc(q.timestamp)}</div>` : '',
].filter(Boolean).join('');
const detailsOpen = _verifQuarOpenDetails.has(q.id);
const artHtml = q.thumb_url
? `<img class="adl-row-art" src="${_adlEsc(q.thumb_url)}" alt="" onerror="this.style.display='none'">`
: '<div class="adl-row-art adl-row-art-empty"></div>';
html += `<div class="adl-row adl-row-failed verif-quar-row" data-quarantine-id="${_adlEsc(q.id)}" onclick="verifQuarInspect(${idx})" title="Click to show/hide details (reason, source uploader, original filename)">
${artHtml}
<div class="adl-row-info">
<div class="adl-row-title">${title}</div>
${meta ? `<div class="adl-row-meta">${meta}</div>` : ''}
<div class="verif-quar-details" id="verif-quar-details-${idx}" style="display:${detailsOpen ? '' : 'none'}">${details || 'No further details in the sidecar.'}</div>
</div>
<div class="verif-actions" onclick="event.stopPropagation()">
<span class="verif-reason-badge ${trigClass}" title="${_adlEsc(q.reason || '')}">${trigLabel}</span>
${q.quality ? `<span class="adl-quality-chip" title="Audio quality of the quarantined file (read from the file itself)">${_adlEsc(q.quality)}</span>` : ''}
${timeAgo ? `<span class="verif-time">${timeAgo}</span>` : ''}
<button class="verif-act verif-act-play" onclick="verifQuarPlay(${idx})" title="Play the quarantined file in the media player"></button>
<button class="verif-act" onclick="verifQuarCompare(${idx}, this)" title="Find the expected track on Soulseek/streaming sources and play it in the media player — compare against the quarantined file"></button>
<button class="verif-act" onclick="verifQuarAudit(${idx})" title="Open the audit trail for this quarantined file (details, embedded tags, lyrics)">🔍</button>
${approveBtn}
<button class="verif-act verif-act-del" onclick="verifQuarDelete(${idx}, this)" title="Delete the quarantined file permanently">🗑</button>
</div>
</div>`;
});
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 += `<div class="verif-quar-group">
<div class="verif-quar-group-hdr" onclick="document.getElementById('${groupId}').classList.toggle('vqg-open')" title="${n} alternative candidates quarantined for this track">
<span class="verif-quar-group-lbl">${title}${artist}</span>
<span class="verif-quar-group-cnt">${n} alternatives &#x25BE;</span>
</div>
<div class="verif-quar-group-members" id="${groupId}">
${group.members.map(m => _verifQuarRowHtml(m, idxById.get(m.id))).join('')}
</div>
</div>`;
}
}
return html;
}

View file

@ -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);

View file

@ -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 = '<div class="library-history-loading">Loading...</div>';
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 = `<div class="library-history-empty">Error: ${escapeHtml(data.error || 'Failed to load')}</div>`;
return;
}
if (entries.length === 0) {
list.innerHTML = '<div class="library-history-empty">🛡️<br><br>No quarantined files. Nice and clean.</div>';
return;
}
list.innerHTML = _groupQuarantineEntries(entries).map(renderQuarantineGroupOrEntry).join('');
} catch (err) {
console.error('Error loading quarantine entries:', err);
list.innerHTML = '<div class="library-history-empty">Error loading quarantine</div>';
}
}
// #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
? `<img class="library-history-thumb" src="${escapeHtml(thumb)}" alt="" onerror="this.style.display='none'">`
: '<div class="library-history-thumb-placeholder">🛡️</div>';
return `<div class="lh-quarantine-group lh-qgroup-collapsed">
<div class="lh-quarantine-group-header" onclick="this.parentElement.classList.toggle('lh-qgroup-collapsed')">
${thumbHtml}
<div class="lh-quarantine-group-text">
<div class="library-history-entry-title">${escapeHtml(title)}</div>
<div class="library-history-entry-meta">${sub}</div>
</div>
<span class="lh-quarantine-group-count">${n} alternatives</span>
<span class="lh-expand-btn">&#x25BE;</span>
</div>
<div class="lh-quarantine-group-members">
${group.members.map(renderQuarantineEntry).join('')}
</div>
</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';
// 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 = `<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>`;
// 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(`<div class="library-history-entry-source"><span class="lh-prov-label">Source:</span> ${escapeHtml(entry.source_username)}</div>`);
}
if (entry.source_filename) {
sourceParts.push(`<div class="library-history-entry-source"><span class="lh-prov-label">Original filename:</span> ${escapeHtml(entry.source_filename)}</div>`);
}
const sourceDetail = sourceParts.join('');
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}" data-entry-id="${entryIdAttr}" onclick="event.stopPropagation();${approveCall}">${approveLabel}</button>
<button class="lh-audit-btn" title="Delete permanently" data-entry-id="${entryIdAttr}" style="border-color:rgba(248,113,113,0.4);color:#f87171" onclick="event.stopPropagation();deleteQuarantineEntryFromButton(this)">Delete</button>
<span class="lh-expand-btn">&#x25BE;</span>
</div>
<div class="library-history-entry-details">
${reasonDetail}
${sourceDetail}
</div>
</div>
</div>`;
}
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;