From ab21855af3a2278e2524f4f86523b2b47fae8051 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:08:26 -0700 Subject: [PATCH] Enrich repair findings with album art, artist images & live job progress - All 9 repair jobs now emit report_progress() for real-time card updates (phase, log lines, per-item activity) via WebSocket repair:progress events - Enrich finding details with album/artist thumb URLs across all repair jobs (dead_file, duplicate, metadata_gap, album_completeness, missing_cover_art, acoustid_scanner, track_number_repair, fake_lossless, orphan_file) - Track number repair: return match_score from fuzzy matching, add suffix-based DB lookup for album/artist art (handles cross-environment path mismatches) - Fix Plex/Jellyfin relative thumb URLs in findings endpoint via fix_artist_image_url - Labeled media cards in finding detail panels (album title + artist name under images) - Dashboard tooltip shows current job name + per-job progress instead of stale stats --- core/repair_jobs/acoustid_scanner.py | 36 +++++- core/repair_jobs/album_completeness.py | 22 +++- core/repair_jobs/dead_file_cleaner.py | 23 +++- core/repair_jobs/duplicate_detector.py | 25 ++++- core/repair_jobs/fake_lossless_detector.py | 17 +++ core/repair_jobs/metadata_gap_filler.py | 23 +++- core/repair_jobs/missing_cover_art.py | 22 +++- core/repair_jobs/orphan_file_detector.py | 15 +++ core/repair_jobs/track_number_repair.py | 122 +++++++++++++++++++-- core/repair_worker.py | 10 +- web_server.py | 9 ++ webui/static/script.js | 103 +++++++++++++---- webui/static/style.css | 40 +++++++ 13 files changed, 422 insertions(+), 45 deletions(-) diff --git a/core/repair_jobs/acoustid_scanner.py b/core/repair_jobs/acoustid_scanner.py index 4ca8b9c5..438fa2f8 100644 --- a/core/repair_jobs/acoustid_scanner.py +++ b/core/repair_jobs/acoustid_scanner.py @@ -103,6 +103,9 @@ class AcoustIDScannerJob(RepairJob): # Build a lookup of known tracks from DB for comparison db_tracks = self._load_db_tracks(context) + if context.report_progress: + context.report_progress(phase=f'Fingerprinting {total} files...', total=total) + batch_count = 0 for i, fpath in enumerate(audio_files): if context.check_stop(): @@ -116,13 +119,22 @@ class AcoustIDScannerJob(RepairJob): result.scanned += 1 batch_count += 1 + fname = os.path.basename(fpath) + if context.report_progress: + context.report_progress( + scanned=i + 1, total=total, + phase=f'Fingerprinting {i + 1} / {total}', + log_line=f'Scanning: {fname}', + log_type='info' + ) + try: self._scan_file( fpath, acoustid_client, db_tracks, context, result, fp_threshold, title_threshold, artist_threshold ) except Exception as e: - logger.debug("Error scanning %s: %s", os.path.basename(fpath), e) + logger.debug("Error scanning %s: %s", fname, e) result.errors += 1 # Rate limit: pause between batches @@ -167,6 +179,11 @@ class AcoustIDScannerJob(RepairJob): if not fp_result or not fp_result.get('recordings'): # No match — could be a very rare/new track + if context.report_progress: + context.report_progress( + log_line=f'No match: {fname}', + log_type='skip' + ) if context.create_finding: context.create_finding( job_id=self.job_id, @@ -180,6 +197,8 @@ class AcoustIDScannerJob(RepairJob): details={ 'expected_title': expected['title'], 'expected_artist': expected['artist'], + 'album_thumb_url': expected.get('album_thumb_url'), + 'artist_thumb_url': expected.get('artist_thumb_url'), } ) result.findings_created += 1 @@ -212,6 +231,11 @@ class AcoustIDScannerJob(RepairJob): return # Mismatch detected + if context.report_progress: + context.report_progress( + log_line=f'Mismatch: {fname} — expected "{expected["title"]}", got "{aid_title}"', + log_type='error' + ) if context.create_finding: severity = 'warning' if best_score >= 0.90 else 'info' context.create_finding( @@ -235,6 +259,8 @@ class AcoustIDScannerJob(RepairJob): 'fingerprint_score': round(best_score, 3), 'title_similarity': round(title_sim, 3), 'artist_similarity': round(artist_sim, 3), + 'album_thumb_url': expected.get('album_thumb_url'), + 'artist_thumb_url': expected.get('artist_thumb_url'), } ) result.findings_created += 1 @@ -247,18 +273,22 @@ class AcoustIDScannerJob(RepairJob): conn = context.db._get_connection() cursor = conn.cursor() cursor.execute(""" - SELECT t.id, t.title, ar.name, t.file_path + SELECT t.id, t.title, ar.name, t.file_path, + al.thumb_url, ar.thumb_url FROM tracks t LEFT JOIN artists ar ON ar.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id WHERE t.file_path IS NOT NULL AND t.file_path != '' AND t.title IS NOT NULL AND t.title != '' """) for row in cursor.fetchall(): - track_id, title, artist_name, file_path = row + track_id, title, artist_name, file_path, album_thumb, artist_thumb = row tracks[os.path.normpath(file_path)] = { 'track_id': track_id, 'title': title or '', 'artist': artist_name or '', + 'album_thumb_url': album_thumb or None, + 'artist_thumb_url': artist_thumb or None, } except Exception as e: logger.error("Error loading tracks from DB: %s", e) diff --git a/core/repair_jobs/album_completeness.py b/core/repair_jobs/album_completeness.py index cd857ddf..227a9399 100644 --- a/core/repair_jobs/album_completeness.py +++ b/core/repair_jobs/album_completeness.py @@ -44,7 +44,7 @@ class AlbumCompletenessJob(RepairJob): cursor = conn.cursor() cursor.execute(""" SELECT al.id, al.title, ar.name, al.spotify_album_id, al.track_count, - COUNT(t.id) as actual_count + COUNT(t.id) as actual_count, al.thumb_url, ar.thumb_url FROM albums al LEFT JOIN artists ar ON ar.id = al.artist_id LEFT JOIN tracks t ON t.album_id = al.id @@ -67,15 +67,26 @@ class AlbumCompletenessJob(RepairJob): logger.info("Checking completeness of %d albums", total) + if context.report_progress: + context.report_progress(phase=f'Checking {total} albums...', total=total) + for i, row in enumerate(albums): if context.check_stop(): return result if i % 10 == 0 and context.wait_if_paused(): return result - album_id, title, artist_name, spotify_album_id, db_track_count, actual_count = row + album_id, title, artist_name, spotify_album_id, db_track_count, actual_count, album_thumb, artist_thumb = row result.scanned += 1 + if context.report_progress: + context.report_progress( + scanned=i + 1, total=total, + phase=f'Checking {i + 1} / {total}', + log_line=f'Album: {title or "Unknown"} — {artist_name or "Unknown"}', + log_type='info' + ) + # If we don't know the expected track count, try to get it from API expected_total = db_track_count @@ -122,6 +133,11 @@ class AlbumCompletenessJob(RepairJob): except Exception as e: logger.debug("Error getting album tracks for %s: %s", spotify_album_id, e) + if context.report_progress: + context.report_progress( + log_line=f'Incomplete: {title or "Unknown"} ({actual_count}/{expected_total})', + log_type='skip' + ) if context.create_finding: try: context.create_finding( @@ -144,6 +160,8 @@ class AlbumCompletenessJob(RepairJob): 'expected_tracks': expected_total, 'actual_tracks': actual_count, 'missing_tracks': missing_tracks, + 'album_thumb_url': album_thumb or None, + 'artist_thumb_url': artist_thumb or None, } ) result.findings_created += 1 diff --git a/core/repair_jobs/dead_file_cleaner.py b/core/repair_jobs/dead_file_cleaner.py index cc35f335..3da58fc9 100644 --- a/core/repair_jobs/dead_file_cleaner.py +++ b/core/repair_jobs/dead_file_cleaner.py @@ -63,7 +63,8 @@ class DeadFileCleanerJob(RepairJob): conn = context.db._get_connection() cursor = conn.cursor() cursor.execute(""" - SELECT t.id, t.title, ar.name, al.title, t.file_path + SELECT t.id, t.title, ar.name, al.title, t.file_path, + al.thumb_url, ar.thumb_url FROM tracks t LEFT JOIN artists ar ON ar.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id @@ -87,20 +88,36 @@ class DeadFileCleanerJob(RepairJob): if context.config_manager: download_folder = context.config_manager.get('soulseek.download_path', '') + if context.report_progress: + context.report_progress(phase=f'Checking {total} tracks...', total=total) + for i, row in enumerate(tracks): if context.check_stop(): return result if i % 200 == 0 and context.wait_if_paused(): return result - track_id, title, artist_name, album_title, file_path = row + track_id, title, artist_name, album_title, file_path, album_thumb, artist_thumb = row result.scanned += 1 + if context.report_progress and i % 50 == 0: + context.report_progress( + scanned=i + 1, total=total, + phase=f'Checking {i + 1} / {total}', + log_line=f'Checking: {title or "Unknown"} — {artist_name or "Unknown"}', + log_type='info' + ) + # 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.report_progress: + context.report_progress( + log_line=f'Missing: {title or "Unknown"} — {os.path.basename(file_path)}', + log_type='error' + ) if context.create_finding: try: context.create_finding( @@ -118,6 +135,8 @@ class DeadFileCleanerJob(RepairJob): 'artist': artist_name, 'album': album_title, 'original_path': file_path, + 'album_thumb_url': album_thumb or None, + 'artist_thumb_url': artist_thumb or None, } ) result.findings_created += 1 diff --git a/core/repair_jobs/duplicate_detector.py b/core/repair_jobs/duplicate_detector.py index 70e754fe..c870fa93 100644 --- a/core/repair_jobs/duplicate_detector.py +++ b/core/repair_jobs/duplicate_detector.py @@ -50,7 +50,7 @@ class DuplicateDetectorJob(RepairJob): cursor = conn.cursor() cursor.execute(""" SELECT t.id, t.title, ar.name, al.title, t.file_path, - t.bitrate, t.duration + t.bitrate, t.duration, al.thumb_url, ar.thumb_url FROM tracks t LEFT JOIN artists ar ON ar.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id @@ -77,7 +77,7 @@ class DuplicateDetectorJob(RepairJob): # Bucket by first 4 chars of normalized title for efficiency buckets = defaultdict(list) for row in tracks: - track_id, title, artist_name, album_title, file_path, bitrate, duration = row + track_id, title, artist_name, album_title, file_path, bitrate, duration, album_thumb, artist_thumb = row norm_title = _normalize(title) bucket_key = norm_title[:4] if len(norm_title) >= 4 else norm_title buckets[bucket_key].append({ @@ -90,12 +90,17 @@ class DuplicateDetectorJob(RepairJob): 'file_path': file_path, 'bitrate': bitrate, 'duration': duration, + 'album_thumb_url': album_thumb or None, + 'artist_thumb_url': artist_thumb or None, }) # Find duplicates within each bucket found_groups = set() # Track IDs already in a group processed = 0 + if context.report_progress: + context.report_progress(phase=f'Comparing {total} tracks...', total=total) + for bucket_key, bucket_tracks in buckets.items(): if context.check_stop(): return result @@ -107,6 +112,14 @@ class DuplicateDetectorJob(RepairJob): processed += 1 result.scanned += 1 + if context.report_progress and processed % 100 == 0: + context.report_progress( + scanned=processed, total=total, + phase=f'Comparing {processed} / {total}', + log_line=f'Checking: {t1["title"]} — {t1["artist"]}', + log_type='info' + ) + if t1['id'] in found_groups: continue @@ -134,6 +147,12 @@ class DuplicateDetectorJob(RepairJob): for t in group: found_groups.add(t['id']) + if context.report_progress: + context.report_progress( + log_line=f'Duplicate: {t1["title"]} — {len(group)} copies', + log_type='skip' + ) + if context.create_finding: try: # Sort group by quality (highest bitrate first) @@ -159,6 +178,8 @@ class DuplicateDetectorJob(RepairJob): 'duration': t['duration'], } for t in group], 'count': len(group), + 'album_thumb_url': group[0].get('album_thumb_url'), + 'artist_thumb_url': group[0].get('artist_thumb_url'), } ) result.findings_created += 1 diff --git a/core/repair_jobs/fake_lossless_detector.py b/core/repair_jobs/fake_lossless_detector.py index 93e81794..cc677ed3 100644 --- a/core/repair_jobs/fake_lossless_detector.py +++ b/core/repair_jobs/fake_lossless_detector.py @@ -72,6 +72,9 @@ class FakeLosslessDetectorJob(RepairJob): logger.info("Scanning %d lossless files for fakes", total) + if context.report_progress: + context.report_progress(phase=f'Analyzing {total} lossless files...', total=total) + for i, fpath in enumerate(lossless_files): if context.check_stop(): return result @@ -79,6 +82,15 @@ class FakeLosslessDetectorJob(RepairJob): return result result.scanned += 1 + fname = os.path.basename(fpath) + + if context.report_progress and i % 5 == 0: + context.report_progress( + scanned=i + 1, total=total, + phase=f'Analyzing {i + 1} / {total}', + log_line=f'Analyzing: {fname}', + log_type='info' + ) try: analysis = _analyze_file(fpath) @@ -92,6 +104,11 @@ class FakeLosslessDetectorJob(RepairJob): if detected_cutoff is not None and detected_cutoff < cutoff_khz: # Likely fake lossless + if context.report_progress: + context.report_progress( + log_line=f'Fake: {fname} — cutoff at {detected_cutoff:.1f} kHz', + log_type='error' + ) if context.create_finding: context.create_finding( job_id=self.job_id, diff --git a/core/repair_jobs/metadata_gap_filler.py b/core/repair_jobs/metadata_gap_filler.py index 606f3581..e9b0b0c9 100644 --- a/core/repair_jobs/metadata_gap_filler.py +++ b/core/repair_jobs/metadata_gap_filler.py @@ -60,7 +60,8 @@ class MetadataGapFillerJob(RepairJob): cursor = conn.cursor() cursor.execute(f""" SELECT t.id, t.title, ar.name, al.title, t.spotify_track_id, - t.isrc, t.musicbrainz_recording_id + t.isrc, t.musicbrainz_recording_id, + al.thumb_url, ar.thumb_url FROM tracks t LEFT JOIN artists ar ON ar.id = t.artist_id LEFT JOIN albums al ON al.id = t.album_id @@ -86,14 +87,25 @@ class MetadataGapFillerJob(RepairJob): logger.info("Found %d tracks with metadata gaps", total) + if context.report_progress: + context.report_progress(phase=f'Enriching {total} tracks...', total=total) + for i, row in enumerate(tracks): if context.check_stop(): return result if i % 20 == 0 and context.wait_if_paused(): return result - track_id, title, artist_name, album_title, spotify_track_id, isrc, mb_id = row + track_id, title, artist_name, album_title, spotify_track_id, isrc, mb_id, album_thumb, artist_thumb = row result.scanned += 1 + + if context.report_progress: + context.report_progress( + scanned=i + 1, total=total, + phase=f'Enriching {i + 1} / {total}', + log_line=f'Looking up: {title or "Unknown"} — {artist_name or "Unknown"}', + log_type='info' + ) found_fields = {} # Try Spotify enrichment first (most reliable for ISRC) @@ -121,6 +133,11 @@ class MetadataGapFillerJob(RepairJob): # Create finding for user to review instead of auto-writing if found_fields: + if context.report_progress: + context.report_progress( + log_line=f'Found: {", ".join(found_fields.keys())} for {title or "Unknown"}', + log_type='success' + ) if context.create_finding: try: field_names = ', '.join(found_fields.keys()) @@ -143,6 +160,8 @@ class MetadataGapFillerJob(RepairJob): 'album': album_title, 'spotify_track_id': spotify_track_id, 'found_fields': found_fields, + 'album_thumb_url': album_thumb or None, + 'artist_thumb_url': artist_thumb or None, } ) result.findings_created += 1 diff --git a/core/repair_jobs/missing_cover_art.py b/core/repair_jobs/missing_cover_art.py index bc8888db..6edb4fdf 100644 --- a/core/repair_jobs/missing_cover_art.py +++ b/core/repair_jobs/missing_cover_art.py @@ -42,7 +42,8 @@ class MissingCoverArtJob(RepairJob): conn = context.db._get_connection() cursor = conn.cursor() cursor.execute(""" - SELECT al.id, al.title, ar.name, al.spotify_album_id, al.thumb_url + SELECT al.id, al.title, ar.name, al.spotify_album_id, al.thumb_url, + ar.thumb_url FROM albums al LEFT JOIN artists ar ON ar.id = al.artist_id WHERE (al.thumb_url IS NULL OR al.thumb_url = '') @@ -63,15 +64,26 @@ class MissingCoverArtJob(RepairJob): logger.info("Found %d albums missing cover art", total) + if context.report_progress: + context.report_progress(phase=f'Searching artwork for {total} albums...', total=total) + for i, row in enumerate(albums): if context.check_stop(): return result if i % 10 == 0 and context.wait_if_paused(): return result - album_id, title, artist_name, spotify_album_id, _ = row + album_id, title, artist_name, spotify_album_id, _, artist_thumb = row result.scanned += 1 + if context.report_progress: + context.report_progress( + scanned=i + 1, total=total, + phase=f'Searching {i + 1} / {total}', + log_line=f'Searching: {title or "Unknown"} — {artist_name or "Unknown"}', + log_type='info' + ) + artwork_url = None # Try to find artwork URL from APIs @@ -85,6 +97,11 @@ class MissingCoverArtJob(RepairJob): artwork_url = self._try_spotify(spotify_album_id, title, artist_name, context) if artwork_url: + if context.report_progress: + context.report_progress( + log_line=f'Found art: {title or "Unknown"}', + log_type='success' + ) # Create finding for user to approve if context.create_finding: try: @@ -103,6 +120,7 @@ class MissingCoverArtJob(RepairJob): 'artist': artist_name, 'found_artwork_url': artwork_url, 'spotify_album_id': spotify_album_id, + 'artist_thumb_url': artist_thumb or None, } ) result.findings_created += 1 diff --git a/core/repair_jobs/orphan_file_detector.py b/core/repair_jobs/orphan_file_detector.py index 89cb292a..771a2842 100644 --- a/core/repair_jobs/orphan_file_detector.py +++ b/core/repair_jobs/orphan_file_detector.py @@ -78,6 +78,8 @@ class OrphanFileDetectorJob(RepairJob): total = len(audio_files) if context.update_progress: context.update_progress(0, total) + if context.report_progress: + context.report_progress(phase=f'Checking {total} files...', total=total) for i, fpath in enumerate(audio_files): if context.check_stop(): @@ -87,6 +89,14 @@ class OrphanFileDetectorJob(RepairJob): result.scanned += 1 + if context.report_progress and i % 50 == 0: + context.report_progress( + scanned=i + 1, total=total, + phase=f'Checking {i + 1} / {total}', + log_line=f'Checking: {os.path.basename(fpath)}', + log_type='info' + ) + # Check if this file matches any known DB path via suffix matching fpath_parts = fpath.replace('\\', '/').split('/') is_known = False @@ -98,6 +108,11 @@ class OrphanFileDetectorJob(RepairJob): if not is_known: # This file is an orphan — create finding + 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('.') diff --git a/core/repair_jobs/track_number_repair.py b/core/repair_jobs/track_number_repair.py index 10d0d201..60a1d91b 100644 --- a/core/repair_jobs/track_number_repair.py +++ b/core/repair_jobs/track_number_repair.py @@ -83,6 +83,11 @@ class TrackNumberRepairJob(RepairJob): total = sum(len(fnames) for fnames in album_folders.values()) if context.update_progress: context.update_progress(0, total) + if context.report_progress: + context.report_progress( + phase=f'Scanning {len(album_folders)} album folders ({total} files)...', + total=total + ) for folder_path, filenames in album_folders.items(): if context.check_stop(): @@ -90,6 +95,15 @@ class TrackNumberRepairJob(RepairJob): if context.wait_if_paused(): return result + folder_name = os.path.basename(folder_path) + if context.report_progress: + context.report_progress( + scanned=result.scanned, total=total, + phase=f'Checking {result.scanned} / {total}', + log_line=f'Album: {folder_name} ({len(filenames)} tracks)', + log_type='info' + ) + try: folder_result = self._repair_album( folder_path, filenames, anomaly_threshold, context, scan_state @@ -98,6 +112,12 @@ class TrackNumberRepairJob(RepairJob): result.auto_fixed += folder_result.auto_fixed result.skipped += folder_result.skipped result.errors += folder_result.errors + result.findings_created += folder_result.findings_created + if folder_result.findings_created > 0 and context.report_progress: + context.report_progress( + log_line=f'Found {folder_result.findings_created} issues in {folder_name}', + log_type='skip' + ) except Exception as e: logger.error("Error processing album folder %s: %s", folder_path, e, exc_info=True) result.errors += 1 @@ -177,6 +197,10 @@ class TrackNumberRepairJob(RepairJob): # Process each file title_sim = scan_state.get('title_similarity', 0.80) dry_run = scan_state.get('dry_run', True) + + # Look up album/artist art once per album folder for enriched findings + art_info = _lookup_album_artist_art(file_track_data, context) if dry_run else {} + for fpath, fname, _ in file_track_data: if context.check_stop(): return result @@ -187,6 +211,16 @@ class TrackNumberRepairJob(RepairJob): finding = _check_single_track(fpath, fname, api_tracks, len(api_tracks), title_sim) if finding: if context.create_finding: + details = finding['details'] + # Enrich with album/artist art and names + if art_info.get('album_thumb_url'): + details['album_thumb_url'] = art_info['album_thumb_url'] + if art_info.get('artist_thumb_url'): + details['artist_thumb_url'] = art_info['artist_thumb_url'] + if art_info.get('album_title'): + details['album_title'] = art_info['album_title'] + if art_info.get('artist_name'): + details['artist_name'] = art_info['artist_name'] context.create_finding( job_id=self.job_id, finding_type='track_number_mismatch', @@ -196,7 +230,7 @@ class TrackNumberRepairJob(RepairJob): file_path=fpath, title=f'Track number fix: {os.path.basename(fpath)}', description=finding['description'], - details=finding['details'] + details=details ) result.findings_created += 1 else: @@ -641,8 +675,8 @@ def _read_album_artist_from_file(file_path: str) -> Tuple[Optional[str], Optiona def _match_title_to_api_track(file_title: str, api_tracks: List[Dict], - threshold: float) -> Optional[Dict]: - """Fuzzy-match a file title to an API track.""" + threshold: float) -> Tuple[Optional[Dict], float]: + """Fuzzy-match a file title to an API track. Returns (track, score).""" norm_file = _normalize_title(file_title) best_match = None best_score = 0.0 @@ -656,8 +690,8 @@ def _match_title_to_api_track(file_title: str, api_tracks: List[Dict], best_match = track if best_score >= threshold: - return best_match - return None + return best_match, best_score + return None, best_score def _normalize_title(title: str) -> str: @@ -803,6 +837,74 @@ def _lookup_album_ids_from_db(file_track_data: List[Tuple[str, str, Any]], return None, None, None +def _lookup_album_artist_art(file_track_data: List[Tuple[str, str, Any]], + context: JobContext) -> Dict[str, Optional[str]]: + """Look up album/artist thumb URLs and names from DB for enriched finding details. + + Uses suffix-based matching since DB paths may differ from local paths + (e.g., /mnt/musicBackup/... vs H:\\Music\\...). + """ + result = {'album_thumb_url': None, 'artist_thumb_url': None, + 'album_title': None, 'artist_name': None} + if not context.db: + return result + + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + + # First try exact path match (fast) + for fpath, _, _ in file_track_data: + cursor.execute(""" + SELECT al.thumb_url, ar.thumb_url, al.title, ar.name + FROM tracks t + LEFT JOIN albums al ON al.id = t.album_id + LEFT JOIN artists ar ON ar.id = t.artist_id + WHERE t.file_path = ? + LIMIT 1 + """, (fpath,)) + row = cursor.fetchone() + if row: + result['album_thumb_url'] = row[0] or None + result['artist_thumb_url'] = row[1] or None + result['album_title'] = row[2] or None + result['artist_name'] = row[3] or None + return result + + # Fallback: suffix-based matching (handles cross-environment path mismatches) + # Build suffix from the first file path (artist/album/filename) + if file_track_data: + fpath = file_track_data[0][0] + parts = fpath.replace('\\', '/').split('/') + # Try matching on last 2 components (album/filename) — most specific without artist + if len(parts) >= 2: + suffix = '/'.join(parts[-2:]) + # Use LIKE with the suffix for cross-platform matching + cursor.execute(""" + SELECT al.thumb_url, ar.thumb_url, al.title, ar.name + FROM tracks t + LEFT JOIN albums al ON al.id = t.album_id + LEFT JOIN artists ar ON ar.id = t.artist_id + WHERE t.file_path LIKE ? + LIMIT 1 + """, (f'%{suffix}',)) + row = cursor.fetchone() + if row: + result['album_thumb_url'] = row[0] or None + result['artist_thumb_url'] = row[1] or None + result['album_title'] = row[2] or None + result['artist_name'] = row[3] or None + + except Exception as e: + logger.debug("Error looking up album/artist art from DB: %s", e) + finally: + if conn: + conn.close() + + return result + + def _check_single_track(file_path: str, filename: str, api_tracks: List[Dict], total_tracks: int, title_similarity: float) -> Optional[Dict]: """Check if a track needs repair and return finding info (dry run mode). @@ -817,15 +919,16 @@ def _check_single_track(file_path: str, filename: str, api_tracks: List[Dict], file_title = _read_title_tag(audio) matched_track = None + match_score = 0.0 if file_title: - matched_track = _match_title_to_api_track(file_title, api_tracks, title_similarity) + matched_track, match_score = _match_title_to_api_track(file_title, api_tracks, title_similarity) if not matched_track: basename = os.path.splitext(filename)[0] clean_name = re.sub(r'^\d{1,3}[\s.\-_]*', '', basename).strip() if clean_name: - matched_track = _match_title_to_api_track(clean_name, api_tracks, title_similarity) + matched_track, match_score = _match_title_to_api_track(clean_name, api_tracks, title_similarity) if not matched_track: return None @@ -858,6 +961,7 @@ def _check_single_track(file_path: str, filename: str, api_tracks: List[Dict], 'matched_title': matched_track.get('name', ''), 'file_title': file_title or filename, 'changes': changes, + 'match_score': round(match_score, 3), } } @@ -880,7 +984,7 @@ def _repair_single_track(file_path: str, filename: str, api_tracks: List[Dict], matched_track = None if file_title: - matched_track = _match_title_to_api_track(file_title, api_tracks, title_similarity) + matched_track, _ = _match_title_to_api_track(file_title, api_tracks, title_similarity) # Fallback: match via filename (without track number prefix and extension) if not matched_track: @@ -888,7 +992,7 @@ def _repair_single_track(file_path: str, filename: str, api_tracks: List[Dict], # Strip leading track number prefix like "01 - " or "01. " clean_name = re.sub(r'^\d{1,3}[\s.\-_]*', '', basename).strip() if clean_name: - matched_track = _match_title_to_api_track(clean_name, api_tracks, title_similarity) + matched_track, _ = _match_title_to_api_track(clean_name, api_tracks, title_similarity) if not matched_track: return False diff --git a/core/repair_worker.py b/core/repair_worker.py index 518b0ead..c49044d4 100644 --- a/core/repair_worker.py +++ b/core/repair_worker.py @@ -366,11 +366,19 @@ class RepairWorker: } if self._current_job_id: + job_progress = self._current_progress.copy() result['current_job'] = { 'job_id': self._current_job_id, 'display_name': self._current_job_name, - 'progress': self._current_progress.copy(), + 'progress': job_progress, } + # Include per-job progress in the overall progress for tooltip display + if job_progress.get('total', 0) > 0: + result['progress']['current_job'] = { + 'scanned': job_progress.get('scanned', 0), + 'total': job_progress.get('total', 0), + 'percent': job_progress.get('percent', 0), + } return result diff --git a/web_server.py b/web_server.py index e8c6dcf2..a0153b65 100644 --- a/web_server.py +++ b/web_server.py @@ -37657,6 +37657,15 @@ def repair_findings_list(): job_id=job_id, status=status, severity=severity, page=page, limit=limit ) + + # Fix Plex/Jellyfin relative thumb URLs in finding details + for item in result.get('items', []): + details = item.get('details') + if details and isinstance(details, dict): + for key in ('album_thumb_url', 'artist_thumb_url'): + if details.get(key): + details[key] = fix_artist_image_url(details[key]) + return jsonify(result), 200 except Exception as e: logger.error(f"Error getting repair findings: {e}") diff --git a/webui/static/script.js b/webui/static/script.js index 130ede75..b0265281 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -49811,11 +49811,17 @@ function updateRepairStatusFromData(data) { if (tooltipCurrent) { if (data.idle) { - tooltipCurrent.textContent = 'All items processed'; + tooltipCurrent.textContent = 'All jobs complete — waiting for next schedule'; + } else if (data.current_job && data.current_job.display_name) { + const jobName = data.current_job.display_name; + const jobProgress = data.progress && data.progress.current_job; + if (jobProgress && jobProgress.total > 0) { + tooltipCurrent.textContent = `${jobName}: ${jobProgress.scanned} / ${jobProgress.total} (${jobProgress.percent}%)`; + } else { + tooltipCurrent.textContent = `Running: ${jobName}`; + } } else if (data.current_item && data.current_item.name) { - const type = data.current_item.type || 'item'; - const name = data.current_item.name; - tooltipCurrent.textContent = `${type.charAt(0).toUpperCase() + type.slice(1)}: "${name}"`; + tooltipCurrent.textContent = `Running: ${data.current_item.name}`; } else { tooltipCurrent.textContent = 'No active repairs'; } @@ -49823,7 +49829,12 @@ function updateRepairStatusFromData(data) { if (tooltipProgress && data.progress) { const tracks = data.progress.tracks || {}; - tooltipProgress.textContent = `Checked: ${tracks.checked || 0} / ${tracks.total || 0} (${tracks.percent || 0}%) | Repaired: ${tracks.repaired || 0}`; + const parts = []; + if (tracks.total > 0) parts.push(`Checked: ${tracks.checked || 0} / ${tracks.total || 0}`); + if (tracks.repaired > 0) parts.push(`Repaired: ${tracks.repaired}`); + const pending = data.findings_pending || 0; + if (pending > 0) parts.push(`Findings: ${pending}`); + tooltipProgress.textContent = parts.length ? parts.join(' · ') : 'No items processed yet'; } // Update findings badge @@ -50524,9 +50535,35 @@ function _formatFileSize(bytes) { return (bytes / 1048576).toFixed(1) + ' MB'; } +function _renderFindingMedia(d) { + const albumUrl = d.album_thumb_url; + const artistUrl = d.artist_thumb_url; + if (!albumUrl && !artistUrl) return ''; + let html = '
'; + if (albumUrl) { + const albumLabel = d.album_title || 'Album'; + html += `
+ Album art + ${_escFinding(albumLabel)} +
`; + } + if (artistUrl) { + const artistLabel = d.artist_name || d.artist || 'Artist'; + html += `
+ Artist + ${_escFinding(artistLabel)} +
`; + } + html += '
'; + return html; +} + function _renderFindingDetail(f) { const d = f.details || {}; const rows = []; + const media = _renderFindingMedia(d); switch (f.finding_type) { case 'dead_file': @@ -50535,7 +50572,7 @@ function _renderFindingDetail(f) { if (d.title) rows.push(['Title', d.title]); if (d.track_id) rows.push(['Track ID', d.track_id]); if (d.original_path) rows.push(['Original Path', d.original_path, 'path']); - return _gridRows(rows); + return media + _gridRows(rows); case 'orphan_file': if (d.folder) rows.push(['Folder', d.folder, 'path']); @@ -50545,8 +50582,8 @@ function _renderFindingDetail(f) { if (f.file_path) rows.push(['Full Path', f.file_path, 'path']); return _gridRows(rows); - case 'acoustid_mismatch': - let html = '
'; + case 'acoustid_mismatch': { + let html = media + '
'; html += _renderScoreBar(d.fingerprint_score, 'Fingerprint'); html += _renderScoreBar(d.title_similarity, 'Title Match'); html += _renderScoreBar(d.artist_similarity, 'Artist Match'); @@ -50557,12 +50594,13 @@ function _renderFindingDetail(f) { rows.push(['AcoustID Artist', d.acoustid_artist || '-', 'highlight']); if (f.file_path) rows.push(['File', f.file_path, 'path']); return html + _gridRows(rows); + } case 'acoustid_no_match': if (d.expected_title) rows.push(['Expected Title', d.expected_title]); if (d.expected_artist) rows.push(['Expected Artist', d.expected_artist]); if (f.file_path) rows.push(['File', f.file_path, 'path']); - return _gridRows(rows); + return media + _gridRows(rows); case 'fake_lossless': { const cutoff = d.detected_cutoff_khz || 0; @@ -50601,7 +50639,7 @@ function _renderFindingDetail(f) { const bDur = best.duration || 0, tDur = t.duration || 0; return (tBr > bBr || (tBr === bBr && tDur > bDur)) ? t : best; }, d.tracks[0]); - return `
${d.tracks.map((t, i) => { + return media + `
${d.tracks.map((t, i) => { const isBest = t.id === bestDup.id; return `
@@ -50617,7 +50655,7 @@ function _renderFindingDetail(f) { if (d.artist) rows.push(['Artist', d.artist]); if (d.album_title) rows.push(['Album', d.album_title]); if (d.spotify_album_id) rows.push(['Spotify ID', d.spotify_album_id]); - let incHtml = _gridRows(rows); + let incHtml = media + _gridRows(rows); const actual = d.actual_tracks || 0, expected = d.expected_tracks || 0; if (expected > 0) { const pct = Math.round((actual / expected) * 100); @@ -50650,29 +50688,50 @@ function _renderFindingDetail(f) { rows.push([`Found: ${k}`, String(v), 'success']); }); } - return _gridRows(rows); + return media + _gridRows(rows); case 'missing_cover_art': if (d.artist) rows.push(['Artist', d.artist]); if (d.album_title) rows.push(['Album', d.album_title]); if (d.spotify_album_id) rows.push(['Spotify ID', d.spotify_album_id]); - let artHtml = _gridRows(rows); - if (d.found_artwork_url) { - artHtml += `
- Album artwork - Found artwork -
`; + let artHtml = ''; + // Show artist image + found artwork side by side + if (d.artist_thumb_url || d.found_artwork_url) { + artHtml += '
'; + if (d.artist_thumb_url) { + artHtml += `
+ Artist + ${_escFinding(d.artist || 'Artist')} +
`; + } + if (d.found_artwork_url) { + artHtml += `
+ Found artwork + Found Artwork +
`; + } + artHtml += '
'; } + artHtml += _gridRows(rows); return artHtml; case 'track_number_mismatch': + if (d.album_title) rows.push(['Album', d.album_title]); + if (d.artist_name) rows.push(['Artist', d.artist_name]); if (d.matched_title) rows.push(['Matched To', d.matched_title]); if (d.file_title) rows.push(['File Title', d.file_title]); if (d.current_track_num !== undefined) rows.push(['Current Track #', String(d.current_track_num)]); if (d.correct_track_num !== undefined) rows.push(['Correct Track #', String(d.correct_track_num), 'success']); if (f.file_path) rows.push(['File', f.file_path, 'path']); - let tnHtml = _gridRows(rows); + let tnHtml = media; + if (d.match_score) { + tnHtml += '
'; + tnHtml += _renderScoreBar(d.match_score, 'Title Match'); + tnHtml += '
'; + } + tnHtml += _gridRows(rows); if (d.changes && d.changes.length) { tnHtml += `
${d.changes.map(c => `
${_escFinding(c)}
`).join('')}
`; @@ -50682,12 +50741,12 @@ function _renderFindingDetail(f) { default: // Generic: render all detail keys Object.entries(d).forEach(([k, v]) => { - if (typeof v !== 'object') { + if (typeof v !== 'object' && !k.endsWith('_thumb_url')) { rows.push([k.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()), String(v)]); } }); if (f.file_path) rows.push(['File', f.file_path, 'path']); - return rows.length ? _gridRows(rows) : 'No additional details available'; + return (media || '') + (rows.length ? _gridRows(rows) : 'No additional details available'); } } diff --git a/webui/static/style.css b/webui/static/style.css index 9d0f7d7c..2cc4b94e 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -40643,6 +40643,46 @@ tr.tag-diff-same { letter-spacing: 0.04em; } +/* Finding media thumbnails (album art + artist image) */ +.repair-finding-media { + display: flex; + gap: 14px; + margin-bottom: 12px; + padding: 10px 12px; + background: rgba(255, 255, 255, 0.03); + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.06); +} +.repair-finding-media-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 0; +} +.repair-finding-media-img { + width: 56px; + height: 56px; + border-radius: 8px; + object-fit: cover; + border: 1px solid rgba(255, 255, 255, 0.12); + flex-shrink: 0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} +.repair-finding-media-img.artist { + border-radius: 50%; +} +.repair-finding-media-label { + font-size: 10px; + color: rgba(255, 255, 255, 0.45); + text-align: center; + max-width: 72px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.2; +} + /* Artwork preview (for missing cover art) */ .repair-artwork-preview { margin-top: 8px;