soulsync/core/repair_jobs/discography_backfill.py
Broque Thomas e95452b465 Surface silent exceptions in workers + repair jobs — ~30 sites
Across all background workers (Spotify/Tidal/Deezer/Qobuz/iTunes/
Discogs/Genius/AudioDB/MusicBrainz/Last.fm/SoulID + the metadata-update
worker) and the repair-job scanners. All converted to
`logger.debug("...: %s", e)`.

Two `_e` renames in genius_worker and soulid_worker where outer scope
was already binding `e`. Two finally-block sites in repair_jobs/
library_reorganize.py left silent (conn.close on shutdown path).

Refs #369
2026-05-07 10:27:24 -07:00

474 lines
20 KiB
Python

"""Discography Backfill Job — finds missing albums/tracks for library artists."""
from core.metadata_service import (
get_album_tracks_for_source,
get_artist_discography,
get_primary_source,
MetadataLookupOptions,
)
from core.repair_jobs import register_job
from core.repair_jobs.base import JobContext, JobResult, RepairJob
from core.watchlist_scanner import (
is_acoustic_version,
is_compilation_album,
is_instrumental_version,
is_live_version,
is_remix_version,
)
from utils.logging_config import get_logger
logger = get_logger("repair_job.discography_backfill")
@register_job
class DiscographyBackfillJob(RepairJob):
job_id = 'discography_backfill'
display_name = 'Discography Backfill'
description = 'Finds missing albums and tracks for artists in your library'
help_text = (
'Scans each artist in your library, fetches their full discography from '
'the configured metadata source, and creates findings for any tracks '
'you don\'t already own. Click Fix on a finding to add it to the '
'wishlist for automatic download.\n\n'
'Respects content filters: live versions, remixes, acoustic versions, '
'instrumentals, and compilations are excluded by default.\n\n'
'Settings:\n'
'- Max Artists Per Run: Limit how many artists to process per scan (default: 50)\n'
'- Auto Add To Wishlist: When on, missing tracks are pushed to the wishlist during the scan as well as logged as findings\n'
'- Include Albums / EPs / Singles: Which release types to check\n'
'- Include Live / Remixes / Acoustic / Compilations / Instrumentals: Content type filters'
)
icon = 'repair-icon-backfill'
default_enabled = False
default_interval_hours = 24 # Daily — the scan is rate-limited at 50 artists per run
# Order matters: the UI renders these in dict-insertion order. Keys beginning
# with `_section_` are rendered as group headers (not settings rows) and are
# stripped from the saved config.
default_settings = {
'_section_core': 'Core',
'max_artists_per_run': 50,
# When on, missing tracks are added to the wishlist during the scan in
# addition to creating findings. When off (default), only findings are
# created; the user reviews them and decides per-track in the repair UI.
'auto_add_to_wishlist': False,
'_section_release_types': 'Release Types',
'include_albums': True,
'include_eps': True,
'include_singles': True,
'_section_content_filters': 'Content Filters',
'include_live': False,
'include_remixes': False,
'include_acoustic': False,
'include_compilations': False,
'include_instrumentals': False,
}
auto_fix = False
def scan(self, context: JobContext) -> JobResult:
result = JobResult()
settings = self._get_settings(context)
max_artists = settings.get('max_artists_per_run', 50)
# Fetch all library artists with their metadata source IDs
artists = self._get_library_artists(context)
if not artists:
logger.info("No artists in library to scan")
return result
total = min(len(artists), max_artists)
if context.update_progress:
context.update_progress(0, total)
if context.report_progress:
context.report_progress(
phase=f'Scanning discography for {total} artists...',
total=total,
)
logger.info("Discography backfill: scanning %d artists (of %d total)", total, len(artists))
primary_source = get_primary_source()
for i, artist in enumerate(artists[:max_artists]):
if context.check_stop():
return result
if i % 5 == 0 and context.wait_if_paused():
return result
artist_id = artist['id']
artist_name = artist['name']
if context.report_progress:
context.report_progress(
scanned=i + 1, total=total,
phase=f'Scanning {i + 1} / {total}',
log_line=f'Fetching discography: {artist_name}',
log_type='info',
)
logger.info("[%d/%d] Scanning %s", i + 1, total, artist_name)
try:
missing_count = self._scan_artist(context, artist, settings, primary_source, result)
if missing_count > 0:
logger.info("[%d/%d] Found %d missing tracks for %s", i + 1, total, missing_count, artist_name)
else:
logger.info("[%d/%d] %s — no missing tracks", i + 1, total, artist_name)
except Exception as e:
logger.warning("[%d/%d] Error scanning discography for %s: %s", i + 1, total, artist_name, e)
result.errors += 1
if context.update_progress and (i + 1) % 3 == 0:
context.update_progress(i + 1, total)
# Rate limit between artists
if context.sleep_or_stop(1.0):
return result
if context.update_progress:
context.update_progress(total, total)
logger.info(
"Discography backfill complete: %d artists scanned, %d missing tracks found, %d errors",
result.scanned, result.findings_created, result.errors,
)
return result
def _scan_artist(self, context, artist, settings, primary_source, result):
"""Scan one artist's discography and create findings for missing tracks.
Uses the same batched in-memory matching the Library and Artists pages
use (get_candidate_albums_for_artist + get_candidate_tracks_for_albums)
so one artist with a big library doesn't trigger thousands of per-track
SQL queries.
"""
artist_name = artist['name']
result.scanned += 1
# Build source ID map for more accurate lookups. Primary fallback
# relies on artist-name search when a source ID is missing.
source_ids = {}
if artist.get('spotify_artist_id'):
source_ids['spotify'] = artist['spotify_artist_id']
if artist.get('itunes_artist_id'):
source_ids['itunes'] = artist['itunes_artist_id']
if artist.get('deezer_id'):
source_ids['deezer'] = artist['deezer_id']
# Fetch full discography
discography = get_artist_discography(
artist_id=str(artist['id']),
artist_name=artist_name,
options=MetadataLookupOptions(
allow_fallback=True,
skip_cache=False,
artist_source_ids=source_ids if source_ids else None,
),
)
if not discography:
result.skipped += 1
return 0
source = discography.get('source', primary_source)
albums = discography.get('albums', [])
singles = discography.get('singles', [])
missing_count = 0
active_server = None
if context.config_manager:
active_server = context.config_manager.get_active_media_server()
auto_add = settings.get('auto_add_to_wishlist', False)
# Pre-fetch the artist's library albums + tracks ONCE per artist for
# fast in-memory matching (same pattern as the Library/Artists page
# completion check). Avoids thousands of per-track SQL calls.
candidate_tracks = None
try:
cand_albums = context.db.get_candidate_albums_for_artist(
artist_name, server_source=active_server
)
if cand_albums:
candidate_tracks = context.db.get_candidate_tracks_for_albums(
[a.id for a in cand_albums]
)
except Exception as exc:
logger.debug("Could not pre-fetch candidates for %s: %s", artist_name, exc)
candidate_tracks = None
# Process albums and singles
for release in albums + singles:
if context.check_stop():
return missing_count
release_name = release.get('name', '')
release_id = release.get('id', '')
total_tracks = release.get('total_tracks', 0) or 0
album_type = release.get('album_type', 'album')
release_image = release.get('image_url', '') or ''
release_date = release.get('release_date', '') or ''
# Filter by release type
if not self._should_include_release(total_tracks, album_type, settings):
continue
# Filter compilation albums
if not settings.get('include_compilations', False):
if is_compilation_album(release_name):
continue
# Fetch tracks for this release
try:
tracks_data = get_album_tracks_for_source(source, str(release_id))
except Exception:
tracks_data = None
if not tracks_data:
continue
# Extract track items
items = []
if isinstance(tracks_data, dict):
items = tracks_data.get('items', [])
elif isinstance(tracks_data, list):
items = tracks_data
if not items:
continue
# Build the full album context once per release so every finding
# created for this release carries the same wishlist-ready dict.
# Matches the shape add_to_wishlist / download pipeline expects.
album_context = {
'id': str(release_id),
'name': release_name,
'album_type': album_type,
'release_date': release_date,
'images': [{'url': release_image}] if release_image else [],
'image_url': release_image,
'artists': [{'name': artist_name}],
'total_tracks': total_tracks,
}
for track_item in items:
if context.check_stop():
return missing_count
track_name = track_item.get('name', '')
if not track_name:
continue
# Extract artist name from track (fall back to the discography artist)
track_artists = track_item.get('artists', [])
if track_artists:
first_artist = track_artists[0]
if isinstance(first_artist, dict):
track_artist = first_artist.get('name', artist_name)
else:
track_artist = str(first_artist)
else:
track_artist = artist_name
# Content type filters
if not settings.get('include_live', False):
if is_live_version(track_name, release_name):
continue
if not settings.get('include_remixes', False):
if is_remix_version(track_name, release_name):
continue
if not settings.get('include_acoustic', False):
if is_acoustic_version(track_name, release_name):
continue
if not settings.get('include_instrumentals', False):
if is_instrumental_version(track_name, release_name):
continue
# Check if track already exists in library — batched in-memory
# match when candidates were pre-fetched (fast path). Falls back
# to the legacy SQL path if pre-fetch failed.
db_track, confidence = context.db.check_track_exists(
track_name, track_artist,
confidence_threshold=0.7,
server_source=active_server,
album=release_name,
candidate_tracks=candidate_tracks,
)
if db_track and confidence >= 0.7:
continue # Already owned
# Check if already in wishlist
try:
track_id = track_item.get('id', '')
if track_id and self._is_in_wishlist(context.db, track_id):
continue
except Exception as e:
logger.debug("wishlist membership check failed: %s", e)
# Build wishlist-ready track data. album is a dict (required by
# add_to_wishlist and by the download pipeline's cover-art
# extraction). Every finding carries enough context that the
# fix handler can hand it straight to the wishlist.
track_data = {
'id': track_item.get('id', f'backfill_{hash(f"{track_artist}_{track_name}") % 100000}'),
'name': track_name,
'artists': [{'name': track_artist}],
'album': dict(album_context), # copy so per-track mutations don't bleed
'duration_ms': track_item.get('duration_ms', 0),
'track_number': track_item.get('track_number', 0),
'disc_number': track_item.get('disc_number', 1),
'image_url': release_image,
}
# Create finding
if context.create_finding:
try:
inserted = context.create_finding(
job_id=self.job_id,
finding_type='missing_discography_track',
severity='info',
entity_type='track',
entity_id=str(track_data['id']),
file_path=None,
title=f'Missing: {track_name}',
description=(
f'"{track_name}" by {track_artist} from '
f'"{release_name}" is not in your library.'
),
details={
'track_data': track_data,
'artist_name': artist_name,
'album_name': release_name,
'album_image_url': release_image,
'source': source,
},
)
if inserted:
result.findings_created += 1
missing_count += 1
else:
result.findings_skipped_dedup += 1
# Auto-wishlist mode: also push to wishlist now. The
# finding still gets created so the user has a log of
# what the backfill picked up. Only fire on a NEW
# finding — skip if dedup-suppressed (already on the
# wishlist or already auto-added in a prior scan).
if auto_add and inserted:
try:
context.db.add_to_wishlist(
spotify_track_data=track_data,
failure_reason='Discography backfill — missing from library (auto-added)',
source_type='repair',
source_info={
'job': 'discography_backfill',
'artist': artist_name,
'auto_added': True,
},
)
except Exception as wl_err:
logger.debug("Auto-add to wishlist failed for '%s': %s", track_name, wl_err)
except Exception as e:
logger.debug("Error creating finding for %s: %s", track_name, e)
result.errors += 1
return missing_count
@staticmethod
def _should_include_release(total_tracks, album_type, settings):
"""Check if a release should be included based on type settings.
Spotify lumps both EPs and true singles under album_type='single', so
only an explicit 'album' / 'ep' / 'compilation' is trusted outright.
Anything else (including 'single' or missing type) falls through to a
track-count disambiguation matching the download pipeline:
- 1-3 tracks -> true single
- 4-6 tracks -> EP
- 7+ tracks -> album
"""
normalized = (album_type or '').lower()
if normalized == 'compilation':
return settings.get('include_compilations', False)
if normalized == 'album':
return settings.get('include_albums', True)
if normalized == 'ep':
return settings.get('include_eps', True)
# 'single' or missing: disambiguate by track count
if total_tracks >= 7:
return settings.get('include_albums', True)
if total_tracks >= 4:
return settings.get('include_eps', True)
if total_tracks >= 1:
return settings.get('include_singles', False)
return settings.get('include_albums', True)
@staticmethod
def _is_in_wishlist(db, track_id):
"""Check if a track ID is already in the wishlist."""
conn = db._get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT COUNT(*) FROM wishlist_tracks WHERE spotify_track_id = ?",
(str(track_id),),
)
return cursor.fetchone()[0] > 0
finally:
conn.close()
def _get_library_artists(self, context):
"""Get all artists from the library database with source IDs."""
conn = None
try:
conn = context.db._get_connection()
cursor = conn.cursor()
# Check which columns exist
cursor.execute("PRAGMA table_info(artists)")
columns = {col[1] for col in cursor.fetchall()}
select = ["id", "name"]
if 'spotify_artist_id' in columns:
select.append("spotify_artist_id")
if 'itunes_artist_id' in columns:
select.append("itunes_artist_id")
if 'deezer_id' in columns:
select.append("deezer_id")
cursor.execute(f"""
SELECT {', '.join(select)}
FROM artists
WHERE name IS NOT NULL AND name != '' AND name != 'Unknown Artist'
ORDER BY name
""")
return [dict(row) for row in cursor.fetchall()]
except Exception as e:
logger.error("Error fetching library artists: %s", e, exc_info=True)
return []
finally:
if conn:
conn.close()
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 artists
WHERE name IS NOT NULL AND name != '' AND name != 'Unknown Artist'
""")
row = cursor.fetchone()
settings = self._get_settings(context)
max_artists = settings.get('max_artists_per_run', 50)
return min(row[0] if row else 0, max_artists)
except Exception:
return 0
finally:
if conn:
conn.close()