Fix MusicBrainz API call in metadata gap filler job

This commit is contained in:
Broque Thomas 2026-03-14 09:46:02 -07:00
parent 945f86c643
commit 363b3d0922
7 changed files with 225 additions and 179 deletions

View file

@ -161,7 +161,7 @@ class AcoustIDScannerJob(RepairJob):
finding_type='acoustid_no_match', finding_type='acoustid_no_match',
severity='info', severity='info',
entity_type='track', entity_type='track',
entity_id=str(expected.get('track_id', '')), entity_id=str(expected.get('track_id') or ''),
file_path=fpath, file_path=fpath,
title=f'No AcoustID match: {fname}', title=f'No AcoustID match: {fname}',
description='File could not be identified by AcoustID fingerprint', description='File could not be identified by AcoustID fingerprint',
@ -207,7 +207,7 @@ class AcoustIDScannerJob(RepairJob):
finding_type='acoustid_mismatch', finding_type='acoustid_mismatch',
severity=severity, severity=severity,
entity_type='track', entity_type='track',
entity_id=str(expected.get('track_id', '')), entity_id=str(expected.get('track_id') or ''),
file_path=fpath, file_path=fpath,
title=f'Possible wrong download: {fname}', title=f'Possible wrong download: {fname}',
description=( description=(
@ -235,17 +235,18 @@ class AcoustIDScannerJob(RepairJob):
conn = context.db._get_connection() conn = context.db._get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT id, title, artist, file_path SELECT t.id, t.title, ar.name, t.file_path
FROM tracks FROM tracks t
WHERE file_path IS NOT NULL AND file_path != '' LEFT JOIN artists ar ON ar.id = t.artist_id
AND title IS NOT NULL AND title != '' 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(): 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)] = { tracks[os.path.normpath(file_path)] = {
'track_id': track_id, 'track_id': track_id,
'title': title or '', 'title': title or '',
'artist': artist or '', 'artist': artist_name or '',
} }
except Exception as e: except Exception as e:
logger.error("Error loading tracks from DB: %s", e) logger.error("Error loading tracks from DB: %s", e)

View file

@ -26,20 +26,21 @@ class AlbumCompletenessJob(RepairJob):
settings = self._get_settings(context) settings = self._get_settings(context)
min_tracks = settings.get('min_tracks_for_check', 3) 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 = [] albums = []
conn = None conn = None
try: try:
conn = context.db._get_connection() conn = context.db._get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT a.id, a.title, a.artist, a.spotify_id, a.total_tracks, SELECT al.id, al.title, ar.name, al.spotify_album_id, al.track_count,
COUNT(t.id) as track_count COUNT(t.id) as actual_count
FROM albums a FROM albums al
LEFT JOIN tracks t ON t.album_id = a.id LEFT JOIN artists ar ON ar.id = al.artist_id
WHERE a.spotify_id IS NOT NULL AND a.spotify_id != '' LEFT JOIN tracks t ON t.album_id = al.id
GROUP BY a.id WHERE al.spotify_album_id IS NOT NULL AND al.spotify_album_id != ''
HAVING track_count >= ? GROUP BY al.id
HAVING actual_count >= ?
""", (min_tracks,)) """, (min_tracks,))
albums = cursor.fetchall() albums = cursor.fetchall()
except Exception as e: except Exception as e:
@ -62,31 +63,31 @@ class AlbumCompletenessJob(RepairJob):
if i % 10 == 0 and context.wait_if_paused(): if i % 10 == 0 and context.wait_if_paused():
return result 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 result.scanned += 1
# If we don't know total_tracks, try to get it from API # If we don't know the expected track count, try to get it from API
expected_total = total_tracks expected_total = db_track_count
missing_tracks = []
if not expected_total and context.spotify_client: if not expected_total and context.spotify_client:
try: try:
album_data = context.spotify_client.get_album(spotify_id) album_data = context.spotify_client.get_album(spotify_album_id)
if album_data: if album_data:
expected_total = album_data.get('total_tracks', 0) expected_total = album_data.get('total_tracks', 0)
except Exception: except Exception:
pass pass
if not expected_total or track_count >= expected_total: if not expected_total or actual_count >= expected_total:
result.skipped += 1 result.skipped += 1
if context.update_progress and (i + 1) % 5 == 0: if context.update_progress and (i + 1) % 5 == 0:
context.update_progress(i + 1, total) context.update_progress(i + 1, total)
continue continue
# Album is incomplete — try to find which tracks are missing # Album is incomplete — try to find which tracks are missing
missing_tracks = []
if context.spotify_client: if context.spotify_client:
try: 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: if api_tracks and 'items' in api_tracks:
# Get track numbers we already have # Get track numbers we already have
owned_numbers = set() owned_numbers = set()
@ -109,7 +110,7 @@ class AlbumCompletenessJob(RepairJob):
'disc_number': item.get('disc_number', 1), 'disc_number': item.get('disc_number', 1),
}) })
except Exception as e: 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: if context.create_finding:
try: try:
@ -120,18 +121,18 @@ class AlbumCompletenessJob(RepairJob):
entity_type='album', entity_type='album',
entity_id=str(album_id), entity_id=str(album_id),
file_path=None, file_path=None,
title=f'Incomplete: {title or "Unknown"} ({track_count}/{expected_total})', title=f'Incomplete: {title or "Unknown"} ({actual_count}/{expected_total})',
description=( 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' f'{expected_total} tracks'
), ),
details={ details={
'album_id': album_id, 'album_id': album_id,
'album_title': title, 'album_title': title,
'artist': artist, 'artist': artist_name,
'spotify_id': spotify_id, 'spotify_album_id': spotify_album_id,
'expected_tracks': expected_total, 'expected_tracks': expected_total,
'actual_tracks': track_count, 'actual_tracks': actual_count,
'missing_tracks': missing_tracks, 'missing_tracks': missing_tracks,
} }
) )
@ -165,7 +166,7 @@ class AlbumCompletenessJob(RepairJob):
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT COUNT(*) FROM albums 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() row = cursor.fetchone()
return row[0] if row else 0 return row[0] if row else 0

View file

@ -23,16 +23,18 @@ class DeadFileCleanerJob(RepairJob):
def scan(self, context: JobContext) -> JobResult: def scan(self, context: JobContext) -> JobResult:
result = JobResult() result = JobResult()
# Fetch all tracks with file paths from DB # Fetch all tracks with file paths, joining to get artist/album names
tracks = [] tracks = []
conn = None conn = None
try: try:
conn = context.db._get_connection() conn = context.db._get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT id, title, artist, album, file_path SELECT t.id, t.title, ar.name, al.title, t.file_path
FROM tracks FROM tracks t
WHERE file_path IS NOT NULL AND file_path != '' 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() tracks = cursor.fetchall()
except Exception as e: except Exception as e:
@ -53,7 +55,7 @@ class DeadFileCleanerJob(RepairJob):
if i % 200 == 0 and context.wait_if_paused(): if i % 200 == 0 and context.wait_if_paused():
return result return result
track_id, title, artist, album, file_path = row track_id, title, artist_name, album_title, file_path = row
result.scanned += 1 result.scanned += 1
if not os.path.exists(file_path): if not os.path.exists(file_path):
@ -68,12 +70,12 @@ class DeadFileCleanerJob(RepairJob):
entity_id=str(track_id), entity_id=str(track_id),
file_path=file_path, file_path=file_path,
title=f'Missing file: {title or "Unknown"}', 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={ details={
'track_id': track_id, 'track_id': track_id,
'title': title, 'title': title,
'artist': artist, 'artist': artist_name,
'album': album, 'album': album_title,
'original_path': file_path, 'original_path': file_path,
} }
) )

View file

@ -32,18 +32,20 @@ class DuplicateDetectorJob(RepairJob):
title_threshold = settings.get('title_similarity', 0.85) title_threshold = settings.get('title_similarity', 0.85)
artist_threshold = settings.get('artist_similarity', 0.80) artist_threshold = settings.get('artist_similarity', 0.80)
# Fetch all tracks from DB # Fetch all tracks with artist/album names via JOIN
tracks = [] tracks = []
conn = None conn = None
try: try:
conn = context.db._get_connection() conn = context.db._get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT id, title, artist, album, file_path, format, bitrate, SELECT t.id, t.title, ar.name, al.title, t.file_path,
sample_rate, bit_depth, file_size, duration t.bitrate, t.duration
FROM tracks FROM tracks t
WHERE title IS NOT NULL AND title != '' LEFT JOIN artists ar ON ar.id = t.artist_id
AND file_path IS NOT NULL AND file_path != '' 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() tracks = cursor.fetchall()
except Exception as e: except Exception as e:
@ -62,31 +64,26 @@ class DuplicateDetectorJob(RepairJob):
context.update_progress(0, total) context.update_progress(0, total)
# Group tracks by normalized key for fast comparison # 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) buckets = defaultdict(list)
for row in tracks: 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) 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 bucket_key = norm_title[:4] if len(norm_title) >= 4 else norm_title
buckets[bucket_key].append({ buckets[bucket_key].append({
'id': track_id, 'id': track_id,
'title': title, 'title': title,
'norm_title': norm_title, 'norm_title': norm_title,
'artist': artist or '', 'artist': artist_name or '',
'norm_artist': _normalize(artist or ''), 'norm_artist': _normalize(artist_name or ''),
'album': album, 'album': album_title,
'file_path': file_path, 'file_path': file_path,
'format': fmt,
'bitrate': bitrate, 'bitrate': bitrate,
'sample_rate': sample_rate,
'bit_depth': bit_depth,
'file_size': file_size,
'duration': duration, 'duration': duration,
}) })
# Second pass: find duplicates within each bucket # Find duplicates within each bucket
found_groups = set() # Track IDs already in a group (frozenset) found_groups = set() # Track IDs already in a group
processed = 0 processed = 0
for bucket_key, bucket_tracks in buckets.items(): for bucket_key, bucket_tracks in buckets.items():
@ -124,9 +121,8 @@ class DuplicateDetectorJob(RepairJob):
if len(group) >= 2: if len(group) >= 2:
# Found a duplicate group # Found a duplicate group
group_ids = frozenset(t['id'] for t in group) for t in group:
for tid in group_ids: found_groups.add(t['id'])
found_groups.add(tid)
if context.create_finding: if context.create_finding:
try: try:
@ -149,11 +145,7 @@ class DuplicateDetectorJob(RepairJob):
'artist': t['artist'], 'artist': t['artist'],
'album': t['album'], 'album': t['album'],
'file_path': t['file_path'], 'file_path': t['file_path'],
'format': t['format'],
'bitrate': t['bitrate'], 'bitrate': t['bitrate'],
'sample_rate': t['sample_rate'],
'bit_depth': t['bit_depth'],
'file_size': t['file_size'],
'duration': t['duration'], 'duration': t['duration'],
} for t in group], } for t in group],
'count': len(group), 'count': len(group),

View file

@ -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 import time
@ -13,58 +13,52 @@ logger = get_logger("repair_job.metadata_gap")
class MetadataGapFillerJob(RepairJob): class MetadataGapFillerJob(RepairJob):
job_id = 'metadata_gap_filler' job_id = 'metadata_gap_filler'
display_name = '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' icon = 'repair-icon-metadata'
default_enabled = False default_enabled = False
default_interval_hours = 72 default_interval_hours = 72
default_settings = { default_settings = {
'fill_genre': True,
'fill_year': True,
'fill_isrc': True, 'fill_isrc': True,
'fill_musicbrainz_id': True, 'fill_musicbrainz_id': True,
'write_to_file': False,
} }
auto_fix = True auto_fix = False
def scan(self, context: JobContext) -> JobResult: def scan(self, context: JobContext) -> JobResult:
result = JobResult() result = JobResult()
settings = self._get_settings(context) 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_isrc = settings.get('fill_isrc', True)
fill_mb_id = settings.get('fill_musicbrainz_id', 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 = [] 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: if fill_isrc:
conditions.append("(isrc IS NULL OR isrc = '')") conditions.append("(t.isrc IS NULL OR t.isrc = '')")
if fill_mb_id: 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: if not conditions:
return result return result
where = " OR ".join(conditions) where = " OR ".join(conditions)
# Fetch tracks with gaps, prioritizing those with spotify_id # Fetch tracks with gaps, prioritizing those with spotify_track_id
tracks = [] tracks = []
conn = None conn = None
try: try:
conn = context.db._get_connection() conn = context.db._get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(f""" cursor.execute(f"""
SELECT id, title, artist, album, spotify_id, isrc, genre, year, musicbrainz_id SELECT t.id, t.title, ar.name, al.title, t.spotify_track_id,
FROM tracks t.isrc, t.musicbrainz_recording_id
WHERE title IS NOT NULL AND title != '' 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}) AND ({where})
ORDER BY ORDER BY
CASE WHEN spotify_id IS NOT NULL AND spotify_id != '' THEN 0 ELSE 1 END, CASE WHEN t.spotify_track_id IS NOT NULL AND t.spotify_track_id != '' THEN 0 ELSE 1 END,
id t.id
LIMIT 500 LIMIT 500
""") """)
tracks = cursor.fetchall() tracks = cursor.fetchall()
@ -88,73 +82,68 @@ class MetadataGapFillerJob(RepairJob):
if i % 20 == 0 and context.wait_if_paused(): if i % 20 == 0 and context.wait_if_paused():
return result 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 result.scanned += 1
updates = {} found_fields = {}
# Try Spotify enrichment first (most reliable) # Try Spotify enrichment first (most reliable for ISRC)
if spotify_id and context.spotify_client: if spotify_track_id and context.spotify_client:
try: 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 track_data:
if fill_isrc and not isrc: if fill_isrc and not isrc:
ext_ids = track_data.get('external_ids', {}) ext_ids = track_data.get('external_ids', {})
if ext_ids.get('isrc'): if ext_ids.get('isrc'):
updates['isrc'] = ext_ids['isrc'] found_fields['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])
except Exception as e: except Exception as e:
logger.debug("Spotify enrichment failed for track %s: %s", track_id, 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: if fill_mb_id and not mb_id and context.mb_client:
try: try:
search_query = f'"{title}" AND artist:"{artist}"' if artist else f'"{title}"' recordings = context.mb_client.search_recording(
mb_results = context.mb_client.search_recordings(search_query, limit=1) title, artist_name=artist_name, limit=1
if mb_results: )
recordings = mb_results.get('recording-list', []) if recordings:
if recordings: found_fields['musicbrainz_recording_id'] = recordings[0].get('id', '')
updates['musicbrainz_id'] = recordings[0].get('id', '')
except Exception as e: except Exception as e:
logger.debug("MusicBrainz lookup failed for track %s: %s", track_id, e) logger.debug("MusicBrainz lookup failed for track %s: %s", track_id, e)
# Apply updates # Create finding for user to review instead of auto-writing
if updates: if found_fields:
try: if context.create_finding:
conn2 = context.db._get_connection() try:
cursor2 = conn2.cursor() field_names = ', '.join(found_fields.keys())
set_parts = [f"{k} = ?" for k in updates.keys()] context.create_finding(
values = list(updates.values()) + [track_id] job_id=self.job_id,
cursor2.execute( finding_type='metadata_gap',
f"UPDATE tracks SET {', '.join(set_parts)}, updated_at = CURRENT_TIMESTAMP WHERE id = ?", severity='info',
values entity_type='track',
) entity_id=str(track_id),
conn2.commit() file_path=None,
conn2.close() title=f'Missing metadata: {title or "Unknown"}',
result.auto_fixed += 1 description=(
logger.debug("Filled %d metadata fields for track '%s' (id=%s)", f'Track "{title}" by {artist_name or "Unknown"} is missing: {field_names}. '
len(updates), title, track_id) f'Found values from API lookup.'
except Exception as e: ),
logger.debug("Error updating metadata for track %s: %s", track_id, e) details={
result.errors += 1 '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: else:
result.skipped += 1 result.skipped += 1
# Rate limit API calls # Rate limit API calls
if spotify_id: if spotify_track_id:
time.sleep(0.5) time.sleep(0.5)
if context.update_progress and (i + 1) % 10 == 0: if context.update_progress and (i + 1) % 10 == 0:
@ -163,8 +152,8 @@ class MetadataGapFillerJob(RepairJob):
if context.update_progress: if context.update_progress:
context.update_progress(total, total) context.update_progress(total, total)
logger.info("Metadata gap fill: %d tracks checked, %d enriched, %d skipped", logger.info("Metadata gap scan: %d tracks checked, %d gaps found, %d skipped",
result.scanned, result.auto_fixed, result.skipped) result.scanned, result.findings_created, result.skipped)
return result return result
def _get_settings(self, context: JobContext) -> dict: def _get_settings(self, context: JobContext) -> dict:
@ -183,10 +172,8 @@ class MetadataGapFillerJob(RepairJob):
cursor.execute(""" cursor.execute("""
SELECT COUNT(*) FROM tracks SELECT COUNT(*) FROM tracks
WHERE title IS NOT NULL AND title != '' WHERE title IS NOT NULL AND title != ''
AND ((genre IS NULL OR genre = '') AND ((isrc IS NULL OR isrc = '')
OR (year IS NULL OR year = '' OR year = '0') OR (musicbrainz_recording_id IS NULL OR musicbrainz_recording_id = ''))
OR (isrc IS NULL OR isrc = '')
OR (musicbrainz_id IS NULL OR musicbrainz_id = ''))
""") """)
row = cursor.fetchone() row = cursor.fetchone()
return min(row[0], 500) if row else 0 return min(row[0], 500) if row else 0

View file

@ -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 import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob from core.repair_jobs.base import JobContext, JobResult, RepairJob
@ -11,15 +11,14 @@ logger = get_logger("repair_job.cover_art")
class MissingCoverArtJob(RepairJob): class MissingCoverArtJob(RepairJob):
job_id = 'missing_cover_art' job_id = 'missing_cover_art'
display_name = 'Cover Art Filler' 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' icon = 'repair-icon-coverart'
default_enabled = True default_enabled = True
default_interval_hours = 48 default_interval_hours = 48
default_settings = { default_settings = {
'prefer_source': 'spotify', 'prefer_source': 'spotify',
'embed_in_file': False,
} }
auto_fix = True auto_fix = False
def scan(self, context: JobContext) -> JobResult: def scan(self, context: JobContext) -> JobResult:
result = JobResult() result = JobResult()
@ -34,10 +33,11 @@ class MissingCoverArtJob(RepairJob):
conn = context.db._get_connection() conn = context.db._get_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT id, title, artist, spotify_id, artwork_url SELECT al.id, al.title, ar.name, al.spotify_album_id, al.thumb_url
FROM albums FROM albums al
WHERE (artwork_url IS NULL OR artwork_url = '') LEFT JOIN artists ar ON ar.id = al.artist_id
AND title IS NOT NULL AND title != '' WHERE (al.thumb_url IS NULL OR al.thumb_url = '')
AND al.title IS NOT NULL AND al.title != ''
""") """)
albums = cursor.fetchall() albums = cursor.fetchall()
except Exception as e: except Exception as e:
@ -60,37 +60,46 @@ class MissingCoverArtJob(RepairJob):
if i % 10 == 0 and context.wait_if_paused(): if i % 10 == 0 and context.wait_if_paused():
return result return result
album_id, title, artist, spotify_id, _ = row album_id, title, artist_name, spotify_album_id, _ = row
result.scanned += 1 result.scanned += 1
artwork_url = None artwork_url = None
# Try Spotify first (or iTunes first based on preference) # Try to find artwork URL from APIs
if prefer_source == 'spotify': 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: if not artwork_url:
artwork_url = self._try_itunes(title, artist, context) artwork_url = self._try_itunes(title, artist_name, context)
else: else:
artwork_url = self._try_itunes(title, artist, context) artwork_url = self._try_itunes(title, artist_name, context)
if not artwork_url: 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: if artwork_url:
# Update DB # Create finding for user to approve
try: if context.create_finding:
conn2 = context.db._get_connection() try:
cursor2 = conn2.cursor() context.create_finding(
cursor2.execute( job_id=self.job_id,
"UPDATE albums SET artwork_url = ? WHERE id = ?", finding_type='missing_cover_art',
(artwork_url, album_id) severity='info',
) entity_type='album',
conn2.commit() entity_id=str(album_id),
conn2.close() file_path=None,
result.auto_fixed += 1 title=f'Missing artwork: {title or "Unknown"}',
logger.debug("Filled artwork for album '%s': %s", title, artwork_url[:80]) description=f'Album "{title}" by {artist_name or "Unknown"} has no cover art. Found artwork from API.',
except Exception as e: details={
logger.debug("Error updating artwork for album %s: %s", album_id, e) 'album_id': album_id,
result.errors += 1 '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: else:
result.skipped += 1 result.skipped += 1
@ -100,11 +109,11 @@ class MissingCoverArtJob(RepairJob):
if context.update_progress: if context.update_progress:
context.update_progress(total, total) context.update_progress(total, total)
logger.info("Cover art fill: %d albums checked, %d filled, %d skipped", logger.info("Cover art scan: %d albums checked, %d found art, %d skipped",
result.scanned, result.auto_fixed, result.skipped) result.scanned, result.findings_created, result.skipped)
return result 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.""" """Try to get album art from Spotify."""
client = context.spotify_client client = context.spotify_client
if not client: if not client:
@ -112,8 +121,8 @@ class MissingCoverArtJob(RepairJob):
try: try:
# If we have a Spotify album ID, fetch directly # If we have a Spotify album ID, fetch directly
if spotify_id and client.is_spotify_authenticated(): if spotify_album_id and client.is_spotify_authenticated():
album_data = client.get_album(spotify_id) album_data = client.get_album(spotify_album_id)
if album_data: if album_data:
images = album_data.get('images', []) images = album_data.get('images', [])
if images: if images:
@ -121,7 +130,7 @@ class MissingCoverArtJob(RepairJob):
# Search by name # Search by name
if title and client.is_spotify_authenticated(): 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) results = client.search_albums(query, limit=1)
if results and hasattr(results[0], 'image_url') and results[0].image_url: if results and hasattr(results[0], 'image_url') and results[0].image_url:
return 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) logger.debug("Spotify art lookup failed for '%s': %s", title, e)
return None return None
def _try_itunes(self, title, artist, context): def _try_itunes(self, title, artist_name, context):
"""Try to get album art from iTunes.""" """Try to get album art from iTunes."""
client = context.itunes_client client = context.itunes_client
if not client: if not client:
return None return None
try: 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) results = client.search_albums(query, limit=1)
if results and hasattr(results[0], 'image_url') and results[0].image_url: if results and hasattr(results[0], 'image_url') and results[0].image_url:
return results[0].image_url return results[0].image_url
@ -159,7 +168,7 @@ class MissingCoverArtJob(RepairJob):
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" cursor.execute("""
SELECT COUNT(*) FROM albums 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 != '' AND title IS NOT NULL AND title != ''
""") """)
row = cursor.fetchone() row = cursor.fetchone()

View file

@ -177,7 +177,7 @@ class TrackNumberRepairJob(RepairJob):
return result 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]]], def _resolve_album_tracklist(self, file_track_data: List[Tuple[str, str, Optional[int]]],
folder_path: str, context: JobContext, folder_path: str, context: JobContext,
@ -188,7 +188,23 @@ class TrackNumberRepairJob(RepairJob):
cache = scan_state['album_tracks_cache'] cache = scan_state['album_tracks_cache']
folder_name = os.path.basename(folder_path) 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 spotify_album_id = None
itunes_album_id = None itunes_album_id = None
spotify_track_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: if spotify_album_id and itunes_album_id and spotify_track_id and mb_album_id and album_name:
break 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): if spotify_album_id and _is_valid_album_id(spotify_album_id):
tracks = _get_album_tracklist(spotify_album_id, context, cache) tracks = _get_album_tracklist(spotify_album_id, context, cache)
if tracks: if tracks:
@ -716,6 +732,44 @@ def _update_db_file_path(db, old_path: str, new_path: str):
conn.close() 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], def _repair_single_track(file_path: str, filename: str, api_tracks: List[Dict],
total_tracks: int, title_similarity: float, total_tracks: int, title_similarity: float,
context: JobContext) -> bool: context: JobContext) -> bool: