soulsync/core/repair_jobs/metadata_gap_filler.py
Broque Thomas fe399636b2 Fix Spotify API calls leaking when Deezer/iTunes is primary source
Spotify was being called for album/artist data fetching across multiple
background workers and the Artists page search even when the user had
Deezer or iTunes set as their primary metadata source. Being authenticated
for playlist sync was treated as permission to use Spotify for everything.

- watchlist_scanner: add _spotify_is_primary_source() that checks both
  auth and primary source config; use it for all album/artist data fetching
  (discovery pool, recent album caching, playlist curation, similar artist
  ID matching, proactive ID backfill). _spotify_available_for_run() is kept
  for sync_spotify_library_cache which must run regardless of primary source
- repair_jobs/metadata_gap_filler: gate Spotify ISRC lookup on primary
  source being 'spotify'; MusicBrainz lookup unaffected
- repair_jobs/unknown_artist_fixer: replace hardcoded spotify_client with
  source-aware client selection — primary source ID tried first, each ID
  matched to its correct client (fixes latent bug passing Deezer IDs to
  Spotify)
- web_server.py /api/match/search: Artists page search was hardcoded to
  spotify_client.search_artists(); now uses _get_metadata_fallback_client()
  so results come from the configured primary source
2026-04-15 09:47:43 -07:00

217 lines
9.1 KiB
Python

"""Metadata Gap Filler Job — finds tracks missing key metadata and locates it from APIs."""
import time
from core.metadata_service import get_primary_source
from core.repair_jobs import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob
from utils.logging_config import get_logger
logger = get_logger("repair_job.metadata_gap")
@register_job
class MetadataGapFillerJob(RepairJob):
job_id = 'metadata_gap_filler'
display_name = 'Metadata Gap Filler'
description = 'Finds tracks missing ISRC or MusicBrainz IDs and locates them'
help_text = (
'Searches for tracks in your library that are missing important metadata identifiers: '
'ISRC codes and MusicBrainz recording IDs. These identifiers are used for accurate '
'matching, scrobbling, and enrichment.\n\n'
'For each track with gaps, the job queries MusicBrainz by title and artist to find '
'the correct IDs. Results are reported as findings for your review.\n\n'
'Settings:\n'
'- Fill ISRC: Look up missing ISRC codes\n'
'- Fill MusicBrainz ID: Look up missing MusicBrainz recording IDs'
)
icon = 'repair-icon-metadata'
default_enabled = False
default_interval_hours = 72
default_settings = {
'fill_isrc': True,
'fill_musicbrainz_id': True,
}
auto_fix = False
def scan(self, context: JobContext) -> JobResult:
result = JobResult()
settings = self._get_settings(context)
fill_isrc = settings.get('fill_isrc', True)
fill_mb_id = settings.get('fill_musicbrainz_id', True)
# Build WHERE clauses for missing fields (only columns that exist on tracks)
conditions = []
if fill_isrc:
conditions.append("(t.isrc IS NULL OR t.isrc = '')")
if fill_mb_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_track_id
tracks = []
conn = None
try:
conn = context.db._get_connection()
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,
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.title IS NOT NULL AND t.title != ''
AND ({where})
ORDER BY
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()
except Exception as e:
logger.error("Error fetching tracks with metadata gaps: %s", e, exc_info=True)
result.errors += 1
return result
finally:
if conn:
conn.close()
total = len(tracks)
if context.update_progress:
context.update_progress(0, total)
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, 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 for ISRC — only when Spotify is the configured primary source.
# If Deezer/iTunes is primary, Spotify may still be authenticated for playlist sync
# but should not be called here, as it burns API quota unnecessarily.
if spotify_track_id and context.spotify_client and not context.is_spotify_rate_limited() and get_primary_source() == 'spotify':
try:
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'):
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 recording ID
if fill_mb_id and not mb_id and context.mb_client:
try:
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)
# 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())
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,
'album_thumb_url': album_thumb or None,
'artist_thumb_url': artist_thumb or None,
}
)
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_track_id:
if context.sleep_or_stop(0.5):
return result
if context.update_progress and (i + 1) % 10 == 0:
context.update_progress(i + 1, total)
if context.update_progress:
context.update_progress(total, total)
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:
if not context.config_manager:
return self.default_settings.copy()
cfg = context.config_manager.get(f'repair.jobs.{self.job_id}.settings', {})
merged = self.default_settings.copy()
merged.update(cfg)
return merged
def estimate_scope(self, context: JobContext) -> int:
conn = None
try:
conn = context.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT COUNT(*) FROM tracks
WHERE title IS NOT NULL AND title != ''
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
except Exception:
return 0
finally:
if conn:
conn.close()