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
This commit is contained in:
Broque Thomas 2026-03-15 14:08:26 -07:00
parent f08550140f
commit ab21855af3
13 changed files with 422 additions and 45 deletions

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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('.')

View file

@ -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

View file

@ -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

View file

@ -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}")

View file

@ -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 = '<div class="repair-finding-media">';
if (albumUrl) {
const albumLabel = d.album_title || 'Album';
html += `<div class="repair-finding-media-card">
<img class="repair-finding-media-img" src="${_escFinding(albumUrl)}" alt="Album art"
onerror="this.parentElement.style.display='none'" />
<span class="repair-finding-media-label">${_escFinding(albumLabel)}</span>
</div>`;
}
if (artistUrl) {
const artistLabel = d.artist_name || d.artist || 'Artist';
html += `<div class="repair-finding-media-card">
<img class="repair-finding-media-img artist" src="${_escFinding(artistUrl)}" alt="Artist"
onerror="this.parentElement.style.display='none'" />
<span class="repair-finding-media-label">${_escFinding(artistLabel)}</span>
</div>`;
}
html += '</div>';
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 = '<div style="margin-bottom:8px">';
case 'acoustid_mismatch': {
let html = media + '<div style="margin-bottom:8px">';
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 `<div class="repair-detail-sublist">${d.tracks.map((t, i) => {
return media + `<div class="repair-detail-sublist">${d.tracks.map((t, i) => {
const isBest = t.id === bestDup.id;
return `<div class="repair-detail-subitem ${isBest ? 'best' : 'removable'}">
<strong>
@ -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 += `<div class="repair-artwork-preview">
<img src="${_escFinding(d.found_artwork_url)}" alt="Album artwork"
onerror="this.parentElement.innerHTML='<span>Image failed to load</span>'" />
<span class="repair-artwork-label">Found artwork</span>
</div>`;
let artHtml = '';
// Show artist image + found artwork side by side
if (d.artist_thumb_url || d.found_artwork_url) {
artHtml += '<div class="repair-finding-media">';
if (d.artist_thumb_url) {
artHtml += `<div class="repair-finding-media-card">
<img class="repair-finding-media-img artist" src="${_escFinding(d.artist_thumb_url)}" alt="Artist"
onerror="this.parentElement.style.display='none'" />
<span class="repair-finding-media-label">${_escFinding(d.artist || 'Artist')}</span>
</div>`;
}
if (d.found_artwork_url) {
artHtml += `<div class="repair-finding-media-card">
<img class="repair-finding-media-img" src="${_escFinding(d.found_artwork_url)}" alt="Found artwork"
onerror="this.parentElement.style.display='none'" />
<span class="repair-finding-media-label">Found Artwork</span>
</div>`;
}
artHtml += '</div>';
}
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 += '<div style="margin-bottom:8px">';
tnHtml += _renderScoreBar(d.match_score, 'Title Match');
tnHtml += '</div>';
}
tnHtml += _gridRows(rows);
if (d.changes && d.changes.length) {
tnHtml += `<div class="repair-detail-sublist">${d.changes.map(c => `
<div class="repair-detail-subitem"><strong>${_escFinding(c)}</strong></div>`).join('')}</div>`;
@ -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) : '<span style="color:rgba(255,255,255,0.3);font-size:12px;">No additional details available</span>';
return (media || '') + (rows.length ? _gridRows(rows) : '<span style="color:rgba(255,255,255,0.3);font-size:12px;">No additional details available</span>');
}
}

View file

@ -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;