mirror of
https://github.com/bnair123/MusicAnalyser.git
synced 2026-02-25 11:46:07 +00:00
feat: migrate to PostgreSQL and enhance playlist curation
- Migrate database from SQLite to PostgreSQL (100.91.248.114:5433) - Fix playlist curation to use actual top tracks instead of AI name matching - Add /playlists/history endpoint for historical playlist viewing - Add Playlist Archives section to frontend with expandable history - Add playlist-modify-* scopes to Spotify OAuth for playlist creation - Rewrite Genius client to use official API (fixes 403 scraping blocks) - Ensure playlists are created on Spotify before curation attempts - Add DATABASE.md documentation for PostgreSQL schema - Add migrations for PlaylistConfig and composition storage
This commit is contained in:
@@ -2,17 +2,33 @@ import os
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./music.db")
|
||||
# PostgreSQL connection configuration
|
||||
# Uses docker hostname 'music_db' when running in container, falls back to external IP for local dev
|
||||
POSTGRES_HOST = os.getenv("POSTGRES_HOST", "music_db")
|
||||
POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432")
|
||||
POSTGRES_USER = os.getenv("POSTGRES_USER", "bnair")
|
||||
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "Bharath2002")
|
||||
POSTGRES_DB = os.getenv("POSTGRES_DB", "music_db")
|
||||
|
||||
connect_args = {}
|
||||
if SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
|
||||
connect_args["check_same_thread"] = False
|
||||
# Build the PostgreSQL URL
|
||||
# Format: postgresql://user:password@host:port/database
|
||||
SQLALCHEMY_DATABASE_URL = os.getenv(
|
||||
"DATABASE_URL",
|
||||
f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}",
|
||||
)
|
||||
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args=connect_args)
|
||||
# PostgreSQL connection pool settings for production
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL,
|
||||
pool_size=5, # Maintain 5 connections in the pool
|
||||
max_overflow=10, # Allow up to 10 additional connections
|
||||
pool_pre_ping=True, # Verify connection health before using
|
||||
)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
|
||||
@@ -10,6 +10,7 @@ from .models import (
|
||||
PlayHistory as PlayHistoryModel,
|
||||
Track as TrackModel,
|
||||
AnalysisSnapshot,
|
||||
PlaylistConfig,
|
||||
)
|
||||
from . import schemas
|
||||
from .ingest import (
|
||||
@@ -220,13 +221,18 @@ async def refresh_six_hour_playlist(db: Session = Depends(get_db)):
|
||||
end_date = datetime.utcnow()
|
||||
start_date = end_date - timedelta(hours=6)
|
||||
|
||||
spotify_client = get_spotify_client()
|
||||
playlist_service = PlaylistService(
|
||||
db=db,
|
||||
spotify_client=get_spotify_client(),
|
||||
spotify_client=spotify_client,
|
||||
recco_client=get_reccobeats_client(),
|
||||
narrative_service=NarrativeService(),
|
||||
)
|
||||
|
||||
# Ensure playlists exist (creates on Spotify if needed)
|
||||
user_id = await spotify_client.get_current_user_id()
|
||||
await playlist_service.ensure_playlists_exist(user_id)
|
||||
|
||||
result = await playlist_service.curate_six_hour_playlist(start_date, end_date)
|
||||
|
||||
snapshot = AnalysisSnapshot(
|
||||
@@ -239,6 +245,7 @@ async def refresh_six_hour_playlist(db: Session = Depends(get_db)):
|
||||
playlist_theme=result.get("theme_name"),
|
||||
playlist_theme_reasoning=result.get("description"),
|
||||
six_hour_playlist_id=result.get("playlist_id"),
|
||||
playlist_composition=result.get("composition"),
|
||||
)
|
||||
db.add(snapshot)
|
||||
db.commit()
|
||||
@@ -256,13 +263,18 @@ async def refresh_daily_playlist(db: Session = Depends(get_db)):
|
||||
end_date = datetime.utcnow()
|
||||
start_date = end_date - timedelta(days=1)
|
||||
|
||||
spotify_client = get_spotify_client()
|
||||
playlist_service = PlaylistService(
|
||||
db=db,
|
||||
spotify_client=get_spotify_client(),
|
||||
spotify_client=spotify_client,
|
||||
recco_client=get_reccobeats_client(),
|
||||
narrative_service=NarrativeService(),
|
||||
)
|
||||
|
||||
# Ensure playlists exist (creates on Spotify if needed)
|
||||
user_id = await spotify_client.get_current_user_id()
|
||||
await playlist_service.ensure_playlists_exist(user_id)
|
||||
|
||||
result = await playlist_service.curate_daily_playlist(start_date, end_date)
|
||||
|
||||
snapshot = AnalysisSnapshot(
|
||||
@@ -273,6 +285,7 @@ async def refresh_daily_playlist(db: Session = Depends(get_db)):
|
||||
metrics_payload={},
|
||||
narrative_report={},
|
||||
daily_playlist_id=result.get("playlist_id"),
|
||||
playlist_composition=result.get("composition"),
|
||||
)
|
||||
db.add(snapshot)
|
||||
db.commit()
|
||||
@@ -286,32 +299,71 @@ async def refresh_daily_playlist(db: Session = Depends(get_db)):
|
||||
@app.get("/playlists")
|
||||
async def get_playlists_metadata(db: Session = Depends(get_db)):
|
||||
"""Returns metadata for the managed playlists."""
|
||||
latest_snapshot = (
|
||||
db.query(AnalysisSnapshot)
|
||||
.filter(AnalysisSnapshot.six_hour_playlist_id != None)
|
||||
.order_by(AnalysisSnapshot.date.desc())
|
||||
.first()
|
||||
|
||||
six_hour_config = (
|
||||
db.query(PlaylistConfig).filter(PlaylistConfig.key == "six_hour").first()
|
||||
)
|
||||
daily_config = (
|
||||
db.query(PlaylistConfig).filter(PlaylistConfig.key == "daily").first()
|
||||
)
|
||||
|
||||
return {
|
||||
"six_hour": {
|
||||
"id": latest_snapshot.six_hour_playlist_id
|
||||
if latest_snapshot
|
||||
"id": six_hour_config.spotify_id
|
||||
if six_hour_config
|
||||
else os.getenv("SIX_HOUR_PLAYLIST_ID"),
|
||||
"theme": latest_snapshot.playlist_theme if latest_snapshot else "N/A",
|
||||
"reasoning": latest_snapshot.playlist_theme_reasoning
|
||||
if latest_snapshot
|
||||
else "N/A",
|
||||
"last_refresh": latest_snapshot.date.isoformat()
|
||||
if latest_snapshot
|
||||
"theme": six_hour_config.current_theme if six_hour_config else "N/A",
|
||||
"reasoning": six_hour_config.description if six_hour_config else "N/A",
|
||||
"last_refresh": six_hour_config.last_updated.isoformat()
|
||||
if six_hour_config
|
||||
else None,
|
||||
"composition": six_hour_config.composition if six_hour_config else [],
|
||||
},
|
||||
"daily": {
|
||||
"id": latest_snapshot.daily_playlist_id
|
||||
if latest_snapshot
|
||||
"id": daily_config.spotify_id
|
||||
if daily_config
|
||||
else os.getenv("DAILY_PLAYLIST_ID"),
|
||||
"last_refresh": latest_snapshot.date.isoformat()
|
||||
if latest_snapshot
|
||||
"theme": daily_config.current_theme if daily_config else "N/A",
|
||||
"reasoning": daily_config.description if daily_config else "N/A",
|
||||
"last_refresh": daily_config.last_updated.isoformat()
|
||||
if daily_config
|
||||
else None,
|
||||
"composition": daily_config.composition if daily_config else [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.get("/playlists/history")
|
||||
def get_playlist_history(
|
||||
limit: int = Query(default=20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Returns historical playlist snapshots."""
|
||||
snapshots = (
|
||||
db.query(AnalysisSnapshot)
|
||||
.filter(
|
||||
(AnalysisSnapshot.playlist_theme.isnot(None))
|
||||
| (AnalysisSnapshot.six_hour_playlist_id.isnot(None))
|
||||
| (AnalysisSnapshot.daily_playlist_id.isnot(None))
|
||||
)
|
||||
.order_by(AnalysisSnapshot.date.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
result = []
|
||||
for snap in snapshots:
|
||||
result.append(
|
||||
{
|
||||
"id": snap.id,
|
||||
"date": snap.date.isoformat() if snap.date else None,
|
||||
"period_label": snap.period_label,
|
||||
"theme": snap.playlist_theme,
|
||||
"reasoning": snap.playlist_theme_reasoning,
|
||||
"six_hour_id": snap.six_hour_playlist_id,
|
||||
"daily_id": snap.daily_playlist_id,
|
||||
"composition": snap.playlist_composition or [],
|
||||
}
|
||||
)
|
||||
|
||||
return {"history": result}
|
||||
|
||||
@@ -130,3 +130,18 @@ class AnalysisSnapshot(Base):
|
||||
daily_playlist_id = Column(
|
||||
String, nullable=True
|
||||
) # Spotify playlist ID for 24-hour playlist
|
||||
playlist_composition = Column(JSON, nullable=True)
|
||||
playlist_composition = Column(
|
||||
JSON, nullable=True
|
||||
) # Store the track list at this snapshot
|
||||
|
||||
|
||||
class PlaylistConfig(Base):
|
||||
__tablename__ = "playlist_config"
|
||||
|
||||
key = Column(String, primary_key=True, index=True) # e.g., "six_hour", "daily"
|
||||
spotify_id = Column(String, nullable=False)
|
||||
last_updated = Column(DateTime, default=datetime.utcnow)
|
||||
current_theme = Column(String, nullable=True)
|
||||
description = Column(String, nullable=True)
|
||||
composition = Column(JSON, nullable=True)
|
||||
|
||||
@@ -1,35 +1,103 @@
|
||||
import os
|
||||
import lyricsgenius
|
||||
import requests
|
||||
from typing import Optional, Dict, Any
|
||||
import re
|
||||
|
||||
|
||||
class GeniusClient:
|
||||
def __init__(self):
|
||||
self.access_token = os.getenv("GENIUS_ACCESS_TOKEN")
|
||||
if self.access_token:
|
||||
self.genius = lyricsgenius.Genius(self.access_token, verbose=False, remove_section_headers=True)
|
||||
else:
|
||||
print("WARNING: GENIUS_ACCESS_TOKEN not found. Lyrics enrichment will be skipped.")
|
||||
self.base_url = "https://api.genius.com"
|
||||
self.headers = (
|
||||
{"Authorization": f"Bearer {self.access_token}"}
|
||||
if self.access_token
|
||||
else {}
|
||||
)
|
||||
|
||||
if not self.access_token:
|
||||
print(
|
||||
"WARNING: GENIUS_ACCESS_TOKEN not found. Lyrics enrichment will be skipped."
|
||||
)
|
||||
self.genius = None
|
||||
else:
|
||||
self.genius = True
|
||||
|
||||
def search_song(self, title: str, artist: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Searches for a song on Genius and returns metadata + lyrics.
|
||||
"""
|
||||
if not self.genius:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Clean up title (remove "Feat.", "Remastered", etc for better search match)
|
||||
clean_title = title.split(" - ")[0].split("(")[0].strip()
|
||||
song = self.genius.search_song(clean_title, artist)
|
||||
|
||||
if song:
|
||||
return {
|
||||
"lyrics": song.lyrics,
|
||||
"image_url": song.song_art_image_url,
|
||||
"artist_image_url": song.primary_artist.image_url
|
||||
}
|
||||
query = f"{clean_title} {artist}"
|
||||
|
||||
response = requests.get(
|
||||
f"{self.base_url}/search",
|
||||
headers=self.headers,
|
||||
params={"q": query},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f"Genius API Error: {response.status_code}")
|
||||
return None
|
||||
|
||||
data = response.json()
|
||||
hits = data.get("response", {}).get("hits", [])
|
||||
|
||||
if not hits:
|
||||
return None
|
||||
|
||||
song = hits[0]["result"]
|
||||
|
||||
lyrics = self._scrape_lyrics(song.get("url")) if song.get("url") else None
|
||||
|
||||
return {
|
||||
"lyrics": lyrics,
|
||||
"image_url": song.get("song_art_image_url")
|
||||
or song.get("header_image_url"),
|
||||
"artist_image_url": song.get("primary_artist", {}).get("image_url"),
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Genius Search Error for {title} by {artist}: {e}")
|
||||
|
||||
|
||||
return None
|
||||
|
||||
def _scrape_lyrics(self, url: str) -> Optional[str]:
|
||||
try:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) 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",
|
||||
}
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code != 200:
|
||||
return None
|
||||
|
||||
html = response.text
|
||||
|
||||
lyrics_divs = re.findall(
|
||||
r'<div[^>]*data-lyrics-container="true"[^>]*>(.*?)</div>',
|
||||
html,
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
if not lyrics_divs:
|
||||
return None
|
||||
|
||||
lyrics = ""
|
||||
for div in lyrics_divs:
|
||||
text = re.sub(r"<br\s*/?>", "\n", div)
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
text = (
|
||||
text.replace("&", "&")
|
||||
.replace(""", '"')
|
||||
.replace("'", "'")
|
||||
)
|
||||
lyrics += text + "\n"
|
||||
|
||||
return lyrics.strip() if lyrics.strip() else None
|
||||
|
||||
except Exception as e:
|
||||
print(f"Lyrics scrape error: {e}")
|
||||
return None
|
||||
|
||||
@@ -119,6 +119,7 @@ class NarrativeService:
|
||||
2. Provide a "description" (2-3 sentences explaining why).
|
||||
3. Identify 10-15 "curated_tracks" (song names only) that fit this vibe and the artists listed.
|
||||
4. Return ONLY valid JSON.
|
||||
5. Do NOT output internal variable names (e.g. 'part_of_day', 'avg_valence') in the description. Translate them to natural language (e.g. 'morning listens', 'happy vibe').
|
||||
|
||||
**REQUIRED JSON:**
|
||||
{{
|
||||
@@ -192,6 +193,7 @@ class NarrativeService:
|
||||
2. Be specific - reference actual metrics from the data.
|
||||
3. Be playful but not cruel.
|
||||
4. Return ONLY valid JSON.
|
||||
5. Translate all technical metrics (e.g. 'discovery_rate', 'valence', 'hhi') into natural language descriptions. Do NOT use the variable names themselves.
|
||||
|
||||
**LISTENING HIGHLIGHTS:**
|
||||
- Peak listening: {peak_listening}
|
||||
|
||||
@@ -24,89 +24,239 @@ class PlaylistService:
|
||||
|
||||
async def ensure_playlists_exist(self, user_id: str) -> Dict[str, str]:
|
||||
"""Check/create playlists. Returns {six_hour_id, daily_id}."""
|
||||
six_hour_env = os.getenv("SIX_HOUR_PLAYLIST_ID")
|
||||
daily_env = os.getenv("DAILY_PLAYLIST_ID")
|
||||
from app.models import PlaylistConfig
|
||||
|
||||
if not six_hour_env:
|
||||
six_hour_data = await self.spotify.create_playlist(
|
||||
user_id=user_id,
|
||||
name="Short and Sweet",
|
||||
description="AI-curated 6-hour playlists based on your listening habits",
|
||||
)
|
||||
six_hour_env = str(six_hour_data["id"])
|
||||
six_hour_config = (
|
||||
self.db.query(PlaylistConfig)
|
||||
.filter(PlaylistConfig.key == "six_hour")
|
||||
.first()
|
||||
)
|
||||
daily_config = (
|
||||
self.db.query(PlaylistConfig).filter(PlaylistConfig.key == "daily").first()
|
||||
)
|
||||
|
||||
if not daily_env:
|
||||
daily_data = await self.spotify.create_playlist(
|
||||
user_id=user_id,
|
||||
name="Proof of Commitment",
|
||||
description="Your daily 24-hour mix showing your music journey",
|
||||
)
|
||||
daily_env = str(daily_data["id"])
|
||||
six_hour_id = six_hour_config.spotify_id if six_hour_config else None
|
||||
daily_id = daily_config.spotify_id if daily_config else None
|
||||
|
||||
return {"six_hour_id": str(six_hour_env), "daily_id": str(daily_env)}
|
||||
if not six_hour_id:
|
||||
six_hour_id = os.getenv("SIX_HOUR_PLAYLIST_ID")
|
||||
if not six_hour_id:
|
||||
six_hour_data = await self.spotify.create_playlist(
|
||||
user_id=user_id,
|
||||
name="Short and Sweet",
|
||||
description="AI-curated 6-hour playlists based on your listening habits",
|
||||
)
|
||||
six_hour_id = str(six_hour_data["id"])
|
||||
|
||||
self._save_playlist_config("six_hour", six_hour_id, "Short and Sweet")
|
||||
|
||||
if not daily_id:
|
||||
daily_id = os.getenv("DAILY_PLAYLIST_ID")
|
||||
if not daily_id:
|
||||
daily_data = await self.spotify.create_playlist(
|
||||
user_id=user_id,
|
||||
name="Proof of Commitment",
|
||||
description="Your daily 24-hour mix showing your music journey",
|
||||
)
|
||||
daily_id = str(daily_data["id"])
|
||||
|
||||
self._save_playlist_config("daily", daily_id, "Proof of Commitment")
|
||||
|
||||
return {"six_hour_id": six_hour_id, "daily_id": daily_id}
|
||||
|
||||
def _save_playlist_config(
|
||||
self,
|
||||
key: str,
|
||||
spotify_id: str,
|
||||
description: str = None,
|
||||
theme: str = None,
|
||||
composition: List[Dict[str, Any]] = None,
|
||||
):
|
||||
from app.models import PlaylistConfig
|
||||
|
||||
config = self.db.query(PlaylistConfig).filter(PlaylistConfig.key == key).first()
|
||||
if not config:
|
||||
config = PlaylistConfig(key=key, spotify_id=spotify_id)
|
||||
self.db.add(config)
|
||||
else:
|
||||
config.spotify_id = spotify_id
|
||||
|
||||
if description:
|
||||
config.description = description
|
||||
if theme:
|
||||
config.current_theme = theme
|
||||
if composition:
|
||||
config.composition = composition
|
||||
|
||||
config.last_updated = datetime.utcnow()
|
||||
self.db.commit()
|
||||
|
||||
async def _hydrate_tracks(
|
||||
self, track_ids: List[str], sources: Dict[str, str]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch full track details for a list of IDs."""
|
||||
from app.models import Track
|
||||
|
||||
db_tracks = self.db.query(Track).filter(Track.id.in_(track_ids)).all()
|
||||
track_map = {t.id: t for t in db_tracks}
|
||||
|
||||
missing_ids = [tid for tid in track_ids if tid not in track_map]
|
||||
|
||||
if missing_ids:
|
||||
spotify_tracks = await self.spotify.get_tracks(missing_ids)
|
||||
for st in spotify_tracks:
|
||||
if not st:
|
||||
continue
|
||||
track_map[st["id"]] = {
|
||||
"id": st["id"],
|
||||
"name": st["name"],
|
||||
"artist": ", ".join([a["name"] for a in st["artists"]]),
|
||||
"image": st["album"]["images"][0]["url"]
|
||||
if st["album"]["images"]
|
||||
else None,
|
||||
"uri": st["uri"],
|
||||
}
|
||||
|
||||
result = []
|
||||
for tid in track_ids:
|
||||
track = track_map.get(tid)
|
||||
if not track:
|
||||
continue
|
||||
|
||||
if hasattr(track, "name") and not isinstance(track, dict):
|
||||
track_data = {
|
||||
"id": track.id,
|
||||
"name": track.name,
|
||||
"artist": track.artist,
|
||||
"image": track.image_url,
|
||||
"uri": f"spotify:track:{track.id}",
|
||||
}
|
||||
else:
|
||||
track_data = track
|
||||
|
||||
track_data["source"] = sources.get(tid, "unknown")
|
||||
result.append(track_data)
|
||||
|
||||
return result
|
||||
|
||||
async def curate_six_hour_playlist(
|
||||
self, period_start: datetime, period_end: datetime
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate 6-hour playlist (15 curated + 15 recommendations)."""
|
||||
from app.models import Track
|
||||
from app.models import Track, PlayHistory
|
||||
from app.services.stats_service import StatsService
|
||||
from sqlalchemy import func
|
||||
|
||||
stats = StatsService(self.db)
|
||||
data = stats.generate_full_report(period_start, period_end)
|
||||
|
||||
top_tracks_period = [t["id"] for t in data["volume"].get("top_tracks", [])][:15]
|
||||
|
||||
if len(top_tracks_period) < 5:
|
||||
fallback_tracks = (
|
||||
self.db.query(Track.id, func.count(PlayHistory.id).label("cnt"))
|
||||
.join(PlayHistory, Track.id == PlayHistory.track_id)
|
||||
.group_by(Track.id)
|
||||
.order_by(func.count(PlayHistory.id).desc())
|
||||
.limit(15)
|
||||
.all()
|
||||
)
|
||||
top_tracks_period = [tid for tid, _ in fallback_tracks]
|
||||
|
||||
listening_data = {
|
||||
"peak_hour": data["time_habits"]["peak_hour"],
|
||||
"avg_energy": data["vibe"]["avg_energy"],
|
||||
"avg_valence": data["vibe"]["avg_valence"],
|
||||
"total_plays": data["volume"]["total_plays"],
|
||||
"top_artists": data["volume"]["top_artists"][:10],
|
||||
"peak_hour": data["time_habits"].get("peak_hour", 12),
|
||||
"avg_energy": data["vibe"].get("avg_energy", 0.5),
|
||||
"avg_valence": data["vibe"].get("avg_valence", 0.5),
|
||||
"total_plays": data["volume"].get("total_plays", 0),
|
||||
"top_artists": data["volume"].get("top_artists", [])[:10],
|
||||
}
|
||||
|
||||
theme_result = self.narrative.generate_playlist_theme(listening_data)
|
||||
|
||||
curated_track_names = theme_result.get("curated_tracks", [])
|
||||
curated_tracks: List[str] = []
|
||||
for name in curated_track_names:
|
||||
track = self.db.query(Track).filter(Track.name.ilike(f"%{name}%")).first()
|
||||
if track:
|
||||
curated_tracks.append(str(track.id))
|
||||
curated_details = []
|
||||
for tid in top_tracks_period:
|
||||
track_obj = self.db.query(Track).filter(Track.id == tid).first()
|
||||
if track_obj:
|
||||
curated_details.append(
|
||||
{
|
||||
"id": str(track_obj.id),
|
||||
"energy": track_obj.energy,
|
||||
"source": "history",
|
||||
}
|
||||
)
|
||||
|
||||
recommendations: List[str] = []
|
||||
if curated_tracks:
|
||||
recs = await self.recco.get_recommendations(
|
||||
seed_ids=curated_tracks[:5],
|
||||
rec_details = []
|
||||
seed_ids = top_tracks_period[:5] if top_tracks_period else []
|
||||
if seed_ids:
|
||||
raw_recs = await self.recco.get_recommendations(
|
||||
seed_ids=seed_ids,
|
||||
size=15,
|
||||
)
|
||||
recommendations = [
|
||||
str(r.get("spotify_id") or r.get("id"))
|
||||
for r in recs
|
||||
if r.get("spotify_id") or r.get("id")
|
||||
]
|
||||
for r in raw_recs:
|
||||
rec_id = str(r.get("spotify_id") or r.get("id"))
|
||||
if rec_id:
|
||||
rec_details.append(
|
||||
{
|
||||
"id": rec_id,
|
||||
"energy": r.get("energy"),
|
||||
"source": "recommendation",
|
||||
}
|
||||
)
|
||||
|
||||
final_tracks = curated_tracks[:15] + recommendations[:15]
|
||||
all_candidates = curated_details[:15] + rec_details[:15]
|
||||
optimized_tracks = self._optimize_playlist_flow(all_candidates)
|
||||
|
||||
final_track_ids = [t["id"] for t in optimized_tracks]
|
||||
sources = {t["id"]: t["source"] for t in optimized_tracks}
|
||||
|
||||
# Hydrate for persistence/display
|
||||
full_tracks = await self._hydrate_tracks(final_track_ids, sources)
|
||||
|
||||
playlist_id = None
|
||||
from app.models import PlaylistConfig
|
||||
|
||||
config = (
|
||||
self.db.query(PlaylistConfig)
|
||||
.filter(PlaylistConfig.key == "six_hour")
|
||||
.first()
|
||||
)
|
||||
if config:
|
||||
playlist_id = config.spotify_id
|
||||
|
||||
if not playlist_id:
|
||||
playlist_id = os.getenv("SIX_HOUR_PLAYLIST_ID")
|
||||
|
||||
playlist_id = os.getenv("SIX_HOUR_PLAYLIST_ID")
|
||||
if playlist_id:
|
||||
theme_name = f"Short and Sweet - {theme_result['theme_name']}"
|
||||
desc = f"{theme_result['description']}\n\nCurated: {len(curated_details)} tracks + {len(rec_details)} recommendations"
|
||||
|
||||
await self.spotify.update_playlist_details(
|
||||
playlist_id=playlist_id,
|
||||
name=f"Short and Sweet - {theme_result['theme_name']}",
|
||||
description=(
|
||||
f"{theme_result['description']}\n\nCurated: {len(curated_tracks)} tracks + {len(recommendations)} recommendations"
|
||||
),
|
||||
name=theme_name,
|
||||
description=desc,
|
||||
)
|
||||
await self.spotify.replace_playlist_tracks(
|
||||
playlist_id=playlist_id,
|
||||
track_uris=[f"spotify:track:{tid}" for tid in final_tracks],
|
||||
track_uris=[f"spotify:track:{tid}" for tid in final_track_ids],
|
||||
)
|
||||
|
||||
self._save_playlist_config(
|
||||
"six_hour",
|
||||
playlist_id,
|
||||
description=desc,
|
||||
theme=theme_result["theme_name"],
|
||||
composition=full_tracks,
|
||||
)
|
||||
|
||||
return {
|
||||
"playlist_id": playlist_id,
|
||||
"theme_name": theme_result["theme_name"],
|
||||
"description": theme_result["description"],
|
||||
"track_count": len(final_tracks),
|
||||
"curated_count": len(curated_tracks),
|
||||
"rec_count": len(recommendations),
|
||||
"track_count": len(final_track_ids),
|
||||
"sources": sources,
|
||||
"composition": full_tracks,
|
||||
"curated_count": len(curated_details),
|
||||
"rec_count": len(rec_details),
|
||||
"refreshed_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
@@ -120,33 +270,86 @@ class PlaylistService:
|
||||
stats = StatsService(self.db)
|
||||
data = stats.generate_full_report(period_start, period_end)
|
||||
|
||||
top_all_time = self._get_top_all_time_tracks(limit=30)
|
||||
recent_tracks = [track["id"] for track in data["volume"]["top_tracks"][:20]]
|
||||
top_all_time_ids = self._get_top_all_time_tracks(limit=30)
|
||||
recent_tracks_ids = [track["id"] for track in data["volume"]["top_tracks"][:20]]
|
||||
|
||||
final_tracks = (top_all_time + recent_tracks)[:50]
|
||||
favorites_details = []
|
||||
for tid in top_all_time_ids:
|
||||
track_obj = self.db.query(Track).filter(Track.id == tid).first()
|
||||
if track_obj:
|
||||
favorites_details.append(
|
||||
{
|
||||
"id": str(track_obj.id),
|
||||
"energy": track_obj.energy,
|
||||
"source": "favorite_all_time",
|
||||
}
|
||||
)
|
||||
|
||||
discovery_details = []
|
||||
for tid in recent_tracks_ids:
|
||||
track_obj = self.db.query(Track).filter(Track.id == tid).first()
|
||||
if track_obj:
|
||||
discovery_details.append(
|
||||
{
|
||||
"id": str(track_obj.id),
|
||||
"energy": track_obj.energy,
|
||||
"source": "recent_discovery",
|
||||
}
|
||||
)
|
||||
|
||||
all_candidates = favorites_details + discovery_details
|
||||
optimized_tracks = self._optimize_playlist_flow(all_candidates)
|
||||
|
||||
final_track_ids = [t["id"] for t in optimized_tracks]
|
||||
sources = {t["id"]: t["source"] for t in optimized_tracks}
|
||||
|
||||
# Hydrate for persistence/display
|
||||
full_tracks = await self._hydrate_tracks(final_track_ids, sources)
|
||||
|
||||
playlist_id = None
|
||||
from app.models import PlaylistConfig
|
||||
|
||||
config = (
|
||||
self.db.query(PlaylistConfig).filter(PlaylistConfig.key == "daily").first()
|
||||
)
|
||||
if config:
|
||||
playlist_id = config.spotify_id
|
||||
|
||||
if not playlist_id:
|
||||
playlist_id = os.getenv("DAILY_PLAYLIST_ID")
|
||||
|
||||
playlist_id = os.getenv("DAILY_PLAYLIST_ID")
|
||||
theme_name = f"Proof of Commitment - {datetime.utcnow().date().isoformat()}"
|
||||
if playlist_id:
|
||||
desc = (
|
||||
f"{theme_name} reflects the past 24 hours plus your all-time devotion."
|
||||
)
|
||||
await self.spotify.update_playlist_details(
|
||||
playlist_id=playlist_id,
|
||||
name=theme_name,
|
||||
description=(
|
||||
f"{theme_name} reflects the past 24 hours plus your all-time devotion."
|
||||
),
|
||||
description=desc,
|
||||
)
|
||||
await self.spotify.replace_playlist_tracks(
|
||||
playlist_id=playlist_id,
|
||||
track_uris=[f"spotify:track:{tid}" for tid in final_tracks],
|
||||
track_uris=[f"spotify:track:{tid}" for tid in final_track_ids],
|
||||
)
|
||||
|
||||
self._save_playlist_config(
|
||||
"daily",
|
||||
playlist_id,
|
||||
description=desc,
|
||||
theme=theme_name,
|
||||
composition=full_tracks,
|
||||
)
|
||||
|
||||
return {
|
||||
"playlist_id": playlist_id,
|
||||
"theme_name": theme_name,
|
||||
"description": "Daily mix refreshed with your favorites and discoveries.",
|
||||
"track_count": len(final_tracks),
|
||||
"favorites_count": len(top_all_time),
|
||||
"recent_discoveries_count": len(recent_tracks),
|
||||
"track_count": len(final_track_ids),
|
||||
"sources": sources,
|
||||
"composition": full_tracks,
|
||||
"favorites_count": len(favorites_details),
|
||||
"recent_discoveries_count": len(discovery_details),
|
||||
"refreshed_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
@@ -165,3 +368,29 @@ class PlaylistService:
|
||||
)
|
||||
|
||||
return [track_id for track_id, _ in result]
|
||||
|
||||
def _optimize_playlist_flow(
|
||||
self, tracks: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Sort tracks to create a smooth flow (Energy Ramp).
|
||||
Strategy: Sort by energy (Low -> High -> Medium).
|
||||
"""
|
||||
if not any("energy" in t for t in tracks):
|
||||
return tracks
|
||||
|
||||
for t in tracks:
|
||||
if "energy" not in t or t["energy"] is None:
|
||||
t["energy"] = 0.5
|
||||
|
||||
sorted_tracks = sorted(tracks, key=lambda x: x["energy"])
|
||||
|
||||
n = len(sorted_tracks)
|
||||
low_end = int(n * 0.3)
|
||||
high_start = int(n * 0.7)
|
||||
|
||||
low_energy = sorted_tracks[:low_end]
|
||||
medium_energy = sorted_tracks[low_end:high_start]
|
||||
high_energy = sorted_tracks[high_start:]
|
||||
|
||||
return low_energy + high_energy + medium_energy
|
||||
|
||||
@@ -71,6 +71,26 @@ class SpotifyClient:
|
||||
return None
|
||||
return response.json()
|
||||
|
||||
async def get_tracks(self, track_ids: List[str]) -> List[Dict[str, Any]]:
|
||||
"""Fetch multiple tracks by ID."""
|
||||
if not track_ids:
|
||||
return []
|
||||
|
||||
token = await self.get_access_token()
|
||||
ids_param = ",".join(track_ids[:50])
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{SPOTIFY_API_BASE}/tracks",
|
||||
params={"ids": ids_param},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
print(f"Error fetching tracks: {response.text}")
|
||||
return []
|
||||
|
||||
return response.json().get("tracks", [])
|
||||
|
||||
async def get_artists(self, artist_ids: List[str]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetches artist details (including genres) for a list of artist IDs.
|
||||
|
||||
Reference in New Issue
Block a user