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
205 lines
8.6 KiB
Python
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()
|