from .services.stats_service import StatsService from .services.narrative_service import NarrativeService from .services.playlist_service import PlaylistService import asyncio import os import time from datetime import datetime, timedelta from sqlalchemy.orm import Session from .models import Track, PlayHistory, Artist, AnalysisSnapshot from .database import SessionLocal from .services.spotify_client import SpotifyClient from .services.reccobeats_client import ReccoBeatsClient from .services.genius_client import GeniusClient from dateutil import parser class PlaybackTracker: def __init__(self): self.current_track_id = None self.track_start_time = None self.accumulated_listen_ms = 0 self.last_progress_ms = 0 self.last_poll_time = None self.is_paused = False def get_spotify_client(): return SpotifyClient( client_id=str(os.getenv("SPOTIFY_CLIENT_ID") or ""), client_secret=str(os.getenv("SPOTIFY_CLIENT_SECRET") or ""), refresh_token=str(os.getenv("SPOTIFY_REFRESH_TOKEN") or ""), ) def get_reccobeats_client(): return ReccoBeatsClient() def get_genius_client(): return GeniusClient() async def ensure_artists_exist(db: Session, artists_data: list): artist_objects = [] for a_data in artists_data: artist_id = a_data["id"] artist = db.query(Artist).filter(Artist.id == artist_id).first() if not artist: img = None if "images" in a_data and a_data["images"]: img = a_data["images"][0]["url"] artist = Artist(id=artist_id, name=a_data["name"], genres=[], image_url=img) db.add(artist) artist_objects.append(artist) return artist_objects async def enrich_tracks( db: Session, spotify_client: SpotifyClient, recco_client: ReccoBeatsClient, genius_client: GeniusClient, ): tracks_missing_features = ( db.query(Track).filter(Track.danceability == None).limit(50).all() ) if tracks_missing_features: print(f"Enriching {len(tracks_missing_features)} tracks with audio features...") ids = [str(t.id) for t in tracks_missing_features] features_list = await recco_client.get_audio_features(ids) features_map = {} for f in features_list: tid = f.get("spotify_id") or f.get("id") if tid: features_map[tid] = f for track in tracks_missing_features: data = features_map.get(track.id) if data: track.danceability = data.get("danceability") track.energy = data.get("energy") track.key = data.get("key") track.loudness = data.get("loudness") track.mode = data.get("mode") track.speechiness = data.get("speechiness") track.acousticness = data.get("acousticness") track.instrumentalness = data.get("instrumentalness") track.liveness = data.get("liveness") track.valence = data.get("valence") track.tempo = data.get("tempo") db.commit() artists_missing_data = ( db.query(Artist) .filter((Artist.genres == None) | (Artist.image_url == None)) .limit(50) .all() ) if artists_missing_data: print(f"Enriching {len(artists_missing_data)} artists with genres/images...") artist_ids_list = [str(a.id) for a in artists_missing_data] artist_data_map = {} for i in range(0, len(artist_ids_list), 50): chunk = artist_ids_list[i : i + 50] artists_data = await spotify_client.get_artists(chunk) for a_data in artists_data: if a_data: img = a_data["images"][0]["url"] if a_data.get("images") else None artist_data_map[a_data["id"]] = { "genres": a_data.get("genres", []), "image_url": img, } for artist in artists_missing_data: data = artist_data_map.get(artist.id) if data: if artist.genres is None: artist.genres = data["genres"] if artist.image_url is None: artist.image_url = data["image_url"] elif artist.genres is None: artist.genres = [] db.commit() tracks_missing_lyrics = ( db.query(Track) .filter(Track.lyrics == None) .order_by(Track.updated_at.desc()) .limit(10) .all() ) if tracks_missing_lyrics and genius_client.genius: print(f"Enriching {len(tracks_missing_lyrics)} tracks with lyrics (Genius)...") for track in tracks_missing_lyrics: artist_name = str(track.artist).split(",")[0] print(f"Searching Genius for: {track.name} by {artist_name}") data = genius_client.search_song(str(track.name), artist_name) if data: track.lyrics = data["lyrics"] if not track.image_url and data.get("image_url"): track.image_url = data["image_url"] else: track.lyrics = "" db.commit() async def ingest_recently_played(db: Session): spotify_client = get_spotify_client() recco_client = get_reccobeats_client() genius_client = get_genius_client() try: items = await spotify_client.get_recently_played(limit=50) except Exception as e: print(f"Error connecting to Spotify: {e}") return print(f"Fetched {len(items)} items from Spotify.") for item in items: track_data = item["track"] played_at_str = item["played_at"] played_at = parser.isoparse(played_at_str) track_id = track_data["id"] track = db.query(Track).filter(Track.id == track_id).first() if not track: print(f"New track found: {track_data['name']}") image_url = None if track_data.get("album") and track_data["album"].get("images"): image_url = track_data["album"]["images"][0]["url"] track = Track( id=track_id, name=track_data["name"], artist=", ".join([a["name"] for a in track_data["artists"]]), album=track_data["album"]["name"], image_url=image_url, duration_ms=track_data["duration_ms"], popularity=track_data["popularity"], raw_data=track_data, ) artists_data = track_data.get("artists", []) artist_objects = await ensure_artists_exist(db, artists_data) track.artists = artist_objects db.add(track) db.commit() if not track.artists and track.raw_data and "artists" in track.raw_data: artist_objects = await ensure_artists_exist(db, track.raw_data["artists"]) track.artists = artist_objects db.commit() exists = ( db.query(PlayHistory) .filter( PlayHistory.track_id == track_id, PlayHistory.played_at == played_at ) .first() ) if not exists: print(f" recording play: {track_data['name']} at {played_at}") play = PlayHistory( track_id=track_id, played_at=played_at, context_uri=item.get("context", {}).get("uri") if item.get("context") else None, source="recently_played", ) db.add(play) db.commit() await enrich_tracks(db, spotify_client, recco_client, genius_client) async def run_worker(): db = SessionLocal() tracker = PlaybackTracker() spotify_client = get_spotify_client() playlist_service = PlaylistService( db=db, spotify_client=spotify_client, recco_client=get_reccobeats_client(), narrative_service=NarrativeService(), ) poll_count = 0 last_6h_refresh = 0 last_daily_refresh = 0 try: while True: poll_count += 1 now = datetime.utcnow() await poll_currently_playing(db, spotify_client, tracker) if poll_count % 4 == 0: print("Worker: Polling recently-played...") await ingest_recently_played(db) current_hour = now.hour if current_hour in [3, 9, 15, 21] and ( time.time() - last_6h_refresh > 3600 ): print(f"Worker: Triggering 6-hour playlist refresh at {now}") try: await playlist_service.curate_six_hour_playlist( now - timedelta(hours=6), now ) last_6h_refresh = time.time() except Exception as e: print(f"6h Refresh Error: {e}") if current_hour == 4 and (time.time() - last_daily_refresh > 80000): print( f"Worker: Triggering daily playlist refresh and analysis at {now}" ) try: stats_service = StatsService(db) stats_json = stats_service.generate_full_report( now - timedelta(days=1), now ) narrative_service = NarrativeService() narrative_json = narrative_service.generate_full_narrative( stats_json ) snapshot = AnalysisSnapshot( period_start=now - timedelta(days=1), period_end=now, period_label="daily_auto", metrics_payload=stats_json, narrative_report=narrative_json, ) db.add(snapshot) db.commit() await playlist_service.curate_daily_playlist( now - timedelta(days=1), now ) last_daily_refresh = time.time() except Exception as e: print(f"Daily Refresh Error: {e}") await asyncio.sleep(15) except Exception as e: print(f"Worker crashed: {e}") finally: db.close() async def poll_currently_playing( db: Session, spotify_client: SpotifyClient, tracker: PlaybackTracker ): try: response = await spotify_client.get_currently_playing() except Exception as e: print(f"Error polling currently-playing: {e}") return now = datetime.utcnow() if not response or response.get("currently_playing_type") != "track": if tracker.current_track_id and tracker.last_poll_time: finalize_track(db, tracker) return item = response.get("item") if not item: return current_track_id = item["id"] current_progress_ms = response.get("progress_ms", 0) is_playing = response.get("is_playing", False) if current_track_id != tracker.current_track_id: if tracker.current_track_id and tracker.last_poll_time: finalize_track(db, tracker) tracker.current_track_id = current_track_id tracker.track_start_time = now - timedelta(milliseconds=current_progress_ms) tracker.accumulated_listen_ms = current_progress_ms if is_playing else 0 tracker.last_progress_ms = current_progress_ms tracker.last_poll_time = now tracker.is_paused = not is_playing await ensure_track_exists(db, item, spotify_client) else: if tracker.last_poll_time: time_delta_ms = (now - tracker.last_poll_time).total_seconds() * 1000 if is_playing and not tracker.is_paused: tracker.accumulated_listen_ms += time_delta_ms tracker.last_progress_ms = current_progress_ms tracker.last_poll_time = now tracker.is_paused = not is_playing def finalize_track(db: Session, tracker: PlaybackTracker): listened_ms = int(tracker.accumulated_listen_ms) skipped = listened_ms < 30000 if tracker.track_start_time is None: return existing = ( db.query(PlayHistory) .filter( PlayHistory.track_id == tracker.current_track_id, PlayHistory.played_at >= tracker.track_start_time - timedelta(seconds=5), PlayHistory.played_at <= tracker.track_start_time + timedelta(seconds=5), ) .first() ) if existing: if existing.listened_ms is None: existing.listened_ms = listened_ms existing.skipped = skipped existing.source = "currently_playing" db.commit() else: play = PlayHistory( track_id=tracker.current_track_id, played_at=tracker.track_start_time, listened_ms=listened_ms, skipped=skipped, source="currently_playing", ) db.add(play) db.commit() print( f"Finalized: {tracker.current_track_id} listened={listened_ms}ms skipped={skipped}" ) tracker.current_track_id = None tracker.track_start_time = None tracker.accumulated_listen_ms = 0 tracker.last_progress_ms = 0 tracker.last_poll_time = None tracker.is_paused = False async def ensure_track_exists( db: Session, track_data: dict, spotify_client: SpotifyClient ): track_id = track_data["id"] track = db.query(Track).filter(Track.id == track_id).first() if not track: image_url = None if track_data.get("album") and track_data["album"].get("images"): image_url = track_data["album"]["images"][0]["url"] track = Track( id=track_id, name=track_data["name"], artist=", ".join([a["name"] for a in track_data.get("artists", [])]), album=track_data.get("album", {}).get("name", "Unknown"), image_url=image_url, duration_ms=track_data.get("duration_ms"), popularity=track_data.get("popularity"), raw_data=track_data, ) artists_data = track_data.get("artists", []) artist_objects = await ensure_artists_exist(db, artists_data) track.artists = artist_objects db.add(track) db.commit()