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:
bnair123
2025-12-30 22:24:56 +04:00
parent 26b4895695
commit 272148c5bf
19 changed files with 1130 additions and 145 deletions

View File

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