soulsync/core/repair_jobs/album_completeness.py
Broque Thomas 2003e58358 Add Spotify rate limit guards to all repair jobs
Repair jobs were making Spotify API calls without checking the global
rate limit ban, causing them to churn through every item getting rejected
one by one during a ban. Now all Spotify calls in repair jobs check
context.is_spotify_rate_limited() first and skip/fallback gracefully.

- Add is_spotify_rate_limited() helper to JobContext (base.py)
- Guard calls in track_number_repair, album_completeness,
  missing_cover_art, and metadata_gap_filler
- Jobs fall through to iTunes/MusicBrainz fallbacks when rate-limited
2026-03-15 16:01:48 -07:00

205 lines
8.6 KiB
Python

"""Album Completeness Checker Job — finds albums missing tracks."""
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.album_complete")
@register_job
class AlbumCompletenessJob(RepairJob):
job_id = 'album_completeness'
display_name = 'Album Completeness'
description = 'Checks if all tracks from albums are present'
help_text = (
'Compares the number of tracks you have for each album against the expected total '
'from the Spotify tracklist. Albums where tracks are missing get flagged as findings '
'with details about which tracks are absent.\n\n'
'Useful for catching partial downloads or albums where some tracks failed to download. '
'You can use the Download Missing feature from the album page to fill gaps.\n\n'
'Settings:\n'
'- Min Tracks For Check: Only check albums with at least this many expected tracks '
'(skips singles and EPs)'
)
icon = 'repair-icon-completeness'
default_enabled = False
default_interval_hours = 168
default_settings = {
'min_tracks_for_check': 3,
}
auto_fix = False
def scan(self, context: JobContext) -> JobResult:
result = JobResult()
settings = self._get_settings(context)
min_tracks = settings.get('min_tracks_for_check', 3)
# 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 al.id, al.title, ar.name, al.spotify_album_id, al.track_count,
COUNT(t.id) as actual_count, al.thumb_url, ar.thumb_url
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:
logger.error("Error fetching albums: %s", e, exc_info=True)
result.errors += 1
return result
finally:
if conn:
conn.close()
total = len(albums)
if context.update_progress:
context.update_progress(0, total)
logger.info("Checking completeness of %d albums", total)
if context.report_progress:
context.report_progress(phase=f'Checking {total} albums...', total=total)
for i, row in enumerate(albums):
if context.check_stop():
return result
if i % 10 == 0 and context.wait_if_paused():
return result
album_id, title, artist_name, spotify_album_id, db_track_count, actual_count, album_thumb, artist_thumb = row
result.scanned += 1
if context.report_progress:
context.report_progress(
scanned=i + 1, total=total,
phase=f'Checking {i + 1} / {total}',
log_line=f'Album: {title or "Unknown"}{artist_name or "Unknown"}',
log_type='info'
)
# 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 and not context.is_spotify_rate_limited():
try:
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 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 and not context.is_spotify_rate_limited():
try:
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()
conn2 = context.db._get_connection()
cursor2 = conn2.cursor()
cursor2.execute(
"SELECT track_number FROM tracks WHERE album_id = ? AND track_number IS NOT NULL",
(album_id,)
)
for tr in cursor2.fetchall():
owned_numbers.add(tr[0])
conn2.close()
for item in api_tracks['items']:
tn = item.get('track_number')
if tn and tn not in owned_numbers:
missing_tracks.append({
'track_number': tn,
'name': item.get('name', ''),
'disc_number': item.get('disc_number', 1),
})
except Exception as e:
logger.debug("Error getting album tracks for %s: %s", spotify_album_id, e)
if context.report_progress:
context.report_progress(
log_line=f'Incomplete: {title or "Unknown"} ({actual_count}/{expected_total})',
log_type='skip'
)
if context.create_finding:
try:
context.create_finding(
job_id=self.job_id,
finding_type='incomplete_album',
severity='info',
entity_type='album',
entity_id=str(album_id),
file_path=None,
title=f'Incomplete: {title or "Unknown"} ({actual_count}/{expected_total})',
description=(
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_name,
'spotify_album_id': spotify_album_id,
'expected_tracks': expected_total,
'actual_tracks': actual_count,
'missing_tracks': missing_tracks,
'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 completeness finding for album %s: %s", album_id, e)
result.errors += 1
if context.update_progress and (i + 1) % 5 == 0:
context.update_progress(i + 1, total)
if context.update_progress:
context.update_progress(total, total)
logger.info("Completeness check: %d albums checked, %d incomplete found",
result.scanned, result.findings_created)
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 albums
WHERE spotify_album_id IS NOT NULL AND spotify_album_id != ''
""")
row = cursor.fetchone()
return row[0] if row else 0
except Exception:
return 0
finally:
if conn:
conn.close()