mirror of
https://github.com/bnair123/MusicAnalyser.git
synced 2026-02-25 11:46:07 +00:00
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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user