`_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.
300 lines
12 KiB
Python
300 lines
12 KiB
Python
"""Metadata Gap Filler Job — finds tracks missing key metadata and locates it 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.metadata_gap")
|
|
|
|
|
|
@register_job
|
|
class MetadataGapFillerJob(RepairJob):
|
|
job_id = 'metadata_gap_filler'
|
|
display_name = 'Metadata Gap Filler'
|
|
description = 'Finds tracks missing ISRC or MusicBrainz IDs and locates them'
|
|
help_text = (
|
|
'Searches for tracks in your library that are missing important metadata identifiers: '
|
|
'ISRC codes and MusicBrainz recording IDs. These identifiers are used for accurate '
|
|
'matching, scrobbling, and enrichment.\n\n'
|
|
'For each track with gaps, the job queries MusicBrainz by title and artist to find '
|
|
'the correct IDs. Results are reported as findings for your review.\n\n'
|
|
'Settings:\n'
|
|
'- Fill ISRC: Look up missing ISRC codes\n'
|
|
'- Fill MusicBrainz ID: Look up missing MusicBrainz recording IDs'
|
|
)
|
|
icon = 'repair-icon-metadata'
|
|
default_enabled = False
|
|
default_interval_hours = 72
|
|
default_settings = {
|
|
'fill_isrc': True,
|
|
'fill_musicbrainz_id': True,
|
|
}
|
|
auto_fix = False
|
|
|
|
def scan(self, context: JobContext) -> JobResult:
|
|
result = JobResult()
|
|
|
|
settings = self._get_settings(context)
|
|
fill_isrc = settings.get('fill_isrc', True)
|
|
fill_mb_id = settings.get('fill_musicbrainz_id', True)
|
|
source_priority = get_source_priority(get_primary_source())
|
|
|
|
# Build WHERE clauses for missing fields (only columns that exist on tracks)
|
|
conditions = []
|
|
if fill_isrc:
|
|
conditions.append("(t.isrc IS NULL OR t.isrc = '')")
|
|
if fill_mb_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 source track IDs.
|
|
tracks = []
|
|
conn = None
|
|
try:
|
|
conn = context.db._get_connection()
|
|
cursor = conn.cursor()
|
|
cursor.execute("PRAGMA table_info(tracks)")
|
|
track_columns = {column[1] for column in cursor.fetchall()}
|
|
|
|
select_cols = [
|
|
"t.id",
|
|
"t.title",
|
|
"ar.name",
|
|
"al.title",
|
|
"t.isrc",
|
|
"t.musicbrainz_recording_id",
|
|
"al.thumb_url",
|
|
"ar.thumb_url",
|
|
]
|
|
column_map = [
|
|
("spotify_track_id", "t.spotify_track_id"),
|
|
("itunes_track_id", "t.itunes_track_id"),
|
|
("deezer_track_id", "t.deezer_track_id"),
|
|
]
|
|
column_index = {}
|
|
for alias, column in column_map:
|
|
if column.split('.', 1)[1] in track_columns:
|
|
column_index[alias] = len(select_cols)
|
|
select_cols.append(f"{column} AS {alias}")
|
|
|
|
cursor.execute(f"""
|
|
SELECT {', '.join(select_cols)}
|
|
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})
|
|
LIMIT 500
|
|
""")
|
|
tracks = cursor.fetchall()
|
|
tracks = sorted(tracks, key=lambda row: _track_row_priority(row, column_index, source_priority))
|
|
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)
|
|
|
|
if context.report_progress:
|
|
context.report_progress(phase=f'Enriching {total} tracks...', total=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_name, album_title, isrc, mb_id, album_thumb, artist_thumb = row[:8]
|
|
source_track_ids = {
|
|
'spotify': row[column_index['spotify_track_id']] if 'spotify_track_id' in column_index else None,
|
|
'itunes': row[column_index['itunes_track_id']] if 'itunes_track_id' in column_index else None,
|
|
'deezer': row[column_index['deezer_track_id']] if 'deezer_track_id' in column_index else None,
|
|
}
|
|
result.scanned += 1
|
|
|
|
if context.report_progress:
|
|
context.report_progress(
|
|
scanned=i + 1, total=total,
|
|
phase=f'Enriching {i + 1} / {total}',
|
|
log_line=f'Looking up: {title or "Unknown"} — {artist_name or "Unknown"}',
|
|
log_type='info'
|
|
)
|
|
found_fields = {}
|
|
resolved_source = None
|
|
resolved_track_id = None
|
|
|
|
# Try source-aware track detail lookups only when ISRC enrichment is enabled.
|
|
if fill_isrc and not isrc:
|
|
for source in source_priority:
|
|
track_source_id = source_track_ids.get(source)
|
|
if not track_source_id:
|
|
continue
|
|
try:
|
|
client = get_client_for_source(source)
|
|
if not client or not hasattr(client, 'get_track_details'):
|
|
continue
|
|
track_data = client.get_track_details(track_source_id)
|
|
if track_data:
|
|
isrc_value = _extract_isrc(track_data)
|
|
if isrc_value:
|
|
found_fields['isrc'] = isrc_value
|
|
resolved_source = source
|
|
resolved_track_id = track_source_id
|
|
break
|
|
except Exception as e:
|
|
logger.debug("%s enrichment failed for track %s: %s", source.capitalize(), track_id, e)
|
|
|
|
# Try MusicBrainz for MB recording ID
|
|
if fill_mb_id and not mb_id and context.mb_client:
|
|
try:
|
|
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)
|
|
|
|
# Create finding for user to review instead of auto-writing
|
|
if found_fields:
|
|
if context.report_progress:
|
|
context.report_progress(
|
|
log_line=f'Found: {", ".join(found_fields.keys())} for {title or "Unknown"}',
|
|
log_type='success'
|
|
)
|
|
if context.create_finding:
|
|
try:
|
|
field_names = ', '.join(found_fields.keys())
|
|
inserted = 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,
|
|
'track_ids': source_track_ids,
|
|
'resolved_source': resolved_source,
|
|
'resolved_track_id': resolved_track_id,
|
|
'found_fields': found_fields,
|
|
'album_thumb_url': album_thumb or None,
|
|
'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 metadata gap finding for track %s: %s", track_id, e)
|
|
result.errors += 1
|
|
else:
|
|
result.skipped += 1
|
|
|
|
# Rate limit API calls
|
|
if fill_isrc and any(source_track_ids.values()):
|
|
if context.sleep_or_stop(0.5):
|
|
return result
|
|
|
|
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 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:
|
|
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 ((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
|
|
except Exception:
|
|
return 0
|
|
finally:
|
|
if conn:
|
|
conn.close()
|
|
|
|
|
|
def _extract_isrc(track_data):
|
|
"""Extract ISRC from a track detail payload."""
|
|
if not track_data or not isinstance(track_data, dict):
|
|
return None
|
|
|
|
external_ids = track_data.get('external_ids')
|
|
if isinstance(external_ids, dict):
|
|
isrc = external_ids.get('isrc')
|
|
if isrc:
|
|
return isrc
|
|
|
|
isrc = track_data.get('isrc')
|
|
if isrc:
|
|
return isrc
|
|
|
|
raw_data = track_data.get('raw_data')
|
|
if isinstance(raw_data, dict):
|
|
external_ids = raw_data.get('external_ids')
|
|
if isinstance(external_ids, dict) and external_ids.get('isrc'):
|
|
return external_ids['isrc']
|
|
if raw_data.get('isrc'):
|
|
return raw_data['isrc']
|
|
|
|
return None
|
|
|
|
|
|
def _track_row_priority(row, column_index, source_priority):
|
|
"""Sort rows by the first source track ID available in priority order."""
|
|
source_columns = {
|
|
'spotify': 'spotify_track_id',
|
|
'itunes': 'itunes_track_id',
|
|
'deezer': 'deezer_track_id',
|
|
}
|
|
|
|
for idx, source in enumerate(source_priority):
|
|
column_name = source_columns.get(source)
|
|
if not column_name:
|
|
continue
|
|
column_pos = column_index.get(column_name)
|
|
if column_pos is not None and row[column_pos]:
|
|
return idx
|
|
|
|
return len(source_priority)
|