Merge pull request #298 from kettui/fix/respect-metadata-provider-in-album-completeness-job

Honor primary metadata source in album_completeness job and associated repair flow
This commit is contained in:
BoulderBadgeDad 2026-04-15 07:15:15 -07:00 committed by GitHub
commit 3618f3fa7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 569 additions and 136 deletions

View file

@ -17,6 +17,9 @@ logger = get_logger("metadata_service")
MetadataProvider = Literal["spotify", "itunes", "auto"] MetadataProvider = Literal["spotify", "itunes", "auto"]
# Ordered by fallback preference. Higher-priority sources appear earlier.
METADATA_SOURCE_PRIORITY = ('deezer', 'itunes', 'spotify', 'discogs', 'hydrabase')
_client_cache_lock = threading.RLock() _client_cache_lock = threading.RLock()
_client_cache: Dict[str, Any] = {} _client_cache: Dict[str, Any] = {}
@ -62,7 +65,72 @@ def get_primary_client():
This is THE single source of truth for "which client should I call?" This is THE single source of truth for "which client should I call?"
""" """
return _get_client_for_source(get_primary_source()) return get_client_for_source(get_primary_source())
def get_source_priority(preferred_source: str):
"""Return supported sources with the preferred source first."""
ordered = []
if preferred_source in METADATA_SOURCE_PRIORITY:
ordered.append(preferred_source)
for source in METADATA_SOURCE_PRIORITY:
if source not in ordered:
ordered.append(source)
return ordered
def get_client_for_source(source: str):
"""Get the client object for an exact metadata source.
Returns the matching client or None if that source is unavailable.
No fallback swaps.
"""
if source == 'spotify':
try:
import importlib
ws = importlib.import_module('web_server')
sc = getattr(ws, 'spotify_client', None)
if sc and sc.is_spotify_authenticated():
return sc
except Exception:
pass
return None
if source == 'deezer':
return get_deezer_client()
if source == 'discogs':
return get_discogs_client()
if source == 'hydrabase':
return get_hydrabase_client(allow_fallback=False)
if source == 'itunes':
return get_itunes_client()
return None
def get_album_tracks_for_source(source: str, album_id: str):
"""Get album tracks for an exact source.
Returns Spotify-compatible dict/list data or None.
No fallback swaps.
"""
client = get_client_for_source(source)
if not client:
return None
try:
fetch = getattr(client, 'get_album_tracks_dict', None) if source == 'hydrabase' else getattr(client, 'get_album_tracks', None)
if not fetch:
return None
return fetch(album_id)
except Exception:
return None
def get_deezer_client(): def get_deezer_client():
@ -122,8 +190,12 @@ def get_discogs_client(token: Optional[str] = None):
return client return client
def get_hydrabase_client(): def get_hydrabase_client(allow_fallback: bool = True):
"""Return current Hydrabase client if connected, else iTunes fallback.""" """Return current Hydrabase client if connected.
If allow_fallback is True, return iTunes fallback when Hydrabase is not
connected. If False, return None instead.
"""
try: try:
import importlib import importlib
ws = importlib.import_module('web_server') ws = importlib.import_module('web_server')
@ -132,7 +204,9 @@ def get_hydrabase_client():
return client return client
except Exception: except Exception:
pass pass
return get_itunes_client() if allow_fallback:
return get_itunes_client()
return None
def clear_cached_metadata_clients(): def clear_cached_metadata_clients():
@ -206,7 +280,7 @@ class MetadataService:
self.preferred_provider = preferred_provider self.preferred_provider = preferred_provider
self.spotify = SpotifyClient() self.spotify = SpotifyClient()
self._fallback_source = get_primary_source() self._fallback_source = get_primary_source()
self.itunes = _get_client_for_source(self._fallback_source) self.itunes = get_client_for_source(self._fallback_source)
self._log_initialization() self._log_initialization()
@ -378,7 +452,7 @@ class MetadataService:
self.spotify.reload_config() self.spotify.reload_config()
new_source = get_primary_source() new_source = get_primary_source()
self._fallback_source = new_source self._fallback_source = new_source
self.itunes = _get_client_for_source(new_source) self.itunes = get_client_for_source(new_source)
self._log_initialization() self._log_initialization()

View file

@ -1,5 +1,10 @@
"""Album Completeness Checker Job — finds albums missing tracks.""" """Album Completeness Checker Job — finds albums missing tracks."""
from core.metadata_service import (
get_album_tracks_for_source,
get_primary_source,
get_source_priority,
)
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
from utils.logging_config import get_logger from utils.logging_config import get_logger
@ -14,8 +19,9 @@ class AlbumCompletenessJob(RepairJob):
description = 'Checks if all tracks from albums are present' description = 'Checks if all tracks from albums are present'
help_text = ( help_text = (
'Compares the number of tracks you have for each album against the expected total ' 'Compares the number of tracks you have for each album against the expected total '
'from the album tracklist (via Spotify, iTunes, or Deezer). Albums where tracks are ' 'from the active metadata provider first, then other supported sources if needed. '
'missing get flagged as findings with details about which tracks are absent.\n\n' 'Albums where tracks are missing get flagged as findings with details about which '
'tracks are absent.\n\n'
'Useful for catching partial downloads or albums where some tracks failed to download. ' 'Useful for catching partial downloads or albums where some tracks failed to download. '
'You can use the Download Missing feature from the album page to fill gaps.\n\n' 'You can use the Download Missing feature from the album page to fill gaps.\n\n'
'Settings:\n' 'Settings:\n'
@ -40,10 +46,13 @@ 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)
min_completion_pct = settings.get('min_completion_pct', 0) min_completion_pct = settings.get('min_completion_pct', 0)
primary_source = self._get_primary_source()
# Fetch all albums with ANY external source ID — not just Spotify # Fetch all albums with ANY external source ID — not just Spotify
albums = [] albums = []
conn = None conn = None
has_itunes = False
has_deezer = False
try: try:
conn = context.db._get_connection() conn = context.db._get_connection()
cursor = conn.cursor() cursor = conn.cursor()
@ -53,29 +62,44 @@ class AlbumCompletenessJob(RepairJob):
columns = {row[1] for row in cursor.fetchall()} columns = {row[1] for row in cursor.fetchall()}
has_itunes = 'itunes_album_id' in columns has_itunes = 'itunes_album_id' in columns
has_deezer = 'deezer_id' in columns has_deezer = 'deezer_id' in columns
has_discogs = 'discogs_id' in columns
has_hydrabase = 'soul_id' in columns
# Build SELECT with available source ID columns # Build SELECT with available source ID columns
select_cols = [ select_cols = [
'al.id', 'al.title', 'ar.name', 'al.spotify_album_id', 'al.track_count', ('al.id', 'album_id'),
'COUNT(t.id) as actual_count', 'al.thumb_url', 'ar.thumb_url', ('al.title', 'album_title'),
('ar.name', 'artist_name'),
('al.spotify_album_id', 'spotify_album_id'),
('al.track_count', 'track_count'),
('COUNT(t.id)', 'actual_count'),
('al.thumb_url', 'album_thumb_url'),
('ar.thumb_url', 'artist_thumb_url'),
] ]
if has_itunes: if has_itunes:
select_cols.append('al.itunes_album_id') select_cols.append(('al.itunes_album_id', 'itunes_album_id'))
if has_deezer: if has_deezer:
select_cols.append('al.deezer_id') select_cols.append(('al.deezer_id', 'deezer_album_id'))
if has_discogs:
select_cols.append(('al.discogs_id', 'discogs_album_id'))
if has_hydrabase:
select_cols.append(('al.soul_id', 'hydrabase_album_id'))
# WHERE: album has at least one source ID # WHERE: album has at least one source ID
where_parts = [] where_parts = ["(al.spotify_album_id IS NOT NULL AND al.spotify_album_id != '')"]
if True: # spotify always exists
where_parts.append("(al.spotify_album_id IS NOT NULL AND al.spotify_album_id != '')")
if has_itunes: if has_itunes:
where_parts.append("(al.itunes_album_id IS NOT NULL AND al.itunes_album_id != '')") where_parts.append("(al.itunes_album_id IS NOT NULL AND al.itunes_album_id != '')")
if has_deezer: if has_deezer:
where_parts.append("(al.deezer_id IS NOT NULL AND al.deezer_id != '')") where_parts.append("(al.deezer_id IS NOT NULL AND al.deezer_id != '')")
if has_discogs:
where_parts.append("(al.discogs_id IS NOT NULL AND al.discogs_id != '')")
if has_hydrabase:
where_parts.append("(al.soul_id IS NOT NULL AND al.soul_id != '')")
where_clause = ' OR '.join(where_parts) where_clause = ' OR '.join(where_parts)
select_sql = ', '.join(f'{expr} AS {alias}' for expr, alias in select_cols)
cursor.execute(f""" cursor.execute(f"""
SELECT {', '.join(select_cols)} SELECT {select_sql}
FROM albums al FROM albums al
LEFT JOIN artists ar ON ar.id = al.artist_id LEFT JOIN artists ar ON ar.id = al.artist_id
LEFT JOIN tracks t ON t.album_id = al.id LEFT JOIN tracks t ON t.album_id = al.id
@ -83,6 +107,7 @@ class AlbumCompletenessJob(RepairJob):
GROUP BY al.id GROUP BY al.id
""") """)
albums = cursor.fetchall() albums = cursor.fetchall()
column_index = {alias: idx for idx, (_, alias) in enumerate(select_cols)}
except Exception as e: except Exception as e:
logger.error("Error fetching albums: %s", e, exc_info=True) logger.error("Error fetching albums: %s", e, exc_info=True)
result.errors += 1 result.errors += 1
@ -100,27 +125,24 @@ class AlbumCompletenessJob(RepairJob):
if context.report_progress: if context.report_progress:
context.report_progress(phase=f'Checking {total} albums...', total=total) context.report_progress(phase=f'Checking {total} albums...', total=total)
# Determine column positions based on what we selected
# Fixed: 0=id, 1=title, 2=artist, 3=spotify_id, 4=track_count, 5=actual, 6=album_thumb, 7=artist_thumb
itunes_col = 8 if has_itunes else None
deezer_col = (9 if has_itunes else 8) if has_deezer else None
for i, row in enumerate(albums): for i, row in enumerate(albums):
if context.check_stop(): if context.check_stop():
return result return result
if i % 10 == 0 and context.wait_if_paused(): if i % 10 == 0 and context.wait_if_paused():
return result return result
album_id = row[0] album_id = row[column_index['album_id']]
title = row[1] title = row[column_index['album_title']]
artist_name = row[2] artist_name = row[column_index['artist_name']]
spotify_album_id = row[3] spotify_album_id = row[column_index['spotify_album_id']]
db_track_count = row[4] db_track_count = row[column_index['track_count']]
actual_count = row[5] actual_count = row[column_index['actual_count']]
album_thumb = row[6] album_thumb = row[column_index['album_thumb_url']]
artist_thumb = row[7] artist_thumb = row[column_index['artist_thumb_url']]
itunes_album_id = row[itunes_col] if itunes_col is not None else None itunes_album_id = row[column_index['itunes_album_id']] if 'itunes_album_id' in column_index else None
deezer_album_id = row[deezer_col] if deezer_col is not None else None deezer_album_id = row[column_index['deezer_album_id']] if 'deezer_album_id' in column_index else None
discogs_album_id = row[column_index['discogs_album_id']] if 'discogs_album_id' in column_index else None
hydrabase_album_id = row[column_index['hydrabase_album_id']] if 'hydrabase_album_id' in column_index else None
result.scanned += 1 result.scanned += 1
@ -135,10 +157,16 @@ class AlbumCompletenessJob(RepairJob):
# If we don't know the expected track count, try to get it from an API # If we don't know the expected track count, try to get it from an API
expected_total = db_track_count expected_total = db_track_count
album_ids = {
'spotify': spotify_album_id or '',
'itunes': itunes_album_id or '',
'deezer': deezer_album_id or '',
'discogs': discogs_album_id or '',
'hydrabase': hydrabase_album_id or '',
}
if not expected_total: if not expected_total:
expected_total = self._get_expected_total( expected_total = self._get_expected_total(context, primary_source, album_ids)
context, spotify_album_id, itunes_album_id, deezer_album_id
)
# Skip singles/EPs based on expected track count (not local count) # Skip singles/EPs based on expected track count (not local count)
if expected_total and expected_total < min_tracks: if expected_total and expected_total < min_tracks:
@ -169,9 +197,7 @@ class AlbumCompletenessJob(RepairJob):
continue continue
# Album is incomplete — try to find which tracks are missing # Album is incomplete — try to find which tracks are missing
missing_tracks = self._find_missing_tracks( missing_tracks = self._find_missing_tracks(context, primary_source, album_id, album_ids)
context, album_id, spotify_album_id, itunes_album_id, deezer_album_id
)
if context.report_progress: if context.report_progress:
context.report_progress( context.report_progress(
@ -180,8 +206,6 @@ class AlbumCompletenessJob(RepairJob):
) )
if context.create_finding: if context.create_finding:
try: try:
# Use whichever source ID is available
source_id = spotify_album_id or itunes_album_id or deezer_album_id or ''
context.create_finding( context.create_finding(
job_id=self.job_id, job_id=self.job_id,
finding_type='incomplete_album', finding_type='incomplete_album',
@ -198,9 +222,13 @@ class AlbumCompletenessJob(RepairJob):
'album_id': album_id, 'album_id': album_id,
'album_title': title, 'album_title': title,
'artist': artist_name, 'artist': artist_name,
'primary_source': primary_source,
'primary_album_id': self._get_album_id_for_source(primary_source, album_ids) or '',
'spotify_album_id': spotify_album_id or '', 'spotify_album_id': spotify_album_id or '',
'itunes_album_id': itunes_album_id or '', 'itunes_album_id': itunes_album_id or '',
'deezer_album_id': deezer_album_id or '', 'deezer_album_id': deezer_album_id or '',
'discogs_album_id': discogs_album_id or '',
'hydrabase_album_id': hydrabase_album_id or '',
'expected_tracks': expected_total, 'expected_tracks': expected_total,
'actual_tracks': actual_count, 'actual_tracks': actual_count,
'missing_tracks': missing_tracks, 'missing_tracks': missing_tracks,
@ -220,42 +248,24 @@ class AlbumCompletenessJob(RepairJob):
context.update_progress(total, total) context.update_progress(total, total)
logger.info("Completeness check: %d albums checked, %d incomplete found", logger.info("Completeness check: %d albums checked, %d incomplete found",
result.scanned, result.findings_created) result.scanned, result.findings_created)
return result return result
def _get_expected_total(self, context, spotify_id, itunes_id, deezer_id): def _get_expected_total(self, context, primary_source, album_ids):
"""Try to get the expected track count from any available API source.""" """Try to get the expected track count from the active metadata provider first."""
# Try Spotify first for source in get_source_priority(primary_source):
if spotify_id and context.spotify_client and not context.is_spotify_rate_limited(): album_id = self._get_album_id_for_source(source, album_ids)
try: if not album_id:
album_data = context.spotify_client.get_album(spotify_id) continue
if album_data: api_tracks = self._get_album_tracks(source, album_id)
total = album_data.get('total_tracks', 0) items = self._extract_track_items(api_tracks)
if total: if items:
return total return len(items)
except Exception:
pass
# Try fallback client (iTunes or Deezer) — both return Spotify-compatible format
# Match the ID to the actual client type to avoid passing iTunes ID to Deezer or vice versa
if context.itunes_client:
is_deezer = type(context.itunes_client).__name__ == 'DeezerClient'
primary_id = deezer_id if is_deezer else itunes_id
secondary_id = itunes_id if is_deezer else deezer_id
for fid in [primary_id, secondary_id]:
if not fid:
continue
try:
api_tracks = context.itunes_client.get_album_tracks(fid)
if api_tracks and 'items' in api_tracks:
return len(api_tracks['items'])
except Exception:
pass
return 0 return 0
def _find_missing_tracks(self, context, album_id, spotify_id, itunes_id, deezer_id): def _find_missing_tracks(self, context, primary_source, album_id, album_ids):
"""Identify which specific tracks are missing using any available API source.""" """Identify which specific tracks are missing using the active metadata provider first."""
# Get track numbers we already have # Get track numbers we already have
owned_numbers = set() owned_numbers = set()
conn = None conn = None
@ -274,37 +284,23 @@ class AlbumCompletenessJob(RepairJob):
if conn: if conn:
conn.close() conn.close()
# Try Spotify first
api_tracks = None api_tracks = None
if spotify_id and context.spotify_client and not context.is_spotify_rate_limited(): for source in get_source_priority(primary_source):
try: source_album_id = self._get_album_id_for_source(source, album_ids)
api_tracks = context.spotify_client.get_album_tracks(spotify_id) if not source_album_id:
except Exception as e: continue
logger.debug("Error getting Spotify album tracks for %s: %s", spotify_id, e) api_tracks = self._get_album_tracks(source, source_album_id)
if self._extract_track_items(api_tracks):
break
# Try fallback client (iTunes or Deezer) items = self._extract_track_items(api_tracks)
if not api_tracks or 'items' not in (api_tracks or {}): if not items:
if context.itunes_client:
is_deezer = type(context.itunes_client).__name__ == 'DeezerClient'
primary_id = deezer_id if is_deezer else itunes_id
secondary_id = itunes_id if is_deezer else deezer_id
for fid in [primary_id, secondary_id]:
if not fid:
continue
try:
api_tracks = context.itunes_client.get_album_tracks(fid)
if api_tracks and 'items' in api_tracks:
break
except Exception as e:
logger.debug("Error getting fallback album tracks for %s: %s", fid, e)
if not api_tracks or 'items' not in api_tracks:
return [] return []
# Both Spotify, iTunes, and Deezer return the same format: # All supported provider responses expose the same core fields once normalized.
# items[].track_number, items[].name, items[].disc_number, items[].id, items[].artists # items[].track_number, items[].name, items[].disc_number, items[].id, items[].artists
missing_tracks = [] missing_tracks = []
for item in api_tracks['items']: for item in items:
tn = item.get('track_number') tn = item.get('track_number')
if tn and tn not in owned_numbers: if tn and tn not in owned_numbers:
track_artists = [] track_artists = []
@ -317,6 +313,9 @@ class AlbumCompletenessJob(RepairJob):
'track_number': tn, 'track_number': tn,
'name': item.get('name', ''), 'name': item.get('name', ''),
'disc_number': item.get('disc_number', 1), 'disc_number': item.get('disc_number', 1),
'source': item.get('_source', primary_source),
'source_track_id': item.get('id', ''),
'track_id': item.get('id', ''),
'spotify_track_id': item.get('id', ''), 'spotify_track_id': item.get('id', ''),
'duration_ms': item.get('duration_ms', 0), 'duration_ms': item.get('duration_ms', 0),
'artists': track_artists, 'artists': track_artists,
@ -331,6 +330,35 @@ class AlbumCompletenessJob(RepairJob):
merged.update(cfg) merged.update(cfg)
return merged return merged
def _get_primary_source(self) -> str:
"""Return the active metadata source used for source prioritization."""
try:
return get_primary_source()
except Exception:
return 'deezer'
def _get_album_id_for_source(self, source: str, album_ids: dict) -> str:
return album_ids.get(source, '')
def _get_album_tracks(self, source: str, album_id: str):
"""Fetch album tracks from a specific source."""
try:
return get_album_tracks_for_source(source, album_id)
except Exception as e:
logger.debug("Error getting %s album tracks for %s: %s", source.capitalize(), album_id, e)
return None
def _extract_track_items(self, api_tracks):
"""Normalize album track responses to a list of item dicts."""
if not api_tracks:
return []
if isinstance(api_tracks, dict):
items = api_tracks.get('items') or []
return items if items else []
if isinstance(api_tracks, list):
return api_tracks
return []
def estimate_scope(self, context: JobContext) -> int: def estimate_scope(self, context: JobContext) -> int:
conn = None conn = None
try: try:
@ -346,6 +374,10 @@ class AlbumCompletenessJob(RepairJob):
where_parts.append("(itunes_album_id IS NOT NULL AND itunes_album_id != '')") where_parts.append("(itunes_album_id IS NOT NULL AND itunes_album_id != '')")
if 'deezer_id' in columns: if 'deezer_id' in columns:
where_parts.append("(deezer_id IS NOT NULL AND deezer_id != '')") where_parts.append("(deezer_id IS NOT NULL AND deezer_id != '')")
if 'discogs_id' in columns:
where_parts.append("(discogs_id IS NOT NULL AND discogs_id != '')")
if 'soul_id' in columns:
where_parts.append("(soul_id IS NOT NULL AND soul_id != '')")
cursor.execute(f""" cursor.execute(f"""
SELECT COUNT(*) FROM albums SELECT COUNT(*) FROM albums

View file

@ -18,6 +18,11 @@ from difflib import SequenceMatcher
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from core.metadata_service import (
get_album_tracks_for_source,
get_source_priority,
get_primary_source,
)
from core.repair_jobs import get_all_jobs from core.repair_jobs import get_all_jobs
from core.repair_jobs.base import JobContext, JobResult, RepairJob from core.repair_jobs.base import JobContext, JobResult, RepairJob
from utils.logging_config import get_logger from utils.logging_config import get_logger
@ -104,7 +109,6 @@ class RepairWorker:
self._on_job_finish = None # (job_id, status, result) -> None self._on_job_finish = None # (job_id, status, result) -> None
# Lazy client accessors # Lazy client accessors
self._spotify_client = None
self._itunes_client = None self._itunes_client = None
self._mb_client = None self._mb_client = None
self._acoustid_client = None self._acoustid_client = None
@ -150,13 +154,12 @@ class RepairWorker:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@property @property
def spotify_client(self): def spotify_client(self):
if self._spotify_client is None: try:
try: from core.metadata_service import get_client_for_source
from core.spotify_client import SpotifyClient return get_client_for_source('spotify')
self._spotify_client = SpotifyClient() except Exception as e:
except Exception as e: logger.error("Failed to resolve shared Spotify client: %s", e)
logger.error("Failed to initialize SpotifyClient: %s", e) return None
return self._spotify_client
@property @property
def itunes_client(self): def itunes_client(self):
@ -1743,7 +1746,9 @@ class RepairWorker:
track_number = mt.get('track_number', 0) track_number = mt.get('track_number', 0)
disc_number = mt.get('disc_number', 1) disc_number = mt.get('disc_number', 1)
track_artists = mt.get('artists', []) track_artists = mt.get('artists', [])
spotify_track_id = mt.get('spotify_track_id', '') source = mt.get('source', '') or 'spotify'
source_track_id = mt.get('source_track_id', '') or mt.get('track_id', '') or mt.get('spotify_track_id', '')
spotify_track_id = mt.get('spotify_track_id', '') or (source_track_id if source == 'spotify' else '')
artist_search = track_artists[0] if track_artists else artist_name artist_search = track_artists[0] if track_artists else artist_name
if not track_name: if not track_name:
@ -1809,7 +1814,7 @@ class RepairWorker:
logger.warning("File operation failed for '%s': %s", track_name, result.get('error')) logger.warning("File operation failed for '%s': %s", track_name, result.get('error'))
# Phase 4: Wishlist fallback # Phase 4: Wishlist fallback
if spotify_track_id: if source_track_id:
try: try:
# Build album images from finding thumb URL # Build album images from finding thumb URL
album_images = [] album_images = []
@ -1818,7 +1823,7 @@ class RepairWorker:
album_images = [{'url': album_thumb, 'height': 300, 'width': 300}] album_images = [{'url': album_thumb, 'height': 300, 'width': 300}]
wishlist_data = { wishlist_data = {
'id': spotify_track_id, 'id': source_track_id,
'name': track_name, 'name': track_name,
'artists': [{'name': a} for a in track_artists] if track_artists else [{'name': artist_name}], 'artists': [{'name': a} for a in track_artists] if track_artists else [{'name': artist_name}],
'album': { 'album': {
@ -1832,6 +1837,7 @@ class RepairWorker:
'duration_ms': mt.get('duration_ms', 0), 'duration_ms': mt.get('duration_ms', 0),
'track_number': track_number, 'track_number': track_number,
'disc_number': disc_number, 'disc_number': disc_number,
'uri': f"{source}:track:{source_track_id}" if source and source_track_id else '',
} }
source_info = { source_info = {
'album_title': album_title, 'album_title': album_title,
@ -1839,6 +1845,8 @@ class RepairWorker:
'track_number': track_number, 'track_number': track_number,
'disc_number': disc_number, 'disc_number': disc_number,
'spotify_album_id': spotify_album_id, 'spotify_album_id': spotify_album_id,
'source': source,
'source_track_id': source_track_id,
'is_album': True, 'is_album': True,
'reason': 'album_completeness_auto_fill', 'reason': 'album_completeness_auto_fill',
} }
@ -1860,7 +1868,7 @@ class RepairWorker:
track_details.append({'track': track_name, 'status': 'skipped', 'reason': f'wishlist error: {e}'}) track_details.append({'track': track_name, 'status': 'skipped', 'reason': f'wishlist error: {e}'})
else: else:
skipped_count += 1 skipped_count += 1
track_details.append({'track': track_name, 'status': 'skipped', 'reason': 'no spotify_track_id for wishlist'}) track_details.append({'track': track_name, 'status': 'skipped', 'reason': 'no source_track_id for wishlist'})
# Build result message # Build result message
parts = [] parts = []
@ -1885,11 +1893,18 @@ class RepairWorker:
def _refetch_missing_tracks(self, album_id, details): def _refetch_missing_tracks(self, album_id, details):
"""Re-fetch missing track list from APIs when the stored list is empty.""" """Re-fetch missing track list from APIs when the stored list is empty."""
configured_primary_source = get_primary_source()
spotify_album_id = details.get('spotify_album_id', '') spotify_album_id = details.get('spotify_album_id', '')
itunes_album_id = details.get('itunes_album_id', '') itunes_album_id = details.get('itunes_album_id', '')
deezer_album_id = details.get('deezer_album_id', '') deezer_album_id = details.get('deezer_album_id', '')
logger.debug("Refetch missing tracks for album %s: spotify=%s, itunes=%s, deezer=%s", discogs_album_id = details.get('discogs_album_id', '')
album_id, spotify_album_id, itunes_album_id, deezer_album_id) hydrabase_album_id = details.get('hydrabase_album_id', '')
primary_source = details.get('primary_source') or configured_primary_source
logger.debug(
"Refetch missing tracks for album %s: primary=%s spotify=%s itunes=%s deezer=%s discogs=%s hydrabase=%s",
album_id, primary_source, spotify_album_id, itunes_album_id, deezer_album_id, discogs_album_id,
hydrabase_album_id
)
# Get track numbers we already own # Get track numbers we already own
owned_numbers = set() owned_numbers = set()
@ -1909,32 +1924,27 @@ class RepairWorker:
if conn: if conn:
conn.close() conn.close()
# Try Spotify first current_source = primary_source
api_tracks = None api_tracks = None
if spotify_album_id: album_sources = {
try: 'spotify': spotify_album_id,
sp = self.spotify_client 'itunes': itunes_album_id,
if sp and hasattr(sp, 'get_album_tracks'): 'deezer': deezer_album_id,
api_tracks = sp.get_album_tracks(spotify_album_id) 'discogs': discogs_album_id,
except Exception as e: 'hydrabase': hydrabase_album_id,
logger.debug("Refetch: Spotify album tracks failed for %s: %s", spotify_album_id, e) }
# Try fallback client (iTunes/Deezer) for source in get_source_priority(primary_source):
if not api_tracks or 'items' not in (api_tracks or {}): fid = album_sources.get(source, '')
itunes = self.itunes_client if not fid:
if itunes: continue
is_deezer = type(itunes).__name__ == 'DeezerClient' try:
primary_id = deezer_album_id if is_deezer else itunes_album_id api_tracks = get_album_tracks_for_source(source, fid)
secondary_id = itunes_album_id if is_deezer else deezer_album_id if api_tracks and 'items' in (api_tracks or {}):
for fid in [primary_id, secondary_id]: current_source = source
if not fid: break
continue except Exception as e:
try: logger.debug("Refetch: %s album tracks failed for %s: %s", source, fid, e)
api_tracks = itunes.get_album_tracks(fid)
if api_tracks and 'items' in api_tracks:
break
except Exception as e:
logger.debug("Refetch: fallback album tracks failed for %s: %s", fid, e)
if not api_tracks or 'items' not in api_tracks: if not api_tracks or 'items' not in api_tracks:
return [] return []
@ -1953,6 +1963,9 @@ class RepairWorker:
'track_number': tn, 'track_number': tn,
'name': item.get('name', ''), 'name': item.get('name', ''),
'disc_number': item.get('disc_number', 1), 'disc_number': item.get('disc_number', 1),
'source': current_source or 'spotify',
'source_track_id': item.get('id', ''),
'track_id': item.get('id', ''),
'spotify_track_id': item.get('id', ''), 'spotify_track_id': item.get('id', ''),
'duration_ms': item.get('duration_ms', 0), 'duration_ms': item.get('duration_ms', 0),
'artists': track_artists, 'artists': track_artists,

View file

@ -6426,10 +6426,10 @@ class MusicDatabase:
with self._get_connection() as conn: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
# Use Spotify track ID as unique identifier # Use track ID as unique identifier. Field name stays legacy-compatible.
track_id = spotify_track_data.get('id') track_id = spotify_track_data.get('id')
if not track_id: if not track_id:
logger.error("Cannot add track to wishlist: missing Spotify track ID") logger.error("Cannot add track to wishlist: missing track ID")
return False return False
track_name = spotify_track_data.get('name', 'Unknown Track') track_name = spotify_track_data.get('name', 'Unknown Track')

View file

@ -0,0 +1,305 @@
import sys
import types
if "spotipy" not in sys.modules:
spotipy = types.ModuleType("spotipy")
class _DummySpotify:
def __init__(self, *args, **kwargs):
pass
oauth2 = types.ModuleType("spotipy.oauth2")
class _DummyOAuth:
def __init__(self, *args, **kwargs):
pass
spotipy.Spotify = _DummySpotify
oauth2.SpotifyOAuth = _DummyOAuth
oauth2.SpotifyClientCredentials = _DummyOAuth
spotipy.oauth2 = oauth2
sys.modules["spotipy"] = spotipy
sys.modules["spotipy.oauth2"] = oauth2
if "config.settings" not in sys.modules:
config_pkg = types.ModuleType("config")
settings_mod = types.ModuleType("config.settings")
class _DummyConfigManager:
def get(self, key, default=None):
return default
settings_mod.config_manager = _DummyConfigManager()
config_pkg.settings = settings_mod
sys.modules["config"] = config_pkg
sys.modules["config.settings"] = settings_mod
from core.repair_jobs.album_completeness import AlbumCompletenessJob
import core.repair_jobs.album_completeness as album_completeness_module
class _FakeCursor:
def __init__(self, owned_track_numbers):
self._owned_track_numbers = owned_track_numbers
self._last_query = ""
def execute(self, query, params=None):
self._last_query = query
return self
def fetchall(self):
if "SELECT track_number" in self._last_query:
return [(track_number,) for track_number in self._owned_track_numbers]
return []
class _FakeConnection:
def __init__(self, owned_track_numbers):
self._cursor = _FakeCursor(owned_track_numbers)
def cursor(self):
return self._cursor
def close(self):
return None
class _FakeDB:
def __init__(self, owned_track_numbers):
self._owned_track_numbers = owned_track_numbers
def _get_connection(self):
return _FakeConnection(self._owned_track_numbers)
class _FakeSpotifyClient:
def __init__(self, track_count=5):
self.track_count = track_count
self.calls = []
def is_spotify_authenticated(self):
return True
def get_album_tracks(self, album_id):
self.calls.append(album_id)
return {
"items": [
{"id": f"sp-{i}", "name": f"Spotify Track {i}", "track_number": i, "disc_number": 1, "artists": []}
for i in range(1, self.track_count + 1)
]
}
class _FakeDeezerClient:
def __init__(self, track_count=2):
self.track_count = track_count
self.calls = []
def is_authenticated(self):
return True
def get_album_tracks(self, album_id):
self.calls.append(album_id)
return {
"items": [
{"id": f"dz-{i}", "name": f"Deezer Track {i}", "track_number": i, "disc_number": 1, "artists": []}
for i in range(1, self.track_count + 1)
]
}
class _FakeITunesClient:
def __init__(self):
self.calls = []
def is_authenticated(self):
return True
def get_album_tracks(self, album_id):
self.calls.append(album_id)
return {"items": []}
class _FakeDiscogsClient:
def __init__(self, track_count=3):
self.track_count = track_count
self.calls = []
def get_album_tracks(self, album_id):
self.calls.append(album_id)
return {
"items": [
{"id": f"dg-{i}", "name": f"Discogs Track {i}", "track_number": i, "disc_number": 1, "artists": []}
for i in range(1, self.track_count + 1)
]
}
class _FakeHydrabaseClient:
def __init__(self, track_count=4):
self.track_count = track_count
self.calls = []
def is_connected(self):
return True
def get_album_tracks_dict(self, album_id):
self.calls.append(album_id)
return {
"items": [
{"id": f"hy-{i}", "name": f"Hydrabase Track {i}", "track_number": i, "disc_number": 1, "artists": []}
for i in range(1, self.track_count + 1)
]
}
def test_album_completeness_uses_primary_provider_first(monkeypatch):
job = AlbumCompletenessJob()
context = types.SimpleNamespace(
db=_FakeDB(owned_track_numbers={1}),
spotify_client=_FakeSpotifyClient(track_count=5),
is_spotify_rate_limited=lambda: False,
)
deezer_client = _FakeDeezerClient(track_count=2)
itunes_client = _FakeITunesClient()
calls = []
album_ids = {
"spotify": "spotify-album",
"itunes": "itunes-album",
"deezer": "deezer-album",
"discogs": "",
"hydrabase": "",
}
monkeypatch.setattr(album_completeness_module, "get_primary_source", lambda: "deezer")
monkeypatch.setattr(
album_completeness_module,
"get_album_tracks_for_source",
lambda source, album_id: (
calls.append((source, album_id)) or
(
deezer_client.get_album_tracks(album_id) if source == "deezer" else
itunes_client.get_album_tracks(album_id) if source == "itunes" else
context.spotify_client.get_album_tracks(album_id) if source == "spotify" else
{"items": []}
)
)
)
expected_total = job._get_expected_total(context, "deezer", album_ids)
missing_tracks = job._find_missing_tracks(context, "deezer", 42, album_ids)
assert expected_total == 2
assert calls == [("deezer", "deezer-album"), ("deezer", "deezer-album")]
assert deezer_client.calls == ["deezer-album", "deezer-album"]
assert context.spotify_client.calls == []
assert itunes_client.calls == []
assert [track["track_number"] for track in missing_tracks] == [2]
assert missing_tracks[0]["source"] == "deezer"
assert missing_tracks[0]["source_track_id"] == "dz-2"
assert missing_tracks[0]["spotify_track_id"] == "dz-2"
def test_album_completeness_supports_discogs_primary(monkeypatch):
job = AlbumCompletenessJob()
context = types.SimpleNamespace(
db=_FakeDB(owned_track_numbers={1}),
spotify_client=_FakeSpotifyClient(track_count=5),
is_spotify_rate_limited=lambda: False,
)
discogs_client = _FakeDiscogsClient(track_count=3)
itunes_client = _FakeITunesClient()
deezer_client = _FakeDeezerClient(track_count=2)
calls = []
album_ids = {
"spotify": "spotify-album",
"itunes": "itunes-album",
"deezer": "deezer-album",
"discogs": "discogs-release",
"hydrabase": "",
}
monkeypatch.setattr(album_completeness_module, "get_primary_source", lambda: "discogs")
monkeypatch.setattr(
album_completeness_module,
"get_album_tracks_for_source",
lambda source, album_id: (
calls.append((source, album_id)) or
(
discogs_client.get_album_tracks(album_id) if source == "discogs" else
itunes_client.get_album_tracks(album_id) if source == "itunes" else
deezer_client.get_album_tracks(album_id) if source == "deezer" else
context.spotify_client.get_album_tracks(album_id) if source == "spotify" else
{"items": []}
)
)
)
expected_total = job._get_expected_total(context, "discogs", album_ids)
missing_tracks = job._find_missing_tracks(context, "discogs", 42, album_ids)
assert expected_total == 3
assert calls == [("discogs", "discogs-release"), ("discogs", "discogs-release")]
assert discogs_client.calls == ["discogs-release", "discogs-release"]
assert context.spotify_client.calls == []
assert itunes_client.calls == []
assert deezer_client.calls == []
assert [track["track_number"] for track in missing_tracks] == [2, 3]
assert missing_tracks[0]["source"] == "discogs"
assert missing_tracks[0]["source_track_id"] == "dg-2"
def test_album_completeness_supports_hydrabase_primary(monkeypatch):
job = AlbumCompletenessJob()
context = types.SimpleNamespace(
db=_FakeDB(owned_track_numbers={1}),
spotify_client=_FakeSpotifyClient(track_count=5),
is_spotify_rate_limited=lambda: False,
)
hydrabase_client = _FakeHydrabaseClient(track_count=4)
itunes_client = _FakeITunesClient()
deezer_client = _FakeDeezerClient(track_count=2)
calls = []
album_ids = {
"spotify": "spotify-album",
"itunes": "itunes-album",
"deezer": "deezer-album",
"discogs": "",
"hydrabase": "soul-album",
}
monkeypatch.setattr(album_completeness_module, "get_primary_source", lambda: "hydrabase")
monkeypatch.setattr(
album_completeness_module,
"get_album_tracks_for_source",
lambda source, album_id: (
calls.append((source, album_id)) or
(
hydrabase_client.get_album_tracks_dict(album_id) if source == "hydrabase" else
itunes_client.get_album_tracks(album_id) if source == "itunes" else
deezer_client.get_album_tracks(album_id) if source == "deezer" else
context.spotify_client.get_album_tracks(album_id) if source == "spotify" else
{"items": []}
)
)
)
expected_total = job._get_expected_total(context, "hydrabase", album_ids)
missing_tracks = job._find_missing_tracks(context, "hydrabase", 42, album_ids)
assert expected_total == 4
assert calls == [("hydrabase", "soul-album"), ("hydrabase", "soul-album")]
assert hydrabase_client.calls == ["soul-album", "soul-album"]
assert context.spotify_client.calls == []
assert itunes_client.calls == []
assert deezer_client.calls == []
assert [track["track_number"] for track in missing_tracks] == [2, 3, 4]
assert missing_tracks[0]["source"] == "hydrabase"
assert missing_tracks[0]["source_track_id"] == "hy-2"

View file

@ -64051,7 +64051,15 @@ function _renderFindingDetail(f) {
case 'incomplete_album': case 'incomplete_album':
if (d.artist) rows.push(['Artist', d.artist]); if (d.artist) rows.push(['Artist', d.artist]);
if (d.album_title) rows.push(['Album', d.album_title]); if (d.album_title) rows.push(['Album', d.album_title]);
if (d.spotify_album_id) rows.push(['Spotify ID', d.spotify_album_id]); if (d.primary_source && d.primary_album_id) {
const primaryLabel = d.primary_source.charAt(0).toUpperCase() + d.primary_source.slice(1);
rows.push([`${primaryLabel} ID`, d.primary_album_id]);
if (d.spotify_album_id && d.primary_source !== 'spotify') {
rows.push(['Spotify ID', d.spotify_album_id]);
}
} else if (d.spotify_album_id) {
rows.push(['Spotify ID', d.spotify_album_id]);
}
let incHtml = media + _gridRows(rows); let incHtml = media + _gridRows(rows);
const actual = d.actual_tracks || 0, expected = d.expected_tracks || 0; const actual = d.actual_tracks || 0, expected = d.expected_tracks || 0;
if (expected > 0) { if (expected > 0) {
@ -64065,6 +64073,7 @@ function _renderFindingDetail(f) {
incHtml += `<div class="repair-detail-sublist">${d.missing_tracks.map(t => ` incHtml += `<div class="repair-detail-sublist">${d.missing_tracks.map(t => `
<div class="repair-detail-subitem"> <div class="repair-detail-subitem">
<strong>#${t.track_number || '?'} ${_escFinding(t.name || t.title || 'Unknown')}</strong> <strong>#${t.track_number || '?'} ${_escFinding(t.name || t.title || 'Unknown')}</strong>
${t.source && t.source !== 'spotify' ? `<span>Source: ${_escFinding(t.source)}${t.source_track_id ? ` · ID: ${_escFinding(t.source_track_id)}` : ''}</span>` : ''}
${t.duration_ms ? `<span>Duration: ${Math.round(t.duration_ms / 1000)}s</span>` : ''} ${t.duration_ms ? `<span>Duration: ${Math.round(t.duration_ms / 1000)}s</span>` : ''}
</div>`).join('')}</div>`; </div>`).join('')}</div>`;
} }