diff --git a/core/seasonal_discovery.py b/core/seasonal_discovery.py index ce6a8421..96821fa1 100644 --- a/core/seasonal_discovery.py +++ b/core/seasonal_discovery.py @@ -298,7 +298,7 @@ class SeasonalDiscoveryService: # Source 3: General search for seasonal content logger.info(f"Searching {source} for {season_key} albums...") - search_albums = self._search_spotify_seasonal_albums(season_key, limit=50) + search_albums = self._search_seasonal_albums(season_key, limit=50) for album in search_albums: if self._add_seasonal_album(season_key, album, source): albums_found += 1 @@ -381,11 +381,8 @@ class SeasonalDiscoveryService: return [] def _search_watchlist_seasonal_albums(self, season_key: str) -> List[Dict]: - """Search for seasonal albums from watchlist artists""" + """Search for seasonal albums from watchlist artists (Spotify + iTunes)""" try: - if not self.spotify_client or not self.spotify_client.is_authenticated(): - return [] - config = SEASONAL_CONFIG[season_key] keywords = config['keywords'] @@ -394,18 +391,29 @@ class SeasonalDiscoveryService: return [] seasonal_albums = [] + source = self._get_source() + use_spotify = self.spotify_client and self.spotify_client.is_authenticated() # IMPROVED: Sample 20 random watchlist artists (up from 10) for more variety sampled_artists = random.sample(watchlist_artists, min(20, len(watchlist_artists))) for artist in sampled_artists: try: - # Get artist's albums (including full albums, singles, and EPs) - albums = self.spotify_client.get_artist_albums( - artist.spotify_artist_id, - album_type='album,single,ep', - limit=50 - ) + albums = [] + if use_spotify and artist.spotify_artist_id: + albums = self.spotify_client.get_artist_albums( + artist.spotify_artist_id, + album_type='album,single,ep', + limit=50 + ) or [] + elif hasattr(artist, 'itunes_artist_id') and artist.itunes_artist_id: + from core.itunes_client import iTunesClient + itunes_client = iTunesClient() + albums = itunes_client.get_artist_albums( + artist.itunes_artist_id, + album_type='album,single,ep', + limit=50 + ) or [] # Filter albums by seasonal keywords in title for album in albums: @@ -418,7 +426,8 @@ class SeasonalDiscoveryService: 'artist_name': artist.artist_name, 'album_cover_url': album.image_url if hasattr(album, 'image_url') else None, 'release_date': album.release_date if hasattr(album, 'release_date') else None, - 'popularity': getattr(album, 'popularity', 50) + 'popularity': getattr(album, 'popularity', 50), + '_source': source }) import time @@ -434,24 +443,23 @@ class SeasonalDiscoveryService: logger.error(f"Error searching watchlist seasonal albums: {e}") return [] - def _search_spotify_seasonal_albums(self, season_key: str, limit: int = 50) -> List[Dict]: + def _search_seasonal_albums(self, season_key: str, limit: int = 50) -> List[Dict]: """ - Search Spotify for seasonal albums using keyword search. + Search for seasonal albums using keyword search (Spotify or iTunes). IMPROVED: Searches more broadly for full albums to get larger track pools. """ try: - if not self.spotify_client or not self.spotify_client.is_authenticated(): - return [] - config = SEASONAL_CONFIG[season_key] keywords = config['keywords'] + source = self._get_source() + use_spotify = self.spotify_client and self.spotify_client.is_authenticated() seasonal_albums = [] seen_album_ids = set() # IMPROVED: Search with top 5 keywords (up from 3) for more variety - search_keywords = keywords[:5] + search_keywords = list(keywords[:5]) # Add specific "album" searches to prioritize full albums over singles season_name = config['name'].lower() @@ -461,41 +469,71 @@ class SeasonalDiscoveryService: elif 'halloween' in season_name: search_keywords.append('halloween album') - for keyword in search_keywords: - try: - # IMPROVED: Get 20 albums per keyword (up from 10) - search_results = self.spotify_client.search_albums(keyword, limit=20) + if use_spotify: + for keyword in search_keywords: + try: + search_results = self.spotify_client.search_albums(keyword, limit=20) - for album in search_results: - if album.id in seen_album_ids: - continue + for album in search_results: + if album.id in seen_album_ids: + continue - seen_album_ids.add(album.id) + seen_album_ids.add(album.id) - seasonal_albums.append({ - 'spotify_album_id': album.id, - 'album_name': album.name, - 'artist_name': ', '.join(album.artists) if album.artists else 'Various Artists', - 'album_cover_url': album.image_url if hasattr(album, 'image_url') else None, - 'release_date': album.release_date if hasattr(album, 'release_date') else None, - 'popularity': getattr(album, 'popularity', 50) - }) + seasonal_albums.append({ + 'spotify_album_id': album.id, + 'album_name': album.name, + 'artist_name': ', '.join(album.artists) if album.artists else 'Various Artists', + 'album_cover_url': album.image_url if hasattr(album, 'image_url') else None, + 'release_date': album.release_date if hasattr(album, 'release_date') else None, + 'popularity': getattr(album, 'popularity', 50) + }) - import time - time.sleep(0.3) # Rate limiting + import time + time.sleep(0.3) # Rate limiting - except Exception as e: - logger.debug(f"Error searching Spotify for '{keyword}': {e}") - continue + except Exception as e: + logger.debug(f"Error searching Spotify for '{keyword}': {e}") + continue + else: + # iTunes fallback + from core.itunes_client import iTunesClient + itunes_client = iTunesClient() - logger.info(f"Found {len(seasonal_albums)} seasonal albums from Spotify search") + for keyword in search_keywords: + try: + search_results = itunes_client.search_albums(keyword, limit=20) + + for album in search_results: + if album.id in seen_album_ids: + continue + + seen_album_ids.add(album.id) + + seasonal_albums.append({ + 'spotify_album_id': album.id, # Column name is spotify_album_id but stores iTunes ID too + 'album_name': album.name, + 'artist_name': ', '.join(album.artists) if album.artists else 'Various Artists', + 'album_cover_url': album.image_url if hasattr(album, 'image_url') else None, + 'release_date': album.release_date if hasattr(album, 'release_date') else None, + 'popularity': 50 # iTunes has no popularity — default mid-range + }) + + import time + time.sleep(0.3) # Rate limiting + + except Exception as e: + logger.debug(f"Error searching iTunes for '{keyword}': {e}") + continue + + logger.info(f"Found {len(seasonal_albums)} seasonal albums from {source} search") # Return up to limit, prioritizing albums with higher popularity seasonal_albums.sort(key=lambda a: a.get('popularity', 0), reverse=True) return seasonal_albums[:limit] except Exception as e: - logger.error(f"Error searching Spotify seasonal albums: {e}") + logger.error(f"Error searching seasonal albums: {e}") return [] def _add_seasonal_album(self, season_key: str, album_data: Dict, source: str = 'spotify') -> bool: @@ -675,46 +713,56 @@ class SeasonalDiscoveryService: # Get tracks from seasonal albums (filtered by source) seasonal_albums = self.get_seasonal_albums(season_key, limit=50, source=source) + use_spotify = self.spotify_client and self.spotify_client.is_authenticated() + for album in seasonal_albums: try: - if not self.spotify_client or not self.spotify_client.is_authenticated(): - break + album_data = None + album_id = album['spotify_album_id'] - # Get album tracks - album_data = self.spotify_client.get_album(album['spotify_album_id']) - if album_data and 'tracks' in album_data: - for track in album_data['tracks'].get('items', []): - # Use track's actual artist, not album artist - track_artist = track['artists'][0]['name'] if track.get('artists') else album['artist_name'] + if use_spotify: + album_data = self.spotify_client.get_album(album_id) + else: + # iTunes fallback + from core.itunes_client import iTunesClient + itunes_client = iTunesClient() + album_data = itunes_client.get_album(album_id) - # Enhance track object with full album data (including total_tracks) - enhanced_track = { - **track, - 'album': { - 'id': album_data['id'], - 'name': album_data.get('name', 'Unknown Album'), - 'images': album_data.get('images', []), - 'release_date': album_data.get('release_date', ''), - 'album_type': album_data.get('album_type', 'album'), - 'total_tracks': album_data.get('total_tracks', 0) - } + if not album_data or 'tracks' not in album_data: + continue + + for track in album_data['tracks'].get('items', []): + # Use track's actual artist, not album artist + track_artist = track['artists'][0]['name'] if track.get('artists') else album['artist_name'] + + # Enhance track object with full album data (including total_tracks) + enhanced_track = { + **track, + 'album': { + 'id': album_data['id'], + 'name': album_data.get('name', 'Unknown Album'), + 'images': album_data.get('images', []), + 'release_date': album_data.get('release_date', ''), + 'album_type': album_data.get('album_type', 'album'), + 'total_tracks': album_data.get('total_tracks', 0) } + } - track_data = { - 'spotify_track_id': track['id'], - 'track_name': track['name'], - 'artist_name': track_artist, - 'album_name': album['album_name'], - 'popularity': album.get('popularity', 50), - 'album_cover_url': album.get('album_cover_url'), - 'duration_ms': track.get('duration_ms', 0), - 'track_data_json': enhanced_track # Add full track data with album info - } + track_data = { + 'spotify_track_id': track['id'], # Stores iTunes track ID when on iTunes source + 'track_name': track['name'], + 'artist_name': track_artist, + 'album_name': album['album_name'], + 'popularity': album.get('popularity', 50), + 'album_cover_url': album.get('album_cover_url'), + 'duration_ms': track.get('duration_ms', 0), + 'track_data_json': enhanced_track + } - all_tracks.append(track_data) + all_tracks.append(track_data) - # Also save track to seasonal_tracks table for later retrieval - self._add_seasonal_track(season_key, track_data, source) + # Also save track to seasonal_tracks table for later retrieval + self._add_seasonal_track(season_key, track_data, source) import time time.sleep(0.3) # Rate limiting diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index f189a0ad..b10ebc80 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -1630,7 +1630,7 @@ class WatchlistScanner: else: # itunes all_albums = itunes_client.get_artist_albums( artist_id, - album_type='album,single', + album_type='album,single,ep', limit=50 ) @@ -1695,9 +1695,8 @@ class WatchlistScanner: album_data = itunes_client.get_album(album.id) if not album_data: continue - # iTunes get_album doesn't include tracks inline, need separate call - tracks_data = itunes_client.get_album_tracks(album.id) - tracks = tracks_data.get('items', []) if tracks_data else [] + # get_album includes tracks by default (include_tracks=True) + tracks = album_data.get('tracks', {}).get('items', []) logger.debug(f" Album {album_idx}: {album_data.get('name', 'Unknown')} ({len(tracks)} tracks)") @@ -1732,13 +1731,39 @@ class WatchlistScanner: } # Build track data for discovery pool with source-specific IDs + # iTunes has no popularity data — synthesize from recency + occurrence + raw_popularity = album_data.get('popularity', 0) + if source == 'itunes' and raw_popularity == 0: + # Base 45, boost by recency and artist occurrence count + synth_pop = 45 + if is_new: + synth_pop += 25 # New releases get a big boost + else: + try: + release_str = album_data.get('release_date', '') + if release_str and len(release_str) >= 10: + rel_date = datetime.strptime(release_str[:10], "%Y-%m-%d") + age_days = (datetime.now() - rel_date).days + if age_days <= 90: + synth_pop += 15 + elif age_days <= 365: + synth_pop += 5 + except: + pass + # Artists that appear similar to multiple watchlist artists are likely more relevant + if similar_artist.occurrence_count >= 3: + synth_pop += 10 + elif similar_artist.occurrence_count >= 2: + synth_pop += 5 + raw_popularity = min(synth_pop, 100) + track_data = { 'track_name': track.get('name', 'Unknown Track'), 'artist_name': similar_artist.similar_artist_name, 'album_name': album_data.get('name', 'Unknown Album'), 'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None, 'duration_ms': track.get('duration_ms', 0), - 'popularity': album_data.get('popularity', 0), + 'popularity': raw_popularity, 'release_date': album_data.get('release_date', ''), 'is_new_release': is_new, 'track_data_json': enhanced_track, @@ -2204,7 +2229,7 @@ class WatchlistScanner: if itunes_id: try: albums = itunes_api_call_with_retry( - itunes_client.get_artist_albums, itunes_id, album_type='album,single', limit=20 + itunes_client.get_artist_albums, itunes_id, album_type='album,single,ep', limit=20 ) for album in albums or []: process_album(album, artist.artist_name, artist.spotify_artist_id, itunes_id, 'itunes') @@ -2252,7 +2277,7 @@ class WatchlistScanner: if itunes_id: try: albums = itunes_api_call_with_retry( - itunes_client.get_artist_albums, itunes_id, album_type='album,single', limit=20 + itunes_client.get_artist_albums, itunes_id, album_type='album,single,ep', limit=20 ) for album in albums or []: process_album(album, artist.similar_artist_name, artist.similar_artist_spotify_id, itunes_id, 'itunes') @@ -2368,7 +2393,10 @@ class WatchlistScanner: # Calculate track score recency_score = max(0, 100 - (days_old * 7)) - popularity_score = track.get('popularity', album_data.get('popularity', 50)) + popularity_score = track.get('popularity', album_data.get('popularity', 0)) + # iTunes has no popularity — use recency-based synthetic score + if source == 'itunes' and popularity_score == 0: + popularity_score = max(40, 70 - days_old) is_single = album.get('album_type', 'album') == 'single' single_bonus = 20 if is_single else 0 total_score = (recency_score * 0.5) + (popularity_score * 0.3) + single_bonus