From 61ed4086e0f7f4996ee6c8740c49703837670c67 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:20:02 -0700 Subject: [PATCH] Redesign maintenance findings/history tabs with dashboard & fix path resolution bugs - Add findings dashboard with summary stats and per-job clickable filter chips - Redesign findings cards with expandable detail panels and per-type renderers - Redesign history tab with status dots, stat pills, and full timestamps - Fix dead file cleaner false positives by using suffix-based path resolution - Fix orphan file detector false positives by matching via path suffixes - Add help text modal for each repair job card - Enlarge maintenance modal (1100px wide, 90vh tall) --- core/repair_jobs/dead_file_cleaner.py | 37 +- core/repair_jobs/orphan_file_detector.py | 28 +- core/repair_worker.py | 41 ++- webui/index.html | 13 +- webui/static/script.js | 344 +++++++++++++++-- webui/static/style.css | 447 ++++++++++++++++++++--- 6 files changed, 808 insertions(+), 102 deletions(-) diff --git a/core/repair_jobs/dead_file_cleaner.py b/core/repair_jobs/dead_file_cleaner.py index ac0e8fe4..cc35f335 100644 --- a/core/repair_jobs/dead_file_cleaner.py +++ b/core/repair_jobs/dead_file_cleaner.py @@ -9,6 +9,31 @@ from utils.logging_config import get_logger logger = get_logger("repair_job.dead_files") +def _resolve_file_path(file_path, transfer_folder, download_folder=None): + """Resolve a stored DB path to an actual file on disk. + + Mirrors _resolve_library_file_path from web_server.py — tries the raw + path first, then progressively shorter suffixes against configured dirs. + """ + if not file_path: + return None + if os.path.exists(file_path): + return file_path + + path_parts = file_path.replace('\\', '/').split('/') + + for base_dir in [transfer_folder, download_folder]: + if not base_dir or not os.path.isdir(base_dir): + continue + # Skip index 0 to avoid drive letter issues (e.g. E:) + for i in range(1, len(path_parts)): + candidate = os.path.join(base_dir, *path_parts[i:]) + if os.path.exists(candidate): + return candidate + + return None + + @register_job class DeadFileCleanerJob(RepairJob): job_id = 'dead_file_cleaner' @@ -57,6 +82,11 @@ class DeadFileCleanerJob(RepairJob): if context.update_progress: context.update_progress(0, total) + # Get download folder for path resolution fallback + download_folder = None + if context.config_manager: + download_folder = context.config_manager.get('soulseek.download_path', '') + for i, row in enumerate(tracks): if context.check_stop(): return result @@ -66,8 +96,11 @@ class DeadFileCleanerJob(RepairJob): track_id, title, artist_name, album_title, file_path = row result.scanned += 1 - if not os.path.exists(file_path): - # File is missing — create finding + # Use the same path resolution logic as library playback + resolved = _resolve_file_path(file_path, context.transfer_folder, download_folder) + + if resolved is None: + # File is truly missing — create finding if context.create_finding: try: context.create_finding( diff --git a/core/repair_jobs/orphan_file_detector.py b/core/repair_jobs/orphan_file_detector.py index 9213ca77..89cb292a 100644 --- a/core/repair_jobs/orphan_file_detector.py +++ b/core/repair_jobs/orphan_file_detector.py @@ -39,16 +39,24 @@ class OrphanFileDetectorJob(RepairJob): logger.warning("Transfer folder does not exist: %s", transfer) return result - # Build set of all known file paths from DB - known_paths = set() + # Build set of known file-path suffixes from DB. + # DB may store paths with a different base prefix than the local filesystem + # (e.g. DB has /mnt/musicBackup/Artist/Album/track.mp3, local disk is + # H:\Music\Artist\Album\track.mp3). We compare using suffix fragments + # of depth 1-3 (filename, album/filename, artist/album/filename) which + # covers all realistic path-prefix mismatches. + known_suffixes = set() conn = None try: conn = context.db._get_connection() cursor = conn.cursor() cursor.execute("SELECT file_path FROM tracks WHERE file_path IS NOT NULL AND file_path != ''") for row in cursor.fetchall(): - # Normalize path for comparison - known_paths.add(os.path.normpath(row[0])) + parts = row[0].replace('\\', '/').split('/') + # Store last 1, 2, and 3 path components as lowercase suffixes + for depth in range(1, min(4, len(parts) + 1)): + suffix = '/'.join(parts[-depth:]).lower() + known_suffixes.add(suffix) except Exception as e: logger.error("Error reading known file paths from DB: %s", e, exc_info=True) result.errors += 1 @@ -78,9 +86,17 @@ class OrphanFileDetectorJob(RepairJob): return result result.scanned += 1 - norm_path = os.path.normpath(fpath) - if norm_path not in known_paths: + # Check if this file matches any known DB path via suffix matching + fpath_parts = fpath.replace('\\', '/').split('/') + is_known = False + for depth in range(1, min(4, len(fpath_parts) + 1)): + suffix = '/'.join(fpath_parts[-depth:]).lower() + if suffix in known_suffixes: + is_known = True + break + + if not is_known: # This file is an orphan — create finding try: stat = os.stat(fpath) diff --git a/core/repair_worker.py b/core/repair_worker.py index fb77d755..291f15f1 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -770,25 +770,50 @@ class RepairWorker: conn.close() def get_findings_counts(self) -> dict: - """Get counts by status.""" + """Get counts by status and by job.""" conn = None try: conn = self.db._get_connection() cursor = conn.cursor() + + # Overall counts by status cursor.execute(""" SELECT status, COUNT(*) FROM repair_findings GROUP BY status """) - counts = {row[0]: row[1] for row in cursor.fetchall()} + status_counts = {row[0]: row[1] for row in cursor.fetchall()} + + # Pending counts per job + cursor.execute(""" + SELECT job_id, finding_type, severity, COUNT(*) FROM repair_findings + WHERE status = 'pending' + GROUP BY job_id, finding_type, severity + """) + by_job = {} + for job_id, finding_type, severity, cnt in cursor.fetchall(): + if job_id not in by_job: + by_job[job_id] = {'total': 0, 'types': {}, 'warning': 0, 'info': 0} + by_job[job_id]['total'] += cnt + by_job[job_id]['types'][finding_type] = by_job[job_id]['types'].get(finding_type, 0) + cnt + if severity in ('warning', 'info'): + by_job[job_id][severity] += cnt + + # Resolve display names + self._ensure_jobs_loaded() + for job_id in by_job: + job = self._jobs.get(job_id) + by_job[job_id]['display_name'] = job.display_name if job else job_id + return { - 'pending': counts.get('pending', 0), - 'resolved': counts.get('resolved', 0), - 'dismissed': counts.get('dismissed', 0), - 'auto_fixed': counts.get('auto_fixed', 0), - 'total': sum(counts.values()), + 'pending': status_counts.get('pending', 0), + 'resolved': status_counts.get('resolved', 0), + 'dismissed': status_counts.get('dismissed', 0), + 'auto_fixed': status_counts.get('auto_fixed', 0), + 'total': sum(status_counts.values()), + 'by_job': by_job, } except Exception: - return {'pending': 0, 'resolved': 0, 'dismissed': 0, 'auto_fixed': 0, 'total': 0} + return {'pending': 0, 'resolved': 0, 'dismissed': 0, 'auto_fixed': 0, 'total': 0, 'by_job': {}} finally: if conn: conn.close() diff --git a/webui/index.html b/webui/index.html index fa413569..a7f71c10 100644 --- a/webui/index.html +++ b/webui/index.html @@ -5638,18 +5638,21 @@