`_create_finding` silently dedup-skipped re-discovered issues but the caller incremented `findings_created` regardless. So a re-scan that found the same issues as a prior scan reported 364 findings in the badge while 0 NEW pending rows hit the db, leaving the findings tab empty. `_create_finding` now returns bool (True on insert, False on dedup-skip / db error). All 16 repair jobs updated to only increment `findings_created` on True. Added `findings_skipped_dedup` counter surfaced in scan log: "Done: X scanned, 0 fixed, 0 findings (363 already existed), 0 errors". Also fixed a missing `job_id` kwarg in album_tag_consistency that was silently breaking finding creation for that scan.
262 lines
11 KiB
Python
262 lines
11 KiB
Python
"""Missing Cover Art Filler Job — finds albums without artwork and locates art from APIs."""
|
|
|
|
from core.metadata_service import get_client_for_source, get_primary_source, get_source_priority
|
|
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 locates art from metadata sources'
|
|
help_text = (
|
|
'Scans your library for albums that have no cover art stored in the database. '
|
|
'For each missing cover, it searches the configured metadata sources using the '
|
|
'album name and artist to find matching artwork. If Prefer Source is set, that '
|
|
'source is tried first; otherwise the primary metadata source is used.\n\n'
|
|
'When artwork is found, a finding is created with the image URL so you can review '
|
|
'and apply it. The job does not download or embed artwork automatically.\n\n'
|
|
'Settings:\n'
|
|
'- Prefer Source: Optional source to try first; otherwise the primary metadata source is used'
|
|
)
|
|
icon = 'repair-icon-coverart'
|
|
default_enabled = True
|
|
default_interval_hours = 48
|
|
default_settings = {}
|
|
auto_fix = False
|
|
|
|
def scan(self, context: JobContext) -> JobResult:
|
|
result = JobResult()
|
|
|
|
settings = self._get_settings(context)
|
|
primary_source = get_primary_source()
|
|
source_priority = get_source_priority(primary_source)
|
|
prefer_source = settings.get('prefer_source')
|
|
if prefer_source and prefer_source != primary_source and prefer_source in source_priority:
|
|
source_priority.remove(prefer_source)
|
|
source_priority.insert(0, prefer_source)
|
|
if primary_source in source_priority:
|
|
source_priority.remove(primary_source)
|
|
source_priority.insert(1, primary_source)
|
|
|
|
# Fetch albums with missing artwork
|
|
albums = []
|
|
conn = None
|
|
try:
|
|
conn = context.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("PRAGMA table_info(albums)")
|
|
album_columns = {column[1] for column in cursor.fetchall()}
|
|
|
|
select_cols = [
|
|
"al.id",
|
|
"al.title",
|
|
"ar.name",
|
|
"al.spotify_album_id",
|
|
"al.thumb_url",
|
|
"ar.thumb_url",
|
|
]
|
|
column_map = [
|
|
("itunes_album_id", "al.itunes_album_id"),
|
|
("deezer_album_id", "al.deezer_id"),
|
|
("discogs_album_id", "al.discogs_id"),
|
|
("hydrabase_album_id", "al.soul_id"),
|
|
]
|
|
column_index = {}
|
|
for alias, column in column_map:
|
|
if column.split('.', 1)[1] in album_columns:
|
|
column_index[alias] = len(select_cols)
|
|
select_cols.append(f"{column} AS {alias}")
|
|
|
|
cursor.execute(f"""
|
|
SELECT {', '.join(select_cols)}
|
|
FROM albums al
|
|
LEFT JOIN artists ar ON ar.id = al.artist_id
|
|
WHERE (al.thumb_url IS NULL OR al.thumb_url = '')
|
|
AND al.title IS NOT NULL AND al.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)
|
|
|
|
if context.report_progress:
|
|
context.report_progress(phase=f'Searching artwork for {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, _, artist_thumb = row[:6]
|
|
source_album_ids = {
|
|
'spotify': spotify_album_id,
|
|
'itunes': row[column_index['itunes_album_id']] if 'itunes_album_id' in column_index else None,
|
|
'deezer': row[column_index['deezer_album_id']] if 'deezer_album_id' in column_index else None,
|
|
'discogs': row[column_index['discogs_album_id']] if 'discogs_album_id' in column_index else None,
|
|
'hydrabase': row[column_index['hydrabase_album_id']] if 'hydrabase_album_id' in column_index else None,
|
|
}
|
|
result.scanned += 1
|
|
|
|
if context.report_progress:
|
|
context.report_progress(
|
|
scanned=i + 1, total=total,
|
|
phase=f'Searching {i + 1} / {total}',
|
|
log_line=f'Searching: {title or "Unknown"} — {artist_name or "Unknown"}',
|
|
log_type='info'
|
|
)
|
|
|
|
artwork_url = None
|
|
|
|
# Try source-specific IDs first, then title/artist search, in priority order.
|
|
for source in source_priority:
|
|
artwork_url = self._try_source(source, source_album_ids.get(source), title, artist_name)
|
|
if artwork_url:
|
|
break
|
|
|
|
if artwork_url:
|
|
if context.report_progress:
|
|
context.report_progress(
|
|
log_line=f'Found art: {title or "Unknown"}',
|
|
log_type='success'
|
|
)
|
|
# Create finding for user to approve
|
|
if context.create_finding:
|
|
try:
|
|
inserted = context.create_finding(
|
|
job_id=self.job_id,
|
|
finding_type='missing_cover_art',
|
|
severity='info',
|
|
entity_type='album',
|
|
entity_id=str(album_id),
|
|
file_path=None,
|
|
title=f'Missing artwork: {title or "Unknown"}',
|
|
description=f'Album "{title}" by {artist_name or "Unknown"} has no cover art. Found artwork from API.',
|
|
details={
|
|
'album_id': album_id,
|
|
'album_title': title,
|
|
'artist': artist_name,
|
|
'found_artwork_url': artwork_url,
|
|
'spotify_album_id': spotify_album_id,
|
|
'artist_thumb_url': artist_thumb or None,
|
|
}
|
|
)
|
|
if inserted:
|
|
result.findings_created += 1
|
|
else:
|
|
result.findings_skipped_dedup += 1
|
|
except Exception as e:
|
|
logger.debug("Error creating cover art finding 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 scan: %d albums checked, %d found art, %d skipped",
|
|
result.scanned, result.findings_created, result.skipped)
|
|
return result
|
|
|
|
def _try_source(self, source, source_album_id, title, artist_name):
|
|
"""Try to get album art from a specific metadata source."""
|
|
client = get_client_for_source(source)
|
|
if not client:
|
|
return None
|
|
|
|
query = f"{artist_name} {title}" if artist_name else title
|
|
|
|
try:
|
|
if source_album_id:
|
|
album_data = self._get_album_for_source(source, client, source_album_id)
|
|
artwork_url = self._extract_artwork_url(album_data)
|
|
if artwork_url:
|
|
return artwork_url
|
|
|
|
if query and hasattr(client, 'search_albums'):
|
|
results = client.search_albums(query, limit=1)
|
|
if results:
|
|
artwork_url = self._extract_artwork_url(results[0])
|
|
if artwork_url:
|
|
return artwork_url
|
|
candidate_id = self._extract_album_id(results[0])
|
|
if candidate_id:
|
|
album_data = self._get_album_for_source(source, client, candidate_id)
|
|
return self._extract_artwork_url(album_data)
|
|
except Exception as e:
|
|
logger.debug("%s art lookup failed for '%s': %s", source.capitalize(), title, e)
|
|
return None
|
|
|
|
@staticmethod
|
|
def _get_album_for_source(source, client, album_id):
|
|
if source == 'spotify':
|
|
return client.get_album(album_id)
|
|
return client.get_album(album_id, include_tracks=False)
|
|
|
|
@staticmethod
|
|
def _extract_album_id(item):
|
|
if hasattr(item, 'id'):
|
|
return getattr(item, 'id', None)
|
|
if isinstance(item, dict):
|
|
return item.get('id')
|
|
return None
|
|
|
|
@staticmethod
|
|
def _extract_artwork_url(item):
|
|
if not item:
|
|
return None
|
|
if hasattr(item, 'image_url') and getattr(item, 'image_url', None):
|
|
return item.image_url
|
|
if isinstance(item, dict):
|
|
if item.get('image_url'):
|
|
return item['image_url']
|
|
images = item.get('images') or []
|
|
if images and isinstance(images, list):
|
|
first = images[0]
|
|
if isinstance(first, dict):
|
|
return first.get('url')
|
|
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 (thumb_url IS NULL OR thumb_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()
|