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

@@ -1,8 +1,12 @@
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
from .models import Track, PlayHistory, Artist, AnalysisSnapshot
from .database import SessionLocal
from .services.spotify_client import SpotifyClient
from .services.reccobeats_client import ReccoBeatsClient
@@ -20,12 +24,11 @@ class PlaybackTracker:
self.is_paused = False
# Initialize Clients
def get_spotify_client():
return SpotifyClient(
client_id=os.getenv("SPOTIFY_CLIENT_ID"),
client_secret=os.getenv("SPOTIFY_CLIENT_SECRET"),
refresh_token=os.getenv("SPOTIFY_REFRESH_TOKEN"),
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 ""),
)
@@ -38,15 +41,11 @@ def get_genius_client():
async def ensure_artists_exist(db: Session, artists_data: list):
"""
Ensures that all artists in the list exist in the Artist table.
"""
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:
# Check if image is available in this payload (rare for track-linked artists, but possible)
img = None
if "images" in a_data and a_data["images"]:
img = a_data["images"][0]["url"]
@@ -63,20 +62,12 @@ async def enrich_tracks(
recco_client: ReccoBeatsClient,
genius_client: GeniusClient,
):
"""
Enrichment Pipeline:
1. Audio Features (ReccoBeats)
2. Artist Metadata: Genres & Images (Spotify)
3. Lyrics & Fallback Images (Genius)
"""
# 1. Enrich Audio Features
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 = [t.id for t in tracks_missing_features]
ids = [str(t.id) for t in tracks_missing_features]
features_list = await recco_client.get_audio_features(ids)
features_map = {}
@@ -102,7 +93,6 @@ async def enrich_tracks(
db.commit()
# 2. Enrich Artist Genres & Images (Spotify)
artists_missing_data = (
db.query(Artist)
.filter((Artist.genres == None) | (Artist.image_url == None))
@@ -111,7 +101,7 @@ async def enrich_tracks(
)
if artists_missing_data:
print(f"Enriching {len(artists_missing_data)} artists with genres/images...")
artist_ids_list = [a.id for a in artists_missing_data]
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):
@@ -133,12 +123,10 @@ async def enrich_tracks(
if artist.image_url is None:
artist.image_url = data["image_url"]
elif artist.genres is None:
artist.genres = [] # Prevent retry loop
artist.genres = []
db.commit()
# 3. Enrich Lyrics (Genius)
# Only fetch for tracks that have been played recently to avoid spamming Genius API
tracks_missing_lyrics = (
db.query(Track)
.filter(Track.lyrics == None)
@@ -150,22 +138,17 @@ async def enrich_tracks(
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:
# We need the primary artist name
artist_name = track.artist.split(",")[0] # Heuristic: take first artist
artist_name = str(track.artist).split(",")[0]
print(f"Searching Genius for: {track.name} by {artist_name}")
data = genius_client.search_song(track.name, artist_name)
data = genius_client.search_song(str(track.name), artist_name)
if data:
track.lyrics = data["lyrics"]
# Fallback: if we didn't get high-res art from Spotify, use Genius
if not track.image_url and data.get("image_url"):
track.image_url = data["image_url"]
else:
track.lyrics = "" # Mark as empty to prevent retry loop
# Small sleep to be nice to API? GeniusClient is synchronous.
# We are in async function but GeniusClient is blocking. It's fine for worker.
track.lyrics = ""
db.commit()
@@ -194,7 +177,6 @@ async def ingest_recently_played(db: Session):
if not track:
print(f"New track found: {track_data['name']}")
# Extract Album Art
image_url = None
if track_data.get("album") and track_data["album"].get("images"):
image_url = track_data["album"]["images"][0]["url"]
@@ -210,7 +192,6 @@ async def ingest_recently_played(db: Session):
raw_data=track_data,
)
# Handle Artists Relation
artists_data = track_data.get("artists", [])
artist_objects = await ensure_artists_exist(db, artists_data)
track.artists = artist_objects
@@ -218,7 +199,6 @@ async def ingest_recently_played(db: Session):
db.add(track)
db.commit()
# Ensure relationships exist logic...
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
@@ -246,7 +226,6 @@ async def ingest_recently_played(db: Session):
db.commit()
# Enrich
await enrich_tracks(db, spotify_client, recco_client, genius_client)
@@ -254,11 +233,20 @@ 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)
@@ -266,6 +254,50 @@ async def run_worker():
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}")
@@ -324,6 +356,9 @@ 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(