Make watchlist scanning source-aware
Move the web watchlist scan core onto the shared metadata source priority so primary provider settings are respected during artist, album, and image resolution. Add coverage for primary-source-first discography lookup and fallback to later providers when the primary source has no albums.
This commit is contained in:
parent
40f39604ad
commit
38b907097d
2 changed files with 443 additions and 104 deletions
|
|
@ -13,6 +13,12 @@ import requests
|
|||
from bs4 import BeautifulSoup
|
||||
from database.music_database import get_database, WatchlistArtist
|
||||
from core.spotify_client import SpotifyClient
|
||||
from core.metadata_service import (
|
||||
get_album_tracks_for_source,
|
||||
get_client_for_source,
|
||||
get_primary_source,
|
||||
get_source_priority,
|
||||
)
|
||||
from core.wishlist_service import get_wishlist_service
|
||||
from core.matching_engine import MusicMatchingEngine
|
||||
from utils.logging_config import get_logger
|
||||
|
|
@ -344,6 +350,16 @@ class ScanResult:
|
|||
success: bool
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatchlistDiscographyResult:
|
||||
"""Resolved watchlist artist discography for a specific metadata source."""
|
||||
source: str
|
||||
client: Any
|
||||
artist_id: str
|
||||
albums: List[Any]
|
||||
image_url: Optional[str] = None
|
||||
|
||||
class WatchlistScanner:
|
||||
"""Service for scanning watched artists for new releases"""
|
||||
|
||||
|
|
@ -428,11 +444,210 @@ class WatchlistScanner:
|
|||
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 _watchlist_source_priority(self) -> List[str]:
|
||||
"""Return watchlist scan sources in the configured priority order."""
|
||||
return list(get_source_priority(get_primary_source()))
|
||||
|
||||
@staticmethod
|
||||
def _artist_id_attribute_for_source(source: str) -> Optional[str]:
|
||||
"""Return the watchlist artist attribute that stores the given source ID."""
|
||||
return {
|
||||
'spotify': 'spotify_artist_id',
|
||||
'itunes': 'itunes_artist_id',
|
||||
'deezer': 'deezer_artist_id',
|
||||
'discogs': 'discogs_artist_id',
|
||||
}.get(source)
|
||||
|
||||
@staticmethod
|
||||
def _extract_entity_id(value: Any) -> Optional[str]:
|
||||
"""Extract an ID from a dataclass, dict, or plain object."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return value.get('id') or value.get('artist_id') or value.get('release_id')
|
||||
return getattr(value, 'id', None) or getattr(value, 'artist_id', None) or getattr(value, 'release_id', None)
|
||||
|
||||
def _cache_watchlist_artist_source_id(self, watchlist_artist: WatchlistArtist, source: str, source_id: str) -> None:
|
||||
"""Cache a resolved artist ID for a watchlist artist when we have a storage column."""
|
||||
if not source_id:
|
||||
return
|
||||
|
||||
if source == 'spotify':
|
||||
self.database.update_watchlist_spotify_id(watchlist_artist.id, source_id)
|
||||
watchlist_artist.spotify_artist_id = source_id
|
||||
elif source == 'itunes':
|
||||
self.database.update_watchlist_itunes_id(watchlist_artist.id, source_id)
|
||||
watchlist_artist.itunes_artist_id = source_id
|
||||
elif source == 'deezer':
|
||||
self.database.update_watchlist_deezer_id(watchlist_artist.id, source_id)
|
||||
watchlist_artist.deezer_artist_id = source_id
|
||||
elif source == 'discogs':
|
||||
self.database.update_watchlist_discogs_id(watchlist_artist.id, source_id)
|
||||
watchlist_artist.discogs_artist_id = source_id
|
||||
|
||||
def _resolve_watchlist_artist_source_id(self, watchlist_artist: WatchlistArtist, source: str, client: Any) -> Optional[str]:
|
||||
"""Resolve the artist ID for an exact source, searching by name if needed."""
|
||||
attr = self._artist_id_attribute_for_source(source)
|
||||
stored_id = getattr(watchlist_artist, attr, None) if attr else None
|
||||
if stored_id:
|
||||
return stored_id
|
||||
|
||||
if not client or not hasattr(client, 'search_artists'):
|
||||
return None
|
||||
|
||||
try:
|
||||
search_results = client.search_artists(watchlist_artist.artist_name, limit=1)
|
||||
except Exception as e:
|
||||
logger.debug("Could not search %s for %s: %s", source, watchlist_artist.artist_name, e)
|
||||
return None
|
||||
|
||||
if not search_results:
|
||||
return None
|
||||
|
||||
found_id = self._extract_entity_id(search_results[0])
|
||||
if found_id and attr:
|
||||
self._cache_watchlist_artist_source_id(watchlist_artist, source, found_id)
|
||||
return found_id
|
||||
|
||||
@staticmethod
|
||||
def _get_artist_image_from_data(artist_data: Any) -> Optional[str]:
|
||||
"""Extract an image URL from artist payloads across providers."""
|
||||
if not artist_data:
|
||||
return None
|
||||
|
||||
if isinstance(artist_data, dict):
|
||||
images = artist_data.get('images') or []
|
||||
if images:
|
||||
first_image = images[0]
|
||||
if isinstance(first_image, dict):
|
||||
return first_image.get('url')
|
||||
return (
|
||||
artist_data.get('image_url')
|
||||
or artist_data.get('thumb_url')
|
||||
or artist_data.get('cover_image')
|
||||
or artist_data.get('picture_xl')
|
||||
or artist_data.get('picture_big')
|
||||
or artist_data.get('picture_medium')
|
||||
)
|
||||
|
||||
images = getattr(artist_data, 'images', None)
|
||||
if images:
|
||||
first_image = images[0]
|
||||
if isinstance(first_image, dict):
|
||||
return first_image.get('url')
|
||||
return (
|
||||
getattr(artist_data, 'image_url', None)
|
||||
or getattr(artist_data, 'thumb_url', None)
|
||||
or getattr(artist_data, 'cover_image', None)
|
||||
)
|
||||
|
||||
def _get_artist_image_for_source(self, watchlist_artist: WatchlistArtist, source: str, client: Any, artist_id: str) -> Optional[str]:
|
||||
"""Fetch an artist image for a specific source."""
|
||||
if not client or not artist_id or not hasattr(client, 'get_artist'):
|
||||
return None
|
||||
|
||||
try:
|
||||
artist_data = client.get_artist(artist_id)
|
||||
except Exception as e:
|
||||
logger.debug("Could not fetch artist image for %s on %s: %s", watchlist_artist.artist_name, source, e)
|
||||
return None
|
||||
|
||||
return self._get_artist_image_from_data(artist_data)
|
||||
|
||||
def _get_album_data_for_source(self, source: str, client: Any, album_id: str, album_name: str = '') -> Optional[Dict[str, Any]]:
|
||||
"""Fetch album data for a specific source and normalize track payloads when needed."""
|
||||
if not client or not album_id or not hasattr(client, 'get_album'):
|
||||
return None
|
||||
|
||||
try:
|
||||
album_data = client.get_album(album_id)
|
||||
except Exception as e:
|
||||
logger.debug("Could not fetch album %s on %s: %s", album_id, source, e)
|
||||
album_data = None
|
||||
|
||||
if not album_data:
|
||||
return None
|
||||
|
||||
# Some providers return album metadata without embedded tracks; normalize that shape.
|
||||
tracks = album_data.get('tracks') if isinstance(album_data, dict) else None
|
||||
if not tracks:
|
||||
track_items = get_album_tracks_for_source(source, album_id)
|
||||
if track_items:
|
||||
if not isinstance(album_data, dict):
|
||||
try:
|
||||
album_data = dict(album_data)
|
||||
except Exception:
|
||||
album_data = {'name': album_name or album_id}
|
||||
if isinstance(track_items, dict):
|
||||
album_data['tracks'] = track_items
|
||||
else:
|
||||
album_data['tracks'] = {'items': track_items}
|
||||
|
||||
return album_data
|
||||
|
||||
@staticmethod
|
||||
def _extract_track_items(album_data: Any) -> List[Dict[str, Any]]:
|
||||
"""Normalize track payloads from different album formats to a list of items."""
|
||||
if not album_data:
|
||||
return []
|
||||
|
||||
tracks = None
|
||||
if isinstance(album_data, dict):
|
||||
tracks = album_data.get('tracks')
|
||||
else:
|
||||
tracks = getattr(album_data, 'tracks', None)
|
||||
|
||||
if not tracks:
|
||||
return []
|
||||
|
||||
if isinstance(tracks, dict):
|
||||
items = tracks.get('items') or tracks.get('data') or []
|
||||
return list(items) if isinstance(items, list) else []
|
||||
|
||||
if isinstance(tracks, list):
|
||||
return tracks
|
||||
|
||||
return []
|
||||
|
||||
def _resolve_watchlist_discography_for_source(
|
||||
self,
|
||||
watchlist_artist: WatchlistArtist,
|
||||
source: str,
|
||||
last_scan_timestamp: Optional[datetime] = None,
|
||||
) -> Optional[WatchlistDiscographyResult]:
|
||||
"""Resolve a watchlist artist to a specific source and fetch its discography."""
|
||||
client = get_client_for_source(source)
|
||||
if not client:
|
||||
return None
|
||||
|
||||
artist_id = self._resolve_watchlist_artist_source_id(watchlist_artist, source, client)
|
||||
if not artist_id:
|
||||
return None
|
||||
|
||||
albums = self._get_artist_discography_with_client(
|
||||
client,
|
||||
artist_id,
|
||||
last_scan_timestamp,
|
||||
lookback_days=watchlist_artist.lookback_days,
|
||||
)
|
||||
if not albums:
|
||||
return None
|
||||
|
||||
image_url = self._get_artist_image_for_source(watchlist_artist, source, client, artist_id)
|
||||
return WatchlistDiscographyResult(
|
||||
source=source,
|
||||
client=client,
|
||||
artist_id=artist_id,
|
||||
albums=albums,
|
||||
image_url=image_url,
|
||||
)
|
||||
|
||||
def _get_active_client_and_artist_id(self, watchlist_artist: WatchlistArtist):
|
||||
"""
|
||||
Get the appropriate client and artist ID based on active provider.
|
||||
|
|
@ -495,26 +710,21 @@ class WatchlistScanner:
|
|||
|
||||
def get_artist_image_url(self, watchlist_artist: WatchlistArtist) -> Optional[str]:
|
||||
"""
|
||||
Get artist image URL using the active provider.
|
||||
Get artist image URL using the configured source priority.
|
||||
|
||||
Returns:
|
||||
Image URL string or None if not available
|
||||
"""
|
||||
client, artist_id, provider = self._get_active_client_and_artist_id(watchlist_artist)
|
||||
if not client or not artist_id:
|
||||
return None
|
||||
|
||||
try:
|
||||
artist_data = client.get_artist(artist_id)
|
||||
if artist_data:
|
||||
# Handle both Spotify and iTunes response formats
|
||||
if 'images' in artist_data and artist_data['images']:
|
||||
return artist_data['images'][0].get('url')
|
||||
elif 'image_url' in artist_data:
|
||||
return artist_data['image_url']
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch artist image for {watchlist_artist.artist_name}: {e}")
|
||||
|
||||
for source in self._watchlist_source_priority():
|
||||
client = get_client_for_source(source)
|
||||
if not client:
|
||||
continue
|
||||
artist_id = self._resolve_watchlist_artist_source_id(watchlist_artist, source, client)
|
||||
if not artist_id:
|
||||
continue
|
||||
image_url = self._get_artist_image_for_source(watchlist_artist, source, client, artist_id)
|
||||
if image_url:
|
||||
return image_url
|
||||
return None
|
||||
|
||||
def backfill_watchlist_artist_images(self, profile_id: int) -> int:
|
||||
|
|
@ -598,57 +808,25 @@ class WatchlistScanner:
|
|||
logger.debug("Error backfilling watchlist artist images for profile %s: %s", profile_id, e, exc_info=True)
|
||||
return 0
|
||||
|
||||
def get_artist_discography_for_watchlist(self, watchlist_artist: WatchlistArtist, last_scan_timestamp: Optional[datetime] = None) -> Optional[List]:
|
||||
def get_artist_discography_for_watchlist(self, watchlist_artist: WatchlistArtist, last_scan_timestamp: Optional[datetime] = None) -> Optional[WatchlistDiscographyResult]:
|
||||
"""
|
||||
Get artist's discography using the active provider, with proper ID resolution.
|
||||
This is the provider-aware version of get_artist_discography.
|
||||
Get artist's discography using the configured source priority, with proper ID resolution.
|
||||
Returns the first provider that can actually return albums.
|
||||
|
||||
Args:
|
||||
watchlist_artist: WatchlistArtist object (has both spotify and itunes IDs)
|
||||
last_scan_timestamp: Only return releases after this date (for incremental scans)
|
||||
|
||||
Returns:
|
||||
List of albums or None on error
|
||||
WatchlistDiscographyResult or None on error
|
||||
"""
|
||||
client, artist_id, provider = self._get_active_client_and_artist_id(watchlist_artist)
|
||||
if not client or not artist_id:
|
||||
logger.warning(f"No valid client/ID for {watchlist_artist.artist_name}")
|
||||
return None
|
||||
for source in self._watchlist_source_priority():
|
||||
result = self._resolve_watchlist_discography_for_source(watchlist_artist, source, last_scan_timestamp)
|
||||
if result:
|
||||
return result
|
||||
|
||||
albums = self._get_artist_discography_with_client(client, artist_id, last_scan_timestamp, lookback_days=watchlist_artist.lookback_days)
|
||||
|
||||
# If primary provider returned nothing, try the other provider as fallback
|
||||
if not albums:
|
||||
fallback_id = None
|
||||
fallback_client = None
|
||||
|
||||
if provider == 'spotify':
|
||||
fallback_client = self.metadata_service.itunes
|
||||
fallback_id = watchlist_artist.itunes_artist_id
|
||||
# If no iTunes ID stored, search by name and cache it
|
||||
if not fallback_id:
|
||||
try:
|
||||
search_results = fallback_client.search_artists(watchlist_artist.artist_name, limit=1)
|
||||
if search_results:
|
||||
fallback_id = search_results[0].id
|
||||
logger.info(f"Resolved iTunes ID {fallback_id} for {watchlist_artist.artist_name}")
|
||||
self.database.update_watchlist_artist_itunes_id(
|
||||
watchlist_artist.spotify_artist_id or str(watchlist_artist.id),
|
||||
fallback_id
|
||||
)
|
||||
watchlist_artist.itunes_artist_id = fallback_id
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not resolve iTunes ID for {watchlist_artist.artist_name}: {e}")
|
||||
|
||||
elif provider == 'itunes':
|
||||
fallback_client = self.metadata_service.spotify
|
||||
fallback_id = watchlist_artist.spotify_artist_id
|
||||
|
||||
if fallback_client and fallback_id:
|
||||
logger.info(f"{provider.capitalize()} returned no albums for {watchlist_artist.artist_name}, falling back to {'iTunes' if provider == 'spotify' else 'Spotify'}")
|
||||
albums = self._get_artist_discography_with_client(fallback_client, fallback_id, last_scan_timestamp, lookback_days=watchlist_artist.lookback_days)
|
||||
|
||||
return albums
|
||||
logger.warning(f"No valid client/ID for {watchlist_artist.artist_name}")
|
||||
return None
|
||||
|
||||
def _apply_global_watchlist_overrides(self, watchlist_artists: List[WatchlistArtist]):
|
||||
"""Apply global watchlist release-type overrides to a batch of artists."""
|
||||
|
|
@ -796,15 +974,13 @@ class WatchlistScanner:
|
|||
_emit('scan_started', profile_id=profile_id, total_artists=len(watchlist_artists))
|
||||
|
||||
# Rate-limit-aware backfill of artist IDs for the providers we can safely resolve.
|
||||
providers_to_backfill = ['itunes', 'deezer']
|
||||
if self.spotify_client and self.spotify_client.is_spotify_authenticated():
|
||||
providers_to_backfill.append('spotify')
|
||||
try:
|
||||
from config.settings import config_manager as _cfg
|
||||
if _cfg.get('discogs.token', ''):
|
||||
providers_to_backfill.append('discogs')
|
||||
except Exception:
|
||||
pass
|
||||
providers_to_backfill = []
|
||||
for source in self._watchlist_source_priority():
|
||||
if source == 'spotify':
|
||||
if self._spotify_available_for_run():
|
||||
providers_to_backfill.append(source)
|
||||
elif source in {'itunes', 'deezer', 'discogs'} and get_client_for_source(source):
|
||||
providers_to_backfill.append(source)
|
||||
|
||||
for provider in providers_to_backfill:
|
||||
try:
|
||||
|
|
@ -855,8 +1031,46 @@ class WatchlistScanner:
|
|||
_emit('cancelled', processed=i, total=len(watchlist_artists))
|
||||
break
|
||||
|
||||
source_artist_id = (
|
||||
artist.spotify_artist_id
|
||||
or artist.itunes_artist_id
|
||||
or artist.deezer_artist_id
|
||||
or artist.discogs_artist_id
|
||||
or str(artist.id)
|
||||
)
|
||||
|
||||
try:
|
||||
artist_image_url = self.get_artist_image_url(artist) or ''
|
||||
discography_result = self.get_artist_discography_for_watchlist(artist, artist.last_scan_timestamp)
|
||||
if discography_result is None:
|
||||
scan_results.append(ScanResult(
|
||||
artist_name=artist.artist_name,
|
||||
spotify_artist_id=source_artist_id,
|
||||
albums_checked=0,
|
||||
new_tracks_found=0,
|
||||
tracks_added_to_wishlist=0,
|
||||
success=False,
|
||||
error_message="Failed to get artist discography",
|
||||
))
|
||||
_emit(
|
||||
'artist_error',
|
||||
artist_name=artist.artist_name,
|
||||
profile_id=profile_id,
|
||||
error_message="Failed to get artist discography",
|
||||
)
|
||||
continue
|
||||
|
||||
if isinstance(discography_result, list):
|
||||
albums = discography_result
|
||||
artist_image_url = self.get_artist_image_url(artist) or ''
|
||||
album_fetcher = lambda album_id, album_name='': self.metadata_service.get_album(album_id)
|
||||
else:
|
||||
source = discography_result.source
|
||||
client = discography_result.client
|
||||
albums = discography_result.albums
|
||||
source_artist_id = discography_result.artist_id
|
||||
artist_image_url = discography_result.image_url or self.get_artist_image_url(artist) or ''
|
||||
album_fetcher = lambda album_id, album_name='': self._get_album_data_for_source(source, client, album_id, album_name)
|
||||
|
||||
absolute_index = artist_index_offset + i + 1
|
||||
if scan_state is not None:
|
||||
scan_state.update({
|
||||
|
|
@ -880,25 +1094,6 @@ class WatchlistScanner:
|
|||
artist_image_url=artist_image_url,
|
||||
)
|
||||
|
||||
albums = self.get_artist_discography_for_watchlist(artist, artist.last_scan_timestamp)
|
||||
if albums is None:
|
||||
scan_results.append(ScanResult(
|
||||
artist_name=artist.artist_name,
|
||||
spotify_artist_id=artist.spotify_artist_id or '',
|
||||
albums_checked=0,
|
||||
new_tracks_found=0,
|
||||
tracks_added_to_wishlist=0,
|
||||
success=False,
|
||||
error_message="Failed to get artist discography",
|
||||
))
|
||||
_emit(
|
||||
'artist_error',
|
||||
artist_name=artist.artist_name,
|
||||
profile_id=profile_id,
|
||||
error_message="Failed to get artist discography",
|
||||
)
|
||||
continue
|
||||
|
||||
if scan_state is not None:
|
||||
scan_state.update({
|
||||
'current_phase': 'checking_albums',
|
||||
|
|
@ -911,26 +1106,39 @@ class WatchlistScanner:
|
|||
|
||||
for album_index, album in enumerate(albums):
|
||||
try:
|
||||
album_data = self.metadata_service.get_album(album.id)
|
||||
if not album_data or 'tracks' not in album_data:
|
||||
album_data = album_fetcher(album.id, getattr(album, 'name', ''))
|
||||
tracks = self._extract_track_items(album_data)
|
||||
if not album_data or not tracks:
|
||||
logger.debug("Skipping album %s (id=%s): no track data returned", album.name, album.id)
|
||||
continue
|
||||
|
||||
tracks = album_data['tracks']['items']
|
||||
album_name = getattr(album, 'name', '')
|
||||
if isinstance(album_data, dict):
|
||||
album_name = album_data.get('name', album_name)
|
||||
else:
|
||||
album_name = getattr(album_data, 'name', album_name)
|
||||
|
||||
if self._has_placeholder_tracks(tracks):
|
||||
logger.info("Skipping album with placeholder tracks: %s", album_data.get('name', album.name))
|
||||
logger.info("Skipping album with placeholder tracks: %s", album_name)
|
||||
continue
|
||||
if not self._should_include_release(len(tracks), artist):
|
||||
continue
|
||||
|
||||
album_image_url = ''
|
||||
if 'images' in album_data and album_data['images']:
|
||||
album_image_url = album_data['images'][0]['url']
|
||||
album_images = []
|
||||
if isinstance(album_data, dict):
|
||||
album_images = album_data.get('images') or []
|
||||
else:
|
||||
album_images = getattr(album_data, 'images', None) or []
|
||||
if album_images:
|
||||
first_image = album_images[0]
|
||||
if isinstance(first_image, dict):
|
||||
album_image_url = first_image.get('url', '')
|
||||
|
||||
if scan_state is not None:
|
||||
scan_state.update({
|
||||
'albums_checked': album_index + 1,
|
||||
'current_album': album.name,
|
||||
'current_album': album_name,
|
||||
'current_album_image_url': album_image_url,
|
||||
'current_phase': f'checking_album_{album_index + 1}_of_{len(albums)}',
|
||||
})
|
||||
|
|
@ -938,7 +1146,7 @@ class WatchlistScanner:
|
|||
_emit(
|
||||
'album_started',
|
||||
artist_name=artist.artist_name,
|
||||
album_name=album.name,
|
||||
album_name=album_name,
|
||||
album_index=album_index + 1,
|
||||
total_albums=len(albums),
|
||||
album_image_url=album_image_url,
|
||||
|
|
@ -952,7 +1160,7 @@ class WatchlistScanner:
|
|||
if scan_state is not None:
|
||||
scan_state['current_track_name'] = track_name
|
||||
|
||||
if self.is_track_missing_from_library(track, album_name=album_data.get('name')):
|
||||
if self.is_track_missing_from_library(track, album_name=album_name):
|
||||
artist_new_tracks += 1
|
||||
if scan_state is not None:
|
||||
scan_state['tracks_found_this_scan'] += 1
|
||||
|
|
@ -984,7 +1192,7 @@ class WatchlistScanner:
|
|||
|
||||
scan_results.append(ScanResult(
|
||||
artist_name=artist.artist_name,
|
||||
spotify_artist_id=artist.spotify_artist_id or '',
|
||||
spotify_artist_id=source_artist_id or artist.spotify_artist_id or '',
|
||||
albums_checked=len(albums),
|
||||
new_tracks_found=artist_new_tracks,
|
||||
tracks_added_to_wishlist=artist_added_tracks,
|
||||
|
|
@ -1005,7 +1213,6 @@ class WatchlistScanner:
|
|||
try:
|
||||
if scan_state is not None:
|
||||
scan_state['current_phase'] = 'fetching_similar_artists'
|
||||
source_artist_id = artist.spotify_artist_id or artist.itunes_artist_id or str(artist.id)
|
||||
artist_profile_id = getattr(artist, 'profile_id', profile_id)
|
||||
spotify_authenticated = self.spotify_client and self.spotify_client.is_spotify_authenticated()
|
||||
if self.database.has_fresh_similar_artists(source_artist_id, days_threshold=30, require_spotify=spotify_authenticated, profile_id=artist_profile_id):
|
||||
|
|
@ -1013,7 +1220,7 @@ class WatchlistScanner:
|
|||
self._backfill_similar_artists_itunes_ids(source_artist_id, profile_id=artist_profile_id)
|
||||
else:
|
||||
logger.info("Fetching similar artists for %s (profile %s)...", artist.artist_name, artist_profile_id)
|
||||
self.update_similar_artists(artist, profile_id=artist_profile_id)
|
||||
self.update_similar_artists(artist, profile_id=artist_profile_id, source_artist_id=source_artist_id)
|
||||
logger.info("Similar artists updated for %s", artist.artist_name)
|
||||
except Exception as similar_error:
|
||||
logger.warning("Failed to update similar artists for %s: %s", artist.artist_name, similar_error)
|
||||
|
|
@ -1027,7 +1234,7 @@ class WatchlistScanner:
|
|||
logger.error("Error scanning artist %s: %s", artist.artist_name, e)
|
||||
scan_results.append(ScanResult(
|
||||
artist_name=artist.artist_name,
|
||||
spotify_artist_id=artist.spotify_artist_id or '',
|
||||
spotify_artist_id=source_artist_id,
|
||||
albums_checked=0,
|
||||
new_tracks_found=0,
|
||||
tracks_added_to_wishlist=0,
|
||||
|
|
@ -2107,7 +2314,13 @@ class WatchlistScanner:
|
|||
logger.error(f"Error backfilling similar artists {fallback_source if 'fallback_source' in dir() else 'fallback'} IDs: {e}")
|
||||
return 0
|
||||
|
||||
def update_similar_artists(self, watchlist_artist: WatchlistArtist, limit: int = 10, profile_id: int = 1) -> bool:
|
||||
def update_similar_artists(
|
||||
self,
|
||||
watchlist_artist: WatchlistArtist,
|
||||
limit: int = 10,
|
||||
profile_id: int = 1,
|
||||
source_artist_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Fetch and store similar artists for a watchlist artist.
|
||||
Called after each artist scan to build discovery pool.
|
||||
|
|
@ -2125,8 +2338,15 @@ class WatchlistScanner:
|
|||
|
||||
logger.info(f"Found {len(similar_artists)} similar artists for {watchlist_artist.artist_name}")
|
||||
|
||||
# Use consistent source artist ID (prefer Spotify, fall back to iTunes or internal ID)
|
||||
source_artist_id = watchlist_artist.spotify_artist_id or watchlist_artist.itunes_artist_id or str(watchlist_artist.id)
|
||||
# Use the ID that matched the scan source when available; otherwise fall back to any known ID.
|
||||
source_artist_id = (
|
||||
source_artist_id
|
||||
or watchlist_artist.spotify_artist_id
|
||||
or watchlist_artist.itunes_artist_id
|
||||
or watchlist_artist.deezer_artist_id
|
||||
or watchlist_artist.discogs_artist_id
|
||||
or str(watchlist_artist.id)
|
||||
)
|
||||
|
||||
# Store each similar artist in database
|
||||
stored_count = 0
|
||||
|
|
|
|||
|
|
@ -22,6 +22,32 @@ if "spotipy" not in sys.modules:
|
|||
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
|
||||
|
||||
def get_active_media_server(self):
|
||||
return "plex"
|
||||
|
||||
settings_mod.config_manager = _DummyConfigManager()
|
||||
config_pkg.settings = settings_mod
|
||||
sys.modules["config"] = config_pkg
|
||||
sys.modules["config.settings"] = settings_mod
|
||||
|
||||
if "core.matching_engine" not in sys.modules:
|
||||
matching_engine_mod = types.ModuleType("core.matching_engine")
|
||||
|
||||
class _DummyMatchingEngine:
|
||||
def clean_title(self, title):
|
||||
return title
|
||||
|
||||
matching_engine_mod.MusicMatchingEngine = _DummyMatchingEngine
|
||||
sys.modules["core.matching_engine"] = matching_engine_mod
|
||||
|
||||
import core.watchlist_scanner as watchlist_scanner_module
|
||||
from core.watchlist_scanner import WatchlistScanner
|
||||
|
||||
|
|
@ -41,6 +67,31 @@ class _FakeMetadataService:
|
|||
return self._album_data
|
||||
|
||||
|
||||
class _FakeSourceClient:
|
||||
def __init__(self, *, artist_id: str, albums, image_url: str):
|
||||
self.artist_id = artist_id
|
||||
self.albums = list(albums)
|
||||
self.image_url = image_url
|
||||
self.search_calls = []
|
||||
self.album_calls = []
|
||||
self.artist_calls = []
|
||||
|
||||
def search_artists(self, query, limit=1):
|
||||
self.search_calls.append((query, limit))
|
||||
return [types.SimpleNamespace(id=self.artist_id, name=query)]
|
||||
|
||||
def get_artist_albums(self, artist_id, album_type='album,single', limit=50, **kwargs):
|
||||
self.album_calls.append((artist_id, album_type, limit, kwargs))
|
||||
return list(self.albums)
|
||||
|
||||
def get_artist(self, artist_id):
|
||||
self.artist_calls.append(artist_id)
|
||||
return {
|
||||
"id": artist_id,
|
||||
"images": [{"url": self.image_url}] if self.image_url else [],
|
||||
}
|
||||
|
||||
|
||||
class _FakeDB:
|
||||
def __init__(self, artists):
|
||||
self.artists = artists
|
||||
|
|
@ -255,3 +306,71 @@ def test_scan_watchlist_artists_honors_cancel_check(monkeypatch):
|
|||
assert scan_state["status"] == "cancelled"
|
||||
assert scan_state["summary"]["cancelled"] is True
|
||||
assert scan_state["summary"]["successful_scans"] == 1
|
||||
|
||||
|
||||
def test_get_artist_discography_for_watchlist_prefers_primary_source(monkeypatch):
|
||||
monkeypatch.setattr(watchlist_scanner_module, "time", types.SimpleNamespace(sleep=lambda *_args, **_kwargs: None))
|
||||
monkeypatch.setattr(watchlist_scanner_module, "get_primary_source", lambda: "deezer")
|
||||
monkeypatch.setattr(watchlist_scanner_module, "get_source_priority", lambda primary: [primary, "spotify", "itunes"])
|
||||
|
||||
deezer_album = types.SimpleNamespace(id="dz-album", name="Deezer Album", release_date=None)
|
||||
spotify_album = types.SimpleNamespace(id="sp-album", name="Spotify Album", release_date=None)
|
||||
|
||||
deezer_client = _FakeSourceClient(artist_id="dz-artist", albums=[deezer_album], image_url="https://example.com/deezer.jpg")
|
||||
spotify_client = _FakeSourceClient(artist_id="sp-artist", albums=[spotify_album], image_url="https://example.com/spotify.jpg")
|
||||
|
||||
def fake_get_client_for_source(source):
|
||||
return {"deezer": deezer_client, "spotify": spotify_client}.get(source)
|
||||
|
||||
monkeypatch.setattr(watchlist_scanner_module, "get_client_for_source", fake_get_client_for_source)
|
||||
|
||||
artist = _build_artist()
|
||||
artist.spotify_artist_id = "sp-artist"
|
||||
artist.deezer_artist_id = "dz-artist"
|
||||
|
||||
scanner = _build_scanner({"tracks": {"items": []}}, [artist])
|
||||
scanner._database.has_fresh_similar_artists = lambda *args, **kwargs: False
|
||||
scanner._get_lookback_period_setting = lambda: "30"
|
||||
scanner._get_rescan_cutoff = lambda: None
|
||||
|
||||
result = scanner.get_artist_discography_for_watchlist(artist, None)
|
||||
|
||||
assert result is not None
|
||||
assert result.source == "deezer"
|
||||
assert result.artist_id == "dz-artist"
|
||||
assert result.albums and result.albums[0].id == "dz-album"
|
||||
assert deezer_client.album_calls
|
||||
assert spotify_client.album_calls == []
|
||||
|
||||
|
||||
def test_get_artist_discography_for_watchlist_falls_back_when_primary_empty(monkeypatch):
|
||||
monkeypatch.setattr(watchlist_scanner_module, "time", types.SimpleNamespace(sleep=lambda *_args, **_kwargs: None))
|
||||
monkeypatch.setattr(watchlist_scanner_module, "get_primary_source", lambda: "deezer")
|
||||
monkeypatch.setattr(watchlist_scanner_module, "get_source_priority", lambda primary: [primary, "spotify", "itunes"])
|
||||
|
||||
deezer_client = _FakeSourceClient(artist_id="dz-artist", albums=[], image_url="https://example.com/deezer.jpg")
|
||||
spotify_album = types.SimpleNamespace(id="sp-album", name="Spotify Album", release_date=None)
|
||||
spotify_client = _FakeSourceClient(artist_id="sp-artist", albums=[spotify_album], image_url="https://example.com/spotify.jpg")
|
||||
|
||||
def fake_get_client_for_source(source):
|
||||
return {"deezer": deezer_client, "spotify": spotify_client}.get(source)
|
||||
|
||||
monkeypatch.setattr(watchlist_scanner_module, "get_client_for_source", fake_get_client_for_source)
|
||||
|
||||
artist = _build_artist()
|
||||
artist.spotify_artist_id = "sp-artist"
|
||||
artist.deezer_artist_id = "dz-artist"
|
||||
|
||||
scanner = _build_scanner({"tracks": {"items": []}}, [artist])
|
||||
scanner._database.has_fresh_similar_artists = lambda *args, **kwargs: False
|
||||
scanner._get_lookback_period_setting = lambda: "30"
|
||||
scanner._get_rescan_cutoff = lambda: None
|
||||
|
||||
result = scanner.get_artist_discography_for_watchlist(artist, None)
|
||||
|
||||
assert result is not None
|
||||
assert result.source == "spotify"
|
||||
assert result.artist_id == "sp-artist"
|
||||
assert result.albums and result.albums[0].id == "sp-album"
|
||||
assert deezer_client.album_calls
|
||||
assert spotify_client.album_calls
|
||||
|
|
|
|||
Loading…
Reference in a new issue