From 363b3d0922ac96b5a65e888e8d87b613ac680964 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sat, 14 Mar 2026 09:46:02 -0700 Subject: [PATCH] Fix MusicBrainz API call in metadata gap filler job --- core/repair_jobs/acoustid_scanner.py | 17 +-- core/repair_jobs/album_completeness.py | 45 ++++---- core/repair_jobs/dead_file_cleaner.py | 18 ++-- core/repair_jobs/duplicate_detector.py | 42 +++----- core/repair_jobs/metadata_gap_filler.py | 137 +++++++++++------------- core/repair_jobs/missing_cover_art.py | 85 ++++++++------- core/repair_jobs/track_number_repair.py | 60 ++++++++++- 7 files changed, 225 insertions(+), 179 deletions(-) diff --git a/core/repair_jobs/acoustid_scanner.py b/core/repair_jobs/acoustid_scanner.py index e3a5cb86..4ac62679 100644 --- a/core/repair_jobs/acoustid_scanner.py +++ b/core/repair_jobs/acoustid_scanner.py @@ -161,7 +161,7 @@ class AcoustIDScannerJob(RepairJob): finding_type='acoustid_no_match', severity='info', entity_type='track', - entity_id=str(expected.get('track_id', '')), + entity_id=str(expected.get('track_id') or ''), file_path=fpath, title=f'No AcoustID match: {fname}', description='File could not be identified by AcoustID fingerprint', @@ -207,7 +207,7 @@ class AcoustIDScannerJob(RepairJob): finding_type='acoustid_mismatch', severity=severity, entity_type='track', - entity_id=str(expected.get('track_id', '')), + entity_id=str(expected.get('track_id') or ''), file_path=fpath, title=f'Possible wrong download: {fname}', description=( @@ -235,17 +235,18 @@ class AcoustIDScannerJob(RepairJob): conn = context.db._get_connection() cursor = conn.cursor() cursor.execute(""" - SELECT id, title, artist, file_path - FROM tracks - WHERE file_path IS NOT NULL AND file_path != '' - AND title IS NOT NULL AND title != '' + SELECT t.id, t.title, ar.name, t.file_path + FROM tracks t + LEFT JOIN artists ar ON ar.id = t.artist_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, file_path = row + track_id, title, artist_name, file_path = row tracks[os.path.normpath(file_path)] = { 'track_id': track_id, 'title': title or '', - 'artist': artist or '', + 'artist': artist_name or '', } 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 f22d6c2e..06886b12 100644 --- a/core/repair_jobs/album_completeness.py +++ b/core/repair_jobs/album_completeness.py @@ -26,20 +26,21 @@ class AlbumCompletenessJob(RepairJob): settings = self._get_settings(context) min_tracks = settings.get('min_tracks_for_check', 3) - # Fetch albums with spotify_id that have enough tracks to check + # Fetch albums with spotify_album_id that have enough tracks to check albums = [] conn = None try: conn = context.db._get_connection() cursor = conn.cursor() cursor.execute(""" - SELECT a.id, a.title, a.artist, a.spotify_id, a.total_tracks, - COUNT(t.id) as track_count - FROM albums a - LEFT JOIN tracks t ON t.album_id = a.id - WHERE a.spotify_id IS NOT NULL AND a.spotify_id != '' - GROUP BY a.id - HAVING track_count >= ? + SELECT al.id, al.title, ar.name, al.spotify_album_id, al.track_count, + COUNT(t.id) as actual_count + FROM albums al + LEFT JOIN artists ar ON ar.id = al.artist_id + LEFT JOIN tracks t ON t.album_id = al.id + WHERE al.spotify_album_id IS NOT NULL AND al.spotify_album_id != '' + GROUP BY al.id + HAVING actual_count >= ? """, (min_tracks,)) albums = cursor.fetchall() except Exception as e: @@ -62,31 +63,31 @@ class AlbumCompletenessJob(RepairJob): if i % 10 == 0 and context.wait_if_paused(): return result - album_id, title, artist, spotify_id, total_tracks, track_count = row + album_id, title, artist_name, spotify_album_id, db_track_count, actual_count = row result.scanned += 1 - # If we don't know total_tracks, try to get it from API - expected_total = total_tracks - missing_tracks = [] + # If we don't know the expected track count, try to get it from API + expected_total = db_track_count if not expected_total and context.spotify_client: try: - album_data = context.spotify_client.get_album(spotify_id) + album_data = context.spotify_client.get_album(spotify_album_id) if album_data: expected_total = album_data.get('total_tracks', 0) except Exception: pass - if not expected_total or track_count >= expected_total: + if not expected_total or actual_count >= expected_total: result.skipped += 1 if context.update_progress and (i + 1) % 5 == 0: context.update_progress(i + 1, total) continue # Album is incomplete — try to find which tracks are missing + missing_tracks = [] if context.spotify_client: try: - api_tracks = context.spotify_client.get_album_tracks(spotify_id) + api_tracks = context.spotify_client.get_album_tracks(spotify_album_id) if api_tracks and 'items' in api_tracks: # Get track numbers we already have owned_numbers = set() @@ -109,7 +110,7 @@ class AlbumCompletenessJob(RepairJob): 'disc_number': item.get('disc_number', 1), }) except Exception as e: - logger.debug("Error getting album tracks for %s: %s", spotify_id, e) + logger.debug("Error getting album tracks for %s: %s", spotify_album_id, e) if context.create_finding: try: @@ -120,18 +121,18 @@ class AlbumCompletenessJob(RepairJob): entity_type='album', entity_id=str(album_id), file_path=None, - title=f'Incomplete: {title or "Unknown"} ({track_count}/{expected_total})', + title=f'Incomplete: {title or "Unknown"} ({actual_count}/{expected_total})', description=( - f'Album "{title}" by {artist or "Unknown"} has {track_count} of ' + f'Album "{title}" by {artist_name or "Unknown"} has {actual_count} of ' f'{expected_total} tracks' ), details={ 'album_id': album_id, 'album_title': title, - 'artist': artist, - 'spotify_id': spotify_id, + 'artist': artist_name, + 'spotify_album_id': spotify_album_id, 'expected_tracks': expected_total, - 'actual_tracks': track_count, + 'actual_tracks': actual_count, 'missing_tracks': missing_tracks, } ) @@ -165,7 +166,7 @@ class AlbumCompletenessJob(RepairJob): cursor = conn.cursor() cursor.execute(""" SELECT COUNT(*) FROM albums - WHERE spotify_id IS NOT NULL AND spotify_id != '' + WHERE spotify_album_id IS NOT NULL AND spotify_album_id != '' """) row = cursor.fetchone() return row[0] if row else 0 diff --git a/core/repair_jobs/dead_file_cleaner.py b/core/repair_jobs/dead_file_cleaner.py index 3f0caeac..60d8c5e2 100644 --- a/core/repair_jobs/dead_file_cleaner.py +++ b/core/repair_jobs/dead_file_cleaner.py @@ -23,16 +23,18 @@ class DeadFileCleanerJob(RepairJob): def scan(self, context: JobContext) -> JobResult: result = JobResult() - # Fetch all tracks with file paths from DB + # Fetch all tracks with file paths, joining to get artist/album names tracks = [] conn = None try: conn = context.db._get_connection() cursor = conn.cursor() cursor.execute(""" - SELECT id, title, artist, album, file_path - FROM tracks - WHERE file_path IS NOT NULL AND file_path != '' + SELECT t.id, t.title, ar.name, al.title, t.file_path + 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 != '' """) tracks = cursor.fetchall() except Exception as e: @@ -53,7 +55,7 @@ class DeadFileCleanerJob(RepairJob): if i % 200 == 0 and context.wait_if_paused(): return result - track_id, title, artist, album, file_path = row + track_id, title, artist_name, album_title, file_path = row result.scanned += 1 if not os.path.exists(file_path): @@ -68,12 +70,12 @@ class DeadFileCleanerJob(RepairJob): entity_id=str(track_id), file_path=file_path, title=f'Missing file: {title or "Unknown"}', - description=f'Track "{title}" by {artist or "Unknown"} points to a file that no longer exists', + description=f'Track "{title}" by {artist_name or "Unknown"} points to a file that no longer exists', details={ 'track_id': track_id, 'title': title, - 'artist': artist, - 'album': album, + 'artist': artist_name, + 'album': album_title, 'original_path': file_path, } ) diff --git a/core/repair_jobs/duplicate_detector.py b/core/repair_jobs/duplicate_detector.py index afd06ea2..d558092d 100644 --- a/core/repair_jobs/duplicate_detector.py +++ b/core/repair_jobs/duplicate_detector.py @@ -32,18 +32,20 @@ class DuplicateDetectorJob(RepairJob): title_threshold = settings.get('title_similarity', 0.85) artist_threshold = settings.get('artist_similarity', 0.80) - # Fetch all tracks from DB + # Fetch all tracks with artist/album names via JOIN tracks = [] conn = None try: conn = context.db._get_connection() cursor = conn.cursor() cursor.execute(""" - SELECT id, title, artist, album, file_path, format, bitrate, - sample_rate, bit_depth, file_size, duration - FROM tracks - WHERE title IS NOT NULL AND title != '' - AND file_path IS NOT NULL AND file_path != '' + SELECT t.id, t.title, ar.name, al.title, t.file_path, + t.bitrate, t.duration + 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.title IS NOT NULL AND t.title != '' + AND t.file_path IS NOT NULL AND t.file_path != '' """) tracks = cursor.fetchall() except Exception as e: @@ -62,31 +64,26 @@ class DuplicateDetectorJob(RepairJob): context.update_progress(0, total) # Group tracks by normalized key for fast comparison - # First pass: bucket by first 3 chars of normalized title for efficiency + # Bucket by first 4 chars of normalized title for efficiency buckets = defaultdict(list) for row in tracks: - track_id, title, artist, album, file_path, fmt, bitrate, sample_rate, bit_depth, file_size, duration = row + track_id, title, artist_name, album_title, file_path, bitrate, duration = row norm_title = _normalize(title) - # Bucket by first few chars to avoid O(n^2) full comparison bucket_key = norm_title[:4] if len(norm_title) >= 4 else norm_title buckets[bucket_key].append({ 'id': track_id, 'title': title, 'norm_title': norm_title, - 'artist': artist or '', - 'norm_artist': _normalize(artist or ''), - 'album': album, + 'artist': artist_name or '', + 'norm_artist': _normalize(artist_name or ''), + 'album': album_title, 'file_path': file_path, - 'format': fmt, 'bitrate': bitrate, - 'sample_rate': sample_rate, - 'bit_depth': bit_depth, - 'file_size': file_size, 'duration': duration, }) - # Second pass: find duplicates within each bucket - found_groups = set() # Track IDs already in a group (frozenset) + # Find duplicates within each bucket + found_groups = set() # Track IDs already in a group processed = 0 for bucket_key, bucket_tracks in buckets.items(): @@ -124,9 +121,8 @@ class DuplicateDetectorJob(RepairJob): if len(group) >= 2: # Found a duplicate group - group_ids = frozenset(t['id'] for t in group) - for tid in group_ids: - found_groups.add(tid) + for t in group: + found_groups.add(t['id']) if context.create_finding: try: @@ -149,11 +145,7 @@ class DuplicateDetectorJob(RepairJob): 'artist': t['artist'], 'album': t['album'], 'file_path': t['file_path'], - 'format': t['format'], 'bitrate': t['bitrate'], - 'sample_rate': t['sample_rate'], - 'bit_depth': t['bit_depth'], - 'file_size': t['file_size'], 'duration': t['duration'], } for t in group], 'count': len(group), diff --git a/core/repair_jobs/metadata_gap_filler.py b/core/repair_jobs/metadata_gap_filler.py index 293e8a80..2b186a0f 100644 --- a/core/repair_jobs/metadata_gap_filler.py +++ b/core/repair_jobs/metadata_gap_filler.py @@ -1,4 +1,4 @@ -"""Metadata Gap Filler Job — fills missing track metadata from APIs.""" +"""Metadata Gap Filler Job — finds tracks missing key metadata and locates it from APIs.""" import time @@ -13,58 +13,52 @@ logger = get_logger("repair_job.metadata_gap") class MetadataGapFillerJob(RepairJob): job_id = 'metadata_gap_filler' display_name = 'Metadata Gap Filler' - description = 'Fills missing genre, year, ISRC, and MusicBrainz IDs' + description = 'Finds tracks missing ISRC or MusicBrainz IDs and locates them' icon = 'repair-icon-metadata' default_enabled = False default_interval_hours = 72 default_settings = { - 'fill_genre': True, - 'fill_year': True, 'fill_isrc': True, 'fill_musicbrainz_id': True, - 'write_to_file': False, } - auto_fix = True + auto_fix = False def scan(self, context: JobContext) -> JobResult: result = JobResult() settings = self._get_settings(context) - fill_genre = settings.get('fill_genre', True) - fill_year = settings.get('fill_year', True) fill_isrc = settings.get('fill_isrc', True) fill_mb_id = settings.get('fill_musicbrainz_id', True) - # Build WHERE clauses for missing fields + # Build WHERE clauses for missing fields (only columns that exist on tracks) conditions = [] - if fill_genre: - conditions.append("(genre IS NULL OR genre = '')") - if fill_year: - conditions.append("(year IS NULL OR year = '' OR year = '0')") if fill_isrc: - conditions.append("(isrc IS NULL OR isrc = '')") + conditions.append("(t.isrc IS NULL OR t.isrc = '')") if fill_mb_id: - conditions.append("(musicbrainz_id IS NULL OR musicbrainz_id = '')") + conditions.append("(t.musicbrainz_recording_id IS NULL OR t.musicbrainz_recording_id = '')") if not conditions: return result where = " OR ".join(conditions) - # Fetch tracks with gaps, prioritizing those with spotify_id + # Fetch tracks with gaps, prioritizing those with spotify_track_id tracks = [] conn = None try: conn = context.db._get_connection() cursor = conn.cursor() cursor.execute(f""" - SELECT id, title, artist, album, spotify_id, isrc, genre, year, musicbrainz_id - FROM tracks - WHERE title IS NOT NULL AND title != '' + SELECT t.id, t.title, ar.name, al.title, t.spotify_track_id, + t.isrc, t.musicbrainz_recording_id + 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.title IS NOT NULL AND t.title != '' AND ({where}) ORDER BY - CASE WHEN spotify_id IS NOT NULL AND spotify_id != '' THEN 0 ELSE 1 END, - id + CASE WHEN t.spotify_track_id IS NOT NULL AND t.spotify_track_id != '' THEN 0 ELSE 1 END, + t.id LIMIT 500 """) tracks = cursor.fetchall() @@ -88,73 +82,68 @@ class MetadataGapFillerJob(RepairJob): if i % 20 == 0 and context.wait_if_paused(): return result - track_id, title, artist, album, spotify_id, isrc, genre, year, mb_id = row + track_id, title, artist_name, album_title, spotify_track_id, isrc, mb_id = row result.scanned += 1 - updates = {} + found_fields = {} - # Try Spotify enrichment first (most reliable) - if spotify_id and context.spotify_client: + # Try Spotify enrichment first (most reliable for ISRC) + if spotify_track_id and context.spotify_client: try: - track_data = context.spotify_client.get_track_details(spotify_id) + track_data = context.spotify_client.get_track_details(spotify_track_id) if track_data: if fill_isrc and not isrc: ext_ids = track_data.get('external_ids', {}) if ext_ids.get('isrc'): - updates['isrc'] = ext_ids['isrc'] - - if fill_year and not year: - album_data = track_data.get('album', {}) - rd = album_data.get('release_date', '') - if rd and len(rd) >= 4: - updates['year'] = rd[:4] - - # Get album for genre (genres are on artists in Spotify) - if fill_genre and not genre: - artists = track_data.get('artists', []) if track_data else [] - if artists: - artist_data = context.spotify_client.get_artist(artists[0].get('id', '')) - if artist_data and artist_data.get('genres'): - updates['genre'] = ', '.join(artist_data['genres'][:3]) - + found_fields['isrc'] = ext_ids['isrc'] except Exception as e: logger.debug("Spotify enrichment failed for track %s: %s", track_id, e) - # Try MusicBrainz for MB ID + # Try MusicBrainz for MB recording ID if fill_mb_id and not mb_id and context.mb_client: try: - search_query = f'"{title}" AND artist:"{artist}"' if artist else f'"{title}"' - mb_results = context.mb_client.search_recordings(search_query, limit=1) - if mb_results: - recordings = mb_results.get('recording-list', []) - if recordings: - updates['musicbrainz_id'] = recordings[0].get('id', '') + recordings = context.mb_client.search_recording( + title, artist_name=artist_name, limit=1 + ) + if recordings: + found_fields['musicbrainz_recording_id'] = recordings[0].get('id', '') except Exception as e: logger.debug("MusicBrainz lookup failed for track %s: %s", track_id, e) - # Apply updates - if updates: - try: - conn2 = context.db._get_connection() - cursor2 = conn2.cursor() - set_parts = [f"{k} = ?" for k in updates.keys()] - values = list(updates.values()) + [track_id] - cursor2.execute( - f"UPDATE tracks SET {', '.join(set_parts)}, updated_at = CURRENT_TIMESTAMP WHERE id = ?", - values - ) - conn2.commit() - conn2.close() - result.auto_fixed += 1 - logger.debug("Filled %d metadata fields for track '%s' (id=%s)", - len(updates), title, track_id) - except Exception as e: - logger.debug("Error updating metadata for track %s: %s", track_id, e) - result.errors += 1 + # Create finding for user to review instead of auto-writing + if found_fields: + if context.create_finding: + try: + field_names = ', '.join(found_fields.keys()) + context.create_finding( + job_id=self.job_id, + finding_type='metadata_gap', + severity='info', + entity_type='track', + entity_id=str(track_id), + file_path=None, + title=f'Missing metadata: {title or "Unknown"}', + description=( + f'Track "{title}" by {artist_name or "Unknown"} is missing: {field_names}. ' + f'Found values from API lookup.' + ), + details={ + 'track_id': track_id, + 'title': title, + 'artist': artist_name, + 'album': album_title, + 'spotify_track_id': spotify_track_id, + 'found_fields': found_fields, + } + ) + result.findings_created += 1 + except Exception as e: + logger.debug("Error creating metadata gap finding for track %s: %s", track_id, e) + result.errors += 1 else: result.skipped += 1 # Rate limit API calls - if spotify_id: + if spotify_track_id: time.sleep(0.5) if context.update_progress and (i + 1) % 10 == 0: @@ -163,8 +152,8 @@ class MetadataGapFillerJob(RepairJob): if context.update_progress: context.update_progress(total, total) - logger.info("Metadata gap fill: %d tracks checked, %d enriched, %d skipped", - result.scanned, result.auto_fixed, result.skipped) + logger.info("Metadata gap scan: %d tracks checked, %d gaps found, %d skipped", + result.scanned, result.findings_created, result.skipped) return result def _get_settings(self, context: JobContext) -> dict: @@ -183,10 +172,8 @@ class MetadataGapFillerJob(RepairJob): cursor.execute(""" SELECT COUNT(*) FROM tracks WHERE title IS NOT NULL AND title != '' - AND ((genre IS NULL OR genre = '') - OR (year IS NULL OR year = '' OR year = '0') - OR (isrc IS NULL OR isrc = '') - OR (musicbrainz_id IS NULL OR musicbrainz_id = '')) + AND ((isrc IS NULL OR isrc = '') + OR (musicbrainz_recording_id IS NULL OR musicbrainz_recording_id = '')) """) row = cursor.fetchone() return min(row[0], 500) if row else 0 diff --git a/core/repair_jobs/missing_cover_art.py b/core/repair_jobs/missing_cover_art.py index 01b2b962..6dffdbfa 100644 --- a/core/repair_jobs/missing_cover_art.py +++ b/core/repair_jobs/missing_cover_art.py @@ -1,4 +1,4 @@ -"""Missing Cover Art Filler Job — finds albums without artwork and fills from APIs.""" +"""Missing Cover Art Filler Job — finds albums without artwork and locates art from APIs.""" from core.repair_jobs import register_job from core.repair_jobs.base import JobContext, JobResult, RepairJob @@ -11,15 +11,14 @@ logger = get_logger("repair_job.cover_art") class MissingCoverArtJob(RepairJob): job_id = 'missing_cover_art' display_name = 'Cover Art Filler' - description = 'Finds albums missing artwork and fills from Spotify/iTunes' + description = 'Finds albums missing artwork and locates art from Spotify/iTunes' icon = 'repair-icon-coverart' default_enabled = True default_interval_hours = 48 default_settings = { 'prefer_source': 'spotify', - 'embed_in_file': False, } - auto_fix = True + auto_fix = False def scan(self, context: JobContext) -> JobResult: result = JobResult() @@ -34,10 +33,11 @@ class MissingCoverArtJob(RepairJob): conn = context.db._get_connection() cursor = conn.cursor() cursor.execute(""" - SELECT id, title, artist, spotify_id, artwork_url - FROM albums - WHERE (artwork_url IS NULL OR artwork_url = '') - AND title IS NOT NULL AND title != '' + SELECT al.id, al.title, ar.name, al.spotify_album_id, al.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 = '') + AND al.title IS NOT NULL AND al.title != '' """) albums = cursor.fetchall() except Exception as e: @@ -60,37 +60,46 @@ class MissingCoverArtJob(RepairJob): if i % 10 == 0 and context.wait_if_paused(): return result - album_id, title, artist, spotify_id, _ = row + album_id, title, artist_name, spotify_album_id, _ = row result.scanned += 1 artwork_url = None - # Try Spotify first (or iTunes first based on preference) + # Try to find artwork URL from APIs if prefer_source == 'spotify': - artwork_url = self._try_spotify(spotify_id, title, artist, context) + artwork_url = self._try_spotify(spotify_album_id, title, artist_name, context) if not artwork_url: - artwork_url = self._try_itunes(title, artist, context) + artwork_url = self._try_itunes(title, artist_name, context) else: - artwork_url = self._try_itunes(title, artist, context) + artwork_url = self._try_itunes(title, artist_name, context) if not artwork_url: - artwork_url = self._try_spotify(spotify_id, title, artist, context) + artwork_url = self._try_spotify(spotify_album_id, title, artist_name, context) if artwork_url: - # Update DB - try: - conn2 = context.db._get_connection() - cursor2 = conn2.cursor() - cursor2.execute( - "UPDATE albums SET artwork_url = ? WHERE id = ?", - (artwork_url, album_id) - ) - conn2.commit() - conn2.close() - result.auto_fixed += 1 - logger.debug("Filled artwork for album '%s': %s", title, artwork_url[:80]) - except Exception as e: - logger.debug("Error updating artwork for album %s: %s", album_id, e) - result.errors += 1 + # Create finding for user to approve + if context.create_finding: + try: + context.create_finding( + job_id=self.job_id, + finding_type='missing_cover_art', + severity='info', + entity_type='album', + entity_id=str(album_id), + file_path=None, + title=f'Missing artwork: {title or "Unknown"}', + description=f'Album "{title}" by {artist_name or "Unknown"} has no cover art. Found artwork from API.', + details={ + 'album_id': album_id, + 'album_title': title, + 'artist': artist_name, + 'found_artwork_url': artwork_url, + 'spotify_album_id': spotify_album_id, + } + ) + result.findings_created += 1 + except Exception as e: + logger.debug("Error creating cover art finding for album %s: %s", album_id, e) + result.errors += 1 else: result.skipped += 1 @@ -100,11 +109,11 @@ class MissingCoverArtJob(RepairJob): if context.update_progress: context.update_progress(total, total) - logger.info("Cover art fill: %d albums checked, %d filled, %d skipped", - result.scanned, result.auto_fixed, result.skipped) + logger.info("Cover art scan: %d albums checked, %d found art, %d skipped", + result.scanned, result.findings_created, result.skipped) return result - def _try_spotify(self, spotify_id, title, artist, context): + def _try_spotify(self, spotify_album_id, title, artist_name, context): """Try to get album art from Spotify.""" client = context.spotify_client if not client: @@ -112,8 +121,8 @@ class MissingCoverArtJob(RepairJob): try: # If we have a Spotify album ID, fetch directly - if spotify_id and client.is_spotify_authenticated(): - album_data = client.get_album(spotify_id) + if spotify_album_id and client.is_spotify_authenticated(): + album_data = client.get_album(spotify_album_id) if album_data: images = album_data.get('images', []) if images: @@ -121,7 +130,7 @@ class MissingCoverArtJob(RepairJob): # Search by name if title and client.is_spotify_authenticated(): - query = f"{artist} {title}" if artist else title + query = f"{artist_name} {title}" if artist_name else title results = client.search_albums(query, limit=1) if results and hasattr(results[0], 'image_url') and results[0].image_url: return results[0].image_url @@ -129,14 +138,14 @@ class MissingCoverArtJob(RepairJob): logger.debug("Spotify art lookup failed for '%s': %s", title, e) return None - def _try_itunes(self, title, artist, context): + def _try_itunes(self, title, artist_name, context): """Try to get album art from iTunes.""" client = context.itunes_client if not client: return None try: - query = f"{artist} {title}" if artist else title + query = f"{artist_name} {title}" if artist_name else title results = client.search_albums(query, limit=1) if results and hasattr(results[0], 'image_url') and results[0].image_url: return results[0].image_url @@ -159,7 +168,7 @@ class MissingCoverArtJob(RepairJob): cursor = conn.cursor() cursor.execute(""" SELECT COUNT(*) FROM albums - WHERE (artwork_url IS NULL OR artwork_url = '') + WHERE (thumb_url IS NULL OR thumb_url = '') AND title IS NOT NULL AND title != '' """) row = cursor.fetchone() diff --git a/core/repair_jobs/track_number_repair.py b/core/repair_jobs/track_number_repair.py index 29ca3e9f..1640349a 100644 --- a/core/repair_jobs/track_number_repair.py +++ b/core/repair_jobs/track_number_repair.py @@ -177,7 +177,7 @@ class TrackNumberRepairJob(RepairJob): return result # ------------------------------------------------------------------ - # Tracklist resolution (6-level fallback cascade) + # Tracklist resolution (7-level fallback cascade) # ------------------------------------------------------------------ def _resolve_album_tracklist(self, file_track_data: List[Tuple[str, str, Optional[int]]], folder_path: str, context: JobContext, @@ -188,7 +188,23 @@ class TrackNumberRepairJob(RepairJob): cache = scan_state['album_tracks_cache'] folder_name = os.path.basename(folder_path) - # Collect all available IDs from files in one pass + # Fallback 0: Check DB first — if these files are tracked and their album + # has a spotify_album_id, use that directly without reading file tags + db_album_id, db_spotify_album_id, db_itunes_album_id = _lookup_album_ids_from_db( + file_track_data, context + ) + if db_spotify_album_id and _is_valid_album_id(db_spotify_album_id): + tracks = _get_album_tracklist(db_spotify_album_id, context, cache) + if tracks: + logger.info("[Repair] %s — resolved via DB spotify_album_id: %s", folder_name, db_spotify_album_id) + return tracks + if db_itunes_album_id and _is_valid_album_id(db_itunes_album_id): + tracks = _get_album_tracklist(db_itunes_album_id, context, cache) + if tracks: + logger.info("[Repair] %s — resolved via DB itunes_album_id: %s", folder_name, db_itunes_album_id) + return tracks + + # Collect available IDs from file tags (fallback when DB has no IDs) spotify_album_id = None itunes_album_id = None spotify_track_id = None @@ -216,7 +232,7 @@ class TrackNumberRepairJob(RepairJob): if spotify_album_id and itunes_album_id and spotify_track_id and mb_album_id and album_name: break - # Fallback 1: Spotify album ID + # Fallback 1: Spotify album ID from file tags if spotify_album_id and _is_valid_album_id(spotify_album_id): tracks = _get_album_tracklist(spotify_album_id, context, cache) if tracks: @@ -716,6 +732,44 @@ def _update_db_file_path(db, old_path: str, new_path: str): conn.close() +def _lookup_album_ids_from_db(file_track_data: List[Tuple[str, str, Any]], + context: JobContext) -> Tuple[Optional[str], Optional[str], Optional[str]]: + """Look up album IDs from the database using file paths. + + Checks if any of the files in this folder are tracked in the DB, and if so, + returns the album's (album_id, spotify_album_id, itunes_album_id). + This avoids expensive file tag reads and API calls when the DB already knows. + """ + if not context.db: + return None, None, None + + conn = None + try: + conn = context.db._get_connection() + cursor = conn.cursor() + + # Try each file path until we find one tracked in the DB + for fpath, _, _ in file_track_data: + cursor.execute(""" + SELECT t.album_id, al.spotify_album_id, al.itunes_album_id + FROM tracks t + JOIN albums al ON al.id = t.album_id + WHERE t.file_path = ? + LIMIT 1 + """, (fpath,)) + row = cursor.fetchone() + if row: + return row[0], row[1], row[2] + + except Exception as e: + logger.debug("Error looking up album IDs from DB: %s", e) + finally: + if conn: + conn.close() + + return None, None, None + + def _repair_single_track(file_path: str, filename: str, api_tracks: List[Dict], total_tracks: int, title_similarity: float, context: JobContext) -> bool: