Add fix actions for maintenance findings (dead files, orphans, duplicates, etc.)

- New fix_finding() dispatcher in repair_worker with per-type handlers:
  dead_file (remove DB entry), orphan_file (delete from disk),
  track_number_mismatch (update DB), missing_cover_art (apply artwork URL),
  metadata_gap (apply found fields), duplicate_tracks (keep best quality)
- New POST /api/repair/findings/<id>/fix endpoint
- Frontend: contextual fix buttons per finding type, bulk "Fix Selected"
- Removed path_mismatch from fixable types (dry-run preview only)
This commit is contained in:
Broque Thomas 2026-03-15 12:51:18 -07:00
parent 61ed4086e0
commit 7a706e8c11
5 changed files with 322 additions and 3 deletions

View file

@ -698,6 +698,222 @@ class RepairWorker:
if conn:
conn.close()
def fix_finding(self, finding_id: int) -> dict:
"""Execute the appropriate fix action for a finding, then mark it resolved."""
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, job_id, finding_type, entity_type, entity_id,
file_path, details_json
FROM repair_findings WHERE id = ? AND status = 'pending'
""", (finding_id,))
row = cursor.fetchone()
if not row:
return {'success': False, 'error': 'Finding not found or already resolved'}
fid, job_id, finding_type, entity_type, entity_id, file_path, details_json = row
details = json.loads(details_json) if details_json else {}
conn.close()
conn = None
# Dispatch fix by finding type
result = self._execute_fix(finding_type, entity_type, entity_id, file_path, details)
if result.get('success'):
self.resolve_finding(finding_id, action=result.get('action', 'auto_fix'))
return result
except Exception as e:
logger.error("Error fixing finding %s: %s", finding_id, e, exc_info=True)
return {'success': False, 'error': str(e)}
finally:
if conn:
conn.close()
def _execute_fix(self, finding_type: str, entity_type: str, entity_id: str,
file_path: str, details: dict) -> dict:
"""Route a fix to the correct handler based on finding_type."""
handlers = {
'dead_file': self._fix_dead_file,
'orphan_file': self._fix_orphan_file,
'track_number_mismatch': self._fix_track_number,
'missing_cover_art': self._fix_missing_cover_art,
'metadata_gap': self._fix_metadata_gap,
'duplicate_tracks': self._fix_duplicates,
}
handler = handlers.get(finding_type)
if not handler:
return {'success': False, 'error': f'No fix available for finding type: {finding_type}'}
return handler(entity_type, entity_id, file_path, details)
def _fix_dead_file(self, entity_type, entity_id, file_path, details):
"""Remove the dead track entry from the database."""
if not entity_id:
return {'success': False, 'error': 'No track ID associated with this finding'}
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM tracks WHERE id = ?", (entity_id,))
conn.commit()
if cursor.rowcount > 0:
return {'success': True, 'action': 'removed_db_entry',
'message': 'Removed dead track entry from database'}
return {'success': False, 'error': 'Track not found in database'}
finally:
if conn:
conn.close()
def _fix_orphan_file(self, entity_type, entity_id, file_path, details):
"""Delete the orphan file from disk."""
if not file_path:
return {'success': False, 'error': 'No file path associated with this finding'}
try:
if os.path.exists(file_path):
os.remove(file_path)
# Clean up empty parent directories
parent = os.path.dirname(file_path)
for _ in range(3): # Up to 3 levels (track/album/artist)
if parent and os.path.isdir(parent) and not os.listdir(parent):
os.rmdir(parent)
parent = os.path.dirname(parent)
else:
break
return {'success': True, 'action': 'deleted_file',
'message': 'Deleted orphan file from disk'}
return {'success': True, 'action': 'already_gone',
'message': 'File was already removed'}
except OSError as e:
return {'success': False, 'error': f'Failed to delete file: {e}'}
def _fix_track_number(self, entity_type, entity_id, file_path, details):
"""Update track number in the database to the correct value."""
correct_num = details.get('correct_track_num')
if correct_num is None:
return {'success': False, 'error': 'No correct track number in finding details'}
if not entity_id:
return {'success': False, 'error': 'No track ID associated with this finding'}
result = self.db.update_track_fields(int(entity_id), {'track_number': int(correct_num)})
if result.get('success'):
return {'success': True, 'action': 'fixed_track_number',
'message': f'Updated track number to {correct_num}'}
return {'success': False, 'error': result.get('error', 'Failed to update track number')}
def _fix_missing_cover_art(self, entity_type, entity_id, file_path, details):
"""Update album thumbnail URL from the found artwork."""
artwork_url = details.get('found_artwork_url')
if not artwork_url:
return {'success': False, 'error': 'No artwork URL found in finding details'}
album_id = details.get('album_id') or entity_id
if not album_id:
return {'success': False, 'error': 'No album ID associated with this finding'}
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
cursor.execute("UPDATE albums SET thumb_url = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(artwork_url, album_id))
conn.commit()
if cursor.rowcount > 0:
return {'success': True, 'action': 'applied_cover_art',
'message': 'Applied cover art to album'}
return {'success': False, 'error': 'Album not found in database'}
finally:
if conn:
conn.close()
def _fix_metadata_gap(self, entity_type, entity_id, file_path, details):
"""Apply found metadata fields to the track."""
found_fields = details.get('found_fields')
if not found_fields or not isinstance(found_fields, dict):
return {'success': False, 'error': 'No metadata fields found in finding details'}
if not entity_id:
return {'success': False, 'error': 'No track ID associated with this finding'}
# Map found_fields to DB-updatable fields
field_map = {
'bpm': 'bpm', 'tempo': 'bpm',
'explicit': 'explicit',
'style': 'style', 'mood': 'mood',
}
updates = {}
for key, value in found_fields.items():
db_field = field_map.get(key.lower())
if db_field:
updates[db_field] = value
# Handle non-whitelisted fields via direct SQL
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
direct_fields = {}
for key, value in found_fields.items():
lk = key.lower()
if lk in ('isrc', 'spotify_track_id', 'musicbrainz_recording_id'):
direct_fields[lk] = value
if direct_fields:
set_parts = [f"{k} = ?" for k in direct_fields]
vals = list(direct_fields.values()) + [entity_id]
cursor.execute(
f"UPDATE tracks SET {', '.join(set_parts)}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
vals
)
conn.commit()
if updates:
conn.close()
conn = None
self.db.update_track_fields(int(entity_id), updates)
applied = list(updates.keys()) + list(direct_fields.keys())
if applied:
return {'success': True, 'action': 'applied_metadata',
'message': f'Applied metadata: {", ".join(applied)}'}
return {'success': False, 'error': 'No applicable metadata fields to update'}
finally:
if conn:
conn.close()
def _fix_duplicates(self, entity_type, entity_id, file_path, details):
"""Keep the best quality duplicate and remove the rest from the database."""
tracks = details.get('tracks', [])
if len(tracks) < 2:
return {'success': False, 'error': 'Not enough duplicate info to determine best copy'}
# Pick best: highest bitrate, then longest duration
best = max(tracks, key=lambda t: (t.get('bitrate', 0) or 0, t.get('duration', 0) or 0))
best_id = best.get('track_id') or best.get('id')
if not best_id:
return {'success': False, 'error': 'Could not determine best track ID'}
remove_ids = []
for t in tracks:
tid = t.get('track_id') or t.get('id')
if tid and str(tid) != str(best_id):
remove_ids.append(tid)
if not remove_ids:
return {'success': False, 'error': 'No duplicates to remove'}
conn = None
try:
conn = self.db._get_connection()
cursor = conn.cursor()
placeholders = ','.join(['?'] * len(remove_ids))
cursor.execute(f"DELETE FROM tracks WHERE id IN ({placeholders})", remove_ids)
conn.commit()
removed = cursor.rowcount
return {'success': True, 'action': 'removed_duplicates',
'message': f'Kept best quality copy, removed {removed} duplicate(s)'}
finally:
if conn:
conn.close()
def dismiss_finding(self, finding_id: int) -> bool:
"""Dismiss a finding."""
conn = None

View file

@ -37675,6 +37675,19 @@ def repair_findings_counts():
logger.error(f"Error getting findings counts: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/repair/findings/<int:finding_id>/fix', methods=['POST'])
def repair_finding_fix(finding_id):
"""Execute the actual fix action for a finding"""
try:
if repair_worker is None:
return jsonify({'error': 'Repair worker not initialized'}), 400
result = repair_worker.fix_finding(finding_id)
return jsonify(result), 200 if result.get('success') else 400
except Exception as e:
logger.error(f"Error fixing finding {finding_id}: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/repair/findings/<int:finding_id>/resolve', methods=['POST'])
def repair_finding_resolve(finding_id):
"""Resolve a finding with optional action"""

View file

@ -5660,8 +5660,8 @@
</select>
</div>
<div class="repair-findings-bulk" id="repair-findings-bulk" style="display:none;">
<button class="repair-bulk-btn fix" onclick="bulkFixFindings()">Fix Selected</button>
<button class="repair-bulk-btn" onclick="bulkRepairAction('dismiss')">Dismiss Selected</button>
<button class="repair-bulk-btn" onclick="bulkRepairAction('resolve')">Resolve Selected</button>
</div>
</div>
<div class="repair-findings-list" id="repair-findings-list">

View file

@ -50425,6 +50425,16 @@ async function loadRepairFindings() {
missing_cover_art: 'Missing Art', track_number_mismatch: 'Track Number'
};
// Finding types that have an automated fix action
const fixableTypes = {
dead_file: 'Remove Entry',
orphan_file: 'Delete File',
track_number_mismatch: 'Fix',
missing_cover_art: 'Apply Art',
metadata_gap: 'Apply',
duplicate_tracks: 'Keep Best',
};
container.innerHTML = items.map(f => {
const icon = severityIcons[f.severity] || '';
const age = formatCacheAge(f.created_at);
@ -50433,6 +50443,7 @@ async function loadRepairFindings() {
const typeLabel = typeLabels[f.finding_type] || f.finding_type.replace(/_/g, ' ');
const d = f.details || {};
const filePath = f.file_path || d.original_path || d.file_path || '';
const fixLabel = fixableTypes[f.finding_type];
return `<div class="repair-finding-card ${f.severity}" data-id="${f.id}">
<div class="repair-finding-main" onclick="toggleFindingDetail(${f.id})">
@ -50459,7 +50470,7 @@ async function loadRepairFindings() {
</div>
<div class="repair-finding-actions" onclick="event.stopPropagation()">
${f.status === 'pending' ? `
<button class="repair-finding-btn resolve" onclick="resolveRepairFinding(${f.id})" title="Resolve">&#10003;</button>
${fixLabel ? `<button class="repair-finding-btn fix" onclick="fixRepairFinding(${f.id})" title="${fixLabel}">${_escFinding(fixLabel)}</button>` : ''}
<button class="repair-finding-btn dismiss" onclick="dismissRepairFinding(${f.id})" title="Dismiss">&times;</button>
` : ''}
<button class="repair-finding-expand-btn" data-finding="${f.id}" title="Details">&#9660;</button>
@ -50689,6 +50700,34 @@ function renderRepairFindingsPagination(total, currentPage) {
container.innerHTML = html;
}
async function fixRepairFinding(id) {
const card = document.querySelector(`.repair-finding-card[data-id="${id}"]`);
const fixBtn = card ? card.querySelector('.repair-finding-btn.fix') : null;
if (fixBtn) {
fixBtn.disabled = true;
fixBtn.textContent = '...';
}
try {
const response = await fetch(`/api/repair/findings/${id}/fix`, { method: 'POST' });
const result = await response.json();
if (result.success) {
showToast(result.message || 'Fixed successfully', 'success');
} else {
showToast(result.error || 'Fix failed', 'error');
}
loadRepairFindingsDashboard();
loadRepairFindings();
updateRepairStatus();
} catch (error) {
console.error('Error fixing finding:', error);
showToast('Error applying fix', 'error');
if (fixBtn) {
fixBtn.disabled = false;
fixBtn.textContent = 'Fix';
}
}
}
async function resolveRepairFinding(id) {
try {
await fetch(`/api/repair/findings/${id}/resolve`, { method: 'POST' });
@ -50730,6 +50769,30 @@ async function bulkRepairAction(action) {
}
}
async function bulkFixFindings() {
if (_repairSelectedFindings.size === 0) return;
const ids = Array.from(_repairSelectedFindings);
let fixed = 0, failed = 0;
showToast(`Fixing ${ids.length} findings...`, 'info');
for (const id of ids) {
try {
const response = await fetch(`/api/repair/findings/${id}/fix`, { method: 'POST' });
const result = await response.json();
if (result.success) fixed++;
else failed++;
} catch {
failed++;
}
}
_repairSelectedFindings.clear();
showToast(`Fixed ${fixed}${failed ? `, ${failed} failed` : ''}`, fixed > 0 ? 'success' : 'error');
loadRepairFindingsDashboard();
loadRepairFindings();
updateRepairStatus();
}
async function loadRepairHistory() {
const container = document.getElementById('repair-history-list');
if (!container) return;

View file

@ -40308,6 +40308,17 @@ tr.tag-diff-same {
color: #fff;
}
.repair-bulk-btn.fix {
background: rgba(var(--accent-rgb, 99, 102, 241), 0.12);
border-color: rgba(var(--accent-rgb, 99, 102, 241), 0.3);
color: rgb(var(--accent-light-rgb, 129, 140, 248));
font-weight: 600;
}
.repair-bulk-btn.fix:hover {
background: rgba(var(--accent-rgb, 99, 102, 241), 0.2);
}
.repair-findings-list {
display: flex;
flex-direction: column;
@ -40430,7 +40441,7 @@ tr.tag-diff-same {
background: none;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 5px;
width: 26px;
min-width: 26px;
height: 26px;
cursor: pointer;
display: flex;
@ -40439,6 +40450,22 @@ tr.tag-diff-same {
font-size: 13px;
transition: all 0.2s ease;
}
.repair-finding-btn.fix {
color: rgb(var(--accent-light-rgb, 129, 140, 248));
border-color: rgba(var(--accent-rgb, 99, 102, 241), 0.25);
font-size: 10px;
font-weight: 600;
padding: 0 8px;
letter-spacing: 0.02em;
}
.repair-finding-btn.fix:hover {
background: rgba(var(--accent-rgb, 99, 102, 241), 0.15);
border-color: rgba(var(--accent-rgb, 99, 102, 241), 0.4);
}
.repair-finding-btn.fix:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.repair-finding-btn.resolve {
color: #22c55e;
}