Harden quarantine approval flows

This commit is contained in:
Broque Thomas 2026-05-18 08:37:11 -07:00
parent cd715f8697
commit f25433ea57
9 changed files with 164 additions and 40 deletions

View file

@ -312,6 +312,7 @@ def build_batch_status_data(batch_id: str, batch: dict, live_transfers_lookup: d
'ui_state': task.get('ui_state', 'normal'), # normal|cancelling|cancelled
'playlist_id': task.get('playlist_id'), # For V2 system identification
'error_message': task.get('error_message'), # Surface failure reasons to UI
'quarantine_entry_id': task.get('quarantine_entry_id'),
'has_candidates': bool(task.get('cached_candidates')), # Whether search found results (for clickable review)
}
_ti = task.get('track_info') if isinstance(task.get('track_info'), dict) else {}

View file

@ -35,6 +35,7 @@ from core.imports.context import (
from core.imports.file_integrity import check_audio_integrity, resolve_duration_tolerance
from core.imports.filename import extract_track_number_from_filename
from core.imports.guards import check_flac_bit_depth, move_to_quarantine
from core.imports.quarantine import entry_id_from_quarantined_filename
from core.imports.side_effects import (
emit_track_downloaded,
record_download_provenance,
@ -79,6 +80,26 @@ __all__ = [
]
def _should_skip_quarantine_check(context: dict, check_name: str) -> bool:
bypass = context.get('_skip_quarantine_check')
if bypass == 'all':
return True
if isinstance(bypass, (list, tuple, set)):
return 'all' in bypass or check_name in bypass
return bypass == check_name
def _mark_task_quarantined(context: dict, quarantine_path: str | None) -> None:
if not quarantine_path:
return
task_id = context.get('task_id')
if not task_id:
return
with tasks_lock:
if task_id in download_tasks:
download_tasks[task_id]['quarantine_entry_id'] = entry_id_from_quarantined_filename(quarantine_path)
def build_import_pipeline_runtime(
*,
automation_engine: Any | None = None,
@ -163,11 +184,9 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
_duration_tolerance_override = resolve_duration_tolerance(
config_manager.get('post_processing.duration_tolerance_seconds', 0)
)
# Per-check quarantine bypass — set by `approve_quarantine_entry`
# when the user explicitly approves a previously-quarantined
# file. Skips ONLY the named check; other gates still run.
_bypass_check = context.get('_skip_quarantine_check')
if _bypass_check == 'integrity':
# User-approved quarantine restores can bypass quarantine gates
# for this one post-processing pass.
if _should_skip_quarantine_check(context, 'integrity'):
logger.info(f"[Integrity] Skipped (user approval) for {_basename}")
integrity = None
else:
@ -193,6 +212,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
automation_engine,
trigger='integrity',
)
_mark_task_quarantined(context, quarantine_path)
logger.error(f"File quarantined due to integrity failure: {quarantine_path}")
except Exception as quarantine_error:
logger.error(f"Quarantine failed ({quarantine_error}), deleting broken file: {file_path}")
@ -227,7 +247,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
f"drift={integrity.checks.get('length_drift_s', 'n/a')})"
)
_skip_acoustid = context.get('_skip_quarantine_check') == 'acoustid'
_skip_acoustid = _should_skip_quarantine_check(context, 'acoustid')
if _skip_acoustid:
logger.info(f"[AcoustID] Skipped (user approval) for {_basename}")
try:
@ -273,6 +293,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
automation_engine,
trigger='acoustid',
)
_mark_task_quarantined(context, quarantine_path)
logger.error(f"File quarantined due to verification failure: {quarantine_path}")
except Exception as quarantine_error:
logger.error(f"Quarantine failed ({quarantine_error}), deleting wrong file: {file_path}")
@ -444,8 +465,9 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
if context['_audio_quality']:
logger.info(f"Audio quality detected: {context['_audio_quality']}")
rejection_reason = None if context.get('_skip_quarantine_check') == 'bit_depth' else check_flac_bit_depth(file_path, context)
if context.get('_skip_quarantine_check') == 'bit_depth':
_skip_bit_depth = _should_skip_quarantine_check(context, 'bit_depth')
rejection_reason = None if _skip_bit_depth else check_flac_bit_depth(file_path, context)
if _skip_bit_depth:
logger.info(f"[BitDepth] Skipped (user approval) for {_basename}")
if rejection_reason:
try:
@ -456,6 +478,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
automation_engine,
trigger='bit_depth',
)
_mark_task_quarantined(context, quarantine_path)
logger.info(f"File quarantined due to bit depth filter: {quarantine_path}")
except Exception as quarantine_error:
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")
@ -575,8 +598,9 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
if context['_audio_quality']:
logger.info(f"Audio quality detected: {context['_audio_quality']}")
rejection_reason = None if context.get('_skip_quarantine_check') == 'bit_depth' else check_flac_bit_depth(file_path, context)
if context.get('_skip_quarantine_check') == 'bit_depth':
_skip_bit_depth = _should_skip_quarantine_check(context, 'bit_depth')
rejection_reason = None if _skip_bit_depth else check_flac_bit_depth(file_path, context)
if _skip_bit_depth:
logger.info(f"[BitDepth] Skipped (user approval) for {_basename}")
if rejection_reason:
try:
@ -587,6 +611,7 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta
automation_engine,
trigger='bit_depth',
)
_mark_task_quarantined(context, quarantine_path)
logger.info(f"File quarantined due to bit depth filter: {quarantine_path}")
except Exception as quarantine_error:
logger.error(f"Quarantine failed ({quarantine_error}), deleting file: {file_path}")

View file

@ -88,6 +88,11 @@ def _entry_id_from_filename(quarantined_filename: str) -> str:
return Path(base).stem
def entry_id_from_quarantined_filename(quarantined_filename: str) -> str:
"""Derive a quarantine entry id from a quarantined filename or path."""
return _entry_id_from_filename(os.path.basename(quarantined_filename))
def list_quarantine_entries(quarantine_dir: str) -> List[Dict[str, Any]]:
"""Enumerate quarantined files paired with their sidecars.

View file

@ -140,6 +140,7 @@ def test_task_status_includes_v2_state_fields():
'cancel_requested': True, 'cancel_timestamp': 12345,
'ui_state': 'cancelling', 'playlist_id': 'pl1',
'error_message': 'oh no', 'cached_candidates': [{'x': 1}],
'quarantine_entry_id': '20260514_120000_song',
}
batch = {'phase': 'downloading', 'queue': ['t1']}
out = st.build_batch_status_data('b1', batch, {}, deps)
@ -149,6 +150,7 @@ def test_task_status_includes_v2_state_fields():
assert t['ui_state'] == 'cancelling'
assert t['playlist_id'] == 'pl1'
assert t['error_message'] == 'oh no'
assert t['quarantine_entry_id'] == '20260514_120000_song'
assert t['has_candidates'] is True

View file

@ -4,10 +4,12 @@ import os
from core.imports.quarantine import (
approve_quarantine_entry,
delete_quarantine_entry,
entry_id_from_quarantined_filename,
list_quarantine_entries,
recover_to_staging,
serialize_quarantine_context,
)
from core.imports.pipeline import _should_skip_quarantine_check
# ──────────────────────────────────────────────────────────────────────
@ -146,6 +148,18 @@ def test_list_swallows_corrupt_sidecar_gracefully(tmp_path):
assert entries[0]["reason"] == "Unknown reason"
def test_entry_id_helper_handles_paths_and_quarantine_suffix():
path = "/music/ss_quarantine/20260514_120000_song.flac.quarantined"
assert entry_id_from_quarantined_filename(path) == "20260514_120000_song"
def test_quarantine_bypass_all_skips_every_gate():
context = {"_skip_quarantine_check": "all"}
assert _should_skip_quarantine_check(context, "integrity") is True
assert _should_skip_quarantine_check(context, "acoustid") is True
assert _should_skip_quarantine_check(context, "bit_depth") is True
# ──────────────────────────────────────────────────────────────────────
# delete_quarantine_entry
# ──────────────────────────────────────────────────────────────────────

View file

@ -6836,8 +6836,7 @@ def delete_quarantine_item(entry_id):
@app.route('/api/quarantine/<entry_id>/approve', methods=['POST'])
def approve_quarantine_item(entry_id):
"""One-click approve: restore the file and re-run post-process with the
matching per-check bypass flag set so the original quarantine trigger
is skipped. Other checks still run."""
quarantine gates skipped for this explicit user-approved pass."""
try:
from core.imports.quarantine import approve_quarantine_entry
# Restore inside the soulseek download dir so existing path-resolution
@ -6854,8 +6853,10 @@ def approve_quarantine_item(entry_id):
"error": "Cannot one-click approve — entry has thin sidecar (no embedded context). Use 'Recover to Staging' instead.",
}), 400
restored_path, context, trigger = result
# Mark the bypass so the pipeline skips the trigger that fired.
context['_skip_quarantine_check'] = trigger
# User approval means "import this file"; skip all quarantine gates
# for this one restored pass so multi-reason failures do not loop.
context['_skip_quarantine_check'] = 'all'
context['_approved_quarantine_trigger'] = trigger
# Re-dispatch through the same pipeline. Run async so the HTTP
# request returns quickly — UI polls /list to see the entry vanish.
context_key = f"approve_{entry_id}_{int(time.time())}"
@ -6863,8 +6864,8 @@ def approve_quarantine_item(entry_id):
target=lambda: _post_process_matched_download(context_key, context, restored_path),
daemon=True,
).start()
logger.info(f"[Quarantine] Approved {entry_id} (bypass={trigger}) → re-running pipeline")
return jsonify({"success": True, "trigger_bypassed": trigger})
logger.info(f"[Quarantine] Approved {entry_id} (original_trigger={trigger}, bypass=all) → re-running pipeline")
return jsonify({"success": True, "trigger_bypassed": "all", "original_trigger": trigger})
except Exception as e:
logger.error(f"[Quarantine] Error approving {entry_id}: {e}")
return jsonify({"success": False, "error": str(e)}), 500

View file

@ -1510,46 +1510,54 @@ async function selectWishlistCategory(category) {
// Sort tracks by track number
albumData.tracks.sort((a, b) => a.trackNumber - b.trackNumber);
const tracksListHTML = albumData.tracks.map(track => `
const tracksListHTML = albumData.tracks.map(track => {
const safeTrackId = escapeHtml(track.spotifyTrackId || '');
const safeTrackName = escapeHtml(track.name || 'Unknown Track');
return `
<div class="wishlist-album-track wishlist-track-item">
<label class="wishlist-checkbox-wrapper">
<input type="checkbox" class="wishlist-select-cb"
data-track-id="${track.spotifyTrackId}">
data-track-id="${safeTrackId}">
<span class="wishlist-checkbox-custom"></span>
</label>
<span class="wishlist-album-track-name">${track.name}</span>
<button class="wishlist-delete-btn wishlist-delete-btn-small" data-track-id="${track.spotifyTrackId}" title="Remove from wishlist">
<span class="wishlist-album-track-name">${safeTrackName}</span>
<button class="wishlist-delete-btn wishlist-delete-btn-small" data-track-id="${safeTrackId}" title="Remove from wishlist">
🗑
</button>
</div>
`).join('');
`;
}).join('');
// Handle missing album images with a placeholder
const safeAlbumId = escapeHtml(albumId);
const safeAlbumName = escapeHtml(albumData.albumName || 'Unknown Album');
const safeArtistName = escapeHtml(albumData.artistName || 'Unknown Artist');
const safeAlbumImage = escapeHtml(albumData.albumImage || '').replace(/'/g, '&#39;');
const albumImageStyle = albumData.albumImage
? `background-image: url('${albumData.albumImage}')`
? `background-image: url('${safeAlbumImage}')`
: `background: linear-gradient(135deg, rgba(30, 30, 30, 0.9) 0%, rgba(50, 50, 50, 0.9) 100%); display: flex; align-items: center; justify-content: center; font-size: 40px;`;
const albumImageContent = albumData.albumImage ? '' : '<span style="opacity: 0.3;">💿</span>';
albumsHTML += `
<div class="wishlist-album-card">
<div class="wishlist-album-header" data-album-id="${albumId}">
<div class="wishlist-album-header" data-album-id="${safeAlbumId}">
<label class="wishlist-checkbox-wrapper">
<input type="checkbox" class="wishlist-album-select-all-cb"
data-album-id="${albumId}">
data-album-id="${safeAlbumId}">
<span class="wishlist-checkbox-custom"></span>
</label>
<div class="wishlist-album-image" style="${albumImageStyle}">${albumImageContent}</div>
<div class="wishlist-album-info">
<div class="wishlist-album-name">${albumData.albumName}</div>
<div class="wishlist-album-artist">${albumData.artistName}</div>
<div class="wishlist-album-name">${safeAlbumName}</div>
<div class="wishlist-album-artist">${safeArtistName}</div>
<div class="wishlist-album-track-count">${albumData.tracks.length} track${albumData.tracks.length !== 1 ? 's' : ''}</div>
</div>
<button class="wishlist-delete-btn wishlist-delete-album-btn" data-album-id="${albumId}" title="Remove all tracks from album">
<button class="wishlist-delete-btn wishlist-delete-album-btn" data-album-id="${safeAlbumId}" title="Remove all tracks from album">
🗑
</button>
<div class="wishlist-album-expand-icon" id="expand-icon-${albumId}"></div>
</div>
<div class="wishlist-album-tracks" id="tracks-${albumId}" style="display: none;">
<div class="wishlist-album-tracks" id="tracks-${safeAlbumId}" style="display: none;">
${tracksListHTML}
</div>
</div>
@ -1601,20 +1609,25 @@ async function selectWishlistCategory(category) {
const albumImage = spotifyData?.album?.images?.[0]?.url || '';
const spotifyTrackId = track.spotify_track_id || track.id || '';
const safeTrackId = escapeHtml(spotifyTrackId);
const safeTrackName = escapeHtml(trackName);
const safeArtistName = escapeHtml(artistName);
const safeAlbumName = escapeHtml(albumName);
const safeAlbumImage = escapeHtml(albumImage).replace(/'/g, '&#39;');
tracksHTML += `
<div class="playlist-track-item-with-image wishlist-track-item">
<label class="wishlist-checkbox-wrapper">
<input type="checkbox" class="wishlist-select-cb"
data-track-id="${spotifyTrackId}">
data-track-id="${safeTrackId}">
<span class="wishlist-checkbox-custom"></span>
</label>
<div class="playlist-track-image" style="background-image: url('${albumImage}')"></div>
<div class="playlist-track-image" style="background-image: url('${safeAlbumImage}')"></div>
<div class="playlist-track-info">
<div class="playlist-track-name">${trackName}</div>
<div class="playlist-track-artist">${artistName} ${albumName}</div>
<div class="playlist-track-name">${safeTrackName}</div>
<div class="playlist-track-artist">${safeArtistName} ${safeAlbumName}</div>
</div>
<button class="wishlist-delete-btn" data-track-id="${spotifyTrackId}" title="Remove from wishlist">
<button class="wishlist-delete-btn" data-track-id="${safeTrackId}" title="Remove from wishlist">
🗑
</button>
</div>
@ -3250,6 +3263,41 @@ async function downloadCandidate(taskId, candidate, trackName) {
}
}
async function approveQuarantineFromDownloadRow(button) {
const entryId = button?.dataset?.entryId || '';
if (!entryId) {
showToast('Open Quarantine to approve this file.', 'warning');
return;
}
const confirmed = await showConfirmDialog({
title: 'Approve Quarantined File',
message: 'Import this quarantined file and skip quarantine checks for this approved pass?',
confirmText: 'Approve & Import',
cancelText: 'Cancel',
});
if (!confirmed) return;
const originalText = button.textContent;
button.disabled = true;
button.textContent = 'Approving...';
try {
const response = await fetch(`/api/quarantine/${encodeURIComponent(entryId)}/approve`, { method: 'POST' });
const data = await response.json();
if (data.success) {
showToast('Approved quarantined file. Re-running post-processing.', 'success');
} else {
showToast(`Approve failed: ${data.error || 'Unknown error'}`, 'error');
button.disabled = false;
button.textContent = originalText;
}
} catch (error) {
showToast(`Approve failed: ${error.message}`, 'error');
button.disabled = false;
button.textContent = originalText;
}
}
function closeCandidatesModal() {
const overlay = document.getElementById('candidates-modal-overlay');
if (overlay) {
@ -3374,6 +3422,7 @@ function processModalStatusUpdate(playlistId, data) {
const actionsEl = document.getElementById(`actions-${playlistId}-${task.track_index}`);
let statusText = '';
let isQuarantinedTask = false;
// V2 SYSTEM: Handle UI state override for cancelling tasks
if (isV2Task && uiState === 'cancelling' && task.status !== 'cancelled') {
statusText = '🔄 Cancelling...';
@ -3390,6 +3439,7 @@ function processModalStatusUpdate(playlistId, data) {
// failures — the file is recoverable, not lost.
const _em = (task.error_message || '').toLowerCase();
if (_em.includes('integrity check failed') || _em.includes('bit depth filter') || _em.includes('verification failed') || _em.includes('quarantin')) {
isQuarantinedTask = true;
statusText = '🛡️ Quarantined';
} else {
statusText = '❌ Failed';
@ -3437,6 +3487,13 @@ function processModalStatusUpdate(playlistId, data) {
const onclickHandler = isV2Task ? 'cancelTrackDownloadV2' : 'cancelTrackDownload';
actionsEl.innerHTML = `<button class="cancel-track-btn" title="Cancel this download" onclick="${onclickHandler}('${playlistId}', ${task.track_index})">×</button>`;
}
} else if (actionsEl && task.status === 'failed' && isQuarantinedTask && task.quarantine_entry_id) {
const entryId = escapeHtml(task.quarantine_entry_id);
actionsEl.innerHTML = `<button class="approve-quarantine-inline-btn" data-entry-id="${entryId}" title="Approve quarantined file">Approve</button>`;
const approveBtn = actionsEl.querySelector('.approve-quarantine-inline-btn');
if (approveBtn) {
approveBtn.addEventListener('click', () => approveQuarantineFromDownloadRow(approveBtn));
}
} else if (actionsEl && ['completed', 'failed', 'cancelled', 'not_found', 'post_processing'].includes(task.status)) {
actionsEl.innerHTML = '-'; // No actions available for terminal or processing states
}
@ -3535,12 +3592,9 @@ function processModalStatusUpdate(playlistId, data) {
setTimeout(() => loadServerPlaylists(), 2000);
}
// Auto-close wishlist modal when completed (for auto-processing)
// Keep visible wishlist results open so failed tracks can be reviewed.
if (playlistId === 'wishlist') {
console.log('🔄 [Auto-Wishlist] Auto-closing completed wishlist modal to enable next cycle');
setTimeout(() => {
closeDownloadMissingModal(playlistId);
}, 3000); // 3-second delay to show completion message
console.log('[Wishlist] Leaving completed wishlist modal open for failed-track review');
}
// Check if any other processes still need polling

View file

@ -19904,6 +19904,28 @@ body.helper-mode-active #dashboard-activity-feed:hover {
transform: scale(1.05);
}
.approve-quarantine-inline-btn {
background: var(--accent-color, #1db954);
color: #ffffff;
border: none;
border-radius: 6px;
padding: 4px 10px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.approve-quarantine-inline-btn:hover:not(:disabled) {
filter: brightness(1.1);
transform: scale(1.04);
}
.approve-quarantine-inline-btn:disabled {
cursor: default;
opacity: 0.65;
}
/* Modal Footer */
.download-missing-modal-footer {
background: linear-gradient(135deg,

View file

@ -3143,7 +3143,7 @@ function renderQuarantineEntry(entry) {
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 only the failing check skipped'
? '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)'
@ -3206,7 +3206,7 @@ function deleteQuarantineEntryFromButton(button) {
async function approveQuarantineEntry(entryId) {
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.',
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',
});