diff --git a/core/repair_worker.py b/core/repair_worker.py index 291f15f1..e5c53e12 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -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 diff --git a/web_server.py b/web_server.py index e0d0ab98..e8c6dcf2 100644 --- a/web_server.py +++ b/web_server.py @@ -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//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//resolve', methods=['POST']) def repair_finding_resolve(finding_id): """Resolve a finding with optional action""" diff --git a/webui/index.html b/webui/index.html index a7f71c10..dffb0629 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5660,8 +5660,8 @@
diff --git a/webui/static/script.js b/webui/static/script.js index 190dae56..39353a17 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 `
@@ -50459,7 +50470,7 @@ async function loadRepairFindings() {
${f.status === 'pending' ? ` - + ${fixLabel ? `` : ''} ` : ''} @@ -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; diff --git a/webui/static/style.css b/webui/static/style.css index a42af26f..705826c9 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; }