Fix Spotify API calls leaking when Deezer/iTunes is primary source

Spotify was being called for album/artist data fetching across multiple
background workers and the Artists page search even when the user had
Deezer or iTunes set as their primary metadata source. Being authenticated
for playlist sync was treated as permission to use Spotify for everything.

- watchlist_scanner: add _spotify_is_primary_source() that checks both
  auth and primary source config; use it for all album/artist data fetching
  (discovery pool, recent album caching, playlist curation, similar artist
  ID matching, proactive ID backfill). _spotify_available_for_run() is kept
  for sync_spotify_library_cache which must run regardless of primary source
- repair_jobs/metadata_gap_filler: gate Spotify ISRC lookup on primary
  source being 'spotify'; MusicBrainz lookup unaffected
- repair_jobs/unknown_artist_fixer: replace hardcoded spotify_client with
  source-aware client selection — primary source ID tried first, each ID
  matched to its correct client (fixes latent bug passing Deezer IDs to
  Spotify)
- web_server.py /api/match/search: Artists page search was hardcoded to
  spotify_client.search_artists(); now uses _get_metadata_fallback_client()
  so results come from the configured primary source
This commit is contained in:
Broque Thomas 2026-04-15 09:47:43 -07:00
parent 3618f3fa7f
commit fe399636b2
5 changed files with 67 additions and 23 deletions

View file

@ -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:

View file

@ -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 {}

View file

@ -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

View file

@ -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:

View file

@ -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' },