197 lines
7.6 KiB
Python
197 lines
7.6 KiB
Python
"""Metadata Gap Filler Job — fills missing track metadata from APIs."""
|
|
|
|
import time
|
|
|
|
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.metadata_gap")
|
|
|
|
|
|
@register_job
|
|
class MetadataGapFillerJob(RepairJob):
|
|
job_id = 'metadata_gap_filler'
|
|
display_name = 'Metadata Gap Filler'
|
|
description = 'Fills missing genre, year, ISRC, and MusicBrainz IDs'
|
|
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
|
|
|
|
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
|
|
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 = '')")
|
|
if fill_mb_id:
|
|
conditions.append("(musicbrainz_id IS NULL OR musicbrainz_id = '')")
|
|
|
|
if not conditions:
|
|
return result
|
|
|
|
where = " OR ".join(conditions)
|
|
|
|
# Fetch tracks with gaps, prioritizing those with spotify_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 != ''
|
|
AND ({where})
|
|
ORDER BY
|
|
CASE WHEN spotify_id IS NOT NULL AND spotify_id != '' THEN 0 ELSE 1 END,
|
|
id
|
|
LIMIT 500
|
|
""")
|
|
tracks = cursor.fetchall()
|
|
except Exception as e:
|
|
logger.error("Error fetching tracks with metadata gaps: %s", e, exc_info=True)
|
|
result.errors += 1
|
|
return result
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
total = len(tracks)
|
|
if context.update_progress:
|
|
context.update_progress(0, total)
|
|
|
|
logger.info("Found %d tracks with metadata gaps", total)
|
|
|
|
for i, row in enumerate(tracks):
|
|
if context.check_stop():
|
|
return result
|
|
if i % 20 == 0 and context.wait_if_paused():
|
|
return result
|
|
|
|
track_id, title, artist, album, spotify_id, isrc, genre, year, mb_id = row
|
|
result.scanned += 1
|
|
updates = {}
|
|
|
|
# Try Spotify enrichment first (most reliable)
|
|
if spotify_id and context.spotify_client:
|
|
try:
|
|
track_data = context.spotify_client.get_track_details(spotify_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])
|
|
|
|
except Exception as e:
|
|
logger.debug("Spotify enrichment failed for track %s: %s", track_id, e)
|
|
|
|
# Try MusicBrainz for MB 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', '')
|
|
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
|
|
else:
|
|
result.skipped += 1
|
|
|
|
# Rate limit API calls
|
|
if spotify_id:
|
|
time.sleep(0.5)
|
|
|
|
if context.update_progress and (i + 1) % 10 == 0:
|
|
context.update_progress(i + 1, total)
|
|
|
|
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)
|
|
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 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 = ''))
|
|
""")
|
|
row = cursor.fetchone()
|
|
return min(row[0], 500) if row else 0
|
|
except Exception:
|
|
return 0
|
|
finally:
|
|
if conn:
|
|
conn.close()
|