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
474 lines
20 KiB
Python
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()
|