diff --git a/core/spotify_client.py b/core/spotify_client.py index 2eac24a2..825ef5b9 100644 --- a/core/spotify_client.py +++ b/core/spotify_client.py @@ -498,9 +498,29 @@ class SpotifyClient: def get_user_info(self) -> Optional[Dict[str, Any]]: if not self.is_authenticated(): return None - + try: return self.sp.current_user() except Exception as e: logger.error(f"Error fetching user info: {e}") + return None + + @rate_limited + def get_artist(self, artist_id: str) -> Optional[Dict[str, Any]]: + """ + Get full artist details from Spotify API. + + Args: + artist_id: Spotify artist ID + + Returns: + Dictionary with artist data including images, genres, popularity + """ + if not self.is_authenticated(): + return None + + try: + return self.sp.artist(artist_id) + except Exception as e: + logger.error(f"Error fetching artist {artist_id}: {e}") return None \ No newline at end of file diff --git a/core/watchlist_scanner.py b/core/watchlist_scanner.py index 6f52da0f..1c0c4827 100644 --- a/core/watchlist_scanner.py +++ b/core/watchlist_scanner.py @@ -9,6 +9,8 @@ from datetime import datetime, timezone from dataclasses import dataclass import re import time +import requests +from bs4 import BeautifulSoup from database.music_database import get_database, WatchlistArtist from core.spotify_client import SpotifyClient from core.wishlist_service import get_wishlist_service @@ -166,7 +168,11 @@ class WatchlistScanner: logger.info(f"Watchlist scan complete: {len(successful_scans)}/{len(scan_results)} artists scanned successfully") logger.info(f"Found {total_new_tracks} new tracks, added {total_added_to_wishlist} to wishlist") - + + # Populate discovery pool with tracks from similar artists + logger.info("Starting discovery pool population...") + self.populate_discovery_pool() + return scan_results except Exception as e: @@ -239,7 +245,10 @@ class WatchlistScanner: # Update last scan timestamp for this artist self.update_artist_scan_timestamp(watchlist_artist.spotify_artist_id) - + + # Fetch and store similar artists for discovery feature + self.update_similar_artists(watchlist_artist) + return ScanResult( artist_name=watchlist_artist.artist_name, spotify_artist_id=watchlist_artist.spotify_artist_id, @@ -490,6 +499,293 @@ class WatchlistScanner: logger.error(f"Error updating scan timestamp for artist {spotify_artist_id}: {e}") return False + def _fetch_similar_artists_from_musicmap(self, artist_name: str, limit: int = 20) -> List[Dict[str, Any]]: + """ + Fetch similar artists from MusicMap and match them to Spotify. + + Args: + artist_name: The artist name to find similar artists for + limit: Maximum number of similar artists to return (default: 20) + + Returns: + List of matched artist dictionaries with Spotify data + """ + try: + logger.info(f"Fetching similar artists from MusicMap for: {artist_name}") + + # Construct MusicMap URL + url_artist = artist_name.lower().replace(' ', '+') + musicmap_url = f'https://www.music-map.com/{url_artist}' + + # Set headers to mimic a browser + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + } + + # Fetch MusicMap page + response = requests.get(musicmap_url, headers=headers, timeout=10) + response.raise_for_status() + + # Parse HTML + soup = BeautifulSoup(response.text, 'html.parser') + gnod_map = soup.find(id='gnodMap') + + if not gnod_map: + logger.warning(f"Could not find artist map on MusicMap for {artist_name}") + return [] + + # Extract similar artist names + all_anchors = gnod_map.find_all('a') + searched_artist_lower = artist_name.lower().strip() + + similar_artist_names = [] + for anchor in all_anchors: + artist_text = anchor.get_text(strip=True) + + # Skip if this is the searched artist + if artist_text.lower() == searched_artist_lower: + continue + + similar_artist_names.append(artist_text) + + logger.info(f"Found {len(similar_artist_names)} similar artists from MusicMap") + + # Get the searched artist's Spotify ID to exclude them + searched_artist_id = None + try: + searched_results = self.spotify_client.search_artists(artist_name, limit=1) + if searched_results and len(searched_results) > 0: + searched_artist_id = searched_results[0].id + except Exception as e: + logger.warning(f"Could not get searched artist ID: {e}") + + # Match each artist to Spotify + matched_artists = [] + seen_artist_ids = set() # Track seen artist IDs to prevent duplicates + + for artist_name_to_match in similar_artist_names[:limit]: + try: + # Search Spotify for the artist + results = self.spotify_client.search_artists(artist_name_to_match, limit=1) + + if results and len(results) > 0: + spotify_artist = results[0] + + # Skip if this is the searched artist + if spotify_artist.id == searched_artist_id: + continue + + # Skip if we've already seen this artist ID (deduplication) + if spotify_artist.id in seen_artist_ids: + continue + + seen_artist_ids.add(spotify_artist.id) + + matched_artists.append({ + 'id': spotify_artist.id, + 'name': spotify_artist.name, + 'image_url': spotify_artist.image_url if hasattr(spotify_artist, 'image_url') else None, + 'genres': spotify_artist.genres if hasattr(spotify_artist, 'genres') else [], + 'popularity': spotify_artist.popularity if hasattr(spotify_artist, 'popularity') else 0 + }) + + logger.debug(f" Matched: {spotify_artist.name}") + + except Exception as match_error: + logger.debug(f"Error matching {artist_name_to_match}: {match_error}") + continue + + logger.info(f"Matched {len(matched_artists)} similar artists to Spotify") + return matched_artists + + except requests.exceptions.RequestException as e: + logger.error(f"Error fetching from MusicMap: {e}") + return [] + except Exception as e: + logger.error(f"Error fetching similar artists from MusicMap: {e}") + return [] + + def update_similar_artists(self, watchlist_artist: WatchlistArtist, limit: int = 10) -> bool: + """ + Fetch and store similar artists for a watchlist artist. + Called after each artist scan to build discovery pool. + Uses MusicMap to find similar artists and matches them to Spotify. + """ + try: + logger.info(f"Fetching similar artists for {watchlist_artist.artist_name}") + + # Get similar artists from MusicMap (returns list of artist dicts) + similar_artists = self._fetch_similar_artists_from_musicmap(watchlist_artist.artist_name, limit=limit) + + if not similar_artists: + logger.debug(f"No similar artists found for {watchlist_artist.artist_name}") + return True # Not an error, just no recommendations + + logger.info(f"Found {len(similar_artists)} similar artists for {watchlist_artist.artist_name}") + + # Store each similar artist in database + stored_count = 0 + for rank, similar_artist in enumerate(similar_artists, 1): + try: + # similar_artist is a dict with 'id' and 'name' keys + success = self.database.add_or_update_similar_artist( + source_artist_id=watchlist_artist.spotify_artist_id, + similar_artist_spotify_id=similar_artist['id'], + similar_artist_name=similar_artist['name'], + similarity_rank=rank + ) + + if success: + stored_count += 1 + logger.debug(f" #{rank}: {similar_artist['name']} (Spotify ID: {similar_artist['id']})") + + except Exception as e: + logger.warning(f"Error storing similar artist {similar_artist.get('name', 'Unknown')}: {e}") + continue + + logger.info(f"Stored {stored_count}/{len(similar_artists)} similar artists for {watchlist_artist.artist_name}") + return True + + except Exception as e: + 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): + """ + Populate discovery pool with tracks from top similar artists. + Called after watchlist scan completes. + """ + try: + from datetime import datetime, timedelta + import random + + logger.info("Populating discovery pool from similar artists...") + + # Get top similar artists across all watchlist (ordered by occurrence_count) + similar_artists = self.database.get_top_similar_artists(limit=top_artists_limit) + + if not similar_artists: + logger.info("No similar artists found to populate discovery pool") + return + + logger.info(f"Processing {len(similar_artists)} top similar artists for discovery pool") + + total_tracks_added = 0 + + for artist_idx, similar_artist in enumerate(similar_artists, 1): + try: + logger.info(f"[{artist_idx}/{len(similar_artists)}] Processing {similar_artist.similar_artist_name} (occurrence: {similar_artist.occurrence_count})") + + # 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 + limit=50 + ) + + if not all_albums: + 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'] + + if len(studio_albums) == 0: + studio_albums = all_albums # Fallback to all if no studio albums + + # Select albums: latest + random selection + selected_albums = [] + + # Always include latest album + if studio_albums: + selected_albums.append(studio_albums[0]) # Latest is first + + # 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) + + logger.info(f" Selected {len(selected_albums)} albums from {len(studio_albums)} available") + + # Process each selected album + for album_idx, album in enumerate(selected_albums, 1): + try: + # Get full album data with tracks + album_data = self.spotify_client.get_album(album.id) + + if not album_data or 'tracks' not in album_data: + continue + + tracks = album_data['tracks'].get('items', []) + logger.debug(f" Album {album_idx}: {album_data.get('name', 'Unknown')} ({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: + if len(release_date_str) == 10: # Full date + 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: + # Build track data for discovery pool + track_data = { + 'spotify_track_id': track['id'], + 'spotify_album_id': album_data['id'], + 'spotify_artist_id': similar_artist.similar_artist_spotify_id, + 'track_name': track['name'], + 'artist_name': similar_artist.similar_artist_name, + 'album_name': album_data.get('name', 'Unknown Album'), + 'album_cover_url': album_data.get('images', [{}])[0].get('url') if album_data.get('images') else None, + 'duration_ms': track.get('duration_ms', 0), + 'popularity': album_data.get('popularity', 0), + 'release_date': album_data.get('release_date', ''), + 'is_new_release': is_new, + 'track_data_json': track # Store full Spotify track object + } + + # Add to discovery pool + 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 + + # Small delay between albums + time.sleep(0.3) + + except Exception as album_error: + logger.warning(f"Error processing album: {album_error}") + continue + + # Delay between artists + if artist_idx < len(similar_artists): + time.sleep(1.0) + + except Exception as artist_error: + logger.warning(f"Error processing artist {similar_artist.similar_artist_name}: {artist_error}") + continue + + logger.info(f"Discovery pool population complete: {total_tracks_added} tracks added") + + # Rotate discovery pool if needed (maintain 1000-2000 track limit) + self.database.rotate_discovery_pool(max_tracks=2000, remove_count=500) + + except Exception as e: + logger.error(f"Error populating discovery pool: {e}") + import traceback + traceback.print_exc() + # Singleton instance _watchlist_scanner_instance = None diff --git a/database/music_database.py b/database/music_database.py index a45ee2c0..5fe9cd0f 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -86,6 +86,47 @@ class WatchlistArtist: created_at: Optional[datetime] = None updated_at: Optional[datetime] = None +@dataclass +class SimilarArtist: + """Similar artist recommendation from Spotify""" + id: int + source_artist_id: str # Watchlist artist's database ID + similar_artist_spotify_id: str + similar_artist_name: str + similarity_rank: int # 1-10, where 1 is most similar + occurrence_count: int # How many watchlist artists share this similar artist + last_updated: datetime + +@dataclass +class DiscoveryTrack: + """Track in the discovery pool for recommendations""" + id: int + spotify_track_id: str + spotify_album_id: str + spotify_artist_id: str + track_name: str + artist_name: str + album_name: str + album_cover_url: Optional[str] + duration_ms: int + popularity: int + release_date: str + is_new_release: bool # Released within last 30 days + track_data_json: str # Full Spotify track object for modal + added_date: datetime + +@dataclass +class RecentRelease: + """Recent album release from watchlist artist""" + id: int + watchlist_artist_id: int + album_spotify_id: str + album_name: str + release_date: str + album_cover_url: Optional[str] + track_count: int + added_date: datetime + class MusicDatabase: """SQLite database manager for SoulSync music library data""" @@ -210,10 +251,13 @@ class MusicDatabase: # Add server_source columns for multi-server support (migration) self._add_server_source_columns(cursor) - + # Migrate ID columns to support both integer (Plex) and string (Jellyfin) IDs self._migrate_id_columns_to_text(cursor) - + + # Add discovery feature tables (migration) + self._add_discovery_tables(cursor) + conn.commit() logger.info("Database initialized successfully") @@ -375,7 +419,77 @@ class MusicDatabase: except Exception as e: logger.error(f"Error migrating ID columns: {e}") # Don't raise - this is a migration, database can still function - + + def _add_discovery_tables(self, cursor): + """Add tables for discovery feature: similar artists, discovery pool, and recent releases""" + try: + # Similar Artists table - stores similar artists for each watchlist artist + cursor.execute(""" + CREATE TABLE IF NOT EXISTS similar_artists ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_artist_id TEXT NOT NULL, + similar_artist_spotify_id TEXT NOT NULL, + similar_artist_name TEXT NOT NULL, + similarity_rank INTEGER DEFAULT 1, + occurrence_count INTEGER DEFAULT 1, + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(source_artist_id, similar_artist_spotify_id) + ) + """) + + # Discovery Pool table - rotating pool of 1000-2000 tracks for recommendations + cursor.execute(""" + CREATE TABLE IF NOT EXISTS discovery_pool ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + spotify_track_id TEXT UNIQUE NOT NULL, + spotify_album_id TEXT NOT NULL, + spotify_artist_id TEXT NOT NULL, + track_name TEXT NOT NULL, + artist_name TEXT NOT NULL, + album_name TEXT NOT NULL, + album_cover_url TEXT, + duration_ms INTEGER, + popularity INTEGER DEFAULT 0, + release_date TEXT, + is_new_release BOOLEAN DEFAULT 0, + track_data_json TEXT NOT NULL, + added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + + # Recent Releases table - tracks new releases from watchlist artists + cursor.execute(""" + CREATE TABLE IF NOT EXISTS recent_releases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + watchlist_artist_id INTEGER NOT NULL, + album_spotify_id TEXT NOT NULL, + album_name TEXT NOT NULL, + release_date TEXT NOT NULL, + album_cover_url TEXT, + track_count INTEGER DEFAULT 0, + added_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(watchlist_artist_id, album_spotify_id), + FOREIGN KEY (watchlist_artist_id) REFERENCES watchlist_artists (id) ON DELETE CASCADE + ) + """) + + # Create indexes for performance + cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_source ON similar_artists (source_artist_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_spotify ON similar_artists (similar_artist_spotify_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_similar_artists_occurrence ON similar_artists (occurrence_count)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_spotify_track ON discovery_pool (spotify_track_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_artist ON discovery_pool (spotify_artist_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_added_date ON discovery_pool (added_date)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_discovery_pool_is_new ON discovery_pool (is_new_release)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_watchlist ON recent_releases (watchlist_artist_id)") + cursor.execute("CREATE INDEX IF NOT EXISTS idx_recent_releases_date ON recent_releases (release_date)") + + logger.info("Discovery tables created successfully") + + except Exception as e: + logger.error(f"Error creating discovery tables: {e}") + # Don't raise - this is a migration, database can still function + def close(self): """Close database connection (no-op since we create connections per operation)""" # Each operation creates and closes its own connection, so nothing to do here @@ -2305,6 +2419,259 @@ class MusicDatabase: logger.error(f"Error getting watchlist count: {e}") return 0 + # === Discovery Feature Methods === + + def add_or_update_similar_artist(self, source_artist_id: str, similar_artist_spotify_id: str, + similar_artist_name: str, similarity_rank: int = 1) -> bool: + """Add or update a similar artist recommendation""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + INSERT INTO similar_artists + (source_artist_id, similar_artist_spotify_id, similar_artist_name, similarity_rank, occurrence_count, last_updated) + VALUES (?, ?, ?, ?, 1, CURRENT_TIMESTAMP) + ON CONFLICT(source_artist_id, similar_artist_spotify_id) + DO UPDATE SET + similarity_rank = excluded.similarity_rank, + occurrence_count = occurrence_count + 1, + last_updated = CURRENT_TIMESTAMP + """, (source_artist_id, similar_artist_spotify_id, similar_artist_name, similarity_rank)) + + conn.commit() + return True + + except Exception as e: + logger.error(f"Error adding similar artist: {e}") + return False + + def get_similar_artists_for_source(self, source_artist_id: str) -> List[SimilarArtist]: + """Get all similar artists for a given source artist""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + SELECT * FROM similar_artists + WHERE source_artist_id = ? + ORDER BY similarity_rank ASC + """, (source_artist_id,)) + + rows = cursor.fetchall() + return [SimilarArtist( + id=row['id'], + source_artist_id=row['source_artist_id'], + similar_artist_spotify_id=row['similar_artist_spotify_id'], + similar_artist_name=row['similar_artist_name'], + similarity_rank=row['similarity_rank'], + occurrence_count=row['occurrence_count'], + last_updated=datetime.fromisoformat(row['last_updated']) + ) for row in rows] + + except Exception as e: + logger.error(f"Error getting similar artists: {e}") + return [] + + def get_top_similar_artists(self, limit: int = 50) -> List[SimilarArtist]: + """Get top similar artists across all watchlist artists, ordered by occurrence count""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + SELECT + MAX(id) as id, + MAX(source_artist_id) as source_artist_id, + similar_artist_spotify_id, + similar_artist_name, + AVG(similarity_rank) as similarity_rank, + SUM(occurrence_count) as occurrence_count, + MAX(last_updated) as last_updated + FROM similar_artists + GROUP BY similar_artist_spotify_id, similar_artist_name + ORDER BY occurrence_count DESC, similarity_rank ASC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + return [SimilarArtist( + id=row['id'], + source_artist_id=row['source_artist_id'], + similar_artist_spotify_id=row['similar_artist_spotify_id'], + similar_artist_name=row['similar_artist_name'], + similarity_rank=int(row['similarity_rank']), + occurrence_count=row['occurrence_count'], + last_updated=datetime.fromisoformat(row['last_updated']) + ) for row in rows] + + except Exception as e: + logger.error(f"Error getting top similar artists: {e}") + return [] + + def add_to_discovery_pool(self, track_data: Dict[str, Any]) -> bool: + """Add a track to the discovery pool""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + # Check if track already exists + cursor.execute("SELECT COUNT(*) as count FROM discovery_pool WHERE spotify_track_id = ?", + (track_data['spotify_track_id'],)) + if cursor.fetchone()['count'] > 0: + return True # Already in pool + + cursor.execute(""" + INSERT INTO discovery_pool + (spotify_track_id, spotify_album_id, spotify_artist_id, track_name, artist_name, + album_name, album_cover_url, duration_ms, popularity, release_date, + is_new_release, track_data_json, added_date) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, ( + track_data['spotify_track_id'], + track_data['spotify_album_id'], + track_data['spotify_artist_id'], + track_data['track_name'], + track_data['artist_name'], + track_data['album_name'], + track_data.get('album_cover_url'), + track_data['duration_ms'], + track_data.get('popularity', 0), + track_data['release_date'], + track_data.get('is_new_release', False), + json.dumps(track_data['track_data_json']) + )) + + conn.commit() + return True + + except Exception as e: + logger.error(f"Error adding to discovery pool: {e}") + return False + + def rotate_discovery_pool(self, max_tracks: int = 2000, remove_count: int = 500): + """Remove oldest tracks from discovery pool if it exceeds max_tracks""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + # Check current count + cursor.execute("SELECT COUNT(*) as count FROM discovery_pool") + current_count = cursor.fetchone()['count'] + + if current_count > max_tracks: + # Remove oldest tracks + cursor.execute(""" + DELETE FROM discovery_pool + WHERE id IN ( + SELECT id FROM discovery_pool + ORDER BY added_date ASC + LIMIT ? + ) + """, (remove_count,)) + + conn.commit() + logger.info(f"Removed {remove_count} oldest tracks from discovery pool") + + except Exception as e: + logger.error(f"Error rotating discovery pool: {e}") + + def get_discovery_pool_tracks(self, limit: int = 100, new_releases_only: bool = False) -> List[DiscoveryTrack]: + """Get tracks from discovery pool""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + if new_releases_only: + cursor.execute(""" + SELECT * FROM discovery_pool + WHERE is_new_release = 1 + ORDER BY added_date DESC + LIMIT ? + """, (limit,)) + else: + cursor.execute(""" + SELECT * FROM discovery_pool + ORDER BY added_date DESC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + return [DiscoveryTrack( + id=row['id'], + spotify_track_id=row['spotify_track_id'], + spotify_album_id=row['spotify_album_id'], + spotify_artist_id=row['spotify_artist_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'], + release_date=row['release_date'], + is_new_release=bool(row['is_new_release']), + track_data_json=row['track_data_json'], + added_date=datetime.fromisoformat(row['added_date']) + ) for row in rows] + + except Exception as e: + logger.error(f"Error getting discovery pool tracks: {e}") + return [] + + def add_recent_release(self, watchlist_artist_id: int, album_data: Dict[str, Any]) -> bool: + """Add a recent release to the recent_releases table""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + INSERT OR IGNORE INTO recent_releases + (watchlist_artist_id, album_spotify_id, album_name, release_date, album_cover_url, track_count, added_date) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, ( + watchlist_artist_id, + album_data['album_spotify_id'], + album_data['album_name'], + album_data['release_date'], + album_data.get('album_cover_url'), + album_data.get('track_count', 0) + )) + + conn.commit() + return True + + except Exception as e: + logger.error(f"Error adding recent release: {e}") + return False + + def get_recent_releases(self, limit: int = 50) -> List[RecentRelease]: + """Get recent releases from watchlist artists""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + + cursor.execute(""" + SELECT * FROM recent_releases + ORDER BY release_date DESC, added_date DESC + LIMIT ? + """, (limit,)) + + rows = cursor.fetchall() + return [RecentRelease( + id=row['id'], + watchlist_artist_id=row['watchlist_artist_id'], + album_spotify_id=row['album_spotify_id'], + album_name=row['album_name'], + release_date=row['release_date'], + album_cover_url=row['album_cover_url'], + track_count=row['track_count'], + added_date=datetime.fromisoformat(row['added_date']) + ) for row in rows] + + except Exception as e: + logger.error(f"Error getting recent releases: {e}") + return [] + def get_database_info(self) -> Dict[str, Any]: """Get comprehensive database information for all servers (legacy method)""" try: diff --git a/web_server.py b/web_server.py index e465a115..f6994cd9 100644 --- a/web_server.py +++ b/web_server.py @@ -14042,6 +14042,17 @@ def start_watchlist_scan(): print(f"Watchlist scan completed: {len(successful_scans)}/{len(scan_results)} artists scanned successfully") print(f"Found {total_new_tracks} new tracks, added {total_added_to_wishlist} to wishlist") + # Populate discovery pool from similar artists + print("🎵 Starting discovery pool population...") + watchlist_scan_state['current_phase'] = 'populating_discovery_pool' + try: + scanner.populate_discovery_pool() + print("✅ Discovery pool population complete") + except Exception as discovery_error: + print(f"⚠️ Error populating discovery pool: {discovery_error}") + import traceback + traceback.print_exc() + except Exception as e: print(f"Error during watchlist scan: {e}") watchlist_scan_state['status'] = 'error' @@ -14086,20 +14097,131 @@ def get_watchlist_scan_status(): "status": "idle", "summary": {} }) - + # Convert datetime objects to ISO format for JSON serialization state = watchlist_scan_state.copy() if 'started_at' in state and state['started_at']: state['started_at'] = state['started_at'].isoformat() if 'completed_at' in state and state['completed_at']: state['completed_at'] = state['completed_at'].isoformat() - + + # Remove results array - it contains ScanResult objects that aren't JSON serializable + # The summary already contains the aggregate data we need + if 'results' in state: + del state['results'] + return jsonify({"success": True, **state}) - + except Exception as e: print(f"Error getting watchlist scan status: {e}") return jsonify({"success": False, "error": str(e)}), 500 +# Similar Artists Update State +similar_artists_update_state = { + 'status': 'idle', # idle, running, completed, error + 'artists_processed': 0, + 'total_artists': 0, + 'current_artist': None, + 'error': None +} +similar_artists_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="SimilarArtistsUpdate") + +@app.route('/api/watchlist/update-similar-artists', methods=['POST']) +def update_similar_artists_endpoint(): + """Update similar artists for all watchlist artists (for discovery feature)""" + try: + global similar_artists_update_state + + if similar_artists_update_state['status'] == 'running': + return jsonify({"success": False, "error": "Similar artists update already in progress"}), 409 + + if not spotify_client or not spotify_client.is_authenticated(): + return jsonify({"success": False, "error": "Spotify client not available"}), 400 + + # Reset state + similar_artists_update_state = { + 'status': 'running', + 'artists_processed': 0, + 'total_artists': 0, + 'current_artist': None, + 'error': None + } + + # Start update in background + similar_artists_executor.submit(_update_similar_artists_worker) + + return jsonify({"success": True, "message": "Similar artists update started"}) + + except Exception as e: + print(f"Error starting similar artists update: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/watchlist/similar-artists-status', methods=['GET']) +def get_similar_artists_update_status(): + """Get status of similar artists update""" + try: + global similar_artists_update_state + return jsonify({"success": True, **similar_artists_update_state}) + except Exception as e: + print(f"Error getting similar artists status: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +def _update_similar_artists_worker(): + """Background worker to update similar artists for all watchlist artists""" + global similar_artists_update_state + + try: + from core.watchlist_scanner import get_watchlist_scanner + from database.music_database import get_database + import time + + print("🎵 [Similar Artists] Starting similar artists update...") + + database = get_database() + watchlist_artists = database.get_watchlist_artists() + + if not watchlist_artists: + similar_artists_update_state['status'] = 'completed' + print("📭 [Similar Artists] No watchlist artists to process") + return + + similar_artists_update_state['total_artists'] = len(watchlist_artists) + print(f"📊 [Similar Artists] Processing {len(watchlist_artists)} watchlist artists") + + scanner = get_watchlist_scanner(spotify_client) + + for idx, artist in enumerate(watchlist_artists, 1): + try: + similar_artists_update_state['artists_processed'] = idx + similar_artists_update_state['current_artist'] = artist.artist_name + + print(f"[{idx}/{len(watchlist_artists)}] Updating similar artists for {artist.artist_name}") + + # Update similar artists for this artist + scanner.update_similar_artists(artist, limit=10) + + # Rate limiting + if idx < len(watchlist_artists): + time.sleep(2.0) # 2 seconds between artists + + except Exception as artist_error: + print(f"❌ [Similar Artists] Error processing {artist.artist_name}: {artist_error}") + continue + + # Update complete + similar_artists_update_state['status'] = 'completed' + similar_artists_update_state['current_artist'] = None + + print(f"✅ [Similar Artists] Update complete! Processed {len(watchlist_artists)} artists") + + except Exception as e: + print(f"❌ [Similar Artists] Critical error: {e}") + import traceback + traceback.print_exc() + + similar_artists_update_state['status'] = 'error' + similar_artists_update_state['error'] = str(e) + # --- Watchlist Auto-Scanning System --- watchlist_scan_state = { @@ -14282,6 +14404,366 @@ metadata_update_state = { metadata_update_worker = None metadata_update_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="metadata_update") +# =============================== +# == DISCOVER PAGE ENDPOINTS == +# =============================== + +@app.route('/api/discover/hero', methods=['GET']) +def get_discover_hero(): + """Get featured similar artists for hero slideshow""" + try: + database = get_database() + + # Get top similar artists (by occurrence count) + similar_artists = database.get_top_similar_artists(limit=10) + + if not similar_artists: + return jsonify({"success": True, "artists": []}) + + # Convert to JSON format with Spotify data enrichment + hero_artists = [] + for artist in similar_artists: + artist_data = { + "spotify_artist_id": artist.similar_artist_spotify_id, + "artist_name": artist.similar_artist_name, + "occurrence_count": artist.occurrence_count, + "similarity_rank": artist.similarity_rank + } + + # Try to get artist image from Spotify + try: + if spotify_client and spotify_client.is_authenticated(): + sp_artist = spotify_client.get_artist(artist.similar_artist_spotify_id) + if sp_artist and sp_artist.get('images'): + artist_data['image_url'] = sp_artist['images'][0]['url'] if sp_artist['images'] else None + artist_data['genres'] = sp_artist.get('genres', []) + artist_data['popularity'] = sp_artist.get('popularity', 0) + except: + pass + + hero_artists.append(artist_data) + + return jsonify({"success": True, "artists": hero_artists}) + + except Exception as e: + print(f"Error getting discover hero: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/discover/recent-releases', methods=['GET']) +def get_discover_recent_releases(): + """Get recent albums/EPs from watchlist artists and similar artists (last 3 months)""" + try: + from datetime import datetime, timedelta + import random + + database = get_database() + + if not spotify_client or not spotify_client.is_authenticated(): + return jsonify({"success": True, "albums": []}) + + # Get watchlist artists (5 random) + watchlist_artists = database.get_watchlist_artists() + watchlist_sample = random.sample(watchlist_artists, min(5, len(watchlist_artists))) if watchlist_artists else [] + + # Get similar artists (5 random from top 20) + similar_artists = database.get_top_similar_artists(limit=20) + similar_sample = random.sample(similar_artists, min(5, len(similar_artists))) if similar_artists else [] + + recent_albums = [] + cutoff_date = datetime.now() - timedelta(days=90) # 3 months + + # Process watchlist artists + for artist in watchlist_sample: + try: + albums = spotify_client.get_artist_albums( + artist.spotify_artist_id, + album_type='album,single', + limit=20 + ) + + for album in albums: + try: + # Check if album is recent (last 3 months) + if hasattr(album, 'release_date') and album.release_date: + release_str = album.release_date + if len(release_str) >= 10: # Full date + release_date = datetime.strptime(release_str[:10], "%Y-%m-%d") + if release_date >= cutoff_date: + recent_albums.append({ + "album_spotify_id": album.id, + "album_name": album.name, + "artist_name": artist.artist_name, + "artist_spotify_id": artist.spotify_artist_id, + "album_cover_url": album.image_url if hasattr(album, 'image_url') else None, + "release_date": release_str, + "album_type": album.album_type if hasattr(album, 'album_type') else 'album' + }) + except Exception as e: + continue + + except Exception as e: + print(f"Error fetching albums for watchlist artist {artist.artist_name}: {e}") + continue + + # Process similar artists + for artist in similar_sample: + try: + albums = spotify_client.get_artist_albums( + artist.similar_artist_spotify_id, + album_type='album,single', + limit=20 + ) + + for album in albums: + try: + # Check if album is recent (last 3 months) + if hasattr(album, 'release_date') and album.release_date: + release_str = album.release_date + if len(release_str) >= 10: # Full date + release_date = datetime.strptime(release_str[:10], "%Y-%m-%d") + if release_date >= cutoff_date: + recent_albums.append({ + "album_spotify_id": album.id, + "album_name": album.name, + "artist_name": artist.similar_artist_name, + "artist_spotify_id": artist.similar_artist_spotify_id, + "album_cover_url": album.image_url if hasattr(album, 'image_url') else None, + "release_date": release_str, + "album_type": album.album_type if hasattr(album, 'album_type') else 'album' + }) + except Exception as e: + continue + + except Exception as e: + print(f"Error fetching albums for similar artist {artist.similar_artist_name}: {e}") + continue + + # Sort by release date (newest first) and limit to 10 + recent_albums.sort(key=lambda x: x['release_date'], reverse=True) + recent_albums = recent_albums[:10] + + return jsonify({"success": True, "albums": recent_albums}) + + except Exception as e: + print(f"Error getting recent releases: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/discover/release-radar', methods=['GET']) +def get_discover_release_radar(): + """Get release radar playlist - 50 tracks randomly selected from recent albums""" + try: + from datetime import datetime, timedelta + import random + + database = get_database() + + if not spotify_client or not spotify_client.is_authenticated(): + return jsonify({"success": True, "tracks": []}) + + # Get watchlist artists (5 random) + watchlist_artists = database.get_watchlist_artists() + watchlist_sample = random.sample(watchlist_artists, min(5, len(watchlist_artists))) if watchlist_artists else [] + + # Get similar artists (5 random from top 20) + similar_artists = database.get_top_similar_artists(limit=20) + similar_sample = random.sample(similar_artists, min(5, len(similar_artists))) if similar_artists else [] + + all_tracks = [] + cutoff_date = datetime.now() - timedelta(days=90) # 3 months + + # Process watchlist artists + for artist in watchlist_sample: + try: + albums = spotify_client.get_artist_albums( + artist.spotify_artist_id, + album_type='album,single', + limit=20 + ) + + for album in albums: + try: + # Check if album is recent (last 3 months) + if hasattr(album, 'release_date') and album.release_date: + release_str = album.release_date + if len(release_str) >= 10: # Full date + release_date = datetime.strptime(release_str[:10], "%Y-%m-%d") + if release_date >= cutoff_date: + # Get album tracks + album_data = spotify_client.get_album(album.id) + if album_data and 'tracks' in album_data: + for track in album_data['tracks']['items']: + all_tracks.append({ + "spotify_track_id": track['id'], + "track_name": track['name'], + "artist_name": artist.artist_name, + "album_name": album.name, + "album_cover_url": album.image_url if hasattr(album, 'image_url') else None, + "duration_ms": track.get('duration_ms', 0), + "track_number": track.get('track_number', 0), + "track_data_json": track + }) + except Exception as e: + continue + + except Exception as e: + print(f"Error fetching albums for watchlist artist {artist.artist_name}: {e}") + continue + + # Process similar artists + for artist in similar_sample: + try: + albums = spotify_client.get_artist_albums( + artist.similar_artist_spotify_id, + album_type='album,single', + limit=20 + ) + + for album in albums: + try: + # Check if album is recent (last 3 months) + if hasattr(album, 'release_date') and album.release_date: + release_str = album.release_date + if len(release_str) >= 10: # Full date + release_date = datetime.strptime(release_str[:10], "%Y-%m-%d") + if release_date >= cutoff_date: + # Get album tracks + album_data = spotify_client.get_album(album.id) + if album_data and 'tracks' in album_data: + for track in album_data['tracks']['items']: + all_tracks.append({ + "spotify_track_id": track['id'], + "track_name": track['name'], + "artist_name": artist.similar_artist_name, + "album_name": album.name, + "album_cover_url": album.image_url if hasattr(album, 'image_url') else None, + "duration_ms": track.get('duration_ms', 0), + "track_number": track.get('track_number', 0), + "track_data_json": track + }) + except Exception as e: + continue + + except Exception as e: + print(f"Error fetching albums for similar artist {artist.similar_artist_name}: {e}") + continue + + # Randomly select 50 tracks + random.shuffle(all_tracks) + selected_tracks = all_tracks[:50] + + return jsonify({"success": True, "tracks": selected_tracks}) + + except Exception as e: + print(f"Error getting release radar: {e}") + import traceback + traceback.print_exc() + return jsonify({"success": False, "error": str(e)}), 500 + +@app.route('/api/discover/weekly', methods=['GET']) +def get_discover_weekly(): + """Get discovery weekly playlist - 50 tracks randomly selected from any albums (not just recent)""" + try: + import random + + database = get_database() + + if not spotify_client or not spotify_client.is_authenticated(): + return jsonify({"success": True, "tracks": []}) + + # Get watchlist artists (5 random) + watchlist_artists = database.get_watchlist_artists() + watchlist_sample = random.sample(watchlist_artists, min(5, len(watchlist_artists))) if watchlist_artists else [] + + # Get similar artists (5 random from top 20) + similar_artists = database.get_top_similar_artists(limit=20) + similar_sample = random.sample(similar_artists, min(5, len(similar_artists))) if similar_artists else [] + + all_tracks = [] + + # Process watchlist artists - get tracks from any albums (not just recent) + for artist in watchlist_sample: + try: + albums = spotify_client.get_artist_albums( + artist.spotify_artist_id, + album_type='album', # Full albums only + limit=50 + ) + + # Select 2-3 random albums per artist + selected_albums = random.sample(albums, min(3, len(albums))) if albums else [] + + for album in selected_albums: + try: + # Get album tracks + album_data = spotify_client.get_album(album.id) + if album_data and 'tracks' in album_data: + for track in album_data['tracks']['items']: + all_tracks.append({ + "spotify_track_id": track['id'], + "track_name": track['name'], + "artist_name": artist.artist_name, + "album_name": album.name, + "album_cover_url": album.image_url if hasattr(album, 'image_url') else None, + "duration_ms": track.get('duration_ms', 0), + "track_number": track.get('track_number', 0), + "track_data_json": track + }) + except Exception as e: + continue + + except Exception as e: + print(f"Error fetching albums for watchlist artist {artist.artist_name}: {e}") + continue + + # Process similar artists - get tracks from any albums + for artist in similar_sample: + try: + albums = spotify_client.get_artist_albums( + artist.similar_artist_spotify_id, + album_type='album', # Full albums only + limit=50 + ) + + # Select 2-3 random albums per artist + selected_albums = random.sample(albums, min(3, len(albums))) if albums else [] + + for album in selected_albums: + try: + # Get album tracks + album_data = spotify_client.get_album(album.id) + if album_data and 'tracks' in album_data: + for track in album_data['tracks']['items']: + all_tracks.append({ + "spotify_track_id": track['id'], + "track_name": track['name'], + "artist_name": artist.similar_artist_name, + "album_name": album.name, + "album_cover_url": album.image_url if hasattr(album, 'image_url') else None, + "duration_ms": track.get('duration_ms', 0), + "track_number": track.get('track_number', 0), + "track_data_json": track + }) + except Exception as e: + continue + + except Exception as e: + print(f"Error fetching albums for similar artist {artist.similar_artist_name}: {e}") + continue + + # Randomly select 50 tracks + random.shuffle(all_tracks) + selected_tracks = all_tracks[:50] + + return jsonify({"success": True, "tracks": selected_tracks}) + + except Exception as e: + print(f"Error getting discovery weekly: {e}") + import traceback + traceback.print_exc() + 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 6a390a1d..c094cb9a 100644 --- a/webui/index.html +++ b/webui/index.html @@ -37,6 +37,10 @@ 📚 Library + + + + +
+
🎧
+
+ + + + +
+
+

Recent Releases

+

New music from artists you follow

+
+ +
+ + +
+
+

Release Radar

+

Fresh tracks from new releases

+
+
+ +
+
+

Loading release radar...

+
+
+
+ + +
+
+

Discovery Weekly

+

A personalized playlist just for you

+
+
+ +
+
+

Curating your discovery playlist...

+
+
+
+ + +
+
+

More For You

+

Curated playlists based on your taste

+
+
+ +
+
+

Loading playlists...

+
+
+
+ + +
-
- +
@@ -18802,6 +18811,103 @@ async function pollWatchlistScanStatus() { } } +/** + * Update similar artists for discovery feature + */ +async function updateSimilarArtists() { + try { + const button = document.getElementById('update-similar-artists-btn'); + const scanButton = document.getElementById('scan-watchlist-btn'); + + button.disabled = true; + button.textContent = 'Updating...'; + if (scanButton) scanButton.disabled = true; + + const response = await fetch('/api/watchlist/update-similar-artists', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + + const data = await response.json(); + if (!data.success) { + throw new Error(data.error || 'Failed to update similar artists'); + } + + showToast('Updating similar artists in background...', 'success'); + + // Poll for completion + pollSimilarArtistsUpdate(); + + } catch (error) { + console.error('Error updating similar artists:', error); + const button = document.getElementById('update-similar-artists-btn'); + const scanButton = document.getElementById('scan-watchlist-btn'); + + button.disabled = false; + button.textContent = 'Update Similar Artists'; + if (scanButton) scanButton.disabled = false; + + showToast(`Error: ${error.message}`, 'error'); + } +} + +/** + * Poll similar artists update status + */ +async function pollSimilarArtistsUpdate() { + try { + const response = await fetch('/api/watchlist/similar-artists-status'); + const data = await response.json(); + + if (data.success) { + const button = document.getElementById('update-similar-artists-btn'); + const scanButton = document.getElementById('scan-watchlist-btn'); + + if (data.status === 'completed') { + if (button) { + button.disabled = false; + button.textContent = 'Update Similar Artists'; + } + if (scanButton) scanButton.disabled = false; + + showToast(`Updated similar artists for ${data.artists_processed || 0} artists!`, 'success'); + return; // Stop polling + + } else if (data.status === 'error') { + if (button) { + button.disabled = false; + button.textContent = 'Update Similar Artists'; + } + if (scanButton) scanButton.disabled = false; + + showToast('Error updating similar artists', 'error'); + return; // Stop polling + } else if (data.status === 'running') { + // Update button text with progress + if (button && data.current_artist) { + button.textContent = `Updating... (${data.artists_processed || 0}/${data.total_artists || 0})`; + } + } + } + + // Continue polling if still running + if (data.success && data.status === 'running') { + setTimeout(pollSimilarArtistsUpdate, 1000); // Poll every 1 second + } + + } catch (error) { + console.error('Error polling similar artists update:', error); + const button = document.getElementById('update-similar-artists-btn'); + const scanButton = document.getElementById('scan-watchlist-btn'); + + if (button) { + button.disabled = false; + button.textContent = 'Update Similar Artists'; + } + if (scanButton) scanButton.disabled = false; + } +} + /** * Remove artist from watchlist via modal */ @@ -24098,3 +24204,361 @@ async function selectPlexLibrary() { alert('Error selecting library. Please try again.'); } } + +// ============================================ +// == DISCOVER PAGE == +// ============================================ + +let discoverHeroIndex = 0; +let discoverHeroArtists = []; +let discoverHeroInterval = null; + +async function loadDiscoverPage() { + console.log('Loading discover page...'); + + // Load all sections + await Promise.all([ + loadDiscoverHero(), + loadDiscoverRecentReleases(), + loadDiscoverReleaseRadar(), + loadDiscoverWeekly(), + loadMoreForYou() + ]); +} + +async function loadDiscoverHero() { + try { + const response = await fetch('/api/discover/hero'); + if (!response.ok) { + console.error('Failed to fetch discover hero'); + return; + } + + const data = await response.json(); + if (!data.success || !data.artists || data.artists.length === 0) { + console.log('No hero artists available'); + showDiscoverHeroEmpty(); + return; + } + + discoverHeroArtists = data.artists; + discoverHeroIndex = 0; + + // Display first artist + displayDiscoverHeroArtist(discoverHeroArtists[0]); + + // Start slideshow (change every 8 seconds) + if (discoverHeroInterval) { + clearInterval(discoverHeroInterval); + } + if (discoverHeroArtists.length > 1) { + discoverHeroInterval = setInterval(() => { + discoverHeroIndex = (discoverHeroIndex + 1) % discoverHeroArtists.length; + displayDiscoverHeroArtist(discoverHeroArtists[discoverHeroIndex]); + }, 8000); + } + + } catch (error) { + console.error('Error loading discover hero:', error); + showDiscoverHeroEmpty(); + } +} + +function displayDiscoverHeroArtist(artist) { + const titleEl = document.getElementById('discover-hero-title'); + const subtitleEl = document.getElementById('discover-hero-subtitle'); + const imageEl = document.getElementById('discover-hero-image'); + const bgEl = document.getElementById('discover-hero-bg'); + + if (titleEl) { + titleEl.textContent = artist.artist_name; + } + + if (subtitleEl) { + const genres = artist.genres && artist.genres.length > 0 + ? artist.genres.slice(0, 3).join(', ') + : 'Discover new music'; + subtitleEl.textContent = genres; + } + + if (imageEl && artist.image_url) { + imageEl.innerHTML = `${artist.artist_name}`; + } else if (imageEl) { + imageEl.innerHTML = '
🎧
'; + } + + if (bgEl && artist.image_url) { + bgEl.style.backgroundImage = `url('${artist.image_url}')`; + bgEl.style.backgroundSize = 'cover'; + bgEl.style.backgroundPosition = 'center'; + } +} + +function showDiscoverHeroEmpty() { + const titleEl = document.getElementById('discover-hero-title'); + const subtitleEl = document.getElementById('discover-hero-subtitle'); + + if (titleEl) titleEl.textContent = 'No Recommendations Yet'; + if (subtitleEl) subtitleEl.textContent = 'Run a watchlist scan to generate personalized recommendations'; +} + +async function loadDiscoverRecentReleases() { + try { + const carousel = document.getElementById('recent-releases-carousel'); + if (!carousel) return; + + carousel.innerHTML = '

Loading recent releases...

'; + + const response = await fetch('/api/discover/recent-releases'); + if (!response.ok) { + throw new Error('Failed to fetch recent releases'); + } + + const data = await response.json(); + if (!data.success || !data.releases || data.releases.length === 0) { + carousel.innerHTML = '

No recent releases found

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

${release.album_name}

+

${release.release_date}

+
+
+ `; + }); + + carousel.innerHTML = html; + + } catch (error) { + console.error('Error loading recent releases:', error); + const carousel = document.getElementById('recent-releases-carousel'); + if (carousel) { + carousel.innerHTML = '

Failed to load recent releases

'; + } + } +} + +async function loadDiscoverReleaseRadar() { + try { + const playlistContainer = document.getElementById('release-radar-playlist'); + if (!playlistContainer) return; + + playlistContainer.innerHTML = '

Loading release radar...

'; + + const response = await fetch('/api/discover/release-radar'); + if (!response.ok) { + throw new Error('Failed to fetch release radar'); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + playlistContainer.innerHTML = '

No new releases available

'; + return; + } + + // 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 release radar:', error); + const playlistContainer = document.getElementById('release-radar-playlist'); + if (playlistContainer) { + playlistContainer.innerHTML = '

Failed to load release radar

'; + } + } +} + +async function loadDiscoverWeekly() { + try { + const playlistContainer = document.getElementById('discovery-weekly-playlist'); + if (!playlistContainer) return; + + playlistContainer.innerHTML = '

Curating your discovery playlist...

'; + + const response = await fetch('/api/discover/weekly'); + if (!response.ok) { + throw new Error('Failed to fetch discovery weekly'); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + playlistContainer.innerHTML = '

No tracks available yet

'; + return; + } + + // 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 discovery weekly:', error); + const playlistContainer = document.getElementById('discovery-weekly-playlist'); + if (playlistContainer) { + playlistContainer.innerHTML = '

Failed to load discovery weekly

'; + } + } +} + +async function loadMoreForYou() { + try { + const grid = document.getElementById('more-playlists-grid'); + if (!grid) return; + + grid.innerHTML = '

Loading playlists...

'; + + // Fetch discovery pool tracks to create curated playlists + const response = await fetch('/api/discover/weekly'); + if (!response.ok) { + throw new Error('Failed to fetch tracks for playlists'); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + grid.innerHTML = '

No playlists available yet

'; + return; + } + + const tracks = data.tracks; + + // Create curated playlists + const playlists = []; + + // 1. Popular Picks (by popularity score) + const popularTracks = [...tracks].sort((a, b) => b.popularity - a.popularity).slice(0, 20); + if (popularTracks.length > 0) { + playlists.push({ + name: 'Popular Picks', + description: 'Trending tracks from similar artists', + track_count: popularTracks.length, + cover: popularTracks[0].album_cover_url + }); + } + + // 2. Deep Cuts (lower popularity, hidden gems) + const deepCuts = [...tracks].filter(t => t.popularity < 50).slice(0, 20); + if (deepCuts.length > 0) { + playlists.push({ + name: 'Deep Cuts', + description: 'Hidden gems you might have missed', + track_count: deepCuts.length, + cover: deepCuts[0].album_cover_url + }); + } + + // 3. Fresh Finds (newest additions to pool) + const freshFinds = [...tracks].slice(0, 20); + if (freshFinds.length > 0) { + playlists.push({ + name: 'Fresh Finds', + description: 'Recently added to your discovery pool', + track_count: freshFinds.length, + cover: freshFinds[0].album_cover_url + }); + } + + // 4. Artist Mix (group by artist diversity) + const artistMap = {}; + tracks.forEach(track => { + if (!artistMap[track.artist_name]) { + artistMap[track.artist_name] = []; + } + if (artistMap[track.artist_name].length < 3) { + artistMap[track.artist_name].push(track); + } + }); + const mixTracks = Object.values(artistMap).flat().slice(0, 25); + if (mixTracks.length > 0) { + playlists.push({ + name: 'Artist Mix', + description: 'Diverse selection from multiple artists', + track_count: mixTracks.length, + cover: mixTracks[0].album_cover_url + }); + } + + // Build playlist grid HTML + let html = ''; + playlists.forEach(playlist => { + const coverUrl = playlist.cover || '/static/placeholder-album.png'; + html += ` +
+
+ ${playlist.name} +
+
+
+

${playlist.name}

+

${playlist.description}

+

${playlist.track_count} tracks

+
+
+ `; + }); + + grid.innerHTML = html; + + } catch (error) { + console.error('Error loading more for you:', error); + const grid = document.getElementById('more-playlists-grid'); + if (grid) { + grid.innerHTML = '

Failed to load playlists

'; + } + } +} diff --git a/webui/static/style.css b/webui/static/style.css index d7fa1d4f..c37d372a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -16100,3 +16100,569 @@ body { .tool-help-modal-body strong { color: #fff; } + +/* ==================================== + Discover Page Styles + ==================================== */ + +.discover-container { + width: 100%; + max-width: 1600px; + margin: 0 auto; + padding: 0; +} + +/* Hero Section */ +.discover-hero { + position: relative; + width: 100%; + height: 500px; + border-radius: 12px; + overflow: hidden; + margin-bottom: 40px; +} + +.discover-hero-background { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background-size: cover; + background-position: center; + filter: blur(0px); +} + +.discover-hero-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(90deg, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0.4) 70%, transparent 100%); +} + +.discover-hero-content { + position: relative; + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 60px; + gap: 40px; +} + +.discover-hero-info { + flex: 1; + max-width: 600px; + z-index: 1; +} + +.discover-hero-label { + font-size: 12px; + font-weight: 700; + letter-spacing: 2px; + color: #1db954; + margin-bottom: 12px; + text-transform: uppercase; +} + +.discover-hero-title { + font-size: 56px; + font-weight: 800; + color: #fff; + margin: 0 0 16px 0; + line-height: 1.1; + text-shadow: 0 4px 12px rgba(0,0,0,0.4); +} + +.discover-hero-subtitle { + font-size: 18px; + color: rgba(255, 255, 255, 0.85); + margin-bottom: 32px; + line-height: 1.6; +} + +.discover-hero-actions { + display: flex; + gap: 16px; +} + +.discover-hero-button { + display: flex; + align-items: center; + gap: 8px; + padding: 14px 32px; + border: none; + border-radius: 50px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; +} + +.discover-hero-button.primary { + background-color: #1db954; + color: #fff; +} + +.discover-hero-button.primary:hover { + background-color: #1ed760; + transform: scale(1.05); +} + +.discover-hero-button.secondary { + background-color: rgba(255, 255, 255, 0.1); + color: #fff; + border: 2px solid rgba(255, 255, 255, 0.3); +} + +.discover-hero-button.secondary:hover { + background-color: rgba(255, 255, 255, 0.2); + border-color: rgba(255, 255, 255, 0.5); +} + +.discover-hero-image { + flex-shrink: 0; + width: 320px; + height: 320px; + border-radius: 12px; + overflow: hidden; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.hero-image-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 120px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); +} + +.discover-hero-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Discover Sections */ +.discover-section { + margin-bottom: 50px; + padding: 0 20px; +} + +.discover-section-header { + margin-bottom: 24px; +} + +.discover-section-title { + font-size: 28px; + font-weight: 700; + color: #fff; + margin: 0 0 8px 0; +} + +.discover-section-subtitle { + font-size: 14px; + color: #999; + margin: 0; +} + +/* Carousel Styles */ +.discover-carousel { + display: flex; + gap: 20px; + overflow-x: auto; + padding-bottom: 20px; + scroll-behavior: smooth; +} + +.discover-carousel::-webkit-scrollbar { + height: 8px; +} + +.discover-carousel::-webkit-scrollbar-track { + background: #2a2a2a; + border-radius: 4px; +} + +.discover-carousel::-webkit-scrollbar-thumb { + background: #555; + border-radius: 4px; +} + +.discover-carousel::-webkit-scrollbar-thumb:hover { + background: #666; +} + +/* Playlist Grid */ +.discover-playlists-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 20px; +} + +/* Loading State */ +.discover-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px 20px; + color: #999; +} + +.discover-loading .loading-spinner { + width: 40px; + height: 40px; + border: 4px solid #333; + border-top-color: #1db954; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 16px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.discover-loading p { + margin: 0; + font-size: 14px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .discover-hero { + height: auto; + min-height: 400px; + } + + .discover-hero-content { + flex-direction: column; + padding: 40px 20px; + text-align: center; + } + + .discover-hero-title { + font-size: 36px; + } + + .discover-hero-image { + width: 240px; + height: 240px; + } + + .discover-hero-actions { + justify-content: center; + } + + .discover-section { + padding: 0 16px; + } +} + +/* Discover Cards */ +.discover-card { + flex-shrink: 0; + width: 200px; + background: #1a1a1a; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.discover-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); +} + +.discover-card-image { + width: 100%; + height: 200px; + overflow: hidden; +} + +.discover-card-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.discover-card-info { + padding: 12px; +} + +.discover-card-title { + font-size: 14px; + font-weight: 600; + color: #fff; + margin: 0 0 4px 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.discover-card-subtitle { + font-size: 12px; + color: #999; + margin: 0 0 4px 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.discover-card-meta { + font-size: 11px; + color: #666; + margin: 0; +} + +/* Discover Empty State */ +.discover-empty { + text-align: center; + padding: 60px 20px; + color: #999; +} + +/* Discover Playlist Tracks */ +.discover-playlist-tracks { + background: #1a1a1a; + border-radius: 8px; + overflow: hidden; +} + +.discover-playlist-track { + display: grid; + grid-template-columns: 40px 50px 1fr 2fr 80px; + gap: 16px; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid #2a2a2a; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.discover-playlist-track:hover { + background-color: #2a2a2a; +} + +.playlist-track-number { + font-size: 14px; + color: #999; + text-align: center; +} + +.playlist-track-image { + width: 50px; + height: 50px; + border-radius: 4px; + overflow: hidden; +} + +.playlist-track-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.playlist-track-info { + min-width: 0; +} + +.playlist-track-name { + font-size: 14px; + font-weight: 500; + color: #fff; + margin-bottom: 4px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.playlist-track-artist { + font-size: 12px; + color: #999; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.playlist-track-album { + font-size: 12px; + color: #999; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.playlist-track-duration { + font-size: 12px; + color: #999; + text-align: right; +} + +/* Compact Playlist Layout */ +.discover-playlist-container.compact { + max-height: 500px; + overflow-y: auto; + background: #1a1a1a; + border-radius: 8px; +} + +.discover-playlist-tracks-compact { + background: #1a1a1a; + border-radius: 8px; + overflow: hidden; +} + +.discover-playlist-track-compact { + display: grid; + grid-template-columns: 30px 40px 1fr 1.5fr 60px; + gap: 12px; + align-items: center; + padding: 8px 12px; + border-bottom: 1px solid #2a2a2a; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.discover-playlist-track-compact:hover { + background-color: #2a2a2a; +} + +.track-compact-number { + font-size: 13px; + color: #999; + text-align: center; +} + +.track-compact-image { + width: 40px; + height: 40px; + border-radius: 4px; + overflow: hidden; +} + +.track-compact-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.track-compact-info { + min-width: 0; +} + +.track-compact-name { + font-size: 13px; + font-weight: 500; + color: #fff; + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.track-compact-artist { + font-size: 11px; + color: #999; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.track-compact-album { + font-size: 11px; + color: #999; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.track-compact-duration { + font-size: 11px; + color: #999; + text-align: right; +} + +/* Discover Playlist Cards */ +.discover-playlist-card { + background: #1a1a1a; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.discover-playlist-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); +} + +.discover-playlist-cover { + position: relative; + width: 100%; + padding-bottom: 100%; + overflow: hidden; +} + +.discover-playlist-cover img { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.playlist-play-overlay { + position: absolute; + bottom: 12px; + right: 12px; + width: 48px; + height: 48px; + background-color: #1db954; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + color: #fff; + opacity: 0; + transform: translateY(8px); + transition: all 0.2s ease; +} + +.discover-playlist-card:hover .playlist-play-overlay { + opacity: 1; + transform: translateY(0); +} + +.discover-playlist-info { + padding: 16px; +} + +.discover-playlist-name { + font-size: 16px; + font-weight: 600; + color: #fff; + margin: 0 0 8px 0; +} + +.discover-playlist-description { + font-size: 13px; + color: #999; + margin: 0 0 8px 0; + line-height: 1.4; +} + +.discover-playlist-count { + font-size: 12px; + color: #666; + margin: 0; +}