discovery page progress

This commit is contained in:
Broque Thomas 2025-11-11 10:39:46 -08:00
parent 0b7b88e892
commit 47f8862fc4
7 changed files with 2302 additions and 12 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -37,6 +37,10 @@
<span class="nav-icon">📚</span>
<span class="nav-text">Library</span>
</button>
<button class="nav-button" data-page="discover">
<span class="nav-icon">🎧</span>
<span class="nav-text">Discover</span>
</button>
<button class="nav-button" data-page="settings">
<span class="nav-icon">⚙️</span>
<span class="nav-text">Settings</span>
@ -1617,6 +1621,97 @@
</div>
</div>
<!-- Discover Page -->
<div class="page" id="discover-page">
<div class="discover-container">
<!-- Hero Section -->
<div class="discover-hero">
<div class="discover-hero-background" id="discover-hero-bg"></div>
<div class="discover-hero-overlay"></div>
<div class="discover-hero-content">
<div class="discover-hero-info">
<div class="discover-hero-label">FEATURED ARTIST</div>
<h1 class="discover-hero-title" id="discover-hero-title">Loading...</h1>
<p class="discover-hero-subtitle" id="discover-hero-subtitle">Discover new music tailored to your taste</p>
<div class="discover-hero-actions">
<button class="discover-hero-button primary" id="discover-hero-play">
<span class="button-icon"></span>
<span class="button-text">Play</span>
</button>
<button class="discover-hero-button secondary" id="discover-hero-add">
<span class="button-icon">+</span>
<span class="button-text">Add to Wishlist</span>
</button>
</div>
</div>
<div class="discover-hero-image" id="discover-hero-image">
<div class="hero-image-placeholder">🎧</div>
</div>
</div>
</div>
<!-- Recent Releases Section -->
<div class="discover-section">
<div class="discover-section-header">
<h2 class="discover-section-title">Recent Releases</h2>
<p class="discover-section-subtitle">New music from artists you follow</p>
</div>
<div class="discover-carousel" id="recent-releases-carousel">
<!-- Content will be populated dynamically -->
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Loading recent releases...</p>
</div>
</div>
</div>
<!-- Release Radar Section -->
<div class="discover-section">
<div class="discover-section-header">
<h2 class="discover-section-title">Release Radar</h2>
<p class="discover-section-subtitle">Fresh tracks from new releases</p>
</div>
<div class="discover-playlist-container compact" id="release-radar-playlist">
<!-- Content will be populated dynamically -->
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Loading release radar...</p>
</div>
</div>
</div>
<!-- Discovery Weekly Section -->
<div class="discover-section">
<div class="discover-section-header">
<h2 class="discover-section-title">Discovery Weekly</h2>
<p class="discover-section-subtitle">A personalized playlist just for you</p>
</div>
<div class="discover-playlist-container compact" id="discovery-weekly-playlist">
<!-- Content will be populated dynamically -->
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Curating your discovery playlist...</p>
</div>
</div>
</div>
<!-- More Playlists Section -->
<div class="discover-section">
<div class="discover-section-header">
<h2 class="discover-section-title">More For You</h2>
<p class="discover-section-subtitle">Curated playlists based on your taste</p>
</div>
<div class="discover-playlists-grid" id="more-playlists-grid">
<!-- Content will be populated dynamically -->
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Loading playlists...</p>
</div>
</div>
</div>
</div>
</div>
<!-- Settings Page -->
<div class="page" id="settings-page">
<div class="page-header">

View file

@ -441,6 +441,9 @@ async function loadPageData(pageId) {
case 'artist-detail':
// Artist detail page is handled separately by navigateToArtistDetail()
break;
case 'discover':
await loadDiscoverPage();
break;
case 'settings':
initializeSettings();
await loadSettingsData();
@ -18596,13 +18599,19 @@ async function showWatchlistModal() {
</div>
<div class="playlist-modal-body">
<div class="watchlist-actions" style="margin-bottom: 20px;">
<button class="playlist-modal-btn playlist-modal-btn-primary"
id="scan-watchlist-btn"
<div class="watchlist-actions" style="margin-bottom: 20px; display: flex; gap: 12px;">
<button class="playlist-modal-btn playlist-modal-btn-primary"
id="scan-watchlist-btn"
onclick="startWatchlistScan()"
${scanStatus === 'scanning' ? 'disabled' : ''}>
${scanStatus === 'scanning' ? 'Scanning...' : 'Scan for New Releases'}
</button>
<button class="playlist-modal-btn playlist-modal-btn-secondary"
id="update-similar-artists-btn"
onclick="updateSimilarArtists()"
${scanStatus === 'scanning' ? 'disabled' : ''}>
Update Similar Artists
</button>
</div>
<div class="watchlist-artists-list">
@ -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 = `<img src="${artist.image_url}" alt="${artist.artist_name}">`;
} else if (imageEl) {
imageEl.innerHTML = '<div class="hero-image-placeholder">🎧</div>';
}
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 = '<div class="discover-loading"><div class="loading-spinner"></div><p>Loading recent releases...</p></div>';
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 = '<div class="discover-empty"><p>No recent releases found</p></div>';
return;
}
// Build carousel HTML
let html = '';
data.releases.forEach(release => {
const coverUrl = release.album_cover_url || '/static/placeholder-album.png';
html += `
<div class="discover-card">
<div class="discover-card-image">
<img src="${coverUrl}" alt="${release.album_name}">
</div>
<div class="discover-card-info">
<h4 class="discover-card-title">${release.album_name}</h4>
<p class="discover-card-subtitle">${release.release_date}</p>
</div>
</div>
`;
});
carousel.innerHTML = html;
} catch (error) {
console.error('Error loading recent releases:', error);
const carousel = document.getElementById('recent-releases-carousel');
if (carousel) {
carousel.innerHTML = '<div class="discover-empty"><p>Failed to load recent releases</p></div>';
}
}
}
async function loadDiscoverReleaseRadar() {
try {
const playlistContainer = document.getElementById('release-radar-playlist');
if (!playlistContainer) return;
playlistContainer.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Loading release radar...</p></div>';
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 = '<div class="discover-empty"><p>No new releases available</p></div>';
return;
}
// Build compact playlist HTML
let html = '<div class="discover-playlist-tracks-compact">';
data.tracks.forEach((track, index) => {
const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
const durationMin = Math.floor(track.duration_ms / 60000);
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
html += `
<div class="discover-playlist-track-compact" data-track-index="${index}">
<div class="track-compact-number">${index + 1}</div>
<div class="track-compact-image">
<img src="${coverUrl}" alt="${track.album_name}">
</div>
<div class="track-compact-info">
<div class="track-compact-name">${track.track_name}</div>
<div class="track-compact-artist">${track.artist_name}</div>
</div>
<div class="track-compact-album">${track.album_name}</div>
<div class="track-compact-duration">${duration}</div>
</div>
`;
});
html += '</div>';
playlistContainer.innerHTML = html;
} catch (error) {
console.error('Error loading release radar:', error);
const playlistContainer = document.getElementById('release-radar-playlist');
if (playlistContainer) {
playlistContainer.innerHTML = '<div class="discover-empty"><p>Failed to load release radar</p></div>';
}
}
}
async function loadDiscoverWeekly() {
try {
const playlistContainer = document.getElementById('discovery-weekly-playlist');
if (!playlistContainer) return;
playlistContainer.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Curating your discovery playlist...</p></div>';
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 = '<div class="discover-empty"><p>No tracks available yet</p></div>';
return;
}
// Build compact playlist HTML
let html = '<div class="discover-playlist-tracks-compact">';
data.tracks.forEach((track, index) => {
const coverUrl = track.album_cover_url || '/static/placeholder-album.png';
const durationMin = Math.floor(track.duration_ms / 60000);
const durationSec = Math.floor((track.duration_ms % 60000) / 1000);
const duration = `${durationMin}:${durationSec.toString().padStart(2, '0')}`;
html += `
<div class="discover-playlist-track-compact" data-track-index="${index}">
<div class="track-compact-number">${index + 1}</div>
<div class="track-compact-image">
<img src="${coverUrl}" alt="${track.album_name}">
</div>
<div class="track-compact-info">
<div class="track-compact-name">${track.track_name}</div>
<div class="track-compact-artist">${track.artist_name}</div>
</div>
<div class="track-compact-album">${track.album_name}</div>
<div class="track-compact-duration">${duration}</div>
</div>
`;
});
html += '</div>';
playlistContainer.innerHTML = html;
} catch (error) {
console.error('Error loading discovery weekly:', error);
const playlistContainer = document.getElementById('discovery-weekly-playlist');
if (playlistContainer) {
playlistContainer.innerHTML = '<div class="discover-empty"><p>Failed to load discovery weekly</p></div>';
}
}
}
async function loadMoreForYou() {
try {
const grid = document.getElementById('more-playlists-grid');
if (!grid) return;
grid.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Loading playlists...</p></div>';
// 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 = '<div class="discover-empty"><p>No playlists available yet</p></div>';
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 += `
<div class="discover-playlist-card">
<div class="discover-playlist-cover">
<img src="${coverUrl}" alt="${playlist.name}">
<div class="playlist-play-overlay"></div>
</div>
<div class="discover-playlist-info">
<h4 class="discover-playlist-name">${playlist.name}</h4>
<p class="discover-playlist-description">${playlist.description}</p>
<p class="discover-playlist-count">${playlist.track_count} tracks</p>
</div>
</div>
`;
});
grid.innerHTML = html;
} catch (error) {
console.error('Error loading more for you:', error);
const grid = document.getElementById('more-playlists-grid');
if (grid) {
grid.innerHTML = '<div class="discover-empty"><p>Failed to load playlists</p></div>';
}
}
}

View file

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