Add mass orphan safety guard to prevent accidental library deletion
When >50% of files are flagged as orphans (likely a DB path mismatch), findings are marked as warnings with mass_orphan flag. Fixing these requires typing "witness me" to confirm — prevents nuking an entire library from a false-positive orphan scan.
This commit is contained in:
parent
ade189fa38
commit
e2345c659d
2 changed files with 167 additions and 39 deletions
|
|
@ -81,6 +81,8 @@ class OrphanFileDetectorJob(RepairJob):
|
||||||
if context.report_progress:
|
if context.report_progress:
|
||||||
context.report_progress(phase=f'Checking {total} files...', total=total)
|
context.report_progress(phase=f'Checking {total} files...', total=total)
|
||||||
|
|
||||||
|
orphan_files = []
|
||||||
|
|
||||||
for i, fpath in enumerate(audio_files):
|
for i, fpath in enumerate(audio_files):
|
||||||
if context.check_stop():
|
if context.check_stop():
|
||||||
return result
|
return result
|
||||||
|
|
@ -107,41 +109,62 @@ class OrphanFileDetectorJob(RepairJob):
|
||||||
break
|
break
|
||||||
|
|
||||||
if not is_known:
|
if not is_known:
|
||||||
# This file is an orphan — create finding
|
orphan_files.append(fpath)
|
||||||
if context.report_progress:
|
|
||||||
context.report_progress(
|
|
||||||
log_line=f'Orphan: {os.path.basename(fpath)}',
|
|
||||||
log_type='skip'
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
stat = os.stat(fpath)
|
|
||||||
ext = os.path.splitext(fpath)[1].lower().lstrip('.')
|
|
||||||
if context.create_finding:
|
|
||||||
context.create_finding(
|
|
||||||
job_id=self.job_id,
|
|
||||||
finding_type='orphan_file',
|
|
||||||
severity='info',
|
|
||||||
entity_type='file',
|
|
||||||
entity_id=None,
|
|
||||||
file_path=fpath,
|
|
||||||
title=f'Orphan file: {os.path.basename(fpath)}',
|
|
||||||
description=f'Audio file in transfer folder is not tracked in the database',
|
|
||||||
details={
|
|
||||||
'file_size': stat.st_size,
|
|
||||||
'format': ext,
|
|
||||||
'modified': time.strftime('%Y-%m-%d %H:%M:%S',
|
|
||||||
time.localtime(stat.st_mtime)),
|
|
||||||
'folder': os.path.dirname(fpath),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
result.findings_created += 1
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Error creating orphan finding for %s: %s", fpath, e)
|
|
||||||
result.errors += 1
|
|
||||||
|
|
||||||
if context.update_progress and (i + 1) % 50 == 0:
|
if context.update_progress and (i + 1) % 50 == 0:
|
||||||
context.update_progress(i + 1, total)
|
context.update_progress(i + 1, total)
|
||||||
|
|
||||||
|
# Safety check: if most files look like orphans, it's probably a path
|
||||||
|
# mismatch between the DB and filesystem — not actual orphans.
|
||||||
|
orphan_ratio = len(orphan_files) / total if total else 0
|
||||||
|
mass_orphan = orphan_ratio > 0.5 and len(orphan_files) > 20
|
||||||
|
|
||||||
|
if mass_orphan:
|
||||||
|
logger.warning(
|
||||||
|
"Mass orphan warning: %d of %d files (%.0f%%) flagged as orphans — "
|
||||||
|
"this likely indicates a DB path mismatch, not actual orphans",
|
||||||
|
len(orphan_files), total, orphan_ratio * 100
|
||||||
|
)
|
||||||
|
|
||||||
|
for fpath in orphan_files:
|
||||||
|
if context.report_progress:
|
||||||
|
context.report_progress(
|
||||||
|
log_line=f'Orphan: {os.path.basename(fpath)}',
|
||||||
|
log_type='skip'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
stat = os.stat(fpath)
|
||||||
|
ext = os.path.splitext(fpath)[1].lower().lstrip('.')
|
||||||
|
if context.create_finding:
|
||||||
|
context.create_finding(
|
||||||
|
job_id=self.job_id,
|
||||||
|
finding_type='orphan_file',
|
||||||
|
severity='warning' if mass_orphan else 'info',
|
||||||
|
entity_type='file',
|
||||||
|
entity_id=None,
|
||||||
|
file_path=fpath,
|
||||||
|
title=f'Orphan file: {os.path.basename(fpath)}',
|
||||||
|
description=(
|
||||||
|
'Audio file in transfer folder is not tracked in the database. '
|
||||||
|
'WARNING: Mass orphan detection triggered — this may be a path '
|
||||||
|
'mismatch, not actual orphans. Verify before deleting!'
|
||||||
|
) if mass_orphan else (
|
||||||
|
'Audio file in transfer folder is not tracked in the database'
|
||||||
|
),
|
||||||
|
details={
|
||||||
|
'file_size': stat.st_size,
|
||||||
|
'format': ext,
|
||||||
|
'modified': time.strftime('%Y-%m-%d %H:%M:%S',
|
||||||
|
time.localtime(stat.st_mtime)),
|
||||||
|
'folder': os.path.dirname(fpath),
|
||||||
|
'mass_orphan': mass_orphan,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result.findings_created += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Error creating orphan finding for %s: %s", fpath, e)
|
||||||
|
result.errors += 1
|
||||||
|
|
||||||
if context.update_progress:
|
if context.update_progress:
|
||||||
context.update_progress(total, total)
|
context.update_progress(total, total)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,92 @@ function resolveConfirmDialog(result) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nuclear confirmation dialog for mass-destructive operations.
|
||||||
|
* User must type an exact phrase to proceed.
|
||||||
|
*/
|
||||||
|
function showWitnessMeDialog(orphanCount) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'confirm-modal-overlay';
|
||||||
|
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center;';
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div style="background:var(--bg-secondary, #1e1e2e);border:2px solid #e74c3c;border-radius:12px;padding:28px;max-width:480px;width:90%;color:var(--text-primary, #fff);font-family:inherit;">
|
||||||
|
<h3 style="margin:0 0 8px;color:#e74c3c;font-size:1.2em;">Mass Deletion Warning</h3>
|
||||||
|
<p style="margin:0 0 12px;font-size:0.95em;opacity:0.9;">
|
||||||
|
You are about to <strong>permanently delete ${orphanCount.toLocaleString()} files</strong> from your disk.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 12px;font-size:0.9em;opacity:0.75;">
|
||||||
|
This many orphans usually means a path mismatch between your database and filesystem
|
||||||
|
— not actual orphan files. A previous user lost their entire library this way.
|
||||||
|
</p>
|
||||||
|
<p style="margin:0 0 6px;font-size:0.9em;opacity:0.9;">
|
||||||
|
To confirm you understand the risk, type <strong style="color:#e74c3c;">witness me</strong> below:
|
||||||
|
</p>
|
||||||
|
<input type="text" id="witness-me-input" autocomplete="off" spellcheck="false"
|
||||||
|
placeholder="Type the phrase here..."
|
||||||
|
style="width:100%;padding:10px;border:1px solid #555;border-radius:6px;background:var(--bg-primary, #111);color:var(--text-primary, #fff);font-size:1em;margin:8px 0 16px;box-sizing:border-box;">
|
||||||
|
<div style="display:flex;gap:10px;justify-content:flex-end;">
|
||||||
|
<button id="witness-cancel" style="padding:8px 20px;border:1px solid #555;border-radius:6px;background:transparent;color:var(--text-primary, #fff);cursor:pointer;font-size:0.9em;">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button id="witness-confirm" disabled
|
||||||
|
style="padding:8px 20px;border:none;border-radius:6px;background:#555;color:#888;cursor:not-allowed;font-size:0.9em;font-weight:600;transition:all 0.2s;">
|
||||||
|
Delete Files
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const input = overlay.querySelector('#witness-me-input');
|
||||||
|
const confirmBtn = overlay.querySelector('#witness-confirm');
|
||||||
|
const cancelBtn = overlay.querySelector('#witness-cancel');
|
||||||
|
|
||||||
|
input.addEventListener('input', () => {
|
||||||
|
const match = input.value.trim().toLowerCase() === 'witness me';
|
||||||
|
confirmBtn.disabled = !match;
|
||||||
|
confirmBtn.style.background = match ? '#e74c3c' : '#555';
|
||||||
|
confirmBtn.style.color = match ? '#fff' : '#888';
|
||||||
|
confirmBtn.style.cursor = match ? 'pointer' : 'not-allowed';
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmBtn.addEventListener('click', () => {
|
||||||
|
document.body.removeChild(overlay);
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', () => {
|
||||||
|
document.body.removeChild(overlay);
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) {
|
||||||
|
document.body.removeChild(overlay);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => input.focus(), 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASS_ORPHAN_THRESHOLD = 20;
|
||||||
|
|
||||||
|
function _isMassOrphanFix(jobId, count) {
|
||||||
|
if (count <= MASS_ORPHAN_THRESHOLD) return false;
|
||||||
|
// Only trigger if mass_orphan flag is actually set on visible findings
|
||||||
|
// (flag is set by backend when >50% of files are orphans — likely path mismatch)
|
||||||
|
if (jobId === 'orphan_file_detector' || !jobId) {
|
||||||
|
const massCards = document.querySelectorAll('.repair-finding-card[data-mass-orphan="true"]');
|
||||||
|
if (massCards.length > 0) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
// WEBSOCKET CONNECTION MANAGER
|
// WEBSOCKET CONNECTION MANAGER
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
@ -52848,7 +52934,7 @@ async function loadRepairFindings() {
|
||||||
const filePath = f.file_path || d.original_path || d.file_path || '';
|
const filePath = f.file_path || d.original_path || d.file_path || '';
|
||||||
const fixLabel = fixableTypes[f.finding_type];
|
const fixLabel = fixableTypes[f.finding_type];
|
||||||
|
|
||||||
return `<div class="repair-finding-card ${f.severity}" data-id="${f.id}">
|
return `<div class="repair-finding-card ${f.severity}" data-id="${f.id}" data-job-id="${f.job_id}" data-mass-orphan="${!!(d.mass_orphan)}">
|
||||||
<div class="repair-finding-main" onclick="toggleFindingDetail(${f.id})">
|
<div class="repair-finding-main" onclick="toggleFindingDetail(${f.id})">
|
||||||
<div class="repair-finding-select" onclick="event.stopPropagation()">
|
<div class="repair-finding-select" onclick="event.stopPropagation()">
|
||||||
<input type="checkbox" onchange="toggleFindingSelect(${f.id}, this.checked)">
|
<input type="checkbox" onchange="toggleFindingSelect(${f.id}, this.checked)">
|
||||||
|
|
@ -53227,13 +53313,18 @@ async function fixAllMatchingFindings() {
|
||||||
const jobId = jobFilter ? jobFilter.value : '';
|
const jobId = jobFilter ? jobFilter.value : '';
|
||||||
const severity = severityFilter ? severityFilter.value : '';
|
const severity = severityFilter ? severityFilter.value : '';
|
||||||
|
|
||||||
const scopeLabel = jobId ? jobId.replace(/_/g, ' ') : 'all jobs';
|
// Mass orphan safety gate
|
||||||
if (!await showConfirmDialog({
|
if (_isMassOrphanFix(jobId, _repairFindingsTotal)) {
|
||||||
title: 'Fix All Findings',
|
if (!await showWitnessMeDialog(_repairFindingsTotal)) return;
|
||||||
message: `Apply fixes to all ${_repairFindingsTotal} pending fixable findings for ${scopeLabel}? This may delete files or remove database entries depending on finding type.`,
|
} else {
|
||||||
confirmText: 'Fix All',
|
const scopeLabel = jobId ? jobId.replace(/_/g, ' ') : 'all jobs';
|
||||||
destructive: true
|
if (!await showConfirmDialog({
|
||||||
})) return;
|
title: 'Fix All Findings',
|
||||||
|
message: `Apply fixes to all ${_repairFindingsTotal} pending fixable findings for ${scopeLabel}? This may delete files or remove database entries depending on finding type.`,
|
||||||
|
confirmText: 'Fix All',
|
||||||
|
destructive: true
|
||||||
|
})) return;
|
||||||
|
}
|
||||||
|
|
||||||
showToast(`Fixing ${_repairFindingsTotal} findings...`, 'info');
|
showToast(`Fixing ${_repairFindingsTotal} findings...`, 'info');
|
||||||
|
|
||||||
|
|
@ -53375,6 +53466,20 @@ async function bulkRepairAction(action) {
|
||||||
async function bulkFixFindings() {
|
async function bulkFixFindings() {
|
||||||
if (_repairSelectedFindings.size === 0) return;
|
if (_repairSelectedFindings.size === 0) return;
|
||||||
const ids = Array.from(_repairSelectedFindings);
|
const ids = Array.from(_repairSelectedFindings);
|
||||||
|
|
||||||
|
// Mass orphan safety gate — check if selected findings include mass-orphan flagged cards
|
||||||
|
const selectedOrphanCards = ids.filter(id => {
|
||||||
|
const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`);
|
||||||
|
return card && card.dataset.jobId === 'orphan_file_detector';
|
||||||
|
});
|
||||||
|
if (selectedOrphanCards.length > MASS_ORPHAN_THRESHOLD) {
|
||||||
|
const hasMassFlag = ids.some(id => {
|
||||||
|
const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`);
|
||||||
|
return card && card.dataset.massOrphan === 'true';
|
||||||
|
});
|
||||||
|
if (hasMassFlag && !await showWitnessMeDialog(selectedOrphanCards.length)) return;
|
||||||
|
}
|
||||||
|
|
||||||
let fixed = 0, failed = 0;
|
let fixed = 0, failed = 0;
|
||||||
showToast(`Fixing ${ids.length} findings...`, 'info');
|
showToast(`Fixing ${ids.length} findings...`, 'info');
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue