discover page updates
This commit is contained in:
parent
b16318f37e
commit
bd4e0d131e
5 changed files with 1574 additions and 61 deletions
765
core/seasonal_discovery.py
Normal file
765
core/seasonal_discovery.py
Normal file
|
|
@ -0,0 +1,765 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
"""
|
||||
Seasonal Discovery Service - Provides seasonal/holiday music content
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
import random
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("seasonal_discovery")
|
||||
|
||||
# Seasonal configuration with keywords and active periods
|
||||
SEASONAL_CONFIG = {
|
||||
"halloween": {
|
||||
"name": "Halloween Hits",
|
||||
"description": "Spooky albums and tracks for Halloween",
|
||||
"keywords": ["halloween", "spooky", "horror", "monster", "witch", "zombie", "ghost", "haunted", "scary"],
|
||||
"active_months": [10], # October
|
||||
"playlist_size": 50,
|
||||
"icon": "🎃"
|
||||
},
|
||||
"christmas": {
|
||||
"name": "Christmas Classics",
|
||||
"description": "Holiday music and Christmas favorites",
|
||||
"keywords": ["christmas", "xmas", "holiday", "santa", "jingle", "winter wonderland", "sleigh", "noel", "carol"],
|
||||
"active_months": [11, 12], # November-December
|
||||
"playlist_size": 50,
|
||||
"icon": "🎄"
|
||||
},
|
||||
"valentines": {
|
||||
"name": "Love Songs",
|
||||
"description": "Romantic tracks for Valentine's Day",
|
||||
"keywords": ["love", "valentine", "romance", "heart", "romantic", "darling"],
|
||||
"active_months": [2], # February
|
||||
"playlist_size": 50,
|
||||
"icon": "❤️"
|
||||
},
|
||||
"summer": {
|
||||
"name": "Summer Vibes",
|
||||
"description": "Hot tracks for summer days",
|
||||
"keywords": ["summer", "beach", "sun", "vacation", "tropical", "poolside", "sunshine"],
|
||||
"active_months": [6, 7, 8], # June-August
|
||||
"playlist_size": 50,
|
||||
"icon": "☀️"
|
||||
},
|
||||
"spring": {
|
||||
"name": "Spring Awakening",
|
||||
"description": "Fresh sounds for spring",
|
||||
"keywords": ["spring", "bloom", "fresh", "renewal", "garden", "flower"],
|
||||
"active_months": [3, 4, 5], # March-May
|
||||
"playlist_size": 50,
|
||||
"icon": "🌸"
|
||||
},
|
||||
"autumn": {
|
||||
"name": "Autumn Sounds",
|
||||
"description": "Cozy tracks for fall",
|
||||
"keywords": ["fall", "autumn", "harvest", "leaves", "cozy", "pumpkin"],
|
||||
"active_months": [9, 10, 11], # September-November (overlaps with Halloween)
|
||||
"playlist_size": 50,
|
||||
"icon": "🍂"
|
||||
}
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class SeasonalAlbum:
|
||||
"""Represents a seasonal album"""
|
||||
spotify_id: str
|
||||
title: str
|
||||
artist_name: str
|
||||
cover_url: Optional[str]
|
||||
release_date: Optional[str]
|
||||
popularity: int
|
||||
season_key: str
|
||||
|
||||
@dataclass
|
||||
class SeasonalTrack:
|
||||
"""Represents a seasonal track"""
|
||||
spotify_id: str
|
||||
title: str
|
||||
artist_name: str
|
||||
album_name: str
|
||||
album_cover_url: Optional[str]
|
||||
duration_ms: int
|
||||
popularity: int
|
||||
season_key: str
|
||||
|
||||
class SeasonalDiscoveryService:
|
||||
"""Service for managing seasonal music discovery"""
|
||||
|
||||
def __init__(self, spotify_client, database):
|
||||
self.spotify_client = spotify_client
|
||||
self.database = database
|
||||
self._ensure_database_schema()
|
||||
|
||||
def _ensure_database_schema(self):
|
||||
"""Create seasonal content tables if they don't exist"""
|
||||
try:
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Seasonal albums cache
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS seasonal_albums (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
season_key TEXT NOT NULL,
|
||||
spotify_album_id TEXT NOT NULL,
|
||||
album_name TEXT,
|
||||
artist_name TEXT,
|
||||
album_cover_url TEXT,
|
||||
release_date TEXT,
|
||||
popularity INTEGER DEFAULT 0,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(season_key, spotify_album_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Seasonal tracks cache
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS seasonal_tracks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
season_key TEXT NOT NULL,
|
||||
spotify_track_id TEXT NOT NULL,
|
||||
track_name TEXT,
|
||||
artist_name TEXT,
|
||||
album_name TEXT,
|
||||
album_cover_url TEXT,
|
||||
duration_ms INTEGER,
|
||||
popularity INTEGER DEFAULT 0,
|
||||
track_data_json TEXT,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(season_key, spotify_track_id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Curated seasonal playlists
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS curated_seasonal_playlists (
|
||||
season_key TEXT PRIMARY KEY,
|
||||
track_ids TEXT,
|
||||
curated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
track_count INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
# Metadata about last seasonal update
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS seasonal_metadata (
|
||||
season_key TEXT PRIMARY KEY,
|
||||
last_populated_at TIMESTAMP,
|
||||
album_count INTEGER DEFAULT 0,
|
||||
track_count INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
logger.info("Seasonal discovery database schema initialized")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating seasonal database schema: {e}")
|
||||
|
||||
def get_current_season(self) -> Optional[str]:
|
||||
"""
|
||||
Detect current season based on current month.
|
||||
|
||||
Returns:
|
||||
Season key (e.g., 'halloween', 'christmas') or None
|
||||
"""
|
||||
current_month = datetime.now().month
|
||||
|
||||
# Check each season to find active ones
|
||||
active_seasons = []
|
||||
for season_key, config in SEASONAL_CONFIG.items():
|
||||
if current_month in config['active_months']:
|
||||
active_seasons.append(season_key)
|
||||
|
||||
if not active_seasons:
|
||||
return None
|
||||
|
||||
# Prioritize specific holidays over general seasons
|
||||
# Halloween > Autumn, Christmas > Winter, etc.
|
||||
priority_order = ['halloween', 'christmas', 'valentines', 'summer', 'spring', 'autumn']
|
||||
|
||||
for priority_season in priority_order:
|
||||
if priority_season in active_seasons:
|
||||
return priority_season
|
||||
|
||||
return active_seasons[0] if active_seasons else None
|
||||
|
||||
def get_all_active_seasons(self) -> List[str]:
|
||||
"""Get all seasons active in current month (for displaying multiple sections)"""
|
||||
current_month = datetime.now().month
|
||||
|
||||
active_seasons = []
|
||||
for season_key, config in SEASONAL_CONFIG.items():
|
||||
if current_month in config['active_months']:
|
||||
active_seasons.append(season_key)
|
||||
|
||||
return active_seasons
|
||||
|
||||
def should_populate_seasonal_content(self, season_key: str, days_threshold: int = 7) -> bool:
|
||||
"""
|
||||
Check if seasonal content should be re-populated.
|
||||
|
||||
Args:
|
||||
season_key: Season to check
|
||||
days_threshold: Minimum days since last population (default: 7)
|
||||
|
||||
Returns:
|
||||
True if should populate, False otherwise
|
||||
"""
|
||||
try:
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT last_populated_at
|
||||
FROM seasonal_metadata
|
||||
WHERE season_key = ?
|
||||
""", (season_key,))
|
||||
|
||||
result = cursor.fetchone()
|
||||
|
||||
if not result or not result['last_populated_at']:
|
||||
return True # Never populated
|
||||
|
||||
last_populated = datetime.fromisoformat(result['last_populated_at'])
|
||||
days_since = (datetime.now() - last_populated).days
|
||||
|
||||
return days_since >= days_threshold
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking seasonal population status: {e}")
|
||||
return True # Populate if we can't determine
|
||||
|
||||
def populate_seasonal_content(self, season_key: str):
|
||||
"""
|
||||
Populate seasonal content from multiple sources:
|
||||
1. Discovery pool keyword search
|
||||
2. Spotify search from watchlist/similar artists
|
||||
3. General Spotify seasonal search
|
||||
|
||||
Args:
|
||||
season_key: Season to populate (e.g., 'halloween', 'christmas')
|
||||
"""
|
||||
try:
|
||||
if season_key not in SEASONAL_CONFIG:
|
||||
logger.error(f"Unknown season key: {season_key}")
|
||||
return
|
||||
|
||||
config = SEASONAL_CONFIG[season_key]
|
||||
logger.info(f"Populating seasonal content for: {config['name']}")
|
||||
|
||||
# Clear existing seasonal content for this season
|
||||
self._clear_seasonal_content(season_key)
|
||||
|
||||
albums_found = 0
|
||||
tracks_found = 0
|
||||
|
||||
# Source 1: Search discovery pool for seasonal tracks
|
||||
logger.info(f"Searching discovery pool for {season_key} tracks...")
|
||||
pool_tracks = self._search_discovery_pool_seasonal(season_key)
|
||||
for track in pool_tracks:
|
||||
if self._add_seasonal_track(season_key, track):
|
||||
tracks_found += 1
|
||||
|
||||
logger.info(f"Found {len(pool_tracks)} tracks from discovery pool")
|
||||
|
||||
# Source 2: Search Spotify for seasonal albums from watchlist artists
|
||||
logger.info(f"Searching Spotify for {season_key} albums from watchlist artists...")
|
||||
watchlist_albums = self._search_watchlist_seasonal_albums(season_key)
|
||||
for album in watchlist_albums:
|
||||
if self._add_seasonal_album(season_key, album):
|
||||
albums_found += 1
|
||||
|
||||
logger.info(f"Found {len(watchlist_albums)} albums from watchlist artists")
|
||||
|
||||
# Source 3: General Spotify search for seasonal content
|
||||
logger.info(f"Searching Spotify for {season_key} albums...")
|
||||
spotify_albums = self._search_spotify_seasonal_albums(season_key, limit=20)
|
||||
for album in spotify_albums:
|
||||
if self._add_seasonal_album(season_key, album):
|
||||
albums_found += 1
|
||||
|
||||
logger.info(f"Found {len(spotify_albums)} albums from general Spotify search")
|
||||
|
||||
# Update metadata
|
||||
self._update_seasonal_metadata(season_key, albums_found, tracks_found)
|
||||
|
||||
logger.info(f"Seasonal content populated for {config['name']}: {albums_found} albums, {tracks_found} tracks")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error populating seasonal content for {season_key}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def _search_discovery_pool_seasonal(self, season_key: str) -> List[Dict]:
|
||||
"""Search discovery pool for tracks matching seasonal keywords"""
|
||||
try:
|
||||
config = SEASONAL_CONFIG[season_key]
|
||||
keywords = config['keywords']
|
||||
|
||||
seasonal_tracks = []
|
||||
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Build keyword search query
|
||||
keyword_conditions = " OR ".join([f"LOWER(track_name) LIKE ?" for _ in keywords])
|
||||
keyword_conditions += " OR " + " OR ".join([f"LOWER(album_name) LIKE ?" for _ in keywords])
|
||||
|
||||
keyword_params = [f"%{kw}%" for kw in keywords] + [f"%{kw}%" for kw in keywords]
|
||||
|
||||
cursor.execute(f"""
|
||||
SELECT DISTINCT
|
||||
spotify_track_id,
|
||||
track_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
album_cover_url,
|
||||
duration_ms,
|
||||
popularity,
|
||||
track_data_json
|
||||
FROM discovery_pool
|
||||
WHERE {keyword_conditions}
|
||||
LIMIT 100
|
||||
""", keyword_params)
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
for row in rows:
|
||||
seasonal_tracks.append({
|
||||
'spotify_track_id': row['spotify_track_id'],
|
||||
'track_name': row['track_name'],
|
||||
'artist_name': row['artist_name'],
|
||||
'album_name': row['album_name'],
|
||||
'album_cover_url': row['album_cover_url'],
|
||||
'duration_ms': row['duration_ms'],
|
||||
'popularity': row['popularity'],
|
||||
'track_data_json': row['track_data_json']
|
||||
})
|
||||
|
||||
return seasonal_tracks
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching discovery pool for seasonal tracks: {e}")
|
||||
return []
|
||||
|
||||
def _search_watchlist_seasonal_albums(self, season_key: str) -> List[Dict]:
|
||||
"""Search for seasonal albums from watchlist artists"""
|
||||
try:
|
||||
if not self.spotify_client or not self.spotify_client.is_authenticated():
|
||||
return []
|
||||
|
||||
config = SEASONAL_CONFIG[season_key]
|
||||
keywords = config['keywords']
|
||||
|
||||
watchlist_artists = self.database.get_watchlist_artists()
|
||||
if not watchlist_artists:
|
||||
return []
|
||||
|
||||
seasonal_albums = []
|
||||
|
||||
# Sample 10 random watchlist artists to avoid API abuse
|
||||
sampled_artists = random.sample(watchlist_artists, min(10, len(watchlist_artists)))
|
||||
|
||||
for artist in sampled_artists:
|
||||
try:
|
||||
# Get artist's albums
|
||||
albums = self.spotify_client.get_artist_albums(
|
||||
artist.spotify_artist_id,
|
||||
album_type='album,single',
|
||||
limit=50
|
||||
)
|
||||
|
||||
# Filter albums by seasonal keywords in title
|
||||
for album in albums:
|
||||
album_name_lower = album.name.lower()
|
||||
|
||||
if any(keyword in album_name_lower for keyword in keywords):
|
||||
seasonal_albums.append({
|
||||
'spotify_album_id': album.id,
|
||||
'album_name': album.name,
|
||||
'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)
|
||||
})
|
||||
|
||||
import time
|
||||
time.sleep(0.5) # Rate limiting
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error searching albums for {artist.artist_name}: {e}")
|
||||
continue
|
||||
|
||||
return seasonal_albums
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching watchlist seasonal albums: {e}")
|
||||
return []
|
||||
|
||||
def _search_spotify_seasonal_albums(self, season_key: str, limit: int = 20) -> List[Dict]:
|
||||
"""Search Spotify for seasonal albums using keyword search"""
|
||||
try:
|
||||
if not self.spotify_client or not self.spotify_client.is_authenticated():
|
||||
return []
|
||||
|
||||
config = SEASONAL_CONFIG[season_key]
|
||||
keywords = config['keywords']
|
||||
|
||||
seasonal_albums = []
|
||||
seen_album_ids = set()
|
||||
|
||||
# Search with top 3 keywords
|
||||
for keyword in keywords[:3]:
|
||||
try:
|
||||
# Search for albums
|
||||
search_results = self.spotify_client.search_albums(keyword, limit=10)
|
||||
|
||||
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,
|
||||
'album_name': album.name,
|
||||
'artist_name': album.artist if hasattr(album, 'artist') 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
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error searching Spotify for '{keyword}': {e}")
|
||||
continue
|
||||
|
||||
# Limit total albums
|
||||
return seasonal_albums[:limit]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching Spotify seasonal albums: {e}")
|
||||
return []
|
||||
|
||||
def _add_seasonal_album(self, season_key: str, album_data: Dict) -> bool:
|
||||
"""Add a seasonal album to the database"""
|
||||
try:
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR IGNORE INTO seasonal_albums (
|
||||
season_key, spotify_album_id, album_name, artist_name,
|
||||
album_cover_url, release_date, popularity
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
season_key,
|
||||
album_data['spotify_album_id'],
|
||||
album_data['album_name'],
|
||||
album_data['artist_name'],
|
||||
album_data.get('album_cover_url'),
|
||||
album_data.get('release_date'),
|
||||
album_data.get('popularity', 50)
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding seasonal album: {e}")
|
||||
return False
|
||||
|
||||
def _add_seasonal_track(self, season_key: str, track_data: Dict) -> bool:
|
||||
"""Add a seasonal track to the database"""
|
||||
try:
|
||||
import json
|
||||
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR IGNORE INTO seasonal_tracks (
|
||||
season_key, spotify_track_id, track_name, artist_name,
|
||||
album_name, album_cover_url, duration_ms, popularity, track_data_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
season_key,
|
||||
track_data['spotify_track_id'],
|
||||
track_data['track_name'],
|
||||
track_data['artist_name'],
|
||||
track_data['album_name'],
|
||||
track_data.get('album_cover_url'),
|
||||
track_data.get('duration_ms', 0),
|
||||
track_data.get('popularity', 50),
|
||||
json.dumps(track_data.get('track_data_json', {}))
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding seasonal track: {e}")
|
||||
return False
|
||||
|
||||
def _clear_seasonal_content(self, season_key: str):
|
||||
"""Clear existing seasonal content for a season"""
|
||||
try:
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM seasonal_albums WHERE season_key = ?", (season_key,))
|
||||
cursor.execute("DELETE FROM seasonal_tracks WHERE season_key = ?", (season_key,))
|
||||
|
||||
conn.commit()
|
||||
logger.debug(f"Cleared existing seasonal content for {season_key}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing seasonal content: {e}")
|
||||
|
||||
def _update_seasonal_metadata(self, season_key: str, album_count: int, track_count: int):
|
||||
"""Update metadata about seasonal content population"""
|
||||
try:
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO seasonal_metadata (
|
||||
season_key, last_populated_at, album_count, track_count
|
||||
) VALUES (?, CURRENT_TIMESTAMP, ?, ?)
|
||||
""", (season_key, album_count, track_count))
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating seasonal metadata: {e}")
|
||||
|
||||
def get_seasonal_albums(self, season_key: str, limit: int = 20) -> List[Dict]:
|
||||
"""Get cached seasonal albums for a season"""
|
||||
try:
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
spotify_album_id,
|
||||
album_name,
|
||||
artist_name,
|
||||
album_cover_url,
|
||||
release_date,
|
||||
popularity
|
||||
FROM seasonal_albums
|
||||
WHERE season_key = ?
|
||||
ORDER BY popularity DESC, album_name ASC
|
||||
LIMIT ?
|
||||
""", (season_key, limit))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting seasonal albums: {e}")
|
||||
return []
|
||||
|
||||
def curate_seasonal_playlist(self, season_key: str):
|
||||
"""
|
||||
Curate a seasonal playlist using Spotify-quality algorithm.
|
||||
|
||||
Strategy:
|
||||
- Pulls tracks from seasonal albums
|
||||
- Balances by artist (max 3 per artist)
|
||||
- Mixes popular + mid-tier + deep cuts (60/30/10 split)
|
||||
- Saves curated playlist to database
|
||||
"""
|
||||
try:
|
||||
if season_key not in SEASONAL_CONFIG:
|
||||
logger.error(f"Unknown season key: {season_key}")
|
||||
return
|
||||
|
||||
config = SEASONAL_CONFIG[season_key]
|
||||
playlist_size = config['playlist_size']
|
||||
|
||||
logger.info(f"Curating seasonal playlist for: {config['name']}")
|
||||
|
||||
# Get all seasonal tracks for this season
|
||||
all_tracks = []
|
||||
|
||||
# Get tracks from seasonal_tracks table
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
spotify_track_id,
|
||||
track_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
popularity
|
||||
FROM seasonal_tracks
|
||||
WHERE season_key = ?
|
||||
""", (season_key,))
|
||||
|
||||
rows = cursor.fetchall()
|
||||
all_tracks.extend([dict(row) for row in rows])
|
||||
|
||||
# Get tracks from seasonal albums
|
||||
seasonal_albums = self.get_seasonal_albums(season_key, limit=30)
|
||||
|
||||
for album in seasonal_albums:
|
||||
try:
|
||||
if not self.spotify_client or not self.spotify_client.is_authenticated():
|
||||
break
|
||||
|
||||
# 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', []):
|
||||
all_tracks.append({
|
||||
'spotify_track_id': track['id'],
|
||||
'track_name': track['name'],
|
||||
'artist_name': album['artist_name'],
|
||||
'album_name': album['album_name'],
|
||||
'popularity': album.get('popularity', 50)
|
||||
})
|
||||
|
||||
import time
|
||||
time.sleep(0.3) # Rate limiting
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error getting tracks from album {album['album_name']}: {e}")
|
||||
continue
|
||||
|
||||
if not all_tracks:
|
||||
logger.warning(f"No tracks found for seasonal playlist: {season_key}")
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(all_tracks)} total tracks for {season_key} curation")
|
||||
|
||||
# Balance by artist - max 3 tracks per artist
|
||||
tracks_by_artist = {}
|
||||
for track in all_tracks:
|
||||
artist = track['artist_name']
|
||||
if artist not in tracks_by_artist:
|
||||
tracks_by_artist[artist] = []
|
||||
tracks_by_artist[artist].append(track)
|
||||
|
||||
balanced_tracks = []
|
||||
for artist, artist_tracks in tracks_by_artist.items():
|
||||
# Sort by popularity and take top 3
|
||||
sorted_tracks = sorted(artist_tracks, key=lambda t: t.get('popularity', 50), reverse=True)
|
||||
balanced_tracks.extend(sorted_tracks[:3])
|
||||
|
||||
# Separate by popularity tiers
|
||||
popular = [t for t in balanced_tracks if t.get('popularity', 50) >= 60]
|
||||
mid_tier = [t for t in balanced_tracks if 40 <= t.get('popularity', 50) < 60]
|
||||
deep_cuts = [t for t in balanced_tracks if t.get('popularity', 50) < 40]
|
||||
|
||||
# Shuffle each tier
|
||||
random.shuffle(popular)
|
||||
random.shuffle(mid_tier)
|
||||
random.shuffle(deep_cuts)
|
||||
|
||||
# Create balanced mix (60% popular, 30% mid-tier, 10% deep cuts)
|
||||
curated_tracks = []
|
||||
curated_tracks.extend(popular[:int(playlist_size * 0.6)])
|
||||
curated_tracks.extend(mid_tier[:int(playlist_size * 0.3)])
|
||||
curated_tracks.extend(deep_cuts[:int(playlist_size * 0.1)])
|
||||
|
||||
# Shuffle final selection
|
||||
random.shuffle(curated_tracks)
|
||||
curated_tracks = curated_tracks[:playlist_size]
|
||||
|
||||
# Extract track IDs
|
||||
track_ids = [track['spotify_track_id'] for track in curated_tracks]
|
||||
|
||||
# Save curated playlist
|
||||
self._save_curated_playlist(season_key, track_ids)
|
||||
|
||||
logger.info(f"Curated {len(track_ids)} tracks for {config['name']} playlist")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error curating seasonal playlist for {season_key}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def _save_curated_playlist(self, season_key: str, track_ids: List[str]):
|
||||
"""Save curated playlist to database"""
|
||||
try:
|
||||
import json
|
||||
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
INSERT OR REPLACE INTO curated_seasonal_playlists (
|
||||
season_key, track_ids, curated_at, track_count
|
||||
) VALUES (?, ?, CURRENT_TIMESTAMP, ?)
|
||||
""", (season_key, json.dumps(track_ids), len(track_ids)))
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving curated seasonal playlist: {e}")
|
||||
|
||||
def get_curated_seasonal_playlist(self, season_key: str) -> List[str]:
|
||||
"""Get curated seasonal playlist track IDs"""
|
||||
try:
|
||||
import json
|
||||
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT track_ids
|
||||
FROM curated_seasonal_playlists
|
||||
WHERE season_key = ?
|
||||
""", (season_key,))
|
||||
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result and result['track_ids']:
|
||||
return json.loads(result['track_ids'])
|
||||
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting curated seasonal playlist: {e}")
|
||||
return []
|
||||
|
||||
def populate_all_seasons(self):
|
||||
"""Populate content for all seasons (run periodically)"""
|
||||
logger.info("Starting population of all seasonal content...")
|
||||
|
||||
for season_key in SEASONAL_CONFIG.keys():
|
||||
try:
|
||||
# Check if needs update (7 day threshold)
|
||||
if self.should_populate_seasonal_content(season_key, days_threshold=7):
|
||||
logger.info(f"Populating {season_key}...")
|
||||
self.populate_seasonal_content(season_key)
|
||||
self.curate_seasonal_playlist(season_key)
|
||||
else:
|
||||
logger.info(f"Skipping {season_key} (recently updated)")
|
||||
except Exception as e:
|
||||
logger.error(f"Error populating season {season_key}: {e}")
|
||||
continue
|
||||
|
||||
logger.info("Finished populating all seasonal content")
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_seasonal_discovery_instance = None
|
||||
|
||||
def get_seasonal_discovery_service(spotify_client, database):
|
||||
"""Get the global seasonal discovery service instance"""
|
||||
global _seasonal_discovery_instance
|
||||
if _seasonal_discovery_instance is None:
|
||||
_seasonal_discovery_instance = SeasonalDiscoveryService(spotify_client, database)
|
||||
return _seasonal_discovery_instance
|
||||
|
|
@ -118,19 +118,69 @@ class WatchlistScanner:
|
|||
|
||||
def scan_all_watchlist_artists(self) -> List[ScanResult]:
|
||||
"""
|
||||
Scan all artists in the watchlist for new releases.
|
||||
Scan artists in the watchlist for new releases.
|
||||
|
||||
OPTIMIZED: Scans up to 50 artists per run using smart selection:
|
||||
- Priority: Artists not scanned in 7+ days (guaranteed)
|
||||
- Remainder: Random selection from other artists
|
||||
|
||||
This reduces API calls while ensuring all artists scanned at least weekly.
|
||||
Only checks releases after their last scan timestamp.
|
||||
"""
|
||||
logger.info("Starting watchlist scan for all artists")
|
||||
|
||||
logger.info("Starting watchlist scan")
|
||||
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
|
||||
# Get all watchlist artists
|
||||
watchlist_artists = self.database.get_watchlist_artists()
|
||||
if not watchlist_artists:
|
||||
all_watchlist_artists = self.database.get_watchlist_artists()
|
||||
if not all_watchlist_artists:
|
||||
logger.info("No artists in watchlist to scan")
|
||||
return []
|
||||
|
||||
logger.info(f"Found {len(watchlist_artists)} artists in watchlist")
|
||||
|
||||
logger.info(f"Found {len(all_watchlist_artists)} total artists in watchlist")
|
||||
|
||||
# OPTIMIZATION: Select up to 50 artists to scan
|
||||
# 1. Must scan: Artists not scanned in 7+ days (or never scanned)
|
||||
seven_days_ago = datetime.now() - timedelta(days=7)
|
||||
must_scan = []
|
||||
can_skip = []
|
||||
|
||||
for artist in all_watchlist_artists:
|
||||
if artist.last_scan_timestamp is None:
|
||||
# Never scanned - must scan
|
||||
must_scan.append(artist)
|
||||
elif artist.last_scan_timestamp < seven_days_ago:
|
||||
# Not scanned in 7+ days - must scan
|
||||
must_scan.append(artist)
|
||||
else:
|
||||
# Scanned recently - can skip (but might randomly select)
|
||||
can_skip.append(artist)
|
||||
|
||||
logger.info(f"Artists requiring scan (not scanned in 7+ days): {len(must_scan)}")
|
||||
logger.info(f"Artists scanned recently (< 7 days): {len(can_skip)}")
|
||||
|
||||
# 2. Fill remaining slots (up to 50 total) with random selection
|
||||
max_artists_per_scan = 50
|
||||
artists_to_scan = must_scan.copy()
|
||||
|
||||
remaining_slots = max_artists_per_scan - len(must_scan)
|
||||
if remaining_slots > 0 and can_skip:
|
||||
# Randomly sample from recently-scanned artists
|
||||
random_sample_size = min(remaining_slots, len(can_skip))
|
||||
random_selection = random.sample(can_skip, random_sample_size)
|
||||
artists_to_scan.extend(random_selection)
|
||||
logger.info(f"Additionally scanning {len(random_selection)} randomly selected artists")
|
||||
|
||||
# Shuffle to avoid always scanning same order
|
||||
random.shuffle(artists_to_scan)
|
||||
|
||||
logger.info(f"Total artists to scan this run: {len(artists_to_scan)}")
|
||||
if len(all_watchlist_artists) > max_artists_per_scan:
|
||||
logger.info(f"Skipping {len(all_watchlist_artists) - len(artists_to_scan)} artists (will be scanned in future runs)")
|
||||
|
||||
watchlist_artists = artists_to_scan
|
||||
|
||||
scan_results = []
|
||||
for i, artist in enumerate(watchlist_artists):
|
||||
|
|
@ -173,6 +223,10 @@ class WatchlistScanner:
|
|||
logger.info("Starting discovery pool population...")
|
||||
self.populate_discovery_pool()
|
||||
|
||||
# Populate seasonal content (runs independently with its own threshold)
|
||||
logger.info("Updating seasonal content...")
|
||||
self._populate_seasonal_content()
|
||||
|
||||
return scan_results
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -661,13 +715,14 @@ class WatchlistScanner:
|
|||
logger.error(f"Error fetching similar artists for {watchlist_artist.artist_name}: {e}")
|
||||
return False
|
||||
|
||||
def populate_discovery_pool(self, top_artists_limit: int = 20, albums_per_artist: int = 5):
|
||||
def populate_discovery_pool(self, top_artists_limit: int = 50, albums_per_artist: int = 10):
|
||||
"""
|
||||
Populate discovery pool with tracks from top similar artists.
|
||||
Called after watchlist scan completes.
|
||||
|
||||
This method now:
|
||||
IMPROVED: Larger pool for better discovery (50 artists x 10 releases = ~500 releases)
|
||||
- Checks if pool was updated in last 24 hours (prevents over-polling Spotify)
|
||||
- Includes albums, singles, and EPs for comprehensive coverage
|
||||
- Appends to existing pool instead of replacing it
|
||||
- Cleans up tracks older than 365 days (maintains 1 year rolling window)
|
||||
"""
|
||||
|
|
@ -700,7 +755,7 @@ class WatchlistScanner:
|
|||
# Get artist's albums from Spotify
|
||||
all_albums = self.spotify_client.get_artist_albums(
|
||||
similar_artist.similar_artist_spotify_id,
|
||||
album_type='album', # Only full albums, not singles
|
||||
album_type='album,single,ep', # Include albums, singles, and EPs for comprehensive discovery
|
||||
limit=50
|
||||
)
|
||||
|
||||
|
|
@ -708,26 +763,35 @@ class WatchlistScanner:
|
|||
logger.debug(f"No albums found for {similar_artist.similar_artist_name}")
|
||||
continue
|
||||
|
||||
# Filter to only studio albums (exclude compilations, live albums)
|
||||
studio_albums = [a for a in all_albums if 'album_type' not in dir(a) or a.album_type == 'album']
|
||||
# IMPROVED: Smart selection mixing albums, singles, and EPs
|
||||
# Prioritize recent releases and popular content
|
||||
|
||||
if len(studio_albums) == 0:
|
||||
studio_albums = all_albums # Fallback to all if no studio albums
|
||||
# Separate by type for balanced selection
|
||||
albums = [a for a in all_albums if hasattr(a, 'album_type') and a.album_type == 'album']
|
||||
singles_eps = [a for a in all_albums if hasattr(a, 'album_type') and a.album_type in ['single', 'ep']]
|
||||
other = [a for a in all_albums if not hasattr(a, 'album_type')]
|
||||
|
||||
# Select albums: latest + random selection
|
||||
# Select albums: latest releases + popular older content
|
||||
selected_albums = []
|
||||
|
||||
# Always include latest album
|
||||
if studio_albums:
|
||||
selected_albums.append(studio_albums[0]) # Latest is first
|
||||
# Always include 3 most recent releases (any type) - this captures new singles/EPs
|
||||
latest_releases = all_albums[:3]
|
||||
selected_albums.extend(latest_releases)
|
||||
|
||||
# Add random albums if we have more
|
||||
if len(studio_albums) > 1:
|
||||
remaining_slots = min(albums_per_artist - 1, len(studio_albums) - 1)
|
||||
random_albums = random.sample(studio_albums[1:], remaining_slots)
|
||||
selected_albums.extend(random_albums)
|
||||
# Add remaining slots with balanced mix
|
||||
remaining_slots = albums_per_artist - len(selected_albums)
|
||||
if remaining_slots > 0:
|
||||
# Combine remaining albums and singles
|
||||
remaining_content = all_albums[3:]
|
||||
|
||||
logger.info(f" Selected {len(selected_albums)} albums from {len(studio_albums)} available")
|
||||
if len(remaining_content) > remaining_slots:
|
||||
# Randomly select from remaining content
|
||||
random_selection = random.sample(remaining_content, remaining_slots)
|
||||
selected_albums.extend(random_selection)
|
||||
else:
|
||||
selected_albums.extend(remaining_content)
|
||||
|
||||
logger.info(f" Selected {len(selected_albums)} releases from {len(all_albums)} available (albums: {len(albums)}, singles/EPs: {len(singles_eps)})")
|
||||
|
||||
# Process each selected album
|
||||
for album_idx, album in enumerate(selected_albums, 1):
|
||||
|
|
@ -904,8 +968,135 @@ class WatchlistScanner:
|
|||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def update_discovery_pool_incremental(self):
|
||||
"""
|
||||
Lightweight incremental update for discovery pool - runs every 6 hours.
|
||||
|
||||
IMPROVED: Quick check for new releases from watchlist artists only
|
||||
- Much faster than full populate_discovery_pool (only checks watchlist, not similar artists)
|
||||
- Only fetches latest 5 releases per artist
|
||||
- Only adds tracks from releases in last 7 days
|
||||
- Respects 6-hour cooldown to avoid over-polling
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Check if we should run (prevents over-polling Spotify)
|
||||
if not self.database.should_populate_discovery_pool(hours_threshold=6):
|
||||
logger.info("Discovery pool was updated recently (< 6 hours ago). Skipping incremental update.")
|
||||
return
|
||||
|
||||
logger.info("Starting incremental discovery pool update (watchlist artists only)...")
|
||||
|
||||
watchlist_artists = self.database.get_watchlist_artists()
|
||||
if not watchlist_artists:
|
||||
logger.info("No watchlist artists to check for incremental update")
|
||||
return
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=7) # Only last week's releases
|
||||
total_tracks_added = 0
|
||||
|
||||
for artist_idx, artist in enumerate(watchlist_artists, 1):
|
||||
try:
|
||||
logger.info(f"[{artist_idx}/{len(watchlist_artists)}] Checking {artist.artist_name} for new releases...")
|
||||
|
||||
# Only fetch latest 5 releases (much faster than full scan)
|
||||
recent_releases = self.spotify_client.get_artist_albums(
|
||||
artist.spotify_artist_id,
|
||||
album_type='album,single,ep',
|
||||
limit=5
|
||||
)
|
||||
|
||||
if not recent_releases:
|
||||
continue
|
||||
|
||||
for release in recent_releases:
|
||||
try:
|
||||
# Check if release is within cutoff
|
||||
if not self.is_album_after_timestamp(release, cutoff_date):
|
||||
continue # Skip older releases
|
||||
|
||||
# Get full album data with tracks
|
||||
album_data = self.spotify_client.get_album(release.id)
|
||||
if not album_data or 'tracks' not in album_data:
|
||||
continue
|
||||
|
||||
tracks = album_data['tracks'].get('items', [])
|
||||
logger.debug(f" New release: {release.name} ({len(tracks)} tracks)")
|
||||
|
||||
# Determine if this is a new release (within last 30 days)
|
||||
is_new = False
|
||||
try:
|
||||
release_date_str = album_data.get('release_date', '')
|
||||
if release_date_str and len(release_date_str) == 10:
|
||||
release_date = datetime.strptime(release_date_str, "%Y-%m-%d")
|
||||
days_old = (datetime.now() - release_date).days
|
||||
is_new = days_old <= 30
|
||||
except:
|
||||
pass
|
||||
|
||||
# Add each track to discovery pool
|
||||
for track in tracks:
|
||||
try:
|
||||
track_data = {
|
||||
'spotify_track_id': track['id'],
|
||||
'spotify_album_id': album_data['id'],
|
||||
'spotify_artist_id': artist.spotify_artist_id,
|
||||
'track_name': track['name'],
|
||||
'artist_name': artist.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),
|
||||
'release_date': album_data.get('release_date', ''),
|
||||
'is_new_release': is_new,
|
||||
'track_data_json': track
|
||||
}
|
||||
|
||||
if self.database.add_to_discovery_pool(track_data):
|
||||
total_tracks_added += 1
|
||||
|
||||
except Exception as track_error:
|
||||
logger.debug(f"Error adding track to discovery pool: {track_error}")
|
||||
continue
|
||||
|
||||
except Exception as release_error:
|
||||
logger.warning(f"Error processing release: {release_error}")
|
||||
continue
|
||||
|
||||
# Small delay between artists
|
||||
if artist_idx < len(watchlist_artists):
|
||||
time.sleep(DELAY_BETWEEN_ARTISTS)
|
||||
|
||||
except Exception as artist_error:
|
||||
logger.warning(f"Error checking {artist.artist_name}: {artist_error}")
|
||||
continue
|
||||
|
||||
logger.info(f"Incremental update complete: {total_tracks_added} new tracks added from watchlist artists")
|
||||
|
||||
# Update timestamp
|
||||
if total_tracks_added > 0:
|
||||
# Get current track count
|
||||
with self.database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) as count FROM discovery_pool")
|
||||
current_count = cursor.fetchone()['count']
|
||||
|
||||
self.database.update_discovery_pool_timestamp(track_count=current_count)
|
||||
logger.info(f"Discovery pool now contains {current_count} total tracks")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during incremental discovery pool update: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def cache_discovery_recent_albums(self):
|
||||
"""Cache recent albums from watchlist and similar artists for discover page"""
|
||||
"""
|
||||
Cache recent albums from watchlist and similar artists for discover page.
|
||||
|
||||
IMPROVED: Checks ALL watchlist artists + top similar artists with 14-day window
|
||||
(like Spotify's Release Radar) for more comprehensive and fresh content.
|
||||
"""
|
||||
try:
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
|
|
@ -915,26 +1106,25 @@ class WatchlistScanner:
|
|||
# Clear existing cache
|
||||
self.database.clear_discovery_recent_albums()
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=90) # 3 months
|
||||
# IMPROVED: 14-day window (like Spotify Release Radar) instead of 90 days
|
||||
cutoff_date = datetime.now() - timedelta(days=14)
|
||||
cached_count = 0
|
||||
albums_checked = 0
|
||||
|
||||
# Get watchlist artists (10 random for more variety)
|
||||
# IMPROVED: Check ALL watchlist artists (not random 10)
|
||||
watchlist_artists = self.database.get_watchlist_artists()
|
||||
watchlist_sample = random.sample(watchlist_artists, min(10, len(watchlist_artists))) if watchlist_artists else []
|
||||
|
||||
# Get similar artists (10 random from top 30 for more variety)
|
||||
similar_artists = self.database.get_top_similar_artists(limit=30)
|
||||
similar_sample = random.sample(similar_artists, min(10, len(similar_artists))) if similar_artists else []
|
||||
# IMPROVED: Check top 50 similar artists (not random 10 from 30)
|
||||
similar_artists = self.database.get_top_similar_artists(limit=50)
|
||||
|
||||
logger.info(f"Checking albums from {len(watchlist_sample)} watchlist + {len(similar_sample)} similar artists for recent releases")
|
||||
logger.info(f"Checking albums from {len(watchlist_artists)} watchlist + {len(similar_artists)} similar artists for recent releases (last 14 days)")
|
||||
|
||||
# Process watchlist artists
|
||||
for artist in watchlist_sample:
|
||||
for artist in watchlist_artists:
|
||||
try:
|
||||
albums = self.spotify_client.get_artist_albums(
|
||||
artist.spotify_artist_id,
|
||||
album_type='album,single',
|
||||
album_type='album,single,ep', # Include EPs for comprehensive coverage
|
||||
limit=20
|
||||
)
|
||||
|
||||
|
|
@ -970,11 +1160,11 @@ class WatchlistScanner:
|
|||
time.sleep(DELAY_BETWEEN_ARTISTS)
|
||||
|
||||
# Process similar artists
|
||||
for artist in similar_sample:
|
||||
for artist in similar_artists:
|
||||
try:
|
||||
albums = self.spotify_client.get_artist_albums(
|
||||
artist.similar_artist_spotify_id,
|
||||
album_type='album,single',
|
||||
album_type='album,single,ep', # Include EPs for comprehensive coverage
|
||||
limit=20
|
||||
)
|
||||
|
||||
|
|
@ -1017,14 +1207,22 @@ class WatchlistScanner:
|
|||
traceback.print_exc()
|
||||
|
||||
def curate_discovery_playlists(self):
|
||||
"""Curate consistent playlist selections that stay the same until next discovery pool update"""
|
||||
"""
|
||||
Curate consistent playlist selections that stay the same until next discovery pool update.
|
||||
|
||||
IMPROVED: Spotify-quality curation with popularity scoring and smart algorithms
|
||||
- Release Radar: Prioritizes freshness + popularity from recent releases
|
||||
- Discovery Weekly: Balanced mix of popular picks, deep cuts, and mid-tier tracks
|
||||
"""
|
||||
try:
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
logger.info("Curating Release Radar playlist...")
|
||||
|
||||
# 1. Curate Release Radar - 50 tracks from recent albums
|
||||
recent_albums = self.database.get_discovery_recent_albums(limit=20)
|
||||
# IMPROVED: Get more albums (50 instead of 20) for better selection
|
||||
recent_albums = self.database.get_discovery_recent_albums(limit=50)
|
||||
release_radar_tracks = []
|
||||
|
||||
if recent_albums:
|
||||
|
|
@ -1037,9 +1235,9 @@ class WatchlistScanner:
|
|||
albums_by_artist[artist].append(album)
|
||||
|
||||
# Get tracks from each album, grouped by artist
|
||||
# Also add these tracks to discovery pool for fast lookup
|
||||
# IMPROVED: Add popularity scoring for smarter selection
|
||||
artist_tracks = {}
|
||||
artist_track_data = {} # Store full track data for discovery pool
|
||||
artist_track_data = {} # Store full track data with scores
|
||||
|
||||
for artist, albums in albums_by_artist.items():
|
||||
artist_tracks[artist] = []
|
||||
|
|
@ -1049,45 +1247,66 @@ class WatchlistScanner:
|
|||
try:
|
||||
album_data = self.spotify_client.get_album(album['album_spotify_id'])
|
||||
if album_data and 'tracks' in album_data:
|
||||
# Calculate days since release for recency score
|
||||
days_old = 14 # Default
|
||||
try:
|
||||
release_date_str = album.get('release_date', '')
|
||||
if release_date_str and len(release_date_str) >= 10:
|
||||
release_date = datetime.strptime(release_date_str[:10], "%Y-%m-%d")
|
||||
days_old = (datetime.now() - release_date).days
|
||||
except:
|
||||
pass
|
||||
|
||||
for track in album_data['tracks']['items']:
|
||||
track_id = track['id']
|
||||
|
||||
# Calculate track score (Spotify-style)
|
||||
# Score factors: recency (50%), popularity (30%), singles bonus (20%)
|
||||
recency_score = max(0, 100 - (days_old * 7)) # Newer = higher
|
||||
popularity_score = track.get('popularity', album_data.get('popularity', 50))
|
||||
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
|
||||
|
||||
artist_tracks[artist].append(track_id)
|
||||
|
||||
# Store full track data for discovery pool
|
||||
# Store full track data with score for sorting
|
||||
full_track = {
|
||||
'id': track_id,
|
||||
'name': track['name'],
|
||||
'artists': track.get('artists', []),
|
||||
'album': album_data,
|
||||
'duration_ms': track.get('duration_ms', 0)
|
||||
'duration_ms': track.get('duration_ms', 0),
|
||||
'popularity': popularity_score,
|
||||
'score': total_score,
|
||||
'days_old': days_old
|
||||
}
|
||||
artist_track_data[artist].append(full_track)
|
||||
|
||||
except Exception as e:
|
||||
continue
|
||||
|
||||
# Balance by artist - max 6 tracks per artist
|
||||
# IMPROVED: Balance by artist with popularity weighting - max 6 tracks per artist
|
||||
balanced_tracks = []
|
||||
balanced_track_data = []
|
||||
|
||||
for artist, tracks in artist_tracks.items():
|
||||
# Shuffle and get indices
|
||||
indices = list(range(len(tracks)))
|
||||
random.shuffle(indices)
|
||||
selected_indices = indices[:6]
|
||||
for artist, track_data in artist_track_data.items():
|
||||
# Sort by score and take top 6 (not random)
|
||||
sorted_tracks = sorted(track_data, key=lambda t: t['score'], reverse=True)
|
||||
selected_tracks = sorted_tracks[:6]
|
||||
|
||||
# Add selected tracks
|
||||
for idx in selected_indices:
|
||||
balanced_tracks.append(tracks[idx])
|
||||
balanced_track_data.append(artist_track_data[artist][idx])
|
||||
for track in selected_tracks:
|
||||
balanced_tracks.append(track['id'])
|
||||
balanced_track_data.append(track)
|
||||
|
||||
# Shuffle and limit to 50
|
||||
combined = list(zip(balanced_tracks, balanced_track_data))
|
||||
random.shuffle(combined)
|
||||
combined = combined[:50]
|
||||
# IMPROVED: Sort by score first, then shuffle within score tiers for variety
|
||||
balanced_track_data.sort(key=lambda t: t['score'], reverse=True)
|
||||
|
||||
release_radar_tracks = [track_id for track_id, _ in combined]
|
||||
release_radar_track_data = [track_data for _, track_data in combined]
|
||||
# Take top 50
|
||||
release_radar_tracks = [track['id'] for track in balanced_track_data[:50]]
|
||||
release_radar_track_data = balanced_track_data[:50]
|
||||
|
||||
# Add Release Radar tracks to discovery pool so they're available for fast lookup
|
||||
logger.info(f"Adding {len(release_radar_track_data)} Release Radar tracks to discovery pool...")
|
||||
|
|
@ -1117,14 +1336,51 @@ class WatchlistScanner:
|
|||
logger.info(f"Release Radar curated: {len(release_radar_tracks)} tracks")
|
||||
|
||||
# 2. Curate Discovery Weekly - 50 tracks from full discovery pool
|
||||
# IMPROVED: Spotify-style algorithm with balanced mix of popular, mid-tier, and deep cuts
|
||||
logger.info("Curating Discovery Weekly playlist...")
|
||||
discovery_tracks = self.database.get_discovery_pool_tracks(limit=1000, new_releases_only=False)
|
||||
discovery_tracks = self.database.get_discovery_pool_tracks(limit=2000, new_releases_only=False)
|
||||
|
||||
discovery_weekly_tracks = []
|
||||
if discovery_tracks:
|
||||
all_track_ids = [track.spotify_track_id for track in discovery_tracks]
|
||||
random.shuffle(all_track_ids)
|
||||
discovery_weekly_tracks = all_track_ids[:50]
|
||||
# Separate tracks by popularity tiers for balanced selection
|
||||
popular_picks = [] # popularity >= 60
|
||||
balanced_mix = [] # 40 <= popularity < 60
|
||||
deep_cuts = [] # popularity < 40
|
||||
|
||||
for track in discovery_tracks:
|
||||
popularity = track.popularity if hasattr(track, 'popularity') else 50
|
||||
|
||||
if popularity >= 60:
|
||||
popular_picks.append(track)
|
||||
elif popularity >= 40:
|
||||
balanced_mix.append(track)
|
||||
else:
|
||||
deep_cuts.append(track)
|
||||
|
||||
logger.info(f"Discovery pool breakdown: {len(popular_picks)} popular, {len(balanced_mix)} mid-tier, {len(deep_cuts)} deep cuts")
|
||||
|
||||
# Create balanced playlist (Spotify-style distribution)
|
||||
# 40% popular picks (20 tracks)
|
||||
# 40% balanced mid-tier (20 tracks)
|
||||
# 20% deep cuts (10 tracks)
|
||||
selected_tracks = []
|
||||
|
||||
# Randomly select from each tier
|
||||
random.shuffle(popular_picks)
|
||||
random.shuffle(balanced_mix)
|
||||
random.shuffle(deep_cuts)
|
||||
|
||||
selected_tracks.extend(popular_picks[:20]) # 20 popular
|
||||
selected_tracks.extend(balanced_mix[:20]) # 20 mid-tier
|
||||
selected_tracks.extend(deep_cuts[:10]) # 10 deep cuts
|
||||
|
||||
# Shuffle final selection for variety
|
||||
random.shuffle(selected_tracks)
|
||||
|
||||
# Extract track IDs
|
||||
discovery_weekly_tracks = [track.spotify_track_id for track in selected_tracks]
|
||||
|
||||
logger.info(f"Discovery Weekly composition: {len(popular_picks[:20])} popular + {len(balanced_mix[:20])} mid-tier + {len(deep_cuts[:10])} deep cuts = {len(discovery_weekly_tracks)} total")
|
||||
|
||||
self.database.save_curated_playlist('discovery_weekly', discovery_weekly_tracks)
|
||||
logger.info(f"Discovery Weekly curated: {len(discovery_weekly_tracks)} tracks")
|
||||
|
|
@ -1136,6 +1392,53 @@ class WatchlistScanner:
|
|||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
def _populate_seasonal_content(self):
|
||||
"""
|
||||
Populate seasonal content as part of watchlist scan.
|
||||
|
||||
IMPROVED: Integrated with discovery system
|
||||
- Checks if seasonal content needs update (7-day threshold)
|
||||
- Populates content for all seasons
|
||||
- Curates seasonal playlists
|
||||
- Runs once per week automatically
|
||||
"""
|
||||
try:
|
||||
from core.seasonal_discovery import get_seasonal_discovery_service
|
||||
|
||||
logger.info("Checking seasonal content update...")
|
||||
|
||||
seasonal_service = get_seasonal_discovery_service(self.spotify_client, self.database)
|
||||
|
||||
# Get current season to prioritize
|
||||
current_season = seasonal_service.get_current_season()
|
||||
|
||||
if current_season:
|
||||
# Always update current season if needed
|
||||
if seasonal_service.should_populate_seasonal_content(current_season, days_threshold=7):
|
||||
logger.info(f"Populating current season: {current_season}")
|
||||
seasonal_service.populate_seasonal_content(current_season)
|
||||
seasonal_service.curate_seasonal_playlist(current_season)
|
||||
else:
|
||||
logger.info(f"Current season '{current_season}' is up to date")
|
||||
|
||||
# Update other seasons in background (less frequently - 14 day threshold)
|
||||
from core.seasonal_discovery import SEASONAL_CONFIG
|
||||
for season_key in SEASONAL_CONFIG.keys():
|
||||
if season_key == current_season:
|
||||
continue # Already handled above
|
||||
|
||||
if seasonal_service.should_populate_seasonal_content(season_key, days_threshold=14):
|
||||
logger.info(f"Populating season: {season_key}")
|
||||
seasonal_service.populate_seasonal_content(season_key)
|
||||
seasonal_service.curate_seasonal_playlist(season_key)
|
||||
|
||||
logger.info("Seasonal content update complete")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error populating seasonal content: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Singleton instance
|
||||
_watchlist_scanner_instance = None
|
||||
|
||||
|
|
|
|||
175
web_server.py
175
web_server.py
|
|
@ -14685,6 +14685,181 @@ def get_discover_weekly():
|
|||
print(f"Error getting discovery weekly: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
# ========================================
|
||||
# SEASONAL DISCOVERY ENDPOINTS
|
||||
# ========================================
|
||||
|
||||
@app.route('/api/discover/seasonal/current', methods=['GET'])
|
||||
def get_current_seasonal_content():
|
||||
"""Auto-detect and return current season's content"""
|
||||
try:
|
||||
from core.seasonal_discovery import get_seasonal_discovery_service
|
||||
|
||||
database = get_database()
|
||||
seasonal_service = get_seasonal_discovery_service(spotify_client, database)
|
||||
|
||||
# Get current season
|
||||
current_season = seasonal_service.get_current_season()
|
||||
|
||||
if not current_season:
|
||||
return jsonify({"success": True, "season": None, "albums": [], "playlist_available": False})
|
||||
|
||||
# Get seasonal config
|
||||
from core.seasonal_discovery import SEASONAL_CONFIG
|
||||
config = SEASONAL_CONFIG[current_season]
|
||||
|
||||
# Get albums
|
||||
albums = seasonal_service.get_seasonal_albums(current_season, limit=20)
|
||||
|
||||
# Check if playlist is curated
|
||||
playlist_track_ids = seasonal_service.get_curated_seasonal_playlist(current_season)
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"season": current_season,
|
||||
"name": config['name'],
|
||||
"description": config['description'],
|
||||
"icon": config['icon'],
|
||||
"albums": albums,
|
||||
"playlist_available": len(playlist_track_ids) > 0
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting current seasonal content: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/discover/seasonal/<season_key>/albums', methods=['GET'])
|
||||
def get_seasonal_albums(season_key):
|
||||
"""Get albums for a specific season"""
|
||||
try:
|
||||
from core.seasonal_discovery import get_seasonal_discovery_service, SEASONAL_CONFIG
|
||||
|
||||
if season_key not in SEASONAL_CONFIG:
|
||||
return jsonify({"success": False, "error": "Invalid season"}), 400
|
||||
|
||||
database = get_database()
|
||||
seasonal_service = get_seasonal_discovery_service(spotify_client, database)
|
||||
|
||||
albums = seasonal_service.get_seasonal_albums(season_key, limit=20)
|
||||
config = SEASONAL_CONFIG[season_key]
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"season": season_key,
|
||||
"name": config['name'],
|
||||
"description": config['description'],
|
||||
"icon": config['icon'],
|
||||
"albums": albums
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting seasonal albums: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/discover/seasonal/<season_key>/playlist', methods=['GET'])
|
||||
def get_seasonal_playlist(season_key):
|
||||
"""Get curated playlist for a specific season"""
|
||||
try:
|
||||
from core.seasonal_discovery import get_seasonal_discovery_service, SEASONAL_CONFIG
|
||||
|
||||
if season_key not in SEASONAL_CONFIG:
|
||||
return jsonify({"success": False, "error": "Invalid season"}), 400
|
||||
|
||||
database = get_database()
|
||||
seasonal_service = get_seasonal_discovery_service(spotify_client, database)
|
||||
|
||||
# Get curated track IDs
|
||||
track_ids = seasonal_service.get_curated_seasonal_playlist(season_key)
|
||||
|
||||
if not track_ids:
|
||||
return jsonify({"success": True, "tracks": []})
|
||||
|
||||
# Fetch track details from discovery pool or seasonal tracks
|
||||
tracks = []
|
||||
with database._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
for track_id in track_ids:
|
||||
# Try seasonal_tracks first
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
spotify_track_id,
|
||||
track_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
album_cover_url,
|
||||
duration_ms,
|
||||
popularity
|
||||
FROM seasonal_tracks
|
||||
WHERE spotify_track_id = ?
|
||||
""", (track_id,))
|
||||
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result:
|
||||
tracks.append(dict(result))
|
||||
else:
|
||||
# Try discovery_pool as fallback
|
||||
cursor.execute("""
|
||||
SELECT
|
||||
spotify_track_id,
|
||||
track_name,
|
||||
artist_name,
|
||||
album_name,
|
||||
album_cover_url,
|
||||
duration_ms,
|
||||
popularity
|
||||
FROM discovery_pool
|
||||
WHERE spotify_track_id = ?
|
||||
""", (track_id,))
|
||||
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
tracks.append(dict(result))
|
||||
|
||||
config = SEASONAL_CONFIG[season_key]
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"season": season_key,
|
||||
"name": config['name'],
|
||||
"description": config['description'],
|
||||
"icon": config['icon'],
|
||||
"tracks": tracks
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error getting seasonal playlist: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/discover/seasonal/refresh', methods=['POST'])
|
||||
def refresh_seasonal_content():
|
||||
"""Manually trigger seasonal content refresh (admin function)"""
|
||||
try:
|
||||
from core.seasonal_discovery import get_seasonal_discovery_service
|
||||
|
||||
database = get_database()
|
||||
seasonal_service = get_seasonal_discovery_service(spotify_client, database)
|
||||
|
||||
# Populate all seasons in background thread
|
||||
import threading
|
||||
def populate_all():
|
||||
try:
|
||||
seasonal_service.populate_all_seasons()
|
||||
except Exception as e:
|
||||
print(f"Error in background seasonal population: {e}")
|
||||
|
||||
thread = threading.Thread(target=populate_all, daemon=True)
|
||||
thread.start()
|
||||
|
||||
return jsonify({"success": True, "message": "Seasonal content refresh started"})
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error refreshing seasonal content: {e}")
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
@app.route('/api/metadata/start', methods=['POST'])
|
||||
def start_metadata_update():
|
||||
"""Start the metadata update process - EXACT copy of dashboard.py logic"""
|
||||
|
|
|
|||
|
|
@ -1708,6 +1708,38 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seasonal Albums Section (Auto-shows based on current season) -->
|
||||
<div class="discover-section" id="seasonal-albums-section" style="display: none;">
|
||||
<div class="discover-section-header">
|
||||
<h2 class="discover-section-title" id="seasonal-albums-title">Seasonal</h2>
|
||||
<p class="discover-section-subtitle" id="seasonal-albums-subtitle">Seasonal music</p>
|
||||
</div>
|
||||
<div class="discover-carousel" id="seasonal-albums-carousel">
|
||||
<!-- Content will be populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seasonal Playlist Section (Auto-shows based on current season) -->
|
||||
<div class="discover-section" id="seasonal-playlist-section" style="display: none;">
|
||||
<div class="discover-section-header">
|
||||
<div>
|
||||
<h2 class="discover-section-title" id="seasonal-playlist-title">Seasonal Mix</h2>
|
||||
<p class="discover-section-subtitle" id="seasonal-playlist-subtitle">Curated seasonal playlist</p>
|
||||
</div>
|
||||
<div class="discover-section-actions">
|
||||
<button class="action-button secondary" onclick="openDownloadModalForSeasonalPlaylist()" title="Download missing tracks">
|
||||
<span class="icon">📥</span> Download Missing
|
||||
</button>
|
||||
<button class="action-button secondary" onclick="syncSeasonalPlaylist()" title="Sync to library">
|
||||
<span class="icon">🔄</span> Sync
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="discover-playlist-container compact" id="seasonal-playlist">
|
||||
<!-- Content will be populated dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fresh Tape Section -->
|
||||
<div class="discover-section">
|
||||
<div class="discover-section-header">
|
||||
|
|
|
|||
|
|
@ -24704,6 +24704,9 @@ let discoverHeroInterval = null;
|
|||
let discoverReleaseRadarTracks = [];
|
||||
let discoverWeeklyTracks = [];
|
||||
let discoverRecentAlbums = [];
|
||||
let discoverSeasonalAlbums = [];
|
||||
let discoverSeasonalTracks = [];
|
||||
let currentSeasonKey = null;
|
||||
|
||||
async function loadDiscoverPage() {
|
||||
console.log('Loading discover page...');
|
||||
|
|
@ -24712,6 +24715,7 @@ async function loadDiscoverPage() {
|
|||
await Promise.all([
|
||||
loadDiscoverHero(),
|
||||
loadDiscoverRecentReleases(),
|
||||
loadSeasonalContent(), // NEW: Seasonal discovery
|
||||
loadDiscoverReleaseRadar(),
|
||||
loadDiscoverWeekly(),
|
||||
loadMoreForYou()
|
||||
|
|
@ -25278,6 +25282,240 @@ async function loadMoreForYou() {
|
|||
}
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// SEASONAL DISCOVERY
|
||||
// ===============================
|
||||
|
||||
async function loadSeasonalContent() {
|
||||
try {
|
||||
const response = await fetch('/api/discover/seasonal/current');
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch seasonal content');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// If no active season, hide seasonal sections
|
||||
if (!data.success || !data.season) {
|
||||
hideSeasonalSections();
|
||||
return;
|
||||
}
|
||||
|
||||
currentSeasonKey = data.season;
|
||||
|
||||
// Load seasonal albums
|
||||
await loadSeasonalAlbums(data);
|
||||
|
||||
// Load seasonal playlist if available
|
||||
if (data.playlist_available) {
|
||||
await loadSeasonalPlaylist(data);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading seasonal content:', error);
|
||||
hideSeasonalSections();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSeasonalAlbums(seasonData) {
|
||||
try {
|
||||
const carousel = document.getElementById('seasonal-albums-carousel');
|
||||
if (!carousel) return;
|
||||
|
||||
// Show seasonal section
|
||||
const seasonalSection = document.getElementById('seasonal-albums-section');
|
||||
if (seasonalSection) {
|
||||
seasonalSection.style.display = 'block';
|
||||
}
|
||||
|
||||
// Update header
|
||||
const seasonalTitle = document.getElementById('seasonal-albums-title');
|
||||
const seasonalSubtitle = document.getElementById('seasonal-albums-subtitle');
|
||||
|
||||
if (seasonalTitle) {
|
||||
seasonalTitle.textContent = `${seasonData.icon} ${seasonData.name}`;
|
||||
}
|
||||
if (seasonalSubtitle) {
|
||||
seasonalSubtitle.textContent = seasonData.description;
|
||||
}
|
||||
|
||||
// Store albums for download functionality
|
||||
discoverSeasonalAlbums = seasonData.albums || [];
|
||||
|
||||
if (discoverSeasonalAlbums.length === 0) {
|
||||
carousel.innerHTML = '<div class="discover-empty"><p>No seasonal albums found</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Build carousel HTML
|
||||
let html = '';
|
||||
discoverSeasonalAlbums.forEach((album, index) => {
|
||||
const coverUrl = album.album_cover_url || '/static/placeholder-album.png';
|
||||
html += `
|
||||
<div class="discover-card" onclick="openDownloadModalForSeasonalAlbum(${index})" style="cursor: pointer;">
|
||||
<div class="discover-card-image">
|
||||
<img src="${coverUrl}" alt="${album.album_name}">
|
||||
</div>
|
||||
<div class="discover-card-info">
|
||||
<h4 class="discover-card-title">${album.album_name}</h4>
|
||||
<p class="discover-card-subtitle">${album.artist_name}</p>
|
||||
${album.release_date ? `<p class="discover-card-meta">${album.release_date}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
carousel.innerHTML = html;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading seasonal albums:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSeasonalPlaylist(seasonData) {
|
||||
try {
|
||||
const playlistContainer = document.getElementById('seasonal-playlist');
|
||||
if (!playlistContainer) return;
|
||||
|
||||
// Show seasonal playlist section
|
||||
const seasonalPlaylistSection = document.getElementById('seasonal-playlist-section');
|
||||
if (seasonalPlaylistSection) {
|
||||
seasonalPlaylistSection.style.display = 'block';
|
||||
}
|
||||
|
||||
// Update header
|
||||
const playlistTitle = document.getElementById('seasonal-playlist-title');
|
||||
const playlistSubtitle = document.getElementById('seasonal-playlist-subtitle');
|
||||
|
||||
if (playlistTitle) {
|
||||
playlistTitle.textContent = `${seasonData.icon} ${seasonData.name} Mix`;
|
||||
}
|
||||
if (playlistSubtitle) {
|
||||
playlistSubtitle.textContent = `Curated playlist for ${seasonData.name.toLowerCase()}`;
|
||||
}
|
||||
|
||||
playlistContainer.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Loading playlist...</p></div>';
|
||||
|
||||
// Fetch playlist tracks
|
||||
const response = await fetch(`/api/discover/seasonal/${currentSeasonKey}/playlist`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch seasonal playlist');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||||
playlistContainer.innerHTML = '<div class="discover-empty"><p>No tracks available yet</p></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Store tracks for download/sync functionality
|
||||
discoverSeasonalTracks = data.tracks;
|
||||
|
||||
// Build compact playlist HTML
|
||||
let html = '<div class="discover-playlist-tracks-compact">';
|
||||
data.tracks.forEach((track, index) => {
|
||||
const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
|
||||
const durationMin = Math.floor(track.duration_ms / 60000);
|
||||
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
|
||||
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
|
||||
|
||||
html += `
|
||||
<div class="discover-playlist-track-compact" data-track-index="${index}">
|
||||
<div class="track-compact-number">${index + 1}</div>
|
||||
<div class="track-compact-image">
|
||||
<img src="${coverUrl}" alt="${track.album_name}">
|
||||
</div>
|
||||
<div class="track-compact-info">
|
||||
<div class="track-compact-name">${track.track_name}</div>
|
||||
<div class="track-compact-artist">${track.artist_name}</div>
|
||||
</div>
|
||||
<div class="track-compact-album">${track.album_name}</div>
|
||||
<div class="track-compact-duration">${duration}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
playlistContainer.innerHTML = html;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading seasonal playlist:', error);
|
||||
const playlistContainer = document.getElementById('seasonal-playlist');
|
||||
if (playlistContainer) {
|
||||
playlistContainer.innerHTML = '<div class="discover-empty"><p>Failed to load playlist</p></div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function hideSeasonalSections() {
|
||||
const seasonalAlbumsSection = document.getElementById('seasonal-albums-section');
|
||||
const seasonalPlaylistSection = document.getElementById('seasonal-playlist-section');
|
||||
|
||||
if (seasonalAlbumsSection) {
|
||||
seasonalAlbumsSection.style.display = 'none';
|
||||
}
|
||||
if (seasonalPlaylistSection) {
|
||||
seasonalPlaylistSection.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function openDownloadModalForSeasonalAlbum(index) {
|
||||
const album = discoverSeasonalAlbums[index];
|
||||
if (!album) return;
|
||||
|
||||
// Fetch album tracks
|
||||
const albumDetails = await fetchAlbumTracks(album.spotify_album_id);
|
||||
if (!albumDetails) return;
|
||||
|
||||
openDownloadMissingModal(albumDetails.tracks, albumDetails.name);
|
||||
}
|
||||
|
||||
async function openDownloadModalForSeasonalPlaylist() {
|
||||
if (!discoverSeasonalTracks || discoverSeasonalTracks.length === 0) {
|
||||
alert('No seasonal tracks available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to track format expected by modal
|
||||
const tracks = discoverSeasonalTracks.map(track => ({
|
||||
id: track.spotify_track_id,
|
||||
name: track.track_name,
|
||||
artists: [{ name: track.artist_name }],
|
||||
album: { name: track.album_name }
|
||||
}));
|
||||
|
||||
openDownloadMissingModal(tracks, `${currentSeasonKey} Seasonal Mix`);
|
||||
}
|
||||
|
||||
async function syncSeasonalPlaylist() {
|
||||
if (!currentSeasonKey) {
|
||||
alert('No active season');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the same sync logic as other discover playlists
|
||||
// Create a virtual playlist ID for tracking
|
||||
const virtualPlaylistId = `discover_seasonal_${currentSeasonKey}`;
|
||||
|
||||
// Build playlist data from seasonal tracks
|
||||
const playlistData = {
|
||||
id: virtualPlaylistId,
|
||||
name: `${currentSeasonKey.charAt(0).toUpperCase() + currentSeasonKey.slice(1)} Mix`,
|
||||
tracks: discoverSeasonalTracks.map(track => ({
|
||||
id: track.spotify_track_id,
|
||||
name: track.track_name,
|
||||
artists: [{ name: track.artist_name }],
|
||||
album: { name: track.album_name },
|
||||
duration_ms: track.duration_ms
|
||||
}))
|
||||
};
|
||||
|
||||
// Trigger sync (reuse existing sync infrastructure)
|
||||
await syncPlaylistToLibrary(playlistData);
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// DISCOVER PLAYLIST ACTIONS
|
||||
// ===============================
|
||||
|
|
|
|||
Loading…
Reference in a new issue