diff --git a/core/repair_jobs/metadata_gap_filler.py b/core/repair_jobs/metadata_gap_filler.py index 323f7522..f709b857 100644 --- a/core/repair_jobs/metadata_gap_filler.py +++ b/core/repair_jobs/metadata_gap_filler.py @@ -2,6 +2,7 @@ import time +from core.metadata_service import get_primary_source from core.repair_jobs import register_job from core.repair_jobs.base import JobContext, JobResult, RepairJob from utils.logging_config import get_logger @@ -108,8 +109,10 @@ class MetadataGapFillerJob(RepairJob): ) found_fields = {} - # Try Spotify enrichment first (most reliable for ISRC) - if spotify_track_id and context.spotify_client and not context.is_spotify_rate_limited(): + # Try Spotify enrichment for ISRC — only when Spotify is the configured primary source. + # If Deezer/iTunes is primary, Spotify may still be authenticated for playlist sync + # but should not be called here, as it burns API quota unnecessarily. + if spotify_track_id and context.spotify_client and not context.is_spotify_rate_limited() and get_primary_source() == 'spotify': try: track_data = context.spotify_client.get_track_details(spotify_track_id) if track_data: diff --git a/core/repair_jobs/unknown_artist_fixer.py b/core/repair_jobs/unknown_artist_fixer.py index 56de6284..c81663c1 100644 --- a/core/repair_jobs/unknown_artist_fixer.py +++ b/core/repair_jobs/unknown_artist_fixer.py @@ -10,6 +10,7 @@ import shutil import sys import time +from core.metadata_service import get_client_for_source, get_primary_client, get_primary_source from core.repair_jobs import register_job from core.repair_jobs.base import JobContext, JobResult, RepairJob from utils.logging_config import get_logger @@ -268,12 +269,27 @@ class UnknownArtistFixerJob(RepairJob): except Exception as e: logger.debug(f"Failed to read tags from {resolved_path}: {e}") - # Priority 2: Look up by source track ID - source_id = (track.get('spotify_track_id') or track.get('deezer_track_id') - or track.get('itunes_track_id')) - if source_id and context.spotify_client: + # Priority 2: Look up by source track ID using the appropriate client. + # Try the primary source's ID first, then fall back to any available ID + # with its matching client so we never pass a Deezer/iTunes ID to Spotify + # (or vice-versa). + _primary = get_primary_source() + _id_candidates = [] + for _src in [_primary] + [s for s in ('spotify', 'deezer', 'itunes') if s != _primary]: + _tid = track.get(f'{_src}_track_id') + if _tid: + _id_candidates.append((_src, _tid)) + source_id = None + _lookup_client = None + for _src, _tid in _id_candidates: + _c = get_client_for_source(_src) + if _c: + source_id = _tid + _lookup_client = _c + break + if source_id and _lookup_client: try: - details = context.spotify_client.get_track_details(str(source_id)) + details = _lookup_client.get_track_details(str(source_id)) if details and details.get('primary_artist'): artist = details['primary_artist'] if artist.lower() not in _UNKNOWN_NAMES: @@ -293,10 +309,11 @@ class UnknownArtistFixerJob(RepairJob): except Exception as e: logger.debug(f"Track ID lookup failed for {source_id}: {e}") - # Priority 3: Search by title - if title and context.spotify_client: + # Priority 3: Search by title using the configured primary metadata source + _search_client = get_primary_client() + if title and _search_client: try: - results = context.spotify_client.search_tracks(title, limit=5) + results = _search_client.search_tracks(title, limit=5) if results: # Score candidates from difflib import SequenceMatcher @@ -319,7 +336,7 @@ class UnknownArtistFixerJob(RepairJob): # Get full details for track_number full_details = None try: - full_details = context.spotify_client.get_track_details(best.id) + full_details = _search_client.get_track_details(best.id) except Exception: pass album_data = full_details.get('album', {}) if full_details else {} diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 61d900ee..0815a528 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -418,6 +418,26 @@ class WatchlistScanner: return False return self.spotify_client.is_spotify_authenticated() + def _spotify_is_primary_source(self) -> bool: + """Check if Spotify is both authenticated and the configured primary metadata source. + + Use this (not _spotify_available_for_run) when deciding whether to fetch + album/artist data from Spotify. Plain auth is not sufficient — the user + may have Spotify connected only for playlist sync while Deezer/iTunes + serves as the metadata source, and calling Spotify for data in that case + burns API quota unnecessarily. + + _spotify_available_for_run() is still used for Spotify-specific features + (e.g. library-cache sync) that must run regardless of primary source. + """ + if not self._spotify_available_for_run(): + return False + try: + from core.metadata_service import get_primary_source + return get_primary_source() == 'spotify' + except Exception: + return False + def _get_active_client_and_artist_id(self, watchlist_artist: WatchlistArtist): """ Get the appropriate client and artist ID based on active provider. @@ -627,7 +647,7 @@ class WatchlistScanner: if self.spotify_client and self.spotify_client.is_rate_limited(): self._disable_spotify_for_run("global Spotify rate limit active") providers_to_backfill = ['itunes', 'deezer'] - if self._spotify_available_for_run(): + if self._spotify_is_primary_source(): providers_to_backfill.append('spotify') try: from config.settings import config_manager as _cfg @@ -1764,8 +1784,8 @@ class WatchlistScanner: searched_spotify_id = None searched_fallback_id = None try: - # Try Spotify search - if self._spotify_available_for_run(): + # Try Spotify search (only when Spotify is the configured primary source) + if self._spotify_is_primary_source(): searched_results = self.spotify_client.search_artists(artist_name, limit=1) if searched_results and len(searched_results) > 0: searched_spotify_id = searched_results[0].id @@ -1801,8 +1821,8 @@ class WatchlistScanner: 'popularity': 0 } - # Try to match on Spotify - if self._spotify_available_for_run(): + # Try to match on Spotify (only when Spotify is the configured primary source) + if self._spotify_is_primary_source(): try: spotify_results = self.spotify_client.search_artists(artist_name_to_match, limit=1) if spotify_results and len(spotify_results) > 0: @@ -2013,7 +2033,7 @@ class WatchlistScanner: logger.info("Populating discovery pool from similar artists...") # Determine which sources are available - spotify_available = self._spotify_available_for_run() + spotify_available = self._spotify_is_primary_source() # Import fallback metadata client (iTunes or Deezer) itunes_client, fallback_source = _get_fallback_metadata_client() @@ -2650,7 +2670,7 @@ class WatchlistScanner: albums_checked = 0 # Determine available sources - spotify_available = self._spotify_available_for_run() + spotify_available = self._spotify_is_primary_source() # Get fallback metadata client (iTunes or Deezer) itunes_client, fallback_source = _get_fallback_metadata_client() @@ -2885,7 +2905,7 @@ class WatchlistScanner: f"{profile['avg_daily_plays']:.1f} avg daily plays") # Determine available sources - spotify_available = self._spotify_available_for_run() + spotify_available = self._spotify_is_primary_source() itunes_client, fallback_source = _get_fallback_metadata_client() # Process each available source diff --git a/web_server.py b/web_server.py index e5cb61cd..c978a05e 100644 --- a/web_server.py +++ b/web_server.py @@ -15803,8 +15803,8 @@ def search_match(): artist_matches = hydrabase_client.search_artists(query, limit=8) provider = 'hydrabase' else: - artist_matches = spotify_client.search_artists(query, limit=8) - provider = _detect_provider(artist_matches, spotify_client) + artist_matches = _get_metadata_fallback_client().search_artists(query, limit=8) + provider = _get_metadata_fallback_source() results = [] for artist in artist_matches: @@ -15840,8 +15840,8 @@ def search_match(): if not artist_id: return jsonify({"error": "Artist ID required for album search"}), 400 # Get artist's albums and filter by query - album_matches = spotify_client.get_artist_albums(artist_id) - provider = _detect_provider(album_matches, spotify_client) + album_matches = _get_metadata_fallback_client().get_artist_albums(artist_id) + provider = _get_metadata_fallback_source() results = [] for album in album_matches: diff --git a/webui/static/helper.js b/webui/static/helper.js index ce07595b..9eaf10b3 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3600,6 +3600,10 @@ function closeHelperSearch() { const WHATS_NEW = { '2.2': [ + // --- April 15, 2026 --- + { date: 'April 15, 2026' }, + { title: 'Fix Spotify API Leaking When Deezer/iTunes is Primary', desc: 'Spotify was being called for watchlist album scanning, similar artist discovery, repair jobs, and the Artists page search even when another source was set as primary. All data-fetching now respects the configured primary source. Spotify playlist sync is unaffected' }, + // --- April 14, 2026 --- { date: 'April 14, 2026' }, { title: 'Fix Staging Files Ignoring Path Template', desc: 'Files matched from the Staging folder were copied to the transfer root with their original filename instead of applying the configured path template. Post-processing now receives full artist/album context for staging matches' },