From bd4e0d131e71d83b8e19cc5bd29bba1f5d68f95b Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sun, 16 Nov 2025 15:11:42 -0800 Subject: [PATCH] discover page updates --- core/seasonal_discovery.py | 765 +++++++++++++++++++++++++++++++++++++ core/watchlist_scanner.py | 425 ++++++++++++++++++--- web_server.py | 175 +++++++++ webui/index.html | 32 ++ webui/static/script.js | 238 ++++++++++++ 5 files changed, 1574 insertions(+), 61 deletions(-) create mode 100644 core/seasonal_discovery.py diff --git a/core/seasonal_discovery.py b/core/seasonal_discovery.py new file mode 100644 index 00000000..eb90054e --- /dev/null +++ b/core/seasonal_discovery.py @@ -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 diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 9d9bc0bc..60e1aca7 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -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 diff --git a/web_server.py b/web_server.py index 314c2a1f..be6f93bd 100644 --- a/web_server.py +++ b/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//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//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""" diff --git a/webui/index.html b/webui/index.html index 574fcb9d..e2c87770 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1708,6 +1708,38 @@ + + + + + +
diff --git a/webui/static/script.js b/webui/static/script.js index c56ecccf..187840e7 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -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 = '

No seasonal albums found

'; + return; + } + + // Build carousel HTML + let html = ''; + discoverSeasonalAlbums.forEach((album, index) => { + const coverUrl = album.album_cover_url || '/static/placeholder-album.png'; + html += ` +
+
+ ${album.album_name} +
+
+

${album.album_name}

+

${album.artist_name}

+ ${album.release_date ? `

${album.release_date}

` : ''} +
+
+ `; + }); + + 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 = '

Loading playlist...

'; + + // 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 = '

No tracks available yet

'; + return; + } + + // Store tracks for download/sync functionality + discoverSeasonalTracks = data.tracks; + + // Build compact playlist HTML + let html = '
'; + 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 += ` +
+
${index + 1}
+
+ ${track.album_name} +
+
+
${track.track_name}
+
${track.artist_name}
+
+
${track.album_name}
+
${duration}
+
+ `; + }); + html += '
'; + + playlistContainer.innerHTML = html; + + } catch (error) { + console.error('Error loading seasonal playlist:', error); + const playlistContainer = document.getElementById('seasonal-playlist'); + if (playlistContainer) { + playlistContainer.innerHTML = '

Failed to load playlist

'; + } + } +} + +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 // ===============================