Fix MusicBrainz API call in metadata gap filler job
This commit is contained in:
parent
945f86c643
commit
363b3d0922
7 changed files with 225 additions and 179 deletions
|
|
@ -161,7 +161,7 @@ class AcoustIDScannerJob(RepairJob):
|
|||
finding_type='acoustid_no_match',
|
||||
severity='info',
|
||||
entity_type='track',
|
||||
entity_id=str(expected.get('track_id', '')),
|
||||
entity_id=str(expected.get('track_id') or ''),
|
||||
file_path=fpath,
|
||||
title=f'No AcoustID match: {fname}',
|
||||
description='File could not be identified by AcoustID fingerprint',
|
||||
|
|
@ -207,7 +207,7 @@ class AcoustIDScannerJob(RepairJob):
|
|||
finding_type='acoustid_mismatch',
|
||||
severity=severity,
|
||||
entity_type='track',
|
||||
entity_id=str(expected.get('track_id', '')),
|
||||
entity_id=str(expected.get('track_id') or ''),
|
||||
file_path=fpath,
|
||||
title=f'Possible wrong download: {fname}',
|
||||
description=(
|
||||
|
|
@ -235,17 +235,18 @@ class AcoustIDScannerJob(RepairJob):
|
|||
conn = context.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, title, artist, file_path
|
||||
FROM tracks
|
||||
WHERE file_path IS NOT NULL AND file_path != ''
|
||||
AND title IS NOT NULL AND title != ''
|
||||
SELECT t.id, t.title, ar.name, t.file_path
|
||||
FROM tracks t
|
||||
LEFT JOIN artists ar ON ar.id = t.artist_id
|
||||
WHERE t.file_path IS NOT NULL AND t.file_path != ''
|
||||
AND t.title IS NOT NULL AND t.title != ''
|
||||
""")
|
||||
for row in cursor.fetchall():
|
||||
track_id, title, artist, file_path = row
|
||||
track_id, title, artist_name, file_path = row
|
||||
tracks[os.path.normpath(file_path)] = {
|
||||
'track_id': track_id,
|
||||
'title': title or '',
|
||||
'artist': artist or '',
|
||||
'artist': artist_name or '',
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Error loading tracks from DB: %s", e)
|
||||
|
|
|
|||
|
|
@ -26,20 +26,21 @@ class AlbumCompletenessJob(RepairJob):
|
|||
settings = self._get_settings(context)
|
||||
min_tracks = settings.get('min_tracks_for_check', 3)
|
||||
|
||||
# Fetch albums with spotify_id that have enough tracks to check
|
||||
# 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 a.id, a.title, a.artist, a.spotify_id, a.total_tracks,
|
||||
COUNT(t.id) as track_count
|
||||
FROM albums a
|
||||
LEFT JOIN tracks t ON t.album_id = a.id
|
||||
WHERE a.spotify_id IS NOT NULL AND a.spotify_id != ''
|
||||
GROUP BY a.id
|
||||
HAVING track_count >= ?
|
||||
SELECT al.id, al.title, ar.name, al.spotify_album_id, al.track_count,
|
||||
COUNT(t.id) as actual_count
|
||||
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:
|
||||
|
|
@ -62,31 +63,31 @@ class AlbumCompletenessJob(RepairJob):
|
|||
if i % 10 == 0 and context.wait_if_paused():
|
||||
return result
|
||||
|
||||
album_id, title, artist, spotify_id, total_tracks, track_count = row
|
||||
album_id, title, artist_name, spotify_album_id, db_track_count, actual_count = row
|
||||
result.scanned += 1
|
||||
|
||||
# If we don't know total_tracks, try to get it from API
|
||||
expected_total = total_tracks
|
||||
missing_tracks = []
|
||||
# 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:
|
||||
try:
|
||||
album_data = context.spotify_client.get_album(spotify_id)
|
||||
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 track_count >= expected_total:
|
||||
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:
|
||||
try:
|
||||
api_tracks = context.spotify_client.get_album_tracks(spotify_id)
|
||||
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()
|
||||
|
|
@ -109,7 +110,7 @@ class AlbumCompletenessJob(RepairJob):
|
|||
'disc_number': item.get('disc_number', 1),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug("Error getting album tracks for %s: %s", spotify_id, e)
|
||||
logger.debug("Error getting album tracks for %s: %s", spotify_album_id, e)
|
||||
|
||||
if context.create_finding:
|
||||
try:
|
||||
|
|
@ -120,18 +121,18 @@ class AlbumCompletenessJob(RepairJob):
|
|||
entity_type='album',
|
||||
entity_id=str(album_id),
|
||||
file_path=None,
|
||||
title=f'Incomplete: {title or "Unknown"} ({track_count}/{expected_total})',
|
||||
title=f'Incomplete: {title or "Unknown"} ({actual_count}/{expected_total})',
|
||||
description=(
|
||||
f'Album "{title}" by {artist or "Unknown"} has {track_count} of '
|
||||
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,
|
||||
'spotify_id': spotify_id,
|
||||
'artist': artist_name,
|
||||
'spotify_album_id': spotify_album_id,
|
||||
'expected_tracks': expected_total,
|
||||
'actual_tracks': track_count,
|
||||
'actual_tracks': actual_count,
|
||||
'missing_tracks': missing_tracks,
|
||||
}
|
||||
)
|
||||
|
|
@ -165,7 +166,7 @@ class AlbumCompletenessJob(RepairJob):
|
|||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM albums
|
||||
WHERE spotify_id IS NOT NULL AND spotify_id != ''
|
||||
WHERE spotify_album_id IS NOT NULL AND spotify_album_id != ''
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
return row[0] if row else 0
|
||||
|
|
|
|||
|
|
@ -23,16 +23,18 @@ class DeadFileCleanerJob(RepairJob):
|
|||
def scan(self, context: JobContext) -> JobResult:
|
||||
result = JobResult()
|
||||
|
||||
# Fetch all tracks with file paths from DB
|
||||
# Fetch all tracks with file paths, joining to get artist/album names
|
||||
tracks = []
|
||||
conn = None
|
||||
try:
|
||||
conn = context.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, title, artist, album, file_path
|
||||
FROM tracks
|
||||
WHERE file_path IS NOT NULL AND file_path != ''
|
||||
SELECT t.id, t.title, ar.name, al.title, t.file_path
|
||||
FROM tracks t
|
||||
LEFT JOIN artists ar ON ar.id = t.artist_id
|
||||
LEFT JOIN albums al ON al.id = t.album_id
|
||||
WHERE t.file_path IS NOT NULL AND t.file_path != ''
|
||||
""")
|
||||
tracks = cursor.fetchall()
|
||||
except Exception as e:
|
||||
|
|
@ -53,7 +55,7 @@ class DeadFileCleanerJob(RepairJob):
|
|||
if i % 200 == 0 and context.wait_if_paused():
|
||||
return result
|
||||
|
||||
track_id, title, artist, album, file_path = row
|
||||
track_id, title, artist_name, album_title, file_path = row
|
||||
result.scanned += 1
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
|
|
@ -68,12 +70,12 @@ class DeadFileCleanerJob(RepairJob):
|
|||
entity_id=str(track_id),
|
||||
file_path=file_path,
|
||||
title=f'Missing file: {title or "Unknown"}',
|
||||
description=f'Track "{title}" by {artist or "Unknown"} points to a file that no longer exists',
|
||||
description=f'Track "{title}" by {artist_name or "Unknown"} points to a file that no longer exists',
|
||||
details={
|
||||
'track_id': track_id,
|
||||
'title': title,
|
||||
'artist': artist,
|
||||
'album': album,
|
||||
'artist': artist_name,
|
||||
'album': album_title,
|
||||
'original_path': file_path,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -32,18 +32,20 @@ class DuplicateDetectorJob(RepairJob):
|
|||
title_threshold = settings.get('title_similarity', 0.85)
|
||||
artist_threshold = settings.get('artist_similarity', 0.80)
|
||||
|
||||
# Fetch all tracks from DB
|
||||
# Fetch all tracks with artist/album names via JOIN
|
||||
tracks = []
|
||||
conn = None
|
||||
try:
|
||||
conn = context.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, title, artist, album, file_path, format, bitrate,
|
||||
sample_rate, bit_depth, file_size, duration
|
||||
FROM tracks
|
||||
WHERE title IS NOT NULL AND title != ''
|
||||
AND file_path IS NOT NULL AND file_path != ''
|
||||
SELECT t.id, t.title, ar.name, al.title, t.file_path,
|
||||
t.bitrate, t.duration
|
||||
FROM tracks t
|
||||
LEFT JOIN artists ar ON ar.id = t.artist_id
|
||||
LEFT JOIN albums al ON al.id = t.album_id
|
||||
WHERE t.title IS NOT NULL AND t.title != ''
|
||||
AND t.file_path IS NOT NULL AND t.file_path != ''
|
||||
""")
|
||||
tracks = cursor.fetchall()
|
||||
except Exception as e:
|
||||
|
|
@ -62,31 +64,26 @@ class DuplicateDetectorJob(RepairJob):
|
|||
context.update_progress(0, total)
|
||||
|
||||
# Group tracks by normalized key for fast comparison
|
||||
# First pass: bucket by first 3 chars of normalized title for efficiency
|
||||
# Bucket by first 4 chars of normalized title for efficiency
|
||||
buckets = defaultdict(list)
|
||||
for row in tracks:
|
||||
track_id, title, artist, album, file_path, fmt, bitrate, sample_rate, bit_depth, file_size, duration = row
|
||||
track_id, title, artist_name, album_title, file_path, bitrate, duration = row
|
||||
norm_title = _normalize(title)
|
||||
# Bucket by first few chars to avoid O(n^2) full comparison
|
||||
bucket_key = norm_title[:4] if len(norm_title) >= 4 else norm_title
|
||||
buckets[bucket_key].append({
|
||||
'id': track_id,
|
||||
'title': title,
|
||||
'norm_title': norm_title,
|
||||
'artist': artist or '',
|
||||
'norm_artist': _normalize(artist or ''),
|
||||
'album': album,
|
||||
'artist': artist_name or '',
|
||||
'norm_artist': _normalize(artist_name or ''),
|
||||
'album': album_title,
|
||||
'file_path': file_path,
|
||||
'format': fmt,
|
||||
'bitrate': bitrate,
|
||||
'sample_rate': sample_rate,
|
||||
'bit_depth': bit_depth,
|
||||
'file_size': file_size,
|
||||
'duration': duration,
|
||||
})
|
||||
|
||||
# Second pass: find duplicates within each bucket
|
||||
found_groups = set() # Track IDs already in a group (frozenset)
|
||||
# Find duplicates within each bucket
|
||||
found_groups = set() # Track IDs already in a group
|
||||
processed = 0
|
||||
|
||||
for bucket_key, bucket_tracks in buckets.items():
|
||||
|
|
@ -124,9 +121,8 @@ class DuplicateDetectorJob(RepairJob):
|
|||
|
||||
if len(group) >= 2:
|
||||
# Found a duplicate group
|
||||
group_ids = frozenset(t['id'] for t in group)
|
||||
for tid in group_ids:
|
||||
found_groups.add(tid)
|
||||
for t in group:
|
||||
found_groups.add(t['id'])
|
||||
|
||||
if context.create_finding:
|
||||
try:
|
||||
|
|
@ -149,11 +145,7 @@ class DuplicateDetectorJob(RepairJob):
|
|||
'artist': t['artist'],
|
||||
'album': t['album'],
|
||||
'file_path': t['file_path'],
|
||||
'format': t['format'],
|
||||
'bitrate': t['bitrate'],
|
||||
'sample_rate': t['sample_rate'],
|
||||
'bit_depth': t['bit_depth'],
|
||||
'file_size': t['file_size'],
|
||||
'duration': t['duration'],
|
||||
} for t in group],
|
||||
'count': len(group),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Metadata Gap Filler Job — fills missing track metadata from APIs."""
|
||||
"""Metadata Gap Filler Job — finds tracks missing key metadata and locates it from APIs."""
|
||||
|
||||
import time
|
||||
|
||||
|
|
@ -13,58 +13,52 @@ logger = get_logger("repair_job.metadata_gap")
|
|||
class MetadataGapFillerJob(RepairJob):
|
||||
job_id = 'metadata_gap_filler'
|
||||
display_name = 'Metadata Gap Filler'
|
||||
description = 'Fills missing genre, year, ISRC, and MusicBrainz IDs'
|
||||
description = 'Finds tracks missing ISRC or MusicBrainz IDs and locates them'
|
||||
icon = 'repair-icon-metadata'
|
||||
default_enabled = False
|
||||
default_interval_hours = 72
|
||||
default_settings = {
|
||||
'fill_genre': True,
|
||||
'fill_year': True,
|
||||
'fill_isrc': True,
|
||||
'fill_musicbrainz_id': True,
|
||||
'write_to_file': False,
|
||||
}
|
||||
auto_fix = True
|
||||
auto_fix = False
|
||||
|
||||
def scan(self, context: JobContext) -> JobResult:
|
||||
result = JobResult()
|
||||
|
||||
settings = self._get_settings(context)
|
||||
fill_genre = settings.get('fill_genre', True)
|
||||
fill_year = settings.get('fill_year', True)
|
||||
fill_isrc = settings.get('fill_isrc', True)
|
||||
fill_mb_id = settings.get('fill_musicbrainz_id', True)
|
||||
|
||||
# Build WHERE clauses for missing fields
|
||||
# Build WHERE clauses for missing fields (only columns that exist on tracks)
|
||||
conditions = []
|
||||
if fill_genre:
|
||||
conditions.append("(genre IS NULL OR genre = '')")
|
||||
if fill_year:
|
||||
conditions.append("(year IS NULL OR year = '' OR year = '0')")
|
||||
if fill_isrc:
|
||||
conditions.append("(isrc IS NULL OR isrc = '')")
|
||||
conditions.append("(t.isrc IS NULL OR t.isrc = '')")
|
||||
if fill_mb_id:
|
||||
conditions.append("(musicbrainz_id IS NULL OR musicbrainz_id = '')")
|
||||
conditions.append("(t.musicbrainz_recording_id IS NULL OR t.musicbrainz_recording_id = '')")
|
||||
|
||||
if not conditions:
|
||||
return result
|
||||
|
||||
where = " OR ".join(conditions)
|
||||
|
||||
# Fetch tracks with gaps, prioritizing those with spotify_id
|
||||
# Fetch tracks with gaps, prioritizing those with spotify_track_id
|
||||
tracks = []
|
||||
conn = None
|
||||
try:
|
||||
conn = context.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"""
|
||||
SELECT id, title, artist, album, spotify_id, isrc, genre, year, musicbrainz_id
|
||||
FROM tracks
|
||||
WHERE title IS NOT NULL AND title != ''
|
||||
SELECT t.id, t.title, ar.name, al.title, t.spotify_track_id,
|
||||
t.isrc, t.musicbrainz_recording_id
|
||||
FROM tracks t
|
||||
LEFT JOIN artists ar ON ar.id = t.artist_id
|
||||
LEFT JOIN albums al ON al.id = t.album_id
|
||||
WHERE t.title IS NOT NULL AND t.title != ''
|
||||
AND ({where})
|
||||
ORDER BY
|
||||
CASE WHEN spotify_id IS NOT NULL AND spotify_id != '' THEN 0 ELSE 1 END,
|
||||
id
|
||||
CASE WHEN t.spotify_track_id IS NOT NULL AND t.spotify_track_id != '' THEN 0 ELSE 1 END,
|
||||
t.id
|
||||
LIMIT 500
|
||||
""")
|
||||
tracks = cursor.fetchall()
|
||||
|
|
@ -88,73 +82,68 @@ class MetadataGapFillerJob(RepairJob):
|
|||
if i % 20 == 0 and context.wait_if_paused():
|
||||
return result
|
||||
|
||||
track_id, title, artist, album, spotify_id, isrc, genre, year, mb_id = row
|
||||
track_id, title, artist_name, album_title, spotify_track_id, isrc, mb_id = row
|
||||
result.scanned += 1
|
||||
updates = {}
|
||||
found_fields = {}
|
||||
|
||||
# Try Spotify enrichment first (most reliable)
|
||||
if spotify_id and context.spotify_client:
|
||||
# Try Spotify enrichment first (most reliable for ISRC)
|
||||
if spotify_track_id and context.spotify_client:
|
||||
try:
|
||||
track_data = context.spotify_client.get_track_details(spotify_id)
|
||||
track_data = context.spotify_client.get_track_details(spotify_track_id)
|
||||
if track_data:
|
||||
if fill_isrc and not isrc:
|
||||
ext_ids = track_data.get('external_ids', {})
|
||||
if ext_ids.get('isrc'):
|
||||
updates['isrc'] = ext_ids['isrc']
|
||||
|
||||
if fill_year and not year:
|
||||
album_data = track_data.get('album', {})
|
||||
rd = album_data.get('release_date', '')
|
||||
if rd and len(rd) >= 4:
|
||||
updates['year'] = rd[:4]
|
||||
|
||||
# Get album for genre (genres are on artists in Spotify)
|
||||
if fill_genre and not genre:
|
||||
artists = track_data.get('artists', []) if track_data else []
|
||||
if artists:
|
||||
artist_data = context.spotify_client.get_artist(artists[0].get('id', ''))
|
||||
if artist_data and artist_data.get('genres'):
|
||||
updates['genre'] = ', '.join(artist_data['genres'][:3])
|
||||
|
||||
found_fields['isrc'] = ext_ids['isrc']
|
||||
except Exception as e:
|
||||
logger.debug("Spotify enrichment failed for track %s: %s", track_id, e)
|
||||
|
||||
# Try MusicBrainz for MB ID
|
||||
# Try MusicBrainz for MB recording ID
|
||||
if fill_mb_id and not mb_id and context.mb_client:
|
||||
try:
|
||||
search_query = f'"{title}" AND artist:"{artist}"' if artist else f'"{title}"'
|
||||
mb_results = context.mb_client.search_recordings(search_query, limit=1)
|
||||
if mb_results:
|
||||
recordings = mb_results.get('recording-list', [])
|
||||
if recordings:
|
||||
updates['musicbrainz_id'] = recordings[0].get('id', '')
|
||||
recordings = context.mb_client.search_recording(
|
||||
title, artist_name=artist_name, limit=1
|
||||
)
|
||||
if recordings:
|
||||
found_fields['musicbrainz_recording_id'] = recordings[0].get('id', '')
|
||||
except Exception as e:
|
||||
logger.debug("MusicBrainz lookup failed for track %s: %s", track_id, e)
|
||||
|
||||
# Apply updates
|
||||
if updates:
|
||||
try:
|
||||
conn2 = context.db._get_connection()
|
||||
cursor2 = conn2.cursor()
|
||||
set_parts = [f"{k} = ?" for k in updates.keys()]
|
||||
values = list(updates.values()) + [track_id]
|
||||
cursor2.execute(
|
||||
f"UPDATE tracks SET {', '.join(set_parts)}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
values
|
||||
)
|
||||
conn2.commit()
|
||||
conn2.close()
|
||||
result.auto_fixed += 1
|
||||
logger.debug("Filled %d metadata fields for track '%s' (id=%s)",
|
||||
len(updates), title, track_id)
|
||||
except Exception as e:
|
||||
logger.debug("Error updating metadata for track %s: %s", track_id, e)
|
||||
result.errors += 1
|
||||
# Create finding for user to review instead of auto-writing
|
||||
if found_fields:
|
||||
if context.create_finding:
|
||||
try:
|
||||
field_names = ', '.join(found_fields.keys())
|
||||
context.create_finding(
|
||||
job_id=self.job_id,
|
||||
finding_type='metadata_gap',
|
||||
severity='info',
|
||||
entity_type='track',
|
||||
entity_id=str(track_id),
|
||||
file_path=None,
|
||||
title=f'Missing metadata: {title or "Unknown"}',
|
||||
description=(
|
||||
f'Track "{title}" by {artist_name or "Unknown"} is missing: {field_names}. '
|
||||
f'Found values from API lookup.'
|
||||
),
|
||||
details={
|
||||
'track_id': track_id,
|
||||
'title': title,
|
||||
'artist': artist_name,
|
||||
'album': album_title,
|
||||
'spotify_track_id': spotify_track_id,
|
||||
'found_fields': found_fields,
|
||||
}
|
||||
)
|
||||
result.findings_created += 1
|
||||
except Exception as e:
|
||||
logger.debug("Error creating metadata gap finding for track %s: %s", track_id, e)
|
||||
result.errors += 1
|
||||
else:
|
||||
result.skipped += 1
|
||||
|
||||
# Rate limit API calls
|
||||
if spotify_id:
|
||||
if spotify_track_id:
|
||||
time.sleep(0.5)
|
||||
|
||||
if context.update_progress and (i + 1) % 10 == 0:
|
||||
|
|
@ -163,8 +152,8 @@ class MetadataGapFillerJob(RepairJob):
|
|||
if context.update_progress:
|
||||
context.update_progress(total, total)
|
||||
|
||||
logger.info("Metadata gap fill: %d tracks checked, %d enriched, %d skipped",
|
||||
result.scanned, result.auto_fixed, result.skipped)
|
||||
logger.info("Metadata gap scan: %d tracks checked, %d gaps found, %d skipped",
|
||||
result.scanned, result.findings_created, result.skipped)
|
||||
return result
|
||||
|
||||
def _get_settings(self, context: JobContext) -> dict:
|
||||
|
|
@ -183,10 +172,8 @@ class MetadataGapFillerJob(RepairJob):
|
|||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM tracks
|
||||
WHERE title IS NOT NULL AND title != ''
|
||||
AND ((genre IS NULL OR genre = '')
|
||||
OR (year IS NULL OR year = '' OR year = '0')
|
||||
OR (isrc IS NULL OR isrc = '')
|
||||
OR (musicbrainz_id IS NULL OR musicbrainz_id = ''))
|
||||
AND ((isrc IS NULL OR isrc = '')
|
||||
OR (musicbrainz_recording_id IS NULL OR musicbrainz_recording_id = ''))
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
return min(row[0], 500) if row else 0
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Missing Cover Art Filler Job — finds albums without artwork and fills from APIs."""
|
||||
"""Missing Cover Art Filler Job — finds albums without artwork and locates art from APIs."""
|
||||
|
||||
from core.repair_jobs import register_job
|
||||
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
||||
|
|
@ -11,15 +11,14 @@ logger = get_logger("repair_job.cover_art")
|
|||
class MissingCoverArtJob(RepairJob):
|
||||
job_id = 'missing_cover_art'
|
||||
display_name = 'Cover Art Filler'
|
||||
description = 'Finds albums missing artwork and fills from Spotify/iTunes'
|
||||
description = 'Finds albums missing artwork and locates art 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
|
||||
auto_fix = False
|
||||
|
||||
def scan(self, context: JobContext) -> JobResult:
|
||||
result = JobResult()
|
||||
|
|
@ -34,10 +33,11 @@ class MissingCoverArtJob(RepairJob):
|
|||
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 != ''
|
||||
SELECT al.id, al.title, ar.name, al.spotify_album_id, al.thumb_url
|
||||
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:
|
||||
|
|
@ -60,37 +60,46 @@ class MissingCoverArtJob(RepairJob):
|
|||
if i % 10 == 0 and context.wait_if_paused():
|
||||
return result
|
||||
|
||||
album_id, title, artist, spotify_id, _ = row
|
||||
album_id, title, artist_name, spotify_album_id, _ = row
|
||||
result.scanned += 1
|
||||
|
||||
artwork_url = None
|
||||
|
||||
# Try Spotify first (or iTunes first based on preference)
|
||||
# Try to find artwork URL from APIs
|
||||
if prefer_source == 'spotify':
|
||||
artwork_url = self._try_spotify(spotify_id, title, artist, context)
|
||||
artwork_url = self._try_spotify(spotify_album_id, title, artist_name, context)
|
||||
if not artwork_url:
|
||||
artwork_url = self._try_itunes(title, artist, context)
|
||||
artwork_url = self._try_itunes(title, artist_name, context)
|
||||
else:
|
||||
artwork_url = self._try_itunes(title, artist, context)
|
||||
artwork_url = self._try_itunes(title, artist_name, context)
|
||||
if not artwork_url:
|
||||
artwork_url = self._try_spotify(spotify_id, title, artist, context)
|
||||
artwork_url = self._try_spotify(spotify_album_id, title, artist_name, 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
|
||||
# Create finding for user to approve
|
||||
if context.create_finding:
|
||||
try:
|
||||
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,
|
||||
}
|
||||
)
|
||||
result.findings_created += 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
|
||||
|
||||
|
|
@ -100,11 +109,11 @@ class MissingCoverArtJob(RepairJob):
|
|||
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)
|
||||
logger.info("Cover art scan: %d albums checked, %d found art, %d skipped",
|
||||
result.scanned, result.findings_created, result.skipped)
|
||||
return result
|
||||
|
||||
def _try_spotify(self, spotify_id, title, artist, context):
|
||||
def _try_spotify(self, spotify_album_id, title, artist_name, context):
|
||||
"""Try to get album art from Spotify."""
|
||||
client = context.spotify_client
|
||||
if not client:
|
||||
|
|
@ -112,8 +121,8 @@ class MissingCoverArtJob(RepairJob):
|
|||
|
||||
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 spotify_album_id and client.is_spotify_authenticated():
|
||||
album_data = client.get_album(spotify_album_id)
|
||||
if album_data:
|
||||
images = album_data.get('images', [])
|
||||
if images:
|
||||
|
|
@ -121,7 +130,7 @@ class MissingCoverArtJob(RepairJob):
|
|||
|
||||
# Search by name
|
||||
if title and client.is_spotify_authenticated():
|
||||
query = f"{artist} {title}" if artist else title
|
||||
query = f"{artist_name} {title}" if artist_name 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
|
||||
|
|
@ -129,14 +138,14 @@ class MissingCoverArtJob(RepairJob):
|
|||
logger.debug("Spotify art lookup failed for '%s': %s", title, e)
|
||||
return None
|
||||
|
||||
def _try_itunes(self, title, artist, context):
|
||||
def _try_itunes(self, title, artist_name, 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
|
||||
query = f"{artist_name} {title}" if artist_name 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
|
||||
|
|
@ -159,7 +168,7 @@ class MissingCoverArtJob(RepairJob):
|
|||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM albums
|
||||
WHERE (artwork_url IS NULL OR artwork_url = '')
|
||||
WHERE (thumb_url IS NULL OR thumb_url = '')
|
||||
AND title IS NOT NULL AND title != ''
|
||||
""")
|
||||
row = cursor.fetchone()
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@ class TrackNumberRepairJob(RepairJob):
|
|||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tracklist resolution (6-level fallback cascade)
|
||||
# Tracklist resolution (7-level fallback cascade)
|
||||
# ------------------------------------------------------------------
|
||||
def _resolve_album_tracklist(self, file_track_data: List[Tuple[str, str, Optional[int]]],
|
||||
folder_path: str, context: JobContext,
|
||||
|
|
@ -188,7 +188,23 @@ class TrackNumberRepairJob(RepairJob):
|
|||
cache = scan_state['album_tracks_cache']
|
||||
folder_name = os.path.basename(folder_path)
|
||||
|
||||
# Collect all available IDs from files in one pass
|
||||
# Fallback 0: Check DB first — if these files are tracked and their album
|
||||
# has a spotify_album_id, use that directly without reading file tags
|
||||
db_album_id, db_spotify_album_id, db_itunes_album_id = _lookup_album_ids_from_db(
|
||||
file_track_data, context
|
||||
)
|
||||
if db_spotify_album_id and _is_valid_album_id(db_spotify_album_id):
|
||||
tracks = _get_album_tracklist(db_spotify_album_id, context, cache)
|
||||
if tracks:
|
||||
logger.info("[Repair] %s — resolved via DB spotify_album_id: %s", folder_name, db_spotify_album_id)
|
||||
return tracks
|
||||
if db_itunes_album_id and _is_valid_album_id(db_itunes_album_id):
|
||||
tracks = _get_album_tracklist(db_itunes_album_id, context, cache)
|
||||
if tracks:
|
||||
logger.info("[Repair] %s — resolved via DB itunes_album_id: %s", folder_name, db_itunes_album_id)
|
||||
return tracks
|
||||
|
||||
# Collect available IDs from file tags (fallback when DB has no IDs)
|
||||
spotify_album_id = None
|
||||
itunes_album_id = None
|
||||
spotify_track_id = None
|
||||
|
|
@ -216,7 +232,7 @@ class TrackNumberRepairJob(RepairJob):
|
|||
if spotify_album_id and itunes_album_id and spotify_track_id and mb_album_id and album_name:
|
||||
break
|
||||
|
||||
# Fallback 1: Spotify album ID
|
||||
# Fallback 1: Spotify album ID from file tags
|
||||
if spotify_album_id and _is_valid_album_id(spotify_album_id):
|
||||
tracks = _get_album_tracklist(spotify_album_id, context, cache)
|
||||
if tracks:
|
||||
|
|
@ -716,6 +732,44 @@ def _update_db_file_path(db, old_path: str, new_path: str):
|
|||
conn.close()
|
||||
|
||||
|
||||
def _lookup_album_ids_from_db(file_track_data: List[Tuple[str, str, Any]],
|
||||
context: JobContext) -> Tuple[Optional[str], Optional[str], Optional[str]]:
|
||||
"""Look up album IDs from the database using file paths.
|
||||
|
||||
Checks if any of the files in this folder are tracked in the DB, and if so,
|
||||
returns the album's (album_id, spotify_album_id, itunes_album_id).
|
||||
This avoids expensive file tag reads and API calls when the DB already knows.
|
||||
"""
|
||||
if not context.db:
|
||||
return None, None, None
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = context.db._get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Try each file path until we find one tracked in the DB
|
||||
for fpath, _, _ in file_track_data:
|
||||
cursor.execute("""
|
||||
SELECT t.album_id, al.spotify_album_id, al.itunes_album_id
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
WHERE t.file_path = ?
|
||||
LIMIT 1
|
||||
""", (fpath,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return row[0], row[1], row[2]
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Error looking up album IDs from DB: %s", e)
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
return None, None, None
|
||||
|
||||
|
||||
def _repair_single_track(file_path: str, filename: str, api_tracks: List[Dict],
|
||||
total_tracks: int, title_similarity: float,
|
||||
context: JobContext) -> bool:
|
||||
|
|
|
|||
Loading…
Reference in a new issue