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}.""" from app.models import PlaylistConfig six_hour_config = ( self.db.query(PlaylistConfig) .filter(PlaylistConfig.key == "six_hour") .first() ) daily_config = ( self.db.query(PlaylistConfig).filter(PlaylistConfig.key == "daily").first() ) 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 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, 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"].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_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", } ) 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, ) 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", } ) 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") 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=theme_name, description=desc, ) await self.spotify.replace_playlist_tracks( playlist_id=playlist_id, 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_track_ids), "sources": sources, "composition": full_tracks, "curated_count": len(curated_details), "rec_count": len(rec_details), "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_ids = self._get_top_all_time_tracks(limit=30) recent_tracks_ids = [track["id"] for track in data["volume"]["top_tracks"][:20]] 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") 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=desc, ) await self.spotify.replace_playlist_tracks( playlist_id=playlist_id, 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_track_ids), "sources": sources, "composition": full_tracks, "favorites_count": len(favorites_details), "recent_discoveries_count": len(discovery_details), "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] 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