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',
|
finding_type='acoustid_no_match',
|
||||||
severity='info',
|
severity='info',
|
||||||
entity_type='track',
|
entity_type='track',
|
||||||
entity_id=str(expected.get('track_id', '')),
|
entity_id=str(expected.get('track_id') or ''),
|
||||||
file_path=fpath,
|
file_path=fpath,
|
||||||
title=f'No AcoustID match: {fname}',
|
title=f'No AcoustID match: {fname}',
|
||||||
description='File could not be identified by AcoustID fingerprint',
|
description='File could not be identified by AcoustID fingerprint',
|
||||||
|
|
@ -207,7 +207,7 @@ class AcoustIDScannerJob(RepairJob):
|
||||||
finding_type='acoustid_mismatch',
|
finding_type='acoustid_mismatch',
|
||||||
severity=severity,
|
severity=severity,
|
||||||
entity_type='track',
|
entity_type='track',
|
||||||
entity_id=str(expected.get('track_id', '')),
|
entity_id=str(expected.get('track_id') or ''),
|
||||||
file_path=fpath,
|
file_path=fpath,
|
||||||
title=f'Possible wrong download: {fname}',
|
title=f'Possible wrong download: {fname}',
|
||||||
description=(
|
description=(
|
||||||
|
|
@ -235,17 +235,18 @@ class AcoustIDScannerJob(RepairJob):
|
||||||
conn = context.db._get_connection()
|
conn = context.db._get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT id, title, artist, file_path
|
SELECT t.id, t.title, ar.name, t.file_path
|
||||||
FROM tracks
|
FROM tracks t
|
||||||
WHERE file_path IS NOT NULL AND file_path != ''
|
LEFT JOIN artists ar ON ar.id = t.artist_id
|
||||||
AND title IS NOT NULL AND title != ''
|
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():
|
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)] = {
|
tracks[os.path.normpath(file_path)] = {
|
||||||
'track_id': track_id,
|
'track_id': track_id,
|
||||||
'title': title or '',
|
'title': title or '',
|
||||||
'artist': artist or '',
|
'artist': artist_name or '',
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error loading tracks from DB: %s", e)
|
logger.error("Error loading tracks from DB: %s", e)
|
||||||
|
|
|
||||||
|
|
@ -26,20 +26,21 @@ class AlbumCompletenessJob(RepairJob):
|
||||||
settings = self._get_settings(context)
|
settings = self._get_settings(context)
|
||||||
min_tracks = settings.get('min_tracks_for_check', 3)
|
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 = []
|
albums = []
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = context.db._get_connection()
|
conn = context.db._get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT a.id, a.title, a.artist, a.spotify_id, a.total_tracks,
|
SELECT al.id, al.title, ar.name, al.spotify_album_id, al.track_count,
|
||||||
COUNT(t.id) as track_count
|
COUNT(t.id) as actual_count
|
||||||
FROM albums a
|
FROM albums al
|
||||||
LEFT JOIN tracks t ON t.album_id = a.id
|
LEFT JOIN artists ar ON ar.id = al.artist_id
|
||||||
WHERE a.spotify_id IS NOT NULL AND a.spotify_id != ''
|
LEFT JOIN tracks t ON t.album_id = al.id
|
||||||
GROUP BY a.id
|
WHERE al.spotify_album_id IS NOT NULL AND al.spotify_album_id != ''
|
||||||
HAVING track_count >= ?
|
GROUP BY al.id
|
||||||
|
HAVING actual_count >= ?
|
||||||
""", (min_tracks,))
|
""", (min_tracks,))
|
||||||
albums = cursor.fetchall()
|
albums = cursor.fetchall()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -62,31 +63,31 @@ class AlbumCompletenessJob(RepairJob):
|
||||||
if i % 10 == 0 and context.wait_if_paused():
|
if i % 10 == 0 and context.wait_if_paused():
|
||||||
return result
|
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
|
result.scanned += 1
|
||||||
|
|
||||||
# If we don't know total_tracks, try to get it from API
|
# If we don't know the expected track count, try to get it from API
|
||||||
expected_total = total_tracks
|
expected_total = db_track_count
|
||||||
missing_tracks = []
|
|
||||||
|
|
||||||
if not expected_total and context.spotify_client:
|
if not expected_total and context.spotify_client:
|
||||||
try:
|
try:
|
||||||
album_data = context.spotify_client.get_album(spotify_id)
|
album_data = context.spotify_client.get_album(spotify_album_id)
|
||||||
if album_data:
|
if album_data:
|
||||||
expected_total = album_data.get('total_tracks', 0)
|
expected_total = album_data.get('total_tracks', 0)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
if not expected_total or track_count >= expected_total:
|
if not expected_total or actual_count >= expected_total:
|
||||||
result.skipped += 1
|
result.skipped += 1
|
||||||
if context.update_progress and (i + 1) % 5 == 0:
|
if context.update_progress and (i + 1) % 5 == 0:
|
||||||
context.update_progress(i + 1, total)
|
context.update_progress(i + 1, total)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Album is incomplete — try to find which tracks are missing
|
# Album is incomplete — try to find which tracks are missing
|
||||||
|
missing_tracks = []
|
||||||
if context.spotify_client:
|
if context.spotify_client:
|
||||||
try:
|
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:
|
if api_tracks and 'items' in api_tracks:
|
||||||
# Get track numbers we already have
|
# Get track numbers we already have
|
||||||
owned_numbers = set()
|
owned_numbers = set()
|
||||||
|
|
@ -109,7 +110,7 @@ class AlbumCompletenessJob(RepairJob):
|
||||||
'disc_number': item.get('disc_number', 1),
|
'disc_number': item.get('disc_number', 1),
|
||||||
})
|
})
|
||||||
except Exception as e:
|
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:
|
if context.create_finding:
|
||||||
try:
|
try:
|
||||||
|
|
@ -120,18 +121,18 @@ class AlbumCompletenessJob(RepairJob):
|
||||||
entity_type='album',
|
entity_type='album',
|
||||||
entity_id=str(album_id),
|
entity_id=str(album_id),
|
||||||
file_path=None,
|
file_path=None,
|
||||||
title=f'Incomplete: {title or "Unknown"} ({track_count}/{expected_total})',
|
title=f'Incomplete: {title or "Unknown"} ({actual_count}/{expected_total})',
|
||||||
description=(
|
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'
|
f'{expected_total} tracks'
|
||||||
),
|
),
|
||||||
details={
|
details={
|
||||||
'album_id': album_id,
|
'album_id': album_id,
|
||||||
'album_title': title,
|
'album_title': title,
|
||||||
'artist': artist,
|
'artist': artist_name,
|
||||||
'spotify_id': spotify_id,
|
'spotify_album_id': spotify_album_id,
|
||||||
'expected_tracks': expected_total,
|
'expected_tracks': expected_total,
|
||||||
'actual_tracks': track_count,
|
'actual_tracks': actual_count,
|
||||||
'missing_tracks': missing_tracks,
|
'missing_tracks': missing_tracks,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -165,7 +166,7 @@ class AlbumCompletenessJob(RepairJob):
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT COUNT(*) FROM albums
|
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()
|
row = cursor.fetchone()
|
||||||
return row[0] if row else 0
|
return row[0] if row else 0
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,18 @@ class DeadFileCleanerJob(RepairJob):
|
||||||
def scan(self, context: JobContext) -> JobResult:
|
def scan(self, context: JobContext) -> JobResult:
|
||||||
result = JobResult()
|
result = JobResult()
|
||||||
|
|
||||||
# Fetch all tracks with file paths from DB
|
# Fetch all tracks with file paths, joining to get artist/album names
|
||||||
tracks = []
|
tracks = []
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = context.db._get_connection()
|
conn = context.db._get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT id, title, artist, album, file_path
|
SELECT t.id, t.title, ar.name, al.title, t.file_path
|
||||||
FROM tracks
|
FROM tracks t
|
||||||
WHERE file_path IS NOT NULL AND file_path != ''
|
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()
|
tracks = cursor.fetchall()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -53,7 +55,7 @@ class DeadFileCleanerJob(RepairJob):
|
||||||
if i % 200 == 0 and context.wait_if_paused():
|
if i % 200 == 0 and context.wait_if_paused():
|
||||||
return result
|
return result
|
||||||
|
|
||||||
track_id, title, artist, album, file_path = row
|
track_id, title, artist_name, album_title, file_path = row
|
||||||
result.scanned += 1
|
result.scanned += 1
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
if not os.path.exists(file_path):
|
||||||
|
|
@ -68,12 +70,12 @@ class DeadFileCleanerJob(RepairJob):
|
||||||
entity_id=str(track_id),
|
entity_id=str(track_id),
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
title=f'Missing file: {title or "Unknown"}',
|
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={
|
details={
|
||||||
'track_id': track_id,
|
'track_id': track_id,
|
||||||
'title': title,
|
'title': title,
|
||||||
'artist': artist,
|
'artist': artist_name,
|
||||||
'album': album,
|
'album': album_title,
|
||||||
'original_path': file_path,
|
'original_path': file_path,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -32,18 +32,20 @@ class DuplicateDetectorJob(RepairJob):
|
||||||
title_threshold = settings.get('title_similarity', 0.85)
|
title_threshold = settings.get('title_similarity', 0.85)
|
||||||
artist_threshold = settings.get('artist_similarity', 0.80)
|
artist_threshold = settings.get('artist_similarity', 0.80)
|
||||||
|
|
||||||
# Fetch all tracks from DB
|
# Fetch all tracks with artist/album names via JOIN
|
||||||
tracks = []
|
tracks = []
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = context.db._get_connection()
|
conn = context.db._get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT id, title, artist, album, file_path, format, bitrate,
|
SELECT t.id, t.title, ar.name, al.title, t.file_path,
|
||||||
sample_rate, bit_depth, file_size, duration
|
t.bitrate, t.duration
|
||||||
FROM tracks
|
FROM tracks t
|
||||||
WHERE title IS NOT NULL AND title != ''
|
LEFT JOIN artists ar ON ar.id = t.artist_id
|
||||||
AND file_path IS NOT NULL AND file_path != ''
|
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()
|
tracks = cursor.fetchall()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -62,31 +64,26 @@ class DuplicateDetectorJob(RepairJob):
|
||||||
context.update_progress(0, total)
|
context.update_progress(0, total)
|
||||||
|
|
||||||
# Group tracks by normalized key for fast comparison
|
# 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)
|
buckets = defaultdict(list)
|
||||||
for row in tracks:
|
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)
|
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
|
bucket_key = norm_title[:4] if len(norm_title) >= 4 else norm_title
|
||||||
buckets[bucket_key].append({
|
buckets[bucket_key].append({
|
||||||
'id': track_id,
|
'id': track_id,
|
||||||
'title': title,
|
'title': title,
|
||||||
'norm_title': norm_title,
|
'norm_title': norm_title,
|
||||||
'artist': artist or '',
|
'artist': artist_name or '',
|
||||||
'norm_artist': _normalize(artist or ''),
|
'norm_artist': _normalize(artist_name or ''),
|
||||||
'album': album,
|
'album': album_title,
|
||||||
'file_path': file_path,
|
'file_path': file_path,
|
||||||
'format': fmt,
|
|
||||||
'bitrate': bitrate,
|
'bitrate': bitrate,
|
||||||
'sample_rate': sample_rate,
|
|
||||||
'bit_depth': bit_depth,
|
|
||||||
'file_size': file_size,
|
|
||||||
'duration': duration,
|
'duration': duration,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Second pass: find duplicates within each bucket
|
# Find duplicates within each bucket
|
||||||
found_groups = set() # Track IDs already in a group (frozenset)
|
found_groups = set() # Track IDs already in a group
|
||||||
processed = 0
|
processed = 0
|
||||||
|
|
||||||
for bucket_key, bucket_tracks in buckets.items():
|
for bucket_key, bucket_tracks in buckets.items():
|
||||||
|
|
@ -124,9 +121,8 @@ class DuplicateDetectorJob(RepairJob):
|
||||||
|
|
||||||
if len(group) >= 2:
|
if len(group) >= 2:
|
||||||
# Found a duplicate group
|
# Found a duplicate group
|
||||||
group_ids = frozenset(t['id'] for t in group)
|
for t in group:
|
||||||
for tid in group_ids:
|
found_groups.add(t['id'])
|
||||||
found_groups.add(tid)
|
|
||||||
|
|
||||||
if context.create_finding:
|
if context.create_finding:
|
||||||
try:
|
try:
|
||||||
|
|
@ -149,11 +145,7 @@ class DuplicateDetectorJob(RepairJob):
|
||||||
'artist': t['artist'],
|
'artist': t['artist'],
|
||||||
'album': t['album'],
|
'album': t['album'],
|
||||||
'file_path': t['file_path'],
|
'file_path': t['file_path'],
|
||||||
'format': t['format'],
|
|
||||||
'bitrate': t['bitrate'],
|
'bitrate': t['bitrate'],
|
||||||
'sample_rate': t['sample_rate'],
|
|
||||||
'bit_depth': t['bit_depth'],
|
|
||||||
'file_size': t['file_size'],
|
|
||||||
'duration': t['duration'],
|
'duration': t['duration'],
|
||||||
} for t in group],
|
} for t in group],
|
||||||
'count': len(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
|
import time
|
||||||
|
|
||||||
|
|
@ -13,58 +13,52 @@ logger = get_logger("repair_job.metadata_gap")
|
||||||
class MetadataGapFillerJob(RepairJob):
|
class MetadataGapFillerJob(RepairJob):
|
||||||
job_id = 'metadata_gap_filler'
|
job_id = 'metadata_gap_filler'
|
||||||
display_name = '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'
|
icon = 'repair-icon-metadata'
|
||||||
default_enabled = False
|
default_enabled = False
|
||||||
default_interval_hours = 72
|
default_interval_hours = 72
|
||||||
default_settings = {
|
default_settings = {
|
||||||
'fill_genre': True,
|
|
||||||
'fill_year': True,
|
|
||||||
'fill_isrc': True,
|
'fill_isrc': True,
|
||||||
'fill_musicbrainz_id': True,
|
'fill_musicbrainz_id': True,
|
||||||
'write_to_file': False,
|
|
||||||
}
|
}
|
||||||
auto_fix = True
|
auto_fix = False
|
||||||
|
|
||||||
def scan(self, context: JobContext) -> JobResult:
|
def scan(self, context: JobContext) -> JobResult:
|
||||||
result = JobResult()
|
result = JobResult()
|
||||||
|
|
||||||
settings = self._get_settings(context)
|
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_isrc = settings.get('fill_isrc', True)
|
||||||
fill_mb_id = settings.get('fill_musicbrainz_id', 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 = []
|
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:
|
if fill_isrc:
|
||||||
conditions.append("(isrc IS NULL OR isrc = '')")
|
conditions.append("(t.isrc IS NULL OR t.isrc = '')")
|
||||||
if fill_mb_id:
|
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:
|
if not conditions:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
where = " OR ".join(conditions)
|
where = " OR ".join(conditions)
|
||||||
|
|
||||||
# Fetch tracks with gaps, prioritizing those with spotify_id
|
# Fetch tracks with gaps, prioritizing those with spotify_track_id
|
||||||
tracks = []
|
tracks = []
|
||||||
conn = None
|
conn = None
|
||||||
try:
|
try:
|
||||||
conn = context.db._get_connection()
|
conn = context.db._get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute(f"""
|
cursor.execute(f"""
|
||||||
SELECT id, title, artist, album, spotify_id, isrc, genre, year, musicbrainz_id
|
SELECT t.id, t.title, ar.name, al.title, t.spotify_track_id,
|
||||||
FROM tracks
|
t.isrc, t.musicbrainz_recording_id
|
||||||
WHERE title IS NOT NULL AND title != ''
|
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})
|
AND ({where})
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE WHEN spotify_id IS NOT NULL AND spotify_id != '' THEN 0 ELSE 1 END,
|
CASE WHEN t.spotify_track_id IS NOT NULL AND t.spotify_track_id != '' THEN 0 ELSE 1 END,
|
||||||
id
|
t.id
|
||||||
LIMIT 500
|
LIMIT 500
|
||||||
""")
|
""")
|
||||||
tracks = cursor.fetchall()
|
tracks = cursor.fetchall()
|
||||||
|
|
@ -88,73 +82,68 @@ class MetadataGapFillerJob(RepairJob):
|
||||||
if i % 20 == 0 and context.wait_if_paused():
|
if i % 20 == 0 and context.wait_if_paused():
|
||||||
return result
|
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
|
result.scanned += 1
|
||||||
updates = {}
|
found_fields = {}
|
||||||
|
|
||||||
# Try Spotify enrichment first (most reliable)
|
# Try Spotify enrichment first (most reliable for ISRC)
|
||||||
if spotify_id and context.spotify_client:
|
if spotify_track_id and context.spotify_client:
|
||||||
try:
|
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 track_data:
|
||||||
if fill_isrc and not isrc:
|
if fill_isrc and not isrc:
|
||||||
ext_ids = track_data.get('external_ids', {})
|
ext_ids = track_data.get('external_ids', {})
|
||||||
if ext_ids.get('isrc'):
|
if ext_ids.get('isrc'):
|
||||||
updates['isrc'] = ext_ids['isrc']
|
found_fields['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:
|
except Exception as e:
|
||||||
logger.debug("Spotify enrichment failed for track %s: %s", track_id, 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:
|
if fill_mb_id and not mb_id and context.mb_client:
|
||||||
try:
|
try:
|
||||||
search_query = f'"{title}" AND artist:"{artist}"' if artist else f'"{title}"'
|
recordings = context.mb_client.search_recording(
|
||||||
mb_results = context.mb_client.search_recordings(search_query, limit=1)
|
title, artist_name=artist_name, limit=1
|
||||||
if mb_results:
|
)
|
||||||
recordings = mb_results.get('recording-list', [])
|
if recordings:
|
||||||
if recordings:
|
found_fields['musicbrainz_recording_id'] = recordings[0].get('id', '')
|
||||||
updates['musicbrainz_id'] = recordings[0].get('id', '')
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("MusicBrainz lookup failed for track %s: %s", track_id, e)
|
logger.debug("MusicBrainz lookup failed for track %s: %s", track_id, e)
|
||||||
|
|
||||||
# Apply updates
|
# Create finding for user to review instead of auto-writing
|
||||||
if updates:
|
if found_fields:
|
||||||
try:
|
if context.create_finding:
|
||||||
conn2 = context.db._get_connection()
|
try:
|
||||||
cursor2 = conn2.cursor()
|
field_names = ', '.join(found_fields.keys())
|
||||||
set_parts = [f"{k} = ?" for k in updates.keys()]
|
context.create_finding(
|
||||||
values = list(updates.values()) + [track_id]
|
job_id=self.job_id,
|
||||||
cursor2.execute(
|
finding_type='metadata_gap',
|
||||||
f"UPDATE tracks SET {', '.join(set_parts)}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
severity='info',
|
||||||
values
|
entity_type='track',
|
||||||
)
|
entity_id=str(track_id),
|
||||||
conn2.commit()
|
file_path=None,
|
||||||
conn2.close()
|
title=f'Missing metadata: {title or "Unknown"}',
|
||||||
result.auto_fixed += 1
|
description=(
|
||||||
logger.debug("Filled %d metadata fields for track '%s' (id=%s)",
|
f'Track "{title}" by {artist_name or "Unknown"} is missing: {field_names}. '
|
||||||
len(updates), title, track_id)
|
f'Found values from API lookup.'
|
||||||
except Exception as e:
|
),
|
||||||
logger.debug("Error updating metadata for track %s: %s", track_id, e)
|
details={
|
||||||
result.errors += 1
|
'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:
|
else:
|
||||||
result.skipped += 1
|
result.skipped += 1
|
||||||
|
|
||||||
# Rate limit API calls
|
# Rate limit API calls
|
||||||
if spotify_id:
|
if spotify_track_id:
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
|
|
||||||
if context.update_progress and (i + 1) % 10 == 0:
|
if context.update_progress and (i + 1) % 10 == 0:
|
||||||
|
|
@ -163,8 +152,8 @@ class MetadataGapFillerJob(RepairJob):
|
||||||
if context.update_progress:
|
if context.update_progress:
|
||||||
context.update_progress(total, total)
|
context.update_progress(total, total)
|
||||||
|
|
||||||
logger.info("Metadata gap fill: %d tracks checked, %d enriched, %d skipped",
|
logger.info("Metadata gap scan: %d tracks checked, %d gaps found, %d skipped",
|
||||||
result.scanned, result.auto_fixed, result.skipped)
|
result.scanned, result.findings_created, result.skipped)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _get_settings(self, context: JobContext) -> dict:
|
def _get_settings(self, context: JobContext) -> dict:
|
||||||
|
|
@ -183,10 +172,8 @@ class MetadataGapFillerJob(RepairJob):
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT COUNT(*) FROM tracks
|
SELECT COUNT(*) FROM tracks
|
||||||
WHERE title IS NOT NULL AND title != ''
|
WHERE title IS NOT NULL AND title != ''
|
||||||
AND ((genre IS NULL OR genre = '')
|
AND ((isrc IS NULL OR isrc = '')
|
||||||
OR (year IS NULL OR year = '' OR year = '0')
|
OR (musicbrainz_recording_id IS NULL OR musicbrainz_recording_id = ''))
|
||||||
OR (isrc IS NULL OR isrc = '')
|
|
||||||
OR (musicbrainz_id IS NULL OR musicbrainz_id = ''))
|
|
||||||
""")
|
""")
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
return min(row[0], 500) if row else 0
|
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 import register_job
|
||||||
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
from core.repair_jobs.base import JobContext, JobResult, RepairJob
|
||||||
|
|
@ -11,15 +11,14 @@ logger = get_logger("repair_job.cover_art")
|
||||||
class MissingCoverArtJob(RepairJob):
|
class MissingCoverArtJob(RepairJob):
|
||||||
job_id = 'missing_cover_art'
|
job_id = 'missing_cover_art'
|
||||||
display_name = 'Cover Art Filler'
|
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'
|
icon = 'repair-icon-coverart'
|
||||||
default_enabled = True
|
default_enabled = True
|
||||||
default_interval_hours = 48
|
default_interval_hours = 48
|
||||||
default_settings = {
|
default_settings = {
|
||||||
'prefer_source': 'spotify',
|
'prefer_source': 'spotify',
|
||||||
'embed_in_file': False,
|
|
||||||
}
|
}
|
||||||
auto_fix = True
|
auto_fix = False
|
||||||
|
|
||||||
def scan(self, context: JobContext) -> JobResult:
|
def scan(self, context: JobContext) -> JobResult:
|
||||||
result = JobResult()
|
result = JobResult()
|
||||||
|
|
@ -34,10 +33,11 @@ class MissingCoverArtJob(RepairJob):
|
||||||
conn = context.db._get_connection()
|
conn = context.db._get_connection()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT id, title, artist, spotify_id, artwork_url
|
SELECT al.id, al.title, ar.name, al.spotify_album_id, al.thumb_url
|
||||||
FROM albums
|
FROM albums al
|
||||||
WHERE (artwork_url IS NULL OR artwork_url = '')
|
LEFT JOIN artists ar ON ar.id = al.artist_id
|
||||||
AND title IS NOT NULL AND title != ''
|
WHERE (al.thumb_url IS NULL OR al.thumb_url = '')
|
||||||
|
AND al.title IS NOT NULL AND al.title != ''
|
||||||
""")
|
""")
|
||||||
albums = cursor.fetchall()
|
albums = cursor.fetchall()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -60,37 +60,46 @@ class MissingCoverArtJob(RepairJob):
|
||||||
if i % 10 == 0 and context.wait_if_paused():
|
if i % 10 == 0 and context.wait_if_paused():
|
||||||
return result
|
return result
|
||||||
|
|
||||||
album_id, title, artist, spotify_id, _ = row
|
album_id, title, artist_name, spotify_album_id, _ = row
|
||||||
result.scanned += 1
|
result.scanned += 1
|
||||||
|
|
||||||
artwork_url = None
|
artwork_url = None
|
||||||
|
|
||||||
# Try Spotify first (or iTunes first based on preference)
|
# Try to find artwork URL from APIs
|
||||||
if prefer_source == 'spotify':
|
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:
|
if not artwork_url:
|
||||||
artwork_url = self._try_itunes(title, artist, context)
|
artwork_url = self._try_itunes(title, artist_name, context)
|
||||||
else:
|
else:
|
||||||
artwork_url = self._try_itunes(title, artist, context)
|
artwork_url = self._try_itunes(title, artist_name, context)
|
||||||
if not artwork_url:
|
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:
|
if artwork_url:
|
||||||
# Update DB
|
# Create finding for user to approve
|
||||||
try:
|
if context.create_finding:
|
||||||
conn2 = context.db._get_connection()
|
try:
|
||||||
cursor2 = conn2.cursor()
|
context.create_finding(
|
||||||
cursor2.execute(
|
job_id=self.job_id,
|
||||||
"UPDATE albums SET artwork_url = ? WHERE id = ?",
|
finding_type='missing_cover_art',
|
||||||
(artwork_url, album_id)
|
severity='info',
|
||||||
)
|
entity_type='album',
|
||||||
conn2.commit()
|
entity_id=str(album_id),
|
||||||
conn2.close()
|
file_path=None,
|
||||||
result.auto_fixed += 1
|
title=f'Missing artwork: {title or "Unknown"}',
|
||||||
logger.debug("Filled artwork for album '%s': %s", title, artwork_url[:80])
|
description=f'Album "{title}" by {artist_name or "Unknown"} has no cover art. Found artwork from API.',
|
||||||
except Exception as e:
|
details={
|
||||||
logger.debug("Error updating artwork for album %s: %s", album_id, e)
|
'album_id': album_id,
|
||||||
result.errors += 1
|
'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:
|
else:
|
||||||
result.skipped += 1
|
result.skipped += 1
|
||||||
|
|
||||||
|
|
@ -100,11 +109,11 @@ class MissingCoverArtJob(RepairJob):
|
||||||
if context.update_progress:
|
if context.update_progress:
|
||||||
context.update_progress(total, total)
|
context.update_progress(total, total)
|
||||||
|
|
||||||
logger.info("Cover art fill: %d albums checked, %d filled, %d skipped",
|
logger.info("Cover art scan: %d albums checked, %d found art, %d skipped",
|
||||||
result.scanned, result.auto_fixed, result.skipped)
|
result.scanned, result.findings_created, result.skipped)
|
||||||
return result
|
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."""
|
"""Try to get album art from Spotify."""
|
||||||
client = context.spotify_client
|
client = context.spotify_client
|
||||||
if not client:
|
if not client:
|
||||||
|
|
@ -112,8 +121,8 @@ class MissingCoverArtJob(RepairJob):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# If we have a Spotify album ID, fetch directly
|
# If we have a Spotify album ID, fetch directly
|
||||||
if spotify_id and client.is_spotify_authenticated():
|
if spotify_album_id and client.is_spotify_authenticated():
|
||||||
album_data = client.get_album(spotify_id)
|
album_data = client.get_album(spotify_album_id)
|
||||||
if album_data:
|
if album_data:
|
||||||
images = album_data.get('images', [])
|
images = album_data.get('images', [])
|
||||||
if images:
|
if images:
|
||||||
|
|
@ -121,7 +130,7 @@ class MissingCoverArtJob(RepairJob):
|
||||||
|
|
||||||
# Search by name
|
# Search by name
|
||||||
if title and client.is_spotify_authenticated():
|
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)
|
results = client.search_albums(query, limit=1)
|
||||||
if results and hasattr(results[0], 'image_url') and results[0].image_url:
|
if results and hasattr(results[0], 'image_url') and results[0].image_url:
|
||||||
return 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)
|
logger.debug("Spotify art lookup failed for '%s': %s", title, e)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _try_itunes(self, title, artist, context):
|
def _try_itunes(self, title, artist_name, context):
|
||||||
"""Try to get album art from iTunes."""
|
"""Try to get album art from iTunes."""
|
||||||
client = context.itunes_client
|
client = context.itunes_client
|
||||||
if not client:
|
if not client:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
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)
|
results = client.search_albums(query, limit=1)
|
||||||
if results and hasattr(results[0], 'image_url') and results[0].image_url:
|
if results and hasattr(results[0], 'image_url') and results[0].image_url:
|
||||||
return results[0].image_url
|
return results[0].image_url
|
||||||
|
|
@ -159,7 +168,7 @@ class MissingCoverArtJob(RepairJob):
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
cursor.execute("""
|
cursor.execute("""
|
||||||
SELECT COUNT(*) FROM albums
|
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 != ''
|
AND title IS NOT NULL AND title != ''
|
||||||
""")
|
""")
|
||||||
row = cursor.fetchone()
|
row = cursor.fetchone()
|
||||||
|
|
|
||||||
|
|
@ -177,7 +177,7 @@ class TrackNumberRepairJob(RepairJob):
|
||||||
return result
|
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]]],
|
def _resolve_album_tracklist(self, file_track_data: List[Tuple[str, str, Optional[int]]],
|
||||||
folder_path: str, context: JobContext,
|
folder_path: str, context: JobContext,
|
||||||
|
|
@ -188,7 +188,23 @@ class TrackNumberRepairJob(RepairJob):
|
||||||
cache = scan_state['album_tracks_cache']
|
cache = scan_state['album_tracks_cache']
|
||||||
folder_name = os.path.basename(folder_path)
|
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
|
spotify_album_id = None
|
||||||
itunes_album_id = None
|
itunes_album_id = None
|
||||||
spotify_track_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:
|
if spotify_album_id and itunes_album_id and spotify_track_id and mb_album_id and album_name:
|
||||||
break
|
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):
|
if spotify_album_id and _is_valid_album_id(spotify_album_id):
|
||||||
tracks = _get_album_tracklist(spotify_album_id, context, cache)
|
tracks = _get_album_tracklist(spotify_album_id, context, cache)
|
||||||
if tracks:
|
if tracks:
|
||||||
|
|
@ -716,6 +732,44 @@ def _update_db_file_path(db, old_path: str, new_path: str):
|
||||||
conn.close()
|
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],
|
def _repair_single_track(file_path: str, filename: str, api_tracks: List[Dict],
|
||||||
total_tracks: int, title_similarity: float,
|
total_tracks: int, title_similarity: float,
|
||||||
context: JobContext) -> bool:
|
context: JobContext) -> bool:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue