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',
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)

View file

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

View file

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

View file

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

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

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

View file

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