Fix iTunes discovery playlists: synthetic popularity, EP inclusion, and seasonal iTunes support
This commit is contained in:
parent
7da7f3b112
commit
c9549e8a9a
2 changed files with 157 additions and 81 deletions
|
|
@ -298,7 +298,7 @@ class SeasonalDiscoveryService:
|
||||||
|
|
||||||
# Source 3: General search for seasonal content
|
# Source 3: General search for seasonal content
|
||||||
logger.info(f"Searching {source} for {season_key} albums...")
|
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:
|
for album in search_albums:
|
||||||
if self._add_seasonal_album(season_key, album, source):
|
if self._add_seasonal_album(season_key, album, source):
|
||||||
albums_found += 1
|
albums_found += 1
|
||||||
|
|
@ -381,11 +381,8 @@ class SeasonalDiscoveryService:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _search_watchlist_seasonal_albums(self, season_key: str) -> List[Dict]:
|
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:
|
try:
|
||||||
if not self.spotify_client or not self.spotify_client.is_authenticated():
|
|
||||||
return []
|
|
||||||
|
|
||||||
config = SEASONAL_CONFIG[season_key]
|
config = SEASONAL_CONFIG[season_key]
|
||||||
keywords = config['keywords']
|
keywords = config['keywords']
|
||||||
|
|
||||||
|
|
@ -394,18 +391,29 @@ class SeasonalDiscoveryService:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
seasonal_albums = []
|
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
|
# IMPROVED: Sample 20 random watchlist artists (up from 10) for more variety
|
||||||
sampled_artists = random.sample(watchlist_artists, min(20, len(watchlist_artists)))
|
sampled_artists = random.sample(watchlist_artists, min(20, len(watchlist_artists)))
|
||||||
|
|
||||||
for artist in sampled_artists:
|
for artist in sampled_artists:
|
||||||
try:
|
try:
|
||||||
# Get artist's albums (including full albums, singles, and EPs)
|
albums = []
|
||||||
albums = self.spotify_client.get_artist_albums(
|
if use_spotify and artist.spotify_artist_id:
|
||||||
artist.spotify_artist_id,
|
albums = self.spotify_client.get_artist_albums(
|
||||||
album_type='album,single,ep',
|
artist.spotify_artist_id,
|
||||||
limit=50
|
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
|
# Filter albums by seasonal keywords in title
|
||||||
for album in albums:
|
for album in albums:
|
||||||
|
|
@ -418,7 +426,8 @@ class SeasonalDiscoveryService:
|
||||||
'artist_name': artist.artist_name,
|
'artist_name': artist.artist_name,
|
||||||
'album_cover_url': album.image_url if hasattr(album, 'image_url') else None,
|
'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,
|
'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
|
import time
|
||||||
|
|
@ -434,24 +443,23 @@ class SeasonalDiscoveryService:
|
||||||
logger.error(f"Error searching watchlist seasonal albums: {e}")
|
logger.error(f"Error searching watchlist seasonal albums: {e}")
|
||||||
return []
|
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.
|
IMPROVED: Searches more broadly for full albums to get larger track pools.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not self.spotify_client or not self.spotify_client.is_authenticated():
|
|
||||||
return []
|
|
||||||
|
|
||||||
config = SEASONAL_CONFIG[season_key]
|
config = SEASONAL_CONFIG[season_key]
|
||||||
keywords = config['keywords']
|
keywords = config['keywords']
|
||||||
|
source = self._get_source()
|
||||||
|
use_spotify = self.spotify_client and self.spotify_client.is_authenticated()
|
||||||
|
|
||||||
seasonal_albums = []
|
seasonal_albums = []
|
||||||
seen_album_ids = set()
|
seen_album_ids = set()
|
||||||
|
|
||||||
# IMPROVED: Search with top 5 keywords (up from 3) for more variety
|
# 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
|
# Add specific "album" searches to prioritize full albums over singles
|
||||||
season_name = config['name'].lower()
|
season_name = config['name'].lower()
|
||||||
|
|
@ -461,41 +469,71 @@ class SeasonalDiscoveryService:
|
||||||
elif 'halloween' in season_name:
|
elif 'halloween' in season_name:
|
||||||
search_keywords.append('halloween album')
|
search_keywords.append('halloween album')
|
||||||
|
|
||||||
for keyword in search_keywords:
|
if use_spotify:
|
||||||
try:
|
for keyword in search_keywords:
|
||||||
# IMPROVED: Get 20 albums per keyword (up from 10)
|
try:
|
||||||
search_results = self.spotify_client.search_albums(keyword, limit=20)
|
search_results = self.spotify_client.search_albums(keyword, limit=20)
|
||||||
|
|
||||||
for album in search_results:
|
for album in search_results:
|
||||||
if album.id in seen_album_ids:
|
if album.id in seen_album_ids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
seen_album_ids.add(album.id)
|
seen_album_ids.add(album.id)
|
||||||
|
|
||||||
seasonal_albums.append({
|
seasonal_albums.append({
|
||||||
'spotify_album_id': album.id,
|
'spotify_album_id': album.id,
|
||||||
'album_name': album.name,
|
'album_name': album.name,
|
||||||
'artist_name': ', '.join(album.artists) if album.artists else 'Various Artists',
|
'artist_name': ', '.join(album.artists) if album.artists else 'Various Artists',
|
||||||
'album_cover_url': album.image_url if hasattr(album, 'image_url') else None,
|
'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,
|
'release_date': album.release_date if hasattr(album, 'release_date') else None,
|
||||||
'popularity': getattr(album, 'popularity', 50)
|
'popularity': getattr(album, 'popularity', 50)
|
||||||
})
|
})
|
||||||
|
|
||||||
import time
|
import time
|
||||||
time.sleep(0.3) # Rate limiting
|
time.sleep(0.3) # Rate limiting
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Error searching Spotify for '{keyword}': {e}")
|
logger.debug(f"Error searching Spotify for '{keyword}': {e}")
|
||||||
continue
|
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
|
# Return up to limit, prioritizing albums with higher popularity
|
||||||
seasonal_albums.sort(key=lambda a: a.get('popularity', 0), reverse=True)
|
seasonal_albums.sort(key=lambda a: a.get('popularity', 0), reverse=True)
|
||||||
return seasonal_albums[:limit]
|
return seasonal_albums[:limit]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error searching Spotify seasonal albums: {e}")
|
logger.error(f"Error searching seasonal albums: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _add_seasonal_album(self, season_key: str, album_data: Dict, source: str = 'spotify') -> bool:
|
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)
|
# Get tracks from seasonal albums (filtered by source)
|
||||||
seasonal_albums = self.get_seasonal_albums(season_key, limit=50, source=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:
|
for album in seasonal_albums:
|
||||||
try:
|
try:
|
||||||
if not self.spotify_client or not self.spotify_client.is_authenticated():
|
album_data = None
|
||||||
break
|
album_id = album['spotify_album_id']
|
||||||
|
|
||||||
# Get album tracks
|
if use_spotify:
|
||||||
album_data = self.spotify_client.get_album(album['spotify_album_id'])
|
album_data = self.spotify_client.get_album(album_id)
|
||||||
if album_data and 'tracks' in album_data:
|
else:
|
||||||
for track in album_data['tracks'].get('items', []):
|
# iTunes fallback
|
||||||
# Use track's actual artist, not album artist
|
from core.itunes_client import iTunesClient
|
||||||
track_artist = track['artists'][0]['name'] if track.get('artists') else album['artist_name']
|
itunes_client = iTunesClient()
|
||||||
|
album_data = itunes_client.get_album(album_id)
|
||||||
|
|
||||||
# Enhance track object with full album data (including total_tracks)
|
if not album_data or 'tracks' not in album_data:
|
||||||
enhanced_track = {
|
continue
|
||||||
**track,
|
|
||||||
'album': {
|
for track in album_data['tracks'].get('items', []):
|
||||||
'id': album_data['id'],
|
# Use track's actual artist, not album artist
|
||||||
'name': album_data.get('name', 'Unknown Album'),
|
track_artist = track['artists'][0]['name'] if track.get('artists') else album['artist_name']
|
||||||
'images': album_data.get('images', []),
|
|
||||||
'release_date': album_data.get('release_date', ''),
|
# Enhance track object with full album data (including total_tracks)
|
||||||
'album_type': album_data.get('album_type', 'album'),
|
enhanced_track = {
|
||||||
'total_tracks': album_data.get('total_tracks', 0)
|
**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 = {
|
track_data = {
|
||||||
'spotify_track_id': track['id'],
|
'spotify_track_id': track['id'], # Stores iTunes track ID when on iTunes source
|
||||||
'track_name': track['name'],
|
'track_name': track['name'],
|
||||||
'artist_name': track_artist,
|
'artist_name': track_artist,
|
||||||
'album_name': album['album_name'],
|
'album_name': album['album_name'],
|
||||||
'popularity': album.get('popularity', 50),
|
'popularity': album.get('popularity', 50),
|
||||||
'album_cover_url': album.get('album_cover_url'),
|
'album_cover_url': album.get('album_cover_url'),
|
||||||
'duration_ms': track.get('duration_ms', 0),
|
'duration_ms': track.get('duration_ms', 0),
|
||||||
'track_data_json': enhanced_track # Add full track data with album info
|
'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
|
# Also save track to seasonal_tracks table for later retrieval
|
||||||
self._add_seasonal_track(season_key, track_data, source)
|
self._add_seasonal_track(season_key, track_data, source)
|
||||||
|
|
||||||
import time
|
import time
|
||||||
time.sleep(0.3) # Rate limiting
|
time.sleep(0.3) # Rate limiting
|
||||||
|
|
|
||||||
|
|
@ -1630,7 +1630,7 @@ class WatchlistScanner:
|
||||||
else: # itunes
|
else: # itunes
|
||||||
all_albums = itunes_client.get_artist_albums(
|
all_albums = itunes_client.get_artist_albums(
|
||||||
artist_id,
|
artist_id,
|
||||||
album_type='album,single',
|
album_type='album,single,ep',
|
||||||
limit=50
|
limit=50
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1695,9 +1695,8 @@ class WatchlistScanner:
|
||||||
album_data = itunes_client.get_album(album.id)
|
album_data = itunes_client.get_album(album.id)
|
||||||
if not album_data:
|
if not album_data:
|
||||||
continue
|
continue
|
||||||
# iTunes get_album doesn't include tracks inline, need separate call
|
# get_album includes tracks by default (include_tracks=True)
|
||||||
tracks_data = itunes_client.get_album_tracks(album.id)
|
tracks = album_data.get('tracks', {}).get('items', [])
|
||||||
tracks = tracks_data.get('items', []) if tracks_data else []
|
|
||||||
|
|
||||||
logger.debug(f" Album {album_idx}: {album_data.get('name', 'Unknown')} ({len(tracks)} tracks)")
|
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
|
# 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_data = {
|
||||||
'track_name': track.get('name', 'Unknown Track'),
|
'track_name': track.get('name', 'Unknown Track'),
|
||||||
'artist_name': similar_artist.similar_artist_name,
|
'artist_name': similar_artist.similar_artist_name,
|
||||||
'album_name': album_data.get('name', 'Unknown Album'),
|
'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,
|
'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None,
|
||||||
'duration_ms': track.get('duration_ms', 0),
|
'duration_ms': track.get('duration_ms', 0),
|
||||||
'popularity': album_data.get('popularity', 0),
|
'popularity': raw_popularity,
|
||||||
'release_date': album_data.get('release_date', ''),
|
'release_date': album_data.get('release_date', ''),
|
||||||
'is_new_release': is_new,
|
'is_new_release': is_new,
|
||||||
'track_data_json': enhanced_track,
|
'track_data_json': enhanced_track,
|
||||||
|
|
@ -2204,7 +2229,7 @@ class WatchlistScanner:
|
||||||
if itunes_id:
|
if itunes_id:
|
||||||
try:
|
try:
|
||||||
albums = itunes_api_call_with_retry(
|
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 []:
|
for album in albums or []:
|
||||||
process_album(album, artist.artist_name, artist.spotify_artist_id, itunes_id, 'itunes')
|
process_album(album, artist.artist_name, artist.spotify_artist_id, itunes_id, 'itunes')
|
||||||
|
|
@ -2252,7 +2277,7 @@ class WatchlistScanner:
|
||||||
if itunes_id:
|
if itunes_id:
|
||||||
try:
|
try:
|
||||||
albums = itunes_api_call_with_retry(
|
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 []:
|
for album in albums or []:
|
||||||
process_album(album, artist.similar_artist_name, artist.similar_artist_spotify_id, itunes_id, 'itunes')
|
process_album(album, artist.similar_artist_name, artist.similar_artist_spotify_id, itunes_id, 'itunes')
|
||||||
|
|
@ -2368,7 +2393,10 @@ class WatchlistScanner:
|
||||||
|
|
||||||
# Calculate track score
|
# Calculate track score
|
||||||
recency_score = max(0, 100 - (days_old * 7))
|
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'
|
is_single = album.get('album_type', 'album') == 'single'
|
||||||
single_bonus = 20 if is_single else 0
|
single_bonus = 20 if is_single else 0
|
||||||
total_score = (recency_score * 0.5) + (popularity_score * 0.3) + single_bonus
|
total_score = (recency_score * 0.5) + (popularity_score * 0.3) + single_bonus
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue