feat: implement AI-curated playlist service and dashboard integration

- Added hierarchical AGENTS.md knowledge base
- Implemented PlaylistService with 6h themed and 24h devotion mix logic
- Integrated AI theme generation for 6h playlists via Gemini/OpenAI
- Added /playlists/refresh and metadata endpoints to API
- Updated background worker with scheduled playlist curation
- Created frontend PlaylistsSection, Tooltip components and integrated into Dashboard
- Added Alembic migration for playlist tracking columns
- Fixed Docker healthcheck with curl installation
This commit is contained in:
bnair123
2025-12-30 09:45:19 +04:00
parent fa28b98c1a
commit 93e7c13f3d
18 changed files with 1037 additions and 295 deletions

View File

@@ -0,0 +1,167 @@
import os
from typing import Dict, Any, List
from datetime import datetime
from sqlalchemy.orm import Session
from .spotify_client import SpotifyClient
from .reccobeats_client import ReccoBeatsClient
from .narrative_service import NarrativeService
class PlaylistService:
def __init__(
self,
db: Session,
spotify_client: SpotifyClient,
recco_client: ReccoBeatsClient,
narrative_service: NarrativeService,
) -> None:
self.db = db
self.spotify = spotify_client
self.recco = recco_client
self.narrative = narrative_service
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")
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"])
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"])
return {"six_hour_id": str(six_hour_env), "daily_id": str(daily_env)}
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.services.stats_service import StatsService
stats = StatsService(self.db)
data = stats.generate_full_report(period_start, period_end)
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],
}
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))
recommendations: List[str] = []
if curated_tracks:
recs = await self.recco.get_recommendations(
seed_ids=curated_tracks[:5],
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")
]
final_tracks = curated_tracks[:15] + recommendations[:15]
playlist_id = os.getenv("SIX_HOUR_PLAYLIST_ID")
if playlist_id:
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"
),
)
await self.spotify.replace_playlist_tracks(
playlist_id=playlist_id,
track_uris=[f"spotify:track:{tid}" for tid in final_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),
"refreshed_at": datetime.utcnow().isoformat(),
}
async def curate_daily_playlist(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
"""Generate 24-hour playlist (30 favorites + 20 discoveries)."""
from app.models import Track
from app.services.stats_service import StatsService
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]]
final_tracks = (top_all_time + recent_tracks)[:50]
playlist_id = os.getenv("DAILY_PLAYLIST_ID")
theme_name = f"Proof of Commitment - {datetime.utcnow().date().isoformat()}"
if playlist_id:
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."
),
)
await self.spotify.replace_playlist_tracks(
playlist_id=playlist_id,
track_uris=[f"spotify:track:{tid}" for tid in final_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),
"refreshed_at": datetime.utcnow().isoformat(),
}
def _get_top_all_time_tracks(self, limit: int = 30) -> List[str]:
"""Get top tracks by play count from all-time history."""
from app.models import PlayHistory, Track
from sqlalchemy import func
result = (
self.db.query(Track.id, func.count(PlayHistory.id).label("play_count"))
.join(PlayHistory, Track.id == PlayHistory.track_id)
.group_by(Track.id)
.order_by(func.count(PlayHistory.id).desc())
.limit(limit)
.all()
)
return [track_id for track_id, _ in result]