soulsync/core/repair_jobs/missing_cover_art.py

171 lines
6.2 KiB
Python

"""Missing Cover Art Filler Job — finds albums without artwork and fills from APIs."""
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.cover_art")
@register_job
class MissingCoverArtJob(RepairJob):
job_id = 'missing_cover_art'
display_name = 'Cover Art Filler'
description = 'Finds albums missing artwork and fills from Spotify/iTunes'
icon = 'repair-icon-coverart'
default_enabled = True
default_interval_hours = 48
default_settings = {
'prefer_source': 'spotify',
'embed_in_file': False,
}
auto_fix = True
def scan(self, context: JobContext) -> JobResult:
result = JobResult()
settings = self._get_settings(context)
prefer_source = settings.get('prefer_source', 'spotify')
# Fetch albums with missing artwork
albums = []
conn = None
try:
conn = context.db._get_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT id, title, artist, spotify_id, artwork_url
FROM albums
WHERE (artwork_url IS NULL OR artwork_url = '')
AND title IS NOT NULL AND title != ''
""")
albums = cursor.fetchall()
except Exception as e:
logger.error("Error fetching albums without artwork: %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("Found %d albums missing cover art", 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, spotify_id, _ = row
result.scanned += 1
artwork_url = None
# Try Spotify first (or iTunes first based on preference)
if prefer_source == 'spotify':
artwork_url = self._try_spotify(spotify_id, title, artist, context)
if not artwork_url:
artwork_url = self._try_itunes(title, artist, context)
else:
artwork_url = self._try_itunes(title, artist, context)
if not artwork_url:
artwork_url = self._try_spotify(spotify_id, title, artist, context)
if artwork_url:
# Update DB
try:
conn2 = context.db._get_connection()
cursor2 = conn2.cursor()
cursor2.execute(
"UPDATE albums SET artwork_url = ? WHERE id = ?",
(artwork_url, album_id)
)
conn2.commit()
conn2.close()
result.auto_fixed += 1
logger.debug("Filled artwork for album '%s': %s", title, artwork_url[:80])
except Exception as e:
logger.debug("Error updating artwork for album %s: %s", album_id, e)
result.errors += 1
else:
result.skipped += 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("Cover art fill: %d albums checked, %d filled, %d skipped",
result.scanned, result.auto_fixed, result.skipped)
return result
def _try_spotify(self, spotify_id, title, artist, context):
"""Try to get album art from Spotify."""
client = context.spotify_client
if not client:
return None
try:
# If we have a Spotify album ID, fetch directly
if spotify_id and client.is_spotify_authenticated():
album_data = client.get_album(spotify_id)
if album_data:
images = album_data.get('images', [])
if images:
return images[0].get('url')
# Search by name
if title and client.is_spotify_authenticated():
query = f"{artist} {title}" if artist else title
results = client.search_albums(query, limit=1)
if results and hasattr(results[0], 'image_url') and results[0].image_url:
return results[0].image_url
except Exception as e:
logger.debug("Spotify art lookup failed for '%s': %s", title, e)
return None
def _try_itunes(self, title, artist, context):
"""Try to get album art from iTunes."""
client = context.itunes_client
if not client:
return None
try:
query = f"{artist} {title}" if artist else title
results = client.search_albums(query, limit=1)
if results and hasattr(results[0], 'image_url') and results[0].image_url:
return results[0].image_url
except Exception as e:
logger.debug("iTunes art lookup failed for '%s': %s", title, e)
return None
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 (artwork_url IS NULL OR artwork_url = '')
AND title IS NOT NULL AND title != ''
""")
row = cursor.fetchone()
return row[0] if row else 0
except Exception:
return 0
finally:
if conn:
conn.close()