discover page updates

This commit is contained in:
Broque Thomas 2025-11-16 15:11:42 -08:00
parent b16318f37e
commit bd4e0d131e
5 changed files with 1574 additions and 61 deletions

765
core/seasonal_discovery.py Normal file
View 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

View file

@ -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

View file

@ -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"""

View file

@ -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">

View file

@ -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
// ===============================