discover page
This commit is contained in:
parent
bd4e0d131e
commit
68a15071f1
4 changed files with 973 additions and 1 deletions
543
core/personalized_playlists.py
Normal file
543
core/personalized_playlists.py
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
"""
|
||||||
|
Personalized Playlists Service - Creates Spotify-quality personalized playlists
|
||||||
|
from user's library and discovery pool
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Dict, Any, Optional, Tuple
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from collections import Counter
|
||||||
|
import random
|
||||||
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("personalized_playlists")
|
||||||
|
|
||||||
|
class PersonalizedPlaylistsService:
|
||||||
|
"""Service for generating personalized playlists from library and discovery pool"""
|
||||||
|
|
||||||
|
def __init__(self, database, spotify_client=None):
|
||||||
|
self.database = database
|
||||||
|
self.spotify_client = spotify_client
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# LIBRARY-BASED PLAYLISTS
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
def get_recently_added(self, limit: int = 50) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Get recently added tracks from library.
|
||||||
|
|
||||||
|
Returns tracks ordered by date_added DESC
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self.database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.spotify_track_id,
|
||||||
|
t.title as track_name,
|
||||||
|
t.duration_ms,
|
||||||
|
ar.name as artist_name,
|
||||||
|
al.title as album_name,
|
||||||
|
al.cover_url as album_cover_url,
|
||||||
|
t.popularity,
|
||||||
|
t.date_added
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN artists ar ON t.artist_id = ar.id
|
||||||
|
LEFT JOIN albums al ON t.album_id = al.id
|
||||||
|
WHERE t.spotify_track_id IS NOT NULL
|
||||||
|
ORDER BY t.date_added DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", (limit,))
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting recently added tracks: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_top_tracks(self, limit: int = 50) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Get user's all-time top tracks based on play count.
|
||||||
|
|
||||||
|
Note: Requires play_count column in tracks table
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self.database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if play_count column exists
|
||||||
|
cursor.execute("PRAGMA table_info(tracks)")
|
||||||
|
columns = [row['name'] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'play_count' not in columns:
|
||||||
|
logger.warning("play_count column not found - using random selection")
|
||||||
|
# Fallback: return random tracks
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.spotify_track_id,
|
||||||
|
t.title as track_name,
|
||||||
|
t.duration_ms,
|
||||||
|
ar.name as artist_name,
|
||||||
|
al.title as album_name,
|
||||||
|
al.cover_url as album_cover_url,
|
||||||
|
t.popularity,
|
||||||
|
0 as play_count
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN artists ar ON t.artist_id = ar.id
|
||||||
|
LEFT JOIN albums al ON t.album_id = al.id
|
||||||
|
WHERE t.spotify_track_id IS NOT NULL
|
||||||
|
ORDER BY RANDOM()
|
||||||
|
LIMIT ?
|
||||||
|
""", (limit,))
|
||||||
|
else:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.spotify_track_id,
|
||||||
|
t.title as track_name,
|
||||||
|
t.duration_ms,
|
||||||
|
ar.name as artist_name,
|
||||||
|
al.title as album_name,
|
||||||
|
al.cover_url as album_cover_url,
|
||||||
|
t.popularity,
|
||||||
|
t.play_count
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN artists ar ON t.artist_id = ar.id
|
||||||
|
LEFT JOIN albums al ON t.album_id = al.id
|
||||||
|
WHERE t.spotify_track_id IS NOT NULL
|
||||||
|
ORDER BY t.play_count DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", (limit,))
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting top tracks: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_forgotten_favorites(self, limit: int = 50) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Get tracks you loved but haven't played recently.
|
||||||
|
|
||||||
|
Criteria: High play count but not played in 60+ days
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with self.database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if required columns exist
|
||||||
|
cursor.execute("PRAGMA table_info(tracks)")
|
||||||
|
columns = [row['name'] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
has_play_count = 'play_count' in columns
|
||||||
|
has_last_played = 'last_played' in columns
|
||||||
|
|
||||||
|
if not has_play_count or not has_last_played:
|
||||||
|
logger.warning("play_count or last_played columns not found - using older tracks")
|
||||||
|
# Fallback: return older tracks by date_added
|
||||||
|
sixty_days_ago = (datetime.now() - timedelta(days=60)).isoformat()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.spotify_track_id,
|
||||||
|
t.title as track_name,
|
||||||
|
t.duration_ms,
|
||||||
|
ar.name as artist_name,
|
||||||
|
al.title as album_name,
|
||||||
|
al.cover_url as album_cover_url,
|
||||||
|
t.popularity,
|
||||||
|
t.date_added
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN artists ar ON t.artist_id = ar.id
|
||||||
|
LEFT JOIN albums al ON t.album_id = al.id
|
||||||
|
WHERE t.spotify_track_id IS NOT NULL
|
||||||
|
AND t.date_added < ?
|
||||||
|
ORDER BY t.date_added DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", (sixty_days_ago, limit))
|
||||||
|
else:
|
||||||
|
sixty_days_ago = (datetime.now() - timedelta(days=60)).isoformat()
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.spotify_track_id,
|
||||||
|
t.title as track_name,
|
||||||
|
t.duration_ms,
|
||||||
|
ar.name as artist_name,
|
||||||
|
al.title as album_name,
|
||||||
|
al.cover_url as album_cover_url,
|
||||||
|
t.popularity,
|
||||||
|
t.play_count,
|
||||||
|
t.last_played
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN artists ar ON t.artist_id = ar.id
|
||||||
|
LEFT JOIN albums al ON t.album_id = al.id
|
||||||
|
WHERE t.spotify_track_id IS NOT NULL
|
||||||
|
AND t.play_count > 5
|
||||||
|
AND (t.last_played IS NULL OR t.last_played < ?)
|
||||||
|
ORDER BY t.play_count DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", (sixty_days_ago, limit))
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting forgotten favorites: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_decade_playlist(self, decade: int, limit: int = 100) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Get tracks from a specific decade.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decade: Decade year (e.g., 2020 for 2020s, 2010 for 2010s)
|
||||||
|
limit: Maximum tracks to return
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
start_year = decade
|
||||||
|
end_year = decade + 9
|
||||||
|
|
||||||
|
with self.database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if release_year column exists
|
||||||
|
cursor.execute("PRAGMA table_info(tracks)")
|
||||||
|
columns = [row['name'] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'release_year' in columns:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.spotify_track_id,
|
||||||
|
t.title as track_name,
|
||||||
|
t.duration_ms,
|
||||||
|
ar.name as artist_name,
|
||||||
|
al.title as album_name,
|
||||||
|
al.cover_url as album_cover_url,
|
||||||
|
t.popularity,
|
||||||
|
t.release_year
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN artists ar ON t.artist_id = ar.id
|
||||||
|
LEFT JOIN albums al ON t.album_id = al.id
|
||||||
|
WHERE t.spotify_track_id IS NOT NULL
|
||||||
|
AND t.release_year BETWEEN ? AND ?
|
||||||
|
ORDER BY t.popularity DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", (start_year, end_year, limit))
|
||||||
|
else:
|
||||||
|
# Try to extract year from album release_date
|
||||||
|
logger.warning("release_year column not found - using album release_date")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.spotify_track_id,
|
||||||
|
t.title as track_name,
|
||||||
|
t.duration_ms,
|
||||||
|
ar.name as artist_name,
|
||||||
|
al.title as album_name,
|
||||||
|
al.cover_url as album_cover_url,
|
||||||
|
t.popularity,
|
||||||
|
al.release_date
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN artists ar ON t.artist_id = ar.id
|
||||||
|
LEFT JOIN albums al ON t.album_id = al.id
|
||||||
|
WHERE t.spotify_track_id IS NOT NULL
|
||||||
|
AND al.release_date IS NOT NULL
|
||||||
|
AND CAST(SUBSTR(al.release_date, 1, 4) AS INTEGER) BETWEEN ? AND ?
|
||||||
|
ORDER BY t.popularity DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", (start_year, end_year, limit))
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting decade playlist for {decade}s: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# DISCOVERY POOL PLAYLISTS
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
def get_popular_picks(self, limit: int = 50) -> List[Dict]:
|
||||||
|
"""Get high popularity tracks from discovery pool"""
|
||||||
|
try:
|
||||||
|
with self.database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
spotify_track_id,
|
||||||
|
track_name,
|
||||||
|
artist_name,
|
||||||
|
album_name,
|
||||||
|
album_cover_url,
|
||||||
|
duration_ms,
|
||||||
|
popularity
|
||||||
|
FROM discovery_pool
|
||||||
|
WHERE popularity >= 60
|
||||||
|
ORDER BY popularity DESC, RANDOM()
|
||||||
|
LIMIT ?
|
||||||
|
""", (limit,))
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting popular picks: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_hidden_gems(self, limit: int = 50) -> List[Dict]:
|
||||||
|
"""Get low popularity (underground/indie) tracks from discovery pool"""
|
||||||
|
try:
|
||||||
|
with self.database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
spotify_track_id,
|
||||||
|
track_name,
|
||||||
|
artist_name,
|
||||||
|
album_name,
|
||||||
|
album_cover_url,
|
||||||
|
duration_ms,
|
||||||
|
popularity
|
||||||
|
FROM discovery_pool
|
||||||
|
WHERE popularity < 40
|
||||||
|
ORDER BY RANDOM()
|
||||||
|
LIMIT ?
|
||||||
|
""", (limit,))
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting hidden gems: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# DAILY MIX (HYBRID PLAYLISTS)
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
def get_top_genres_from_library(self, limit: int = 5) -> List[Tuple[str, int]]:
|
||||||
|
"""
|
||||||
|
Get top genres from user's library by track count.
|
||||||
|
|
||||||
|
Returns: List of (genre_name, track_count) tuples
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get all genres from library tracks
|
||||||
|
with self.database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Try to get genres from tracks or albums
|
||||||
|
cursor.execute("PRAGMA table_info(tracks)")
|
||||||
|
columns = [row['name'] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'genres' in columns:
|
||||||
|
# Get genres directly from tracks
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT genres FROM tracks WHERE genres IS NOT NULL
|
||||||
|
""")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
# Parse genres (assuming JSON array or comma-separated)
|
||||||
|
all_genres = []
|
||||||
|
for row in rows:
|
||||||
|
genres_str = row['genres']
|
||||||
|
if genres_str:
|
||||||
|
# Try JSON parse first
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
genres = json.loads(genres_str)
|
||||||
|
all_genres.extend(genres)
|
||||||
|
except:
|
||||||
|
# Fallback to comma-separated
|
||||||
|
genres = [g.strip() for g in genres_str.split(',')]
|
||||||
|
all_genres.extend(genres)
|
||||||
|
|
||||||
|
# Count genres
|
||||||
|
genre_counts = Counter(all_genres)
|
||||||
|
return genre_counts.most_common(limit)
|
||||||
|
else:
|
||||||
|
# Fallback: use artist names as "genres"
|
||||||
|
logger.warning("No genres column - using top artists as categories")
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT ar.name, COUNT(*) as count
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN artists ar ON t.artist_id = ar.id
|
||||||
|
WHERE ar.name IS NOT NULL
|
||||||
|
GROUP BY ar.name
|
||||||
|
ORDER BY count DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", (limit,))
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [(row['name'], row['count']) for row in rows]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting top genres: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def create_daily_mix(self, genre_or_artist: str, mix_number: int = 1) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Create a Daily Mix playlist - hybrid of library + discovery pool.
|
||||||
|
|
||||||
|
Strategy:
|
||||||
|
- 50% tracks from user's library matching genre/artist
|
||||||
|
- 50% tracks from discovery pool matching genre/artist
|
||||||
|
|
||||||
|
Args:
|
||||||
|
genre_or_artist: Genre name or artist name to base mix on
|
||||||
|
mix_number: Mix number (1, 2, 3, etc.)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with playlist metadata and tracks
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info(f"Creating Daily Mix #{mix_number} for: {genre_or_artist}")
|
||||||
|
|
||||||
|
mix_size = 50
|
||||||
|
library_portion = mix_size // 2 # 25 tracks
|
||||||
|
discovery_portion = mix_size - library_portion # 25 tracks
|
||||||
|
|
||||||
|
# Get tracks from library
|
||||||
|
library_tracks = self._get_library_tracks_by_category(genre_or_artist, library_portion)
|
||||||
|
|
||||||
|
# Get tracks from discovery pool
|
||||||
|
discovery_tracks = self._get_discovery_tracks_by_category(genre_or_artist, discovery_portion)
|
||||||
|
|
||||||
|
# Combine and shuffle
|
||||||
|
all_tracks = library_tracks + discovery_tracks
|
||||||
|
random.shuffle(all_tracks)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'mix_number': mix_number,
|
||||||
|
'name': f"Daily Mix {mix_number}",
|
||||||
|
'description': f"{genre_or_artist} mix",
|
||||||
|
'category': genre_or_artist,
|
||||||
|
'track_count': len(all_tracks),
|
||||||
|
'tracks': all_tracks
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error creating daily mix: {e}")
|
||||||
|
return {
|
||||||
|
'mix_number': mix_number,
|
||||||
|
'name': f"Daily Mix {mix_number}",
|
||||||
|
'description': 'Mix',
|
||||||
|
'category': genre_or_artist,
|
||||||
|
'track_count': 0,
|
||||||
|
'tracks': []
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_library_tracks_by_category(self, category: str, limit: int) -> List[Dict]:
|
||||||
|
"""Get tracks from library matching genre or artist"""
|
||||||
|
try:
|
||||||
|
with self.database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Try genre match first, then artist match
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.spotify_track_id,
|
||||||
|
t.title as track_name,
|
||||||
|
t.duration_ms,
|
||||||
|
ar.name as artist_name,
|
||||||
|
al.title as album_name,
|
||||||
|
al.cover_url as album_cover_url,
|
||||||
|
t.popularity
|
||||||
|
FROM tracks t
|
||||||
|
LEFT JOIN artists ar ON t.artist_id = ar.id
|
||||||
|
LEFT JOIN albums al ON t.album_id = al.id
|
||||||
|
WHERE t.spotify_track_id IS NOT NULL
|
||||||
|
AND (ar.name LIKE ? OR t.genres LIKE ?)
|
||||||
|
ORDER BY RANDOM()
|
||||||
|
LIMIT ?
|
||||||
|
""", (f'%{category}%', f'%{category}%', limit))
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting library tracks by category: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _get_discovery_tracks_by_category(self, category: str, limit: int) -> List[Dict]:
|
||||||
|
"""Get tracks from discovery pool matching genre or artist"""
|
||||||
|
try:
|
||||||
|
with self.database._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
spotify_track_id,
|
||||||
|
track_name,
|
||||||
|
artist_name,
|
||||||
|
album_name,
|
||||||
|
album_cover_url,
|
||||||
|
duration_ms,
|
||||||
|
popularity
|
||||||
|
FROM discovery_pool
|
||||||
|
WHERE artist_name LIKE ? OR track_name LIKE ?
|
||||||
|
ORDER BY RANDOM()
|
||||||
|
LIMIT ?
|
||||||
|
""", (f'%{category}%', f'%{category}%', limit))
|
||||||
|
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting discovery tracks by category: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_all_daily_mixes(self, max_mixes: int = 4) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Generate multiple Daily Mix playlists based on top genres/artists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_mixes: Maximum number of mixes to generate (default: 4)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of daily mix dictionaries
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get top categories (genres or artists)
|
||||||
|
top_categories = self.get_top_genres_from_library(limit=max_mixes)
|
||||||
|
|
||||||
|
if not top_categories:
|
||||||
|
logger.warning("No categories found for Daily Mixes")
|
||||||
|
return []
|
||||||
|
|
||||||
|
daily_mixes = []
|
||||||
|
for i, (category, _count) in enumerate(top_categories, 1):
|
||||||
|
mix = self.create_daily_mix(category, mix_number=i)
|
||||||
|
if mix['track_count'] > 0:
|
||||||
|
daily_mixes.append(mix)
|
||||||
|
|
||||||
|
logger.info(f"Created {len(daily_mixes)} Daily Mixes")
|
||||||
|
return daily_mixes
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting all daily mixes: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_personalized_playlists_instance = None
|
||||||
|
|
||||||
|
def get_personalized_playlists_service(database, spotify_client=None):
|
||||||
|
"""Get the global personalized playlists service instance"""
|
||||||
|
global _personalized_playlists_instance
|
||||||
|
if _personalized_playlists_instance is None:
|
||||||
|
_personalized_playlists_instance = PersonalizedPlaylistsService(database, spotify_client)
|
||||||
|
return _personalized_playlists_instance
|
||||||
147
web_server.py
147
web_server.py
|
|
@ -14860,6 +14860,153 @@ def refresh_seasonal_content():
|
||||||
print(f"Error refreshing seasonal content: {e}")
|
print(f"Error refreshing seasonal content: {e}")
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# PERSONALIZED PLAYLISTS ENDPOINTS
|
||||||
|
# ========================================
|
||||||
|
|
||||||
|
@app.route('/api/discover/personalized/recently-added', methods=['GET'])
|
||||||
|
def get_recently_added_playlist():
|
||||||
|
"""Get recently added tracks from library"""
|
||||||
|
try:
|
||||||
|
from core.personalized_playlists import get_personalized_playlists_service
|
||||||
|
|
||||||
|
database = get_database()
|
||||||
|
service = get_personalized_playlists_service(database, spotify_client)
|
||||||
|
|
||||||
|
tracks = service.get_recently_added(limit=50)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"tracks": tracks
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting recently added playlist: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/discover/personalized/top-tracks', methods=['GET'])
|
||||||
|
def get_top_tracks_playlist():
|
||||||
|
"""Get user's all-time top tracks"""
|
||||||
|
try:
|
||||||
|
from core.personalized_playlists import get_personalized_playlists_service
|
||||||
|
|
||||||
|
database = get_database()
|
||||||
|
service = get_personalized_playlists_service(database, spotify_client)
|
||||||
|
|
||||||
|
tracks = service.get_top_tracks(limit=50)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"tracks": tracks
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting top tracks playlist: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/discover/personalized/forgotten-favorites', methods=['GET'])
|
||||||
|
def get_forgotten_favorites_playlist():
|
||||||
|
"""Get forgotten favorites - tracks you loved but haven't played recently"""
|
||||||
|
try:
|
||||||
|
from core.personalized_playlists import get_personalized_playlists_service
|
||||||
|
|
||||||
|
database = get_database()
|
||||||
|
service = get_personalized_playlists_service(database, spotify_client)
|
||||||
|
|
||||||
|
tracks = service.get_forgotten_favorites(limit=50)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"tracks": tracks
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting forgotten favorites playlist: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/discover/personalized/decade/<int:decade>', methods=['GET'])
|
||||||
|
def get_decade_playlist(decade):
|
||||||
|
"""Get tracks from a specific decade"""
|
||||||
|
try:
|
||||||
|
from core.personalized_playlists import get_personalized_playlists_service
|
||||||
|
|
||||||
|
database = get_database()
|
||||||
|
service = get_personalized_playlists_service(database, spotify_client)
|
||||||
|
|
||||||
|
tracks = service.get_decade_playlist(decade, limit=100)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"decade": decade,
|
||||||
|
"tracks": tracks
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting decade playlist: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/discover/personalized/popular-picks', methods=['GET'])
|
||||||
|
def get_popular_picks_playlist():
|
||||||
|
"""Get high popularity tracks from discovery pool"""
|
||||||
|
try:
|
||||||
|
from core.personalized_playlists import get_personalized_playlists_service
|
||||||
|
|
||||||
|
database = get_database()
|
||||||
|
service = get_personalized_playlists_service(database, spotify_client)
|
||||||
|
|
||||||
|
tracks = service.get_popular_picks(limit=50)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"tracks": tracks
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting popular picks playlist: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/discover/personalized/hidden-gems', methods=['GET'])
|
||||||
|
def get_hidden_gems_playlist():
|
||||||
|
"""Get hidden gems (low popularity) from discovery pool"""
|
||||||
|
try:
|
||||||
|
from core.personalized_playlists import get_personalized_playlists_service
|
||||||
|
|
||||||
|
database = get_database()
|
||||||
|
service = get_personalized_playlists_service(database, spotify_client)
|
||||||
|
|
||||||
|
tracks = service.get_hidden_gems(limit=50)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"tracks": tracks
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting hidden gems playlist: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route('/api/discover/personalized/daily-mixes', methods=['GET'])
|
||||||
|
def get_daily_mixes():
|
||||||
|
"""Get all Daily Mix playlists (hybrid library + discovery)"""
|
||||||
|
try:
|
||||||
|
from core.personalized_playlists import get_personalized_playlists_service
|
||||||
|
|
||||||
|
database = get_database()
|
||||||
|
service = get_personalized_playlists_service(database, spotify_client)
|
||||||
|
|
||||||
|
mixes = service.get_all_daily_mixes(max_mixes=4)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"mixes": mixes
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting daily mixes: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
@app.route('/api/metadata/start', methods=['POST'])
|
@app.route('/api/metadata/start', methods=['POST'])
|
||||||
def start_metadata_update():
|
def start_metadata_update():
|
||||||
"""Start the metadata update process - EXACT copy of dashboard.py logic"""
|
"""Start the metadata update process - EXACT copy of dashboard.py logic"""
|
||||||
|
|
|
||||||
|
|
@ -1740,6 +1740,28 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recently Added Section -->
|
||||||
|
<div class="discover-section" style="display: none;">
|
||||||
|
<div class="discover-section-header">
|
||||||
|
<h2 class="discover-section-title">🆕 Recently Added</h2>
|
||||||
|
<p class="discover-section-subtitle">Latest additions to your library</p>
|
||||||
|
</div>
|
||||||
|
<div class="discover-playlist-container compact" id="personalized-recently-added">
|
||||||
|
<!-- Content will be populated dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Daily Mixes Section -->
|
||||||
|
<div class="discover-section" style="display: none;">
|
||||||
|
<div class="discover-section-header">
|
||||||
|
<h2 class="discover-section-title">🎵 Daily Mixes</h2>
|
||||||
|
<p class="discover-section-subtitle">Personalized mixes based on your taste</p>
|
||||||
|
</div>
|
||||||
|
<div class="discover-more-playlists-grid" id="daily-mixes-grid">
|
||||||
|
<!-- Content will be populated dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Fresh Tape Section -->
|
<!-- Fresh Tape Section -->
|
||||||
<div class="discover-section">
|
<div class="discover-section">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
|
|
@ -1828,6 +1850,50 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Popular Picks Section -->
|
||||||
|
<div class="discover-section" style="display: none;">
|
||||||
|
<div class="discover-section-header">
|
||||||
|
<h2 class="discover-section-title">🔥 Popular Picks</h2>
|
||||||
|
<p class="discover-section-subtitle">Trending tracks from new discoveries</p>
|
||||||
|
</div>
|
||||||
|
<div class="discover-playlist-container compact" id="personalized-popular-picks">
|
||||||
|
<!-- Content will be populated dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden Gems Section -->
|
||||||
|
<div class="discover-section" style="display: none;">
|
||||||
|
<div class="discover-section-header">
|
||||||
|
<h2 class="discover-section-title">🌟 Hidden Gems</h2>
|
||||||
|
<p class="discover-section-subtitle">Underground discoveries waiting for you</p>
|
||||||
|
</div>
|
||||||
|
<div class="discover-playlist-container compact" id="personalized-hidden-gems">
|
||||||
|
<!-- Content will be populated dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Your Top 50 Section -->
|
||||||
|
<div class="discover-section" style="display: none;">
|
||||||
|
<div class="discover-section-header">
|
||||||
|
<h2 class="discover-section-title">🏆 Your Top 50</h2>
|
||||||
|
<p class="discover-section-subtitle">All-time favorites from your library</p>
|
||||||
|
</div>
|
||||||
|
<div class="discover-playlist-container compact" id="personalized-top-tracks">
|
||||||
|
<!-- Content will be populated dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Forgotten Favorites Section -->
|
||||||
|
<div class="discover-section" style="display: none;">
|
||||||
|
<div class="discover-section-header">
|
||||||
|
<h2 class="discover-section-title">💎 Forgotten Favorites</h2>
|
||||||
|
<p class="discover-section-subtitle">Rediscover tracks you used to love</p>
|
||||||
|
</div>
|
||||||
|
<div class="discover-playlist-container compact" id="personalized-forgotten-favorites">
|
||||||
|
<!-- Content will be populated dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- More Playlists Section -->
|
<!-- More Playlists Section -->
|
||||||
<div class="discover-section">
|
<div class="discover-section">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
|
|
|
||||||
|
|
@ -24708,6 +24708,14 @@ let discoverSeasonalAlbums = [];
|
||||||
let discoverSeasonalTracks = [];
|
let discoverSeasonalTracks = [];
|
||||||
let currentSeasonKey = null;
|
let currentSeasonKey = null;
|
||||||
|
|
||||||
|
// Personalized playlists storage
|
||||||
|
let personalizedRecentlyAdded = [];
|
||||||
|
let personalizedTopTracks = [];
|
||||||
|
let personalizedForgottenFavorites = [];
|
||||||
|
let personalizedPopularPicks = [];
|
||||||
|
let personalizedHiddenGems = [];
|
||||||
|
let personalizedDailyMixes = [];
|
||||||
|
|
||||||
async function loadDiscoverPage() {
|
async function loadDiscoverPage() {
|
||||||
console.log('Loading discover page...');
|
console.log('Loading discover page...');
|
||||||
|
|
||||||
|
|
@ -24715,9 +24723,15 @@ async function loadDiscoverPage() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadDiscoverHero(),
|
loadDiscoverHero(),
|
||||||
loadDiscoverRecentReleases(),
|
loadDiscoverRecentReleases(),
|
||||||
loadSeasonalContent(), // NEW: Seasonal discovery
|
loadSeasonalContent(), // Seasonal discovery
|
||||||
|
loadPersonalizedRecentlyAdded(), // NEW: Recently added from library
|
||||||
|
loadPersonalizedDailyMixes(), // NEW: Daily Mix playlists
|
||||||
loadDiscoverReleaseRadar(),
|
loadDiscoverReleaseRadar(),
|
||||||
loadDiscoverWeekly(),
|
loadDiscoverWeekly(),
|
||||||
|
loadPersonalizedPopularPicks(), // NEW: Popular picks from discovery pool
|
||||||
|
loadPersonalizedHiddenGems(), // NEW: Hidden gems from discovery pool
|
||||||
|
loadPersonalizedTopTracks(), // NEW: Your top tracks
|
||||||
|
loadPersonalizedForgottenFavorites(), // NEW: Forgotten favorites
|
||||||
loadMoreForYou()
|
loadMoreForYou()
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -25516,6 +25530,208 @@ async function syncSeasonalPlaylist() {
|
||||||
await syncPlaylistToLibrary(playlistData);
|
await syncPlaylistToLibrary(playlistData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// PERSONALIZED PLAYLISTS
|
||||||
|
// ===============================
|
||||||
|
|
||||||
|
async function loadPersonalizedRecentlyAdded() {
|
||||||
|
try {
|
||||||
|
const container = document.getElementById('personalized-recently-added');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const response = await fetch('/api/discover/personalized/recently-added');
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||||||
|
container.closest('.discover-section').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
personalizedRecentlyAdded = data.tracks;
|
||||||
|
renderCompactPlaylist(container, data.tracks);
|
||||||
|
container.closest('.discover-section').style.display = 'block';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading recently added:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPersonalizedTopTracks() {
|
||||||
|
try {
|
||||||
|
const container = document.getElementById('personalized-top-tracks');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const response = await fetch('/api/discover/personalized/top-tracks');
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||||||
|
container.closest('.discover-section').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
personalizedTopTracks = data.tracks;
|
||||||
|
renderCompactPlaylist(container, data.tracks);
|
||||||
|
container.closest('.discover-section').style.display = 'block';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading top tracks:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPersonalizedForgottenFavorites() {
|
||||||
|
try {
|
||||||
|
const container = document.getElementById('personalized-forgotten-favorites');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const response = await fetch('/api/discover/personalized/forgotten-favorites');
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||||||
|
container.closest('.discover-section').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
personalizedForgottenFavorites = data.tracks;
|
||||||
|
renderCompactPlaylist(container, data.tracks);
|
||||||
|
container.closest('.discover-section').style.display = 'block';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading forgotten favorites:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPersonalizedPopularPicks() {
|
||||||
|
try {
|
||||||
|
const container = document.getElementById('personalized-popular-picks');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const response = await fetch('/api/discover/personalized/popular-picks');
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||||||
|
container.closest('.discover-section').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
personalizedPopularPicks = data.tracks;
|
||||||
|
renderCompactPlaylist(container, data.tracks);
|
||||||
|
container.closest('.discover-section').style.display = 'block';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading popular picks:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPersonalizedHiddenGems() {
|
||||||
|
try {
|
||||||
|
const container = document.getElementById('personalized-hidden-gems');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const response = await fetch('/api/discover/personalized/hidden-gems');
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success || !data.tracks || data.tracks.length === 0) {
|
||||||
|
container.closest('.discover-section').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
personalizedHiddenGems = data.tracks;
|
||||||
|
renderCompactPlaylist(container, data.tracks);
|
||||||
|
container.closest('.discover-section').style.display = 'block';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading hidden gems:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPersonalizedDailyMixes() {
|
||||||
|
try {
|
||||||
|
const container = document.getElementById('daily-mixes-grid');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const response = await fetch('/api/discover/personalized/daily-mixes');
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.success || !data.mixes || data.mixes.length === 0) {
|
||||||
|
container.closest('.discover-section').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
personalizedDailyMixes = data.mixes;
|
||||||
|
|
||||||
|
// Render Daily Mix cards
|
||||||
|
let html = '';
|
||||||
|
data.mixes.forEach((mix, index) => {
|
||||||
|
const coverUrl = mix.tracks && mix.tracks.length > 0 ?
|
||||||
|
(mix.tracks[0].album_cover_url || '/static/placeholder-album.png') :
|
||||||
|
'/static/placeholder-album.png';
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="discover-playlist-card" onclick="openDailyMix(${index})">
|
||||||
|
<div class="discover-playlist-cover">
|
||||||
|
<img src="${coverUrl}" alt="${mix.name}">
|
||||||
|
<div class="playlist-play-overlay">▶</div>
|
||||||
|
</div>
|
||||||
|
<div class="discover-playlist-info">
|
||||||
|
<h4 class="discover-playlist-name">${mix.name}</h4>
|
||||||
|
<p class="discover-playlist-description">${mix.description}</p>
|
||||||
|
<p class="discover-playlist-count">${mix.track_count} tracks</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
container.closest('.discover-section').style.display = 'block';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading daily mixes:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCompactPlaylist(container, tracks) {
|
||||||
|
let html = '<div class="discover-playlist-tracks-compact">';
|
||||||
|
|
||||||
|
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>';
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDailyMix(mixIndex) {
|
||||||
|
const mix = personalizedDailyMixes[mixIndex];
|
||||||
|
if (!mix || !mix.tracks) return;
|
||||||
|
|
||||||
|
// TODO: Open modal or dedicated view for Daily Mix
|
||||||
|
console.log('Opening Daily Mix:', mix.name);
|
||||||
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
// DISCOVER PLAYLIST ACTIONS
|
// DISCOVER PLAYLIST ACTIONS
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue