discovery page progress
This commit is contained in:
parent
0b7b88e892
commit
47f8862fc4
7 changed files with 2302 additions and 12 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
488
web_server.py
488
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"""
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue