Backfill all metadata source IDs at start of every watchlist scan

- Was only backfilling the active provider — artists added via Deezer
  never got Spotify/iTunes IDs, and vice versa
- Now backfills iTunes (always), Deezer (always), and Spotify (if
  authenticated) at the start of every scan
- Added _match_to_deezer() and update_watchlist_deezer_id() for
  Deezer cross-provider matching
- Generalized backfill with provider→attribute/function maps
This commit is contained in:
Broque Thomas 2026-04-02 13:51:46 -07:00
parent 74028f6c9f
commit f6b0bd30e3
2 changed files with 87 additions and 41 deletions

View file

@ -604,20 +604,18 @@ class WatchlistScanner:
watchlist_artists = artists_to_scan watchlist_artists = artists_to_scan
# PROACTIVE ID BACKFILLING (cross-provider support) # PROACTIVE ID BACKFILLING (cross-provider support)
# Before scanning, ensure all artists have IDs for the current provider # Before scanning, ensure ALL artists have IDs for ALL available sources
logger.info(f"DEBUG: About to check backfilling. _metadata_service = {getattr(self, '_metadata_service', 'ATTRIBUTE MISSING')}") # iTunes and Deezer are always available; Spotify requires authentication
if self._metadata_service is not None: providers_to_backfill = ['itunes', 'deezer']
if self.spotify_client and self.spotify_client.is_spotify_authenticated():
providers_to_backfill.append('spotify')
for provider in providers_to_backfill:
try: try:
active_provider = self._metadata_service.get_active_provider() self._backfill_missing_ids(all_watchlist_artists, provider)
logger.info(f"🔍 Checking for missing {active_provider} IDs in watchlist...")
self._backfill_missing_ids(all_watchlist_artists, active_provider)
except Exception as backfill_error: except Exception as backfill_error:
logger.warning(f"Error during ID backfilling: {backfill_error}") logger.warning(f"Error during {provider} ID backfilling: {backfill_error}")
import traceback
traceback.print_exc()
# Continue with scan even if backfilling fails # Continue with scan even if backfilling fails
else:
logger.warning(f"⚠️ Backfilling SKIPPED - _metadata_service is None")
scan_results = [] scan_results = []
for i, artist in enumerate(watchlist_artists): for i, artist in enumerate(watchlist_artists):
@ -976,46 +974,54 @@ class WatchlistScanner:
Example: User has 50 artists with only Spotify IDs. Example: User has 50 artists with only Spotify IDs.
When iTunes becomes active, this matches ALL 50 to iTunes in one batch. When iTunes becomes active, this matches ALL 50 to iTunes in one batch.
""" """
artists_to_match = [] # Find artists missing IDs for the active provider (regardless of which other IDs they have)
id_attr = {
if provider == 'spotify': 'spotify': 'spotify_artist_id',
# Find all artists missing Spotify IDs 'itunes': 'itunes_artist_id',
artists_to_match = [a for a in artists if not a.spotify_artist_id and a.itunes_artist_id] 'deezer': 'deezer_artist_id',
elif provider == 'itunes': }.get(provider)
# Find all artists missing iTunes IDs
artists_to_match = [a for a in artists if not a.itunes_artist_id and a.spotify_artist_id] if not id_attr:
logger.debug(f"Backfill not supported for provider: {provider}")
return
artists_to_match = [a for a in artists if not getattr(a, id_attr, None)]
if not artists_to_match: if not artists_to_match:
logger.info(f"✅ All artists already have {provider} IDs") logger.info(f"✅ All artists already have {provider} IDs")
return return
logger.info(f"🔄 Backfilling {len(artists_to_match)} artists with {provider} IDs...") logger.info(f"🔄 Backfilling {len(artists_to_match)} artists with {provider} IDs...")
match_fn = {
'spotify': self._match_to_spotify,
'itunes': self._match_to_itunes,
'deezer': self._match_to_deezer,
}.get(provider)
update_fn = {
'spotify': self.database.update_watchlist_spotify_id,
'itunes': self.database.update_watchlist_itunes_id,
'deezer': getattr(self.database, 'update_watchlist_deezer_id', None),
}.get(provider)
if not match_fn or not update_fn:
logger.debug(f"No match/update function available for provider: {provider}")
return
matched_count = 0 matched_count = 0
unmatched_names = [] unmatched_names = []
for artist in artists_to_match: for artist in artists_to_match:
try: try:
if provider == 'spotify': new_id = match_fn(artist.artist_name)
new_id = self._match_to_spotify(artist.artist_name) if new_id:
if new_id: update_fn(artist.id, new_id)
self.database.update_watchlist_spotify_id(artist.id, new_id) setattr(artist, id_attr, new_id)
artist.spotify_artist_id = new_id # Update in memory matched_count += 1
matched_count += 1 logger.info(f"✅ Matched '{artist.artist_name}' to {provider}: {new_id}")
logger.info(f"✅ Matched '{artist.artist_name}' to Spotify: {new_id}") else:
else: unmatched_names.append(artist.artist_name)
unmatched_names.append(artist.artist_name)
elif provider == 'itunes':
new_id = self._match_to_itunes(artist.artist_name)
if new_id:
self.database.update_watchlist_itunes_id(artist.id, new_id)
artist.itunes_artist_id = new_id # Update in memory
matched_count += 1
logger.info(f"✅ Matched '{artist.artist_name}' to iTunes: {new_id}")
else:
unmatched_names.append(artist.artist_name)
# Small delay to avoid API rate limits
time.sleep(0.3) time.sleep(0.3)
except Exception as e: except Exception as e:
@ -1124,6 +1130,26 @@ class WatchlistScanner:
logger.warning(f"Could not match {artist_name} to iTunes: {e}") logger.warning(f"Could not match {artist_name} to iTunes: {e}")
return None return None
def _match_to_deezer(self, artist_name: str) -> Optional[str]:
"""Match artist name to Deezer ID using fuzzy name comparison."""
try:
# Try MetadataService fallback client (if it's Deezer)
if hasattr(self, '_metadata_service') and self._metadata_service:
client = self._metadata_service.itunes # Named 'itunes' but may be DeezerClient
from core.deezer_client import DeezerClient
if isinstance(client, DeezerClient):
results = client.search_artists(artist_name, limit=5)
return self._best_artist_match(results, artist_name)
# Fallback: create a fresh Deezer client
from core.deezer_client import DeezerClient
client = DeezerClient()
results = client.search_artists(artist_name, limit=5)
return self._best_artist_match(results, artist_name)
except Exception as e:
logger.warning(f"Could not match {artist_name} to Deezer: {e}")
return None
def _get_lookback_period_setting(self) -> str: def _get_lookback_period_setting(self) -> str:
""" """
Get the discovery lookback period setting from database. Get the discovery lookback period setting from database.

View file

@ -6813,6 +6813,26 @@ class MusicDatabase:
logger.error(f"Error updating watchlist iTunes ID: {e}") logger.error(f"Error updating watchlist iTunes ID: {e}")
return False return False
def update_watchlist_deezer_id(self, watchlist_id: int, deezer_id: str) -> bool:
"""Update the Deezer artist ID for a watchlist artist (cross-provider support)"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE watchlist_artists
SET deezer_artist_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
""", (deezer_id, watchlist_id))
conn.commit()
logger.info(f"Updated Deezer ID for watchlist artist {watchlist_id}: {deezer_id}")
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating watchlist Deezer ID: {e}")
return False
def update_watchlist_artist_itunes_id(self, spotify_artist_id: str, itunes_id: str) -> bool: def update_watchlist_artist_itunes_id(self, spotify_artist_id: str, itunes_id: str) -> bool:
"""Update the iTunes artist ID for a watchlist artist by Spotify ID (for cross-provider caching)""" """Update the iTunes artist ID for a watchlist artist by Spotify ID (for cross-provider caching)"""
try: try: