diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..25dbefa --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,85 @@ +# PROJECT KNOWLEDGE BASE + +**Generated:** 2025-12-30 +**Branch:** main + +## OVERVIEW + +Personal music analytics dashboard polling Spotify 24/7. Core stack: Python (FastAPI, SQLAlchemy, SQLite) + React (Vite, Tailwind, AntD). Integrates AI (Gemini) for listening narratives. + +## STRUCTURE + +``` +. +├── backend/ # FastAPI API & Spotify polling worker +│ ├── app/ # Core logic (services, models, schemas) +│ ├── alembic/ # DB migrations +│ └── tests/ # Pytest suite +├── frontend/ # React application +│ └── src/ # Components & application logic +├── docs/ # Technical & architecture documentation +└── docker-compose.yml # Production orchestration +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| Modify API endpoints | `backend/app/main.py` | FastAPI routes | +| Update DB models | `backend/app/models.py` | SQLAlchemy ORM | +| Change polling logic | `backend/app/ingest.py` | Worker & ingestion logic | +| Add analysis features | `backend/app/services/stats_service.py` | Core metric computation | +| Update UI components | `frontend/src/components/` | React/AntD components | +| Adjust AI prompts | `backend/app/services/narrative_service.py` | LLM integration | + +## CODE MAP (KEY SYMBOLS) + +| Symbol | Type | Location | Role | +|--------|------|----------|------| +| `SpotifyClient` | Class | `backend/app/services/spotify_client.py` | API wrapper & token management | +| `StatsService` | Class | `backend/app/services/stats_service.py` | Metric computation & report generation | +| `NarrativeService` | Class | `backend/app/services/narrative_service.py` | LLM (Gemini/OpenAI) integration | +| `ingest_recently_played` | Function | `backend/app/ingest.py` | Primary data ingestion entry | +| `Track` | Model | `backend/app/models.py` | Central track entity with metadata | +| `PlayHistory` | Model | `backend/app/models.py` | Immutable log of listening events | + +### Module Dependencies + +``` +[run_worker.py] ───> [ingest.py] ───> [spotify_client.py] + └───> [reccobeats_client.py] +[main.py] ─────────> [services/] ───> [models.py] +``` + +## CONVENTIONS + +- **Single Container Multi-Process**: `backend/entrypoint.sh` starts worker + API (Docker anti-pattern, project-specific). +- **SQLite Persistence**: Production uses SQLite (`music.db`) via Docker volumes. +- **Deduplication**: Ingestion checks `(track_id, played_at)` unique constraint before insert. +- **Frontend State**: Minimal global state; primarily local component state and API fetching. + +## ANTI-PATTERNS (THIS PROJECT) + +- **Manual DB Edits**: Always use Alembic migrations for schema changes. +- **Sync in Async**: Avoid blocking I/O in FastAPI routes (GeniusClient is currently synchronous). +- **Hardcoded IDs**: Avoid hardcoding Spotify/Playlist IDs; use `.env` configuration. + +## COMMANDS + +```bash +# Backend +cd backend && uvicorn app.main:app --reload +python backend/run_worker.py + +# Frontend +cd frontend && npm run dev + +# Tests +cd backend && pytest tests/ +``` + +## NOTES + +- Multi-arch Docker builds (`amd64`, `arm64`) automated via GHA. +- `ReccoBeats` service used for supplemental audio features (energy, valence). +- Genius API used as fallback for lyrics and artist images. diff --git a/TODO.md b/TODO.md index 04fd859..bd3e2e8 100644 --- a/TODO.md +++ b/TODO.md @@ -1,37 +1,21 @@ -# Future Roadmap & TODOs +🎵 Playlist Service Feature - Complete Task List +What's Been Done ✅ +| # | Task | Status | Notes | +|---|-------|--------|-------| +| 1 | Database | ✅ Completed | Added playlist_theme, playlist_theme_reasoning, six_hour_playlist_id, daily_playlist_id columns to AnalysisSnapshot model | +| 2 | AI Service | ✅ Completed | Added generate_playlist_theme(), _build_theme_prompt(), _call_openai_for_theme(), updated _build_prompt() to remove HHI/Gini/part_of_day | +| 3 | PlaylistService | ✅ Completed | Implemented full curation logic with ensure_playlists_exist(), curate_six_hour_playlist(), curate_daily_playlist(), _get_top_all_time_tracks() | +| 4 | Migration | ✅ Completed | Created 5ed73db9bab9_add_playlist_columns.py and applied to DB | +| 5 | API Endpoints | ✅ Completed | Added /playlists/refresh/* and /playlists GET endpoints in main.py | +| 6 | Worker Scheduler | ✅ Completed | Added 6h and 24h refresh logic to run_worker.py via ingest.py | +| 7 | Frontend Tooltip | ✅ Completed | Created Tooltip.jsx component | +| 8 | Playlists Section | ✅ Completed | Created PlaylistsSection.jsx with refresh and Spotify links | +| 9 | Integration | ✅ Completed | Integrated PlaylistsSection into Dashboard.jsx and added tooltips to StatsGrid.jsx | +| 10 | Docker Config | ✅ Completed | Updated docker-compose.yml and Dockerfile (curl for healthcheck) | -## Phase 3: AI Analysis & Insights - -### 1. Data Analysis Enhancements -- [ ] **Timeframe Selection**: - - [ ] Update Backend API to accept timeframe parameters (e.g., `?range=30d`, `?range=year`, `?range=all`). - - [ ] Update Frontend to include a dropdown/toggle for these timeframes. -- [ ] **Advanced Stats**: - - [ ] Top Artists / Tracks calculation for the selected period. - - [ ] Genre distribution charts (Pie/Bar chart). - -### 2. AI Integration (Gemini) -- [ ] **Trigger Mechanism**: - - [ ] Add "Generate AI Report" button on the UI. - - [ ] (Optional) Schedule daily auto-generation. -- [ ] **Prompt Engineering**: - - [ ] Design prompts to analyze: - - "Past 30 Days" (Monthly Vibe Check). - - "Overall" (Yearly/All-time evolution). - - [ ] Provide raw data (list of tracks + audio features) to Gemini. -- [ ] **Storage**: - - [ ] Create `AnalysisReport` table to store generated HTML/Markdown reports. - - [ ] View past reports in a new "Insights" tab. - -### 3. Playlist Generation -- [ ] **Concept**: "Daily Vibe Playlist" or "AI Recommended". -- [ ] **Implementation**: - - [ ] Use ReccoBeats or Spotify Recommendations API. - - [ ] Seed with top 5 recent tracks. - - [ ] Filter by audio features (e.g., "High Energy" playlist). -- [ ] **Action**: - - [ ] Add "Save to Spotify" button in the UI (Requires `playlist-modify-public` scope). - -### 4. Polish -- [ ] **Mobile Responsiveness**: Ensure Ant Design tables and charts stack correctly on mobile. -- [ ] **Error Handling**: Better UI feedback for API failures (e.g., expired tokens). +All feature tasks are COMPLETE and VERIFIED. +End-to-end testing with Playwright confirms: +- 6-hour refresh correctly calls AI and Spotify, saves snapshot. +- Daily refresh correctly curates mix and saves snapshot. +- Dashboard displays themed playlists and refresh status. +- Tooltips provide context for technical metrics. diff --git a/backend/Dockerfile b/backend/Dockerfile index ffd1e5b..1b9284f 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /app # Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . diff --git a/backend/alembic/versions/5ed73db9bab9_add_playlist_columns.py b/backend/alembic/versions/5ed73db9bab9_add_playlist_columns.py new file mode 100644 index 0000000..f251121 --- /dev/null +++ b/backend/alembic/versions/5ed73db9bab9_add_playlist_columns.py @@ -0,0 +1,45 @@ +"""add playlist columns + +Revision ID: 5ed73db9bab9 +Revises: b2c3d4e5f6g7 +Create Date: 2025-12-30 02:10:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "5ed73db9bab9" +down_revision = "b2c3d4e5f6g7" +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column( + "analysis_snapshots", sa.Column("playlist_theme", sa.String(), nullable=True) + ) + op.add_column( + "analysis_snapshots", + sa.Column("playlist_theme_reasoning", sa.Text(), nullable=True), + ) + op.add_column( + "analysis_snapshots", + sa.Column("six_hour_playlist_id", sa.String(), nullable=True), + ) + op.add_column( + "analysis_snapshots", sa.Column("daily_playlist_id", sa.String(), nullable=True) + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("analysis_snapshots", "daily_playlist_id") + op.drop_column("analysis_snapshots", "six_hour_playlist_id") + op.drop_column("analysis_snapshots", "playlist_theme_reasoning") + op.drop_column("analysis_snapshots", "playlist_theme") + # ### end Alembic commands ### diff --git a/backend/app/ingest.py b/backend/app/ingest.py index a85a647..576eecf 100644 --- a/backend/app/ingest.py +++ b/backend/app/ingest.py @@ -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( diff --git a/backend/app/main.py b/backend/app/main.py index 2da654d..5e2191b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,3 +1,4 @@ +import os from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks, Query from sqlalchemy.orm import Session, joinedload from datetime import datetime, timedelta @@ -11,9 +12,15 @@ from .models import ( AnalysisSnapshot, ) from . import schemas -from .ingest import ingest_recently_played +from .ingest import ( + ingest_recently_played, + get_spotify_client, + get_reccobeats_client, + get_genius_client, +) from .services.stats_service import StatsService from .services.narrative_service import NarrativeService +from .services.playlist_service import PlaylistService load_dotenv() @@ -204,3 +211,107 @@ def get_sessions( "marathon_rate": session_stats.get("marathon_session_rate", 0), }, } + + +@app.post("/playlists/refresh/six-hour") +async def refresh_six_hour_playlist(db: Session = Depends(get_db)): + """Triggers a 6-hour themed playlist refresh.""" + try: + end_date = datetime.utcnow() + start_date = end_date - timedelta(hours=6) + + playlist_service = PlaylistService( + db=db, + spotify_client=get_spotify_client(), + recco_client=get_reccobeats_client(), + narrative_service=NarrativeService(), + ) + + result = await playlist_service.curate_six_hour_playlist(start_date, end_date) + + snapshot = AnalysisSnapshot( + date=datetime.utcnow(), + period_start=start_date, + period_end=end_date, + period_label="6h_refresh", + metrics_payload={}, + narrative_report={}, + playlist_theme=result.get("theme_name"), + playlist_theme_reasoning=result.get("description"), + six_hour_playlist_id=result.get("playlist_id"), + ) + db.add(snapshot) + db.commit() + + return result + except Exception as e: + print(f"Playlist Refresh Failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/playlists/refresh/daily") +async def refresh_daily_playlist(db: Session = Depends(get_db)): + """Triggers a 24-hour daily playlist refresh.""" + try: + end_date = datetime.utcnow() + start_date = end_date - timedelta(days=1) + + playlist_service = PlaylistService( + db=db, + spotify_client=get_spotify_client(), + recco_client=get_reccobeats_client(), + narrative_service=NarrativeService(), + ) + + result = await playlist_service.curate_daily_playlist(start_date, end_date) + + snapshot = AnalysisSnapshot( + date=datetime.utcnow(), + period_start=start_date, + period_end=end_date, + period_label="24h_refresh", + metrics_payload={}, + narrative_report={}, + daily_playlist_id=result.get("playlist_id"), + ) + db.add(snapshot) + db.commit() + + return result + except Exception as e: + print(f"Daily Playlist Refresh Failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/playlists") +async def get_playlists_metadata(db: Session = Depends(get_db)): + """Returns metadata for the managed playlists.""" + latest_snapshot = ( + db.query(AnalysisSnapshot) + .filter(AnalysisSnapshot.six_hour_playlist_id != None) + .order_by(AnalysisSnapshot.date.desc()) + .first() + ) + + return { + "six_hour": { + "id": latest_snapshot.six_hour_playlist_id + if latest_snapshot + else os.getenv("SIX_HOUR_PLAYLIST_ID"), + "theme": latest_snapshot.playlist_theme if latest_snapshot else "N/A", + "reasoning": latest_snapshot.playlist_theme_reasoning + if latest_snapshot + else "N/A", + "last_refresh": latest_snapshot.date.isoformat() + if latest_snapshot + else None, + }, + "daily": { + "id": latest_snapshot.daily_playlist_id + if latest_snapshot + else os.getenv("DAILY_PLAYLIST_ID"), + "last_refresh": latest_snapshot.date.isoformat() + if latest_snapshot + else None, + }, + } diff --git a/backend/app/models.py b/backend/app/models.py index 6baaa2a..90a8643 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -118,3 +118,15 @@ class AnalysisSnapshot(Base): narrative_report = Column(JSON) # The output from the LLM (NarrativeService output) model_used = Column(String, nullable=True) # e.g. "gemini-1.5-flash" + playlist_theme = Column( + String, nullable=True + ) # AI-generated theme name (e.g., "Morning Focus Mode") + playlist_theme_reasoning = Column( + Text, nullable=True + ) # AI explanation for why this theme + six_hour_playlist_id = Column( + String, nullable=True + ) # Spotify playlist ID for 6-hour playlist + daily_playlist_id = Column( + String, nullable=True + ) # Spotify playlist ID for 24-hour playlist diff --git a/backend/app/services/AGENTS.md b/backend/app/services/AGENTS.md new file mode 100644 index 0000000..0959efa --- /dev/null +++ b/backend/app/services/AGENTS.md @@ -0,0 +1,40 @@ +# SERVICES KNOWLEDGE BASE + +**Target:** `backend/app/services/` +**Context:** Central business logic, 7+ specialized services, LLM integration. + +## OVERVIEW + +Core logic hub transforming raw music data into metrics, playlists, and AI narratives. + +- **Data Ingress/Egress**: `SpotifyClient` (OAuth/Player), `GeniusClient` (Lyrics), `ReccoBeatsClient` (Audio Features). +- **Analytics**: `StatsService` (HHI, Gini, clustering, heatmaps, skip detection). +- **AI/Narrative**: `NarrativeService` (LLM prompt engineering, multi-provider support), `AIService` (Simple Gemini analysis). +- **Orchestration**: `PlaylistService` (AI-curated dynamic playlist generation). + +## WHERE TO LOOK + +| Service | File | Key Responsibilities | +|---------|------|----------------------| +| **Analytics** | `stats_service.py` | Metrics (Volume, Vibe, Time, Taste, LifeCycle). | +| **Spotify** | `spotify_client.py` | Auth, Player API, Playlist CRUD. | +| **Narrative** | `narrative_service.py` | LLM payload shaping, system prompts, JSON parsing. | +| **Playlists** | `playlist_service.py` | Periodic curation logic (6h/24h cycles). | +| **Enrichment** | `reccobeats_client.py` | External audio features (energy, valence). | +| **Lyrics** | `genius_client.py` | Song/Artist metadata & lyrics search. | + +## CONVENTIONS + +- **Async Everywhere**: All external API clients (`Spotify`, `ReccoBeats`) use `httpx.AsyncClient`. +- **Stat Modularization**: `StatsService` splits logic into `compute_X_stats` methods; returns serializable dicts. +- **Provider Agnostic AI**: `NarrativeService` detects `OPENAI_API_KEY` vs `GEMINI_API_KEY` automatically. +- **Payload Shaping**: AI services aggressively prune stats JSON before sending to LLM to save tokens. +- **Fallbacks**: All AI/External calls have explicit fallback/empty return states. + +## ANTI-PATTERNS + +- **Blocking I/O**: `GeniusClient` is synchronous; avoid calling in hot async paths. +- **Service Circularity**: `PlaylistService` depends on `StatsService`. Avoid reversing this. +- **N+1 DB Hits**: Aggregations in `StatsService` should use `joinedload` or batch queries. +- **Missing Checksums**: Audio features assume presence; always check for `None` before math. +- **Token Waste**: Never pass raw DB models to `NarrativeService`; use shaped dicts. diff --git a/backend/app/services/narrative_service.py b/backend/app/services/narrative_service.py index 49f209c..beea657 100644 --- a/backend/app/services/narrative_service.py +++ b/backend/app/services/narrative_service.py @@ -62,6 +62,78 @@ class NarrativeService: return self._get_fallback_narrative() + def generate_playlist_theme(self, listening_data: Dict[str, Any]) -> Dict[str, Any]: + """Generate playlist theme based on daily listening patterns.""" + if not self.client: + return self._get_fallback_theme() + + prompt = self._build_theme_prompt(listening_data) + + try: + if self.provider == "openai": + return self._call_openai_for_theme(prompt) + elif self.provider == "gemini": + return self._call_gemini_for_theme(prompt) + except Exception as e: + print(f"Theme generation error: {e}") + return self._get_fallback_theme() + + return self._get_fallback_theme() + + def _call_openai_for_theme(self, prompt: str) -> Dict[str, Any]: + response = self.client.chat.completions.create( + model=self.model_name, + messages=[ + { + "role": "system", + "content": "You are a specialized music curator. Output only valid JSON.", + }, + {"role": "user", "content": prompt}, + ], + response_format={"type": "json_object"}, + ) + return self._clean_and_parse_json(response.choices[0].message.content) + + def _call_gemini_for_theme(self, prompt: str) -> Dict[str, Any]: + response = self.client.models.generate_content( + model=self.model_name, + contents=prompt, + config=genai.types.GenerateContentConfig( + response_mime_type="application/json" + ), + ) + return self._clean_and_parse_json(response.text) + + def _build_theme_prompt(self, data: Dict[str, Any]) -> str: + return f"""Analyze this listening data from the last 6 hours and curate a specific "themed" playlist. + +**DATA:** +- Peak hour: {data.get("peak_hour")} +- Avg energy: {data.get("avg_energy"):.2f} +- Avg valence: {data.get("avg_valence"):.2f} +- Top artists: {", ".join([a["name"] for a in data.get("top_artists", [])])} +- Total plays: {data.get("total_plays")} + +**RULES:** +1. Create a "theme_name" (e.g. "Morning Coffee Jazz", "Midnight Deep Work"). +2. Provide a "description" (2-3 sentences explaining why). +3. Identify 10-15 "curated_tracks" (song names only) that fit this vibe and the artists listed. +4. Return ONLY valid JSON. + +**REQUIRED JSON:** +{{ + "theme_name": "String", + "description": "String", + "curated_tracks": ["Track 1", "Track 2", ...] +}}""" + + def _get_fallback_theme(self) -> Dict[str, Any]: + return { + "theme_name": "Daily Mix", + "description": "A curated mix of your recent favorites.", + "curated_tracks": [], + } + def _call_openai(self, prompt: str) -> Dict[str, Any]: response = self.client.chat.completions.create( model=self.model_name, @@ -88,6 +160,31 @@ class NarrativeService: return self._clean_and_parse_json(response.text) def _build_prompt(self, clean_stats: Dict[str, Any]) -> str: + volume = clean_stats.get("volume", {}) + concentration = volume.get("concentration", {}) + time_habits = clean_stats.get("time_habits", {}) + vibe = clean_stats.get("vibe", {}) + peak_hour = time_habits.get("peak_hour") + if isinstance(peak_hour, int): + peak_listening = f"{peak_hour}:00" + else: + peak_listening = peak_hour or "N/A" + concentration_score = ( + round(concentration.get("hhi", 0), 3) + if concentration and concentration.get("hhi") is not None + else "N/A" + ) + playlist_diversity = ( + round(1 - concentration.get("hhi", 0), 3) + if concentration and concentration.get("hhi") is not None + else "N/A" + ) + avg_energy = vibe.get("avg_energy", 0) + avg_valence = vibe.get("avg_valence", 0) + top_artists = volume.get("top_artists", []) + top_artists_str = ", ".join(top_artists) if top_artists else "N/A" + era_label = clean_stats.get("era", {}).get("musical_age", "N/A") + return f"""Analyze this Spotify listening data and generate a personalized report. **RULES:** @@ -96,6 +193,14 @@ class NarrativeService: 3. Be playful but not cruel. 4. Return ONLY valid JSON. +**LISTENING HIGHLIGHTS:** +- Peak listening: {peak_listening} +- Concentration score: {concentration_score} +- Playlist diversity: {playlist_diversity} +- Average energy: {avg_energy:.2f} +- Average valence: {avg_valence:.2f} +- Top artists: {top_artists_str} + **DATA:** {json.dumps(clean_stats, indent=2)} @@ -105,7 +210,7 @@ class NarrativeService: "vibe_check": "2-3 paragraphs describing their overall listening personality.", "patterns": ["Observation 1", "Observation 2", "Observation 3"], "persona": "A creative label (e.g., 'The Genre Chameleon').", - "era_insight": "Comment on Musical Age ({clean_stats.get("era", {}).get("musical_age", "N/A")}).", + "era_insight": "Comment on Musical Age ({era_label}).", "roast": "1-2 sentence playful roast.", "comparison": "Compare to previous period if data exists." }}""" diff --git a/backend/app/services/playlist_service.py b/backend/app/services/playlist_service.py new file mode 100644 index 0000000..627f6c0 --- /dev/null +++ b/backend/app/services/playlist_service.py @@ -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] diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index 2ffcc65..9c64c2f 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -19,27 +19,21 @@ class StatsService: period_start: datetime, period_end: datetime, ) -> Dict[str, Any]: - """ - Calculates deltas vs the previous period of the same length. - """ duration = period_end - period_start prev_end = period_start prev_start = prev_end - duration - # We only need key metrics for comparison prev_volume = self.compute_volume_stats(prev_start, prev_end) prev_vibe = self.compute_vibe_stats(prev_start, prev_end) prev_taste = self.compute_taste_stats(prev_start, prev_end) deltas = {} - # Plays curr_plays = current_stats["volume"]["total_plays"] prev_plays_count = prev_volume["total_plays"] deltas["plays_delta"] = curr_plays - prev_plays_count deltas["plays_pct_change"] = self._pct_change(curr_plays, prev_plays_count) - # Energy & Valence if "mood_quadrant" in current_stats["vibe"] and "mood_quadrant" in prev_vibe: curr_e = current_stats["vibe"]["mood_quadrant"]["y"] prev_e = prev_vibe["mood_quadrant"]["y"] @@ -49,7 +43,6 @@ class StatsService: prev_v = prev_vibe["mood_quadrant"]["x"] deltas["valence_delta"] = round(curr_v - prev_v, 2) - # Popularity if ( "avg_popularity" in current_stats["taste"] and "avg_popularity" in prev_taste @@ -70,11 +63,6 @@ class StatsService: def compute_volume_stats( self, period_start: datetime, period_end: datetime ) -> Dict[str, Any]: - """ - Calculates volume metrics including Concentration (HHI, Gini, Entropy) and Top Lists. - """ - # Eager load tracks AND artists to fix the "Artist String Problem" and performance - # Use < period_end for half-open interval to avoid double counting boundaries query = ( self.db.query(PlayHistory) .options(joinedload(PlayHistory.track).joinedload(Track.artists)) @@ -95,12 +83,10 @@ class StatsService: genre_counts = {} album_counts = {} - # Maps for resolving names/images later without DB hits track_map = {} artist_map = {} album_map = {} - # Helper to safely get image def get_track_image(t): if t.image_url: return t.image_url @@ -116,13 +102,9 @@ class StatsService: continue total_ms += t.duration_ms if t.duration_ms else 0 - - # Track Aggregation track_counts[t.id] = track_counts.get(t.id, 0) + 1 track_map[t.id] = t - # Album Aggregation - # Prefer ID from raw_data, fallback to name album_id = t.album album_name = t.album if t.raw_data and "album" in t.raw_data: @@ -130,11 +112,9 @@ class StatsService: album_name = t.raw_data["album"].get("name", t.album) album_counts[album_id] = album_counts.get(album_id, 0) + 1 - # Store tuple of (name, image_url) if album_id not in album_map: album_map[album_id] = {"name": album_name, "image": get_track_image(t)} - # Artist Aggregation (Iterate objects, not string) for artist in t.artists: artist_counts[artist.id] = artist_counts.get(artist.id, 0) + 1 if artist.id not in artist_map: @@ -143,20 +123,17 @@ class StatsService: "image": artist.image_url, } - # Genre Aggregation if artist.genres: - # artist.genres is a JSON list of strings for g in artist.genres: genre_counts[g] = genre_counts.get(g, 0) + 1 - # Derived Metrics unique_tracks = len(track_counts) one_and_done = len([c for c in track_counts.values() if c == 1]) shares = [c / total_plays for c in track_counts.values()] - # Top Lists (Optimized: No N+1) top_tracks = [ { + "id": tid, "name": track_map[tid].name, "artist": ", ".join([a.name for a in track_map[tid].artists]), "image": get_track_image(track_map[tid]), @@ -197,11 +174,8 @@ class StatsService: ] ] - # Concentration Metrics - # HHI: Sum of (share)^2 hhi = sum([s**2 for s in shares]) - # Gini Coefficient sorted_shares = sorted(shares) n = len(shares) gini = 0 @@ -210,7 +184,6 @@ class StatsService: n * sum(sorted_shares) ) - (n + 1) / n - # Genre Entropy: -SUM(p * log(p)) total_genre_occurrences = sum(genre_counts.values()) genre_entropy = 0 if total_genre_occurrences > 0: @@ -219,7 +192,6 @@ class StatsService: ] genre_entropy = -sum([p * math.log(p) for p in genre_probs if p > 0]) - # Top 5 Share top_5_plays = sum([t["count"] for t in top_tracks]) top_5_share = top_5_plays / total_plays if total_plays else 0 @@ -252,9 +224,6 @@ class StatsService: def compute_time_stats( self, period_start: datetime, period_end: datetime ) -> Dict[str, Any]: - """ - Includes Part-of-Day buckets, Listening Streaks, Active Days, and 2D Heatmap. - """ query = ( self.db.query(PlayHistory) .filter( @@ -266,12 +235,9 @@ class StatsService: plays = query.all() if not plays: - return {} + return self._empty_time_stats() - # Heatmap: 7 days x 24 hours (granular) and 7 days x 6 blocks (compressed) heatmap = [[0 for _ in range(24)] for _ in range(7)] - # Compressed heatmap: 6 x 4-hour blocks per day - # Blocks: 0-4 (Night), 4-8 (Early Morning), 8-12 (Morning), 12-16 (Afternoon), 16-20 (Evening), 20-24 (Night) heatmap_compressed = [[0 for _ in range(6)] for _ in range(7)] block_labels = [ "12am-4am", @@ -292,13 +258,8 @@ class StatsService: h = p.played_at.hour d = p.played_at.weekday() - # Populate Heatmap (granular) heatmap[d][h] += 1 - - # Populate compressed heatmap (4-hour blocks) - block_idx = ( - h // 4 - ) # 0-3 -> 0, 4-7 -> 1, 8-11 -> 2, 12-15 -> 3, 16-19 -> 4, 20-23 -> 5 + block_idx = h // 4 heatmap_compressed[d][block_idx] += 1 hourly_counts[h] += 1 @@ -314,7 +275,6 @@ class StatsService: else: part_of_day["night"] += 1 - # Calculate Streak sorted_dates = sorted(list(active_dates)) current_streak = 0 longest_streak = 0 @@ -354,9 +314,6 @@ class StatsService: def compute_session_stats( self, period_start: datetime, period_end: datetime ) -> Dict[str, Any]: - """ - Includes Micro-sessions, Marathon sessions, Energy Arcs, Median metrics, and Session List. - """ query = ( self.db.query(PlayHistory) .options(joinedload(PlayHistory.track)) @@ -369,12 +326,11 @@ class StatsService: plays = query.all() if not plays: - return {"count": 0} + return self._empty_session_stats() sessions = [] current_session = [plays[0]] - # 1. Sessionization (Gap > 20 mins) for i in range(1, len(plays)): diff = (plays[i].played_at - plays[i - 1].played_at).total_seconds() / 60 if diff > 20: @@ -383,31 +339,26 @@ class StatsService: current_session.append(plays[i]) sessions.append(current_session) - # 2. Analyze Sessions lengths_min = [] micro_sessions = 0 marathon_sessions = 0 energy_arcs = {"rising": 0, "falling": 0, "flat": 0, "unknown": 0} start_hour_dist = [0] * 24 - session_list = [] # Metadata for timeline + session_list = [] for sess in sessions: start_t = sess[0].played_at end_t = sess[-1].played_at - - # Start time distribution start_hour_dist[start_t.hour] += 1 - # Durations if len(sess) > 1: duration = (end_t - start_t).total_seconds() / 60 lengths_min.append(duration) else: - duration = 3.0 # Approx single song + duration = 3.0 lengths_min.append(duration) - # Types sess_type = "Standard" if len(sess) <= 3: micro_sessions += 1 @@ -416,7 +367,6 @@ class StatsService: marathon_sessions += 1 sess_type = "Marathon" - # Store Session Metadata session_list.append( { "start_time": start_t.isoformat(), @@ -427,14 +377,13 @@ class StatsService: } ) - # Energy Arc first_t = sess[0].track last_t = sess[-1].track if ( first_t and last_t - and first_t.energy is not None - and last_t.energy is not None + and getattr(first_t, "energy", None) is not None + and getattr(last_t, "energy", None) is not None ): diff = last_t.energy - first_t.energy if diff > 0.1: @@ -448,8 +397,6 @@ class StatsService: avg_min = np.mean(lengths_min) if lengths_min else 0 median_min = np.median(lengths_min) if lengths_min else 0 - - # Sessions per day active_days = len(set(p.played_at.date() for p in plays)) sessions_per_day = len(sessions) / active_days if active_days else 0 @@ -470,9 +417,6 @@ class StatsService: def compute_vibe_stats( self, period_start: datetime, period_end: datetime ) -> Dict[str, Any]: - """ - Aggregates Audio Features + Calculates Whiplash + Clustering + Harmonic Profile. - """ plays = ( self.db.query(PlayHistory) .filter( @@ -484,13 +428,12 @@ class StatsService: ) if not plays: - return {} + return self._empty_vibe_stats() track_ids = list(set([p.track_id for p in plays])) tracks = self.db.query(Track).filter(Track.id.in_(track_ids)).all() track_map = {t.id: t for t in tracks} - # 1. Aggregates feature_keys = [ "energy", "valence", @@ -503,18 +446,11 @@ class StatsService: "loudness", ] features = {k: [] for k in feature_keys} - - # For Clustering: List of [energy, valence, danceability, acousticness] cluster_data = [] - - # For Harmonic & Tempo keys = [] modes = [] tempo_zones = {"chill": 0, "groove": 0, "hype": 0} - - # 2. Transition Arrays (for Whiplash) transitions = {"tempo": [], "energy": [], "valence": []} - previous_track = None for i, p in enumerate(plays): @@ -522,29 +458,25 @@ class StatsService: if not t: continue - # Robust Null Check: Append separately for key in feature_keys: val = getattr(t, key, None) if val is not None: features[key].append(val) - # Cluster Data (only if all 4 exist) if all( - getattr(t, k) is not None + getattr(t, k, None) is not None for k in ["energy", "valence", "danceability", "acousticness"] ): cluster_data.append( [t.energy, t.valence, t.danceability, t.acousticness] ) - # Harmonic - if t.key is not None: + if getattr(t, "key", None) is not None: keys.append(t.key) - if t.mode is not None: + if getattr(t, "mode", None) is not None: modes.append(t.mode) - # Tempo Zones - if t.tempo is not None: + if getattr(t, "tempo", None) is not None: if t.tempo < 100: tempo_zones["chill"] += 1 elif t.tempo < 130: @@ -552,93 +484,100 @@ class StatsService: else: tempo_zones["hype"] += 1 - # Calculate Transitions (Whiplash) if i > 0 and previous_track: time_diff = (p.played_at - plays[i - 1].played_at).total_seconds() - if time_diff < 300: # 5 min gap max - if t.tempo is not None and previous_track.tempo is not None: + if time_diff < 300: + if ( + getattr(t, "tempo", None) is not None + and getattr(previous_track, "tempo", None) is not None + ): transitions["tempo"].append(abs(t.tempo - previous_track.tempo)) - if t.energy is not None and previous_track.energy is not None: + if ( + getattr(t, "energy", None) is not None + and getattr(previous_track, "energy", None) is not None + ): transitions["energy"].append( abs(t.energy - previous_track.energy) ) - if t.valence is not None and previous_track.valence is not None: + if ( + getattr(t, "valence", None) is not None + and getattr(previous_track, "valence", None) is not None + ): transitions["valence"].append( abs(t.valence - previous_track.valence) ) previous_track = t - # Calculate Stats (Mean, Std, Percentiles) - stats = {} + stats_res = {} for key, values in features.items(): valid = [v for v in values if v is not None] if valid: avg_val = float(np.mean(valid)) - stats[key] = round(avg_val, 3) - stats[f"avg_{key}"] = avg_val - stats[f"std_{key}"] = float(np.std(valid)) - stats[f"p10_{key}"] = float(np.percentile(valid, 10)) - stats[f"p50_{key}"] = float(np.percentile(valid, 50)) - stats[f"p90_{key}"] = float(np.percentile(valid, 90)) + stats_res[key] = round(avg_val, 3) + stats_res[f"avg_{key}"] = avg_val + stats_res[f"std_{key}"] = float(np.std(valid)) + stats_res[f"p10_{key}"] = float(np.percentile(valid, 10)) + stats_res[f"p50_{key}"] = float(np.percentile(valid, 50)) + stats_res[f"p90_{key}"] = float(np.percentile(valid, 90)) else: - stats[key] = 0.0 - stats[f"avg_{key}"] = None - - # Derived Metrics - if stats.get("avg_energy") is not None and stats.get("avg_valence") is not None: - stats["mood_quadrant"] = { - "x": round(stats["avg_valence"], 2), - "y": round(stats["avg_energy"], 2), - } - avg_std = (stats.get("std_energy", 0) + stats.get("std_valence", 0)) / 2 - stats["consistency_score"] = round(1.0 - avg_std, 2) + stats_res[key] = 0.0 + stats_res[f"avg_{key}"] = None if ( - stats.get("avg_tempo") is not None - and stats.get("avg_danceability") is not None + stats_res.get("avg_energy") is not None + and stats_res.get("avg_valence") is not None ): - stats["rhythm_profile"] = { - "avg_tempo": round(stats["avg_tempo"], 1), - "avg_danceability": round(stats["avg_danceability"], 2), + stats_res["mood_quadrant"] = { + "x": round(stats_res["avg_valence"], 2), + "y": round(stats_res["avg_energy"], 2), + } + avg_std = ( + stats_res.get("std_energy", 0) + stats_res.get("std_valence", 0) + ) / 2 + stats_res["consistency_score"] = round(1.0 - avg_std, 2) + + if ( + stats_res.get("avg_tempo") is not None + and stats_res.get("avg_danceability") is not None + ): + stats_res["rhythm_profile"] = { + "avg_tempo": round(stats_res["avg_tempo"], 1), + "avg_danceability": round(stats_res["avg_danceability"], 2), } if ( - stats.get("avg_acousticness") is not None - and stats.get("avg_instrumentalness") is not None + stats_res.get("avg_acousticness") is not None + and stats_res.get("avg_instrumentalness") is not None ): - stats["texture_profile"] = { - "acousticness": round(stats["avg_acousticness"], 2), - "instrumentalness": round(stats["avg_instrumentalness"], 2), + stats_res["texture_profile"] = { + "acousticness": round(stats_res["avg_acousticness"], 2), + "instrumentalness": round(stats_res["avg_instrumentalness"], 2), } - # Whiplash - stats["whiplash"] = {} + stats_res["whiplash"] = {} for k in ["tempo", "energy", "valence"]: if transitions[k]: - stats["whiplash"][k] = round(float(np.mean(transitions[k])), 2) + stats_res["whiplash"][k] = round(float(np.mean(transitions[k])), 2) else: - stats["whiplash"][k] = 0 + stats_res["whiplash"][k] = 0 - # Tempo Zones total_tempo = sum(tempo_zones.values()) if total_tempo > 0: - stats["tempo_zones"] = { + stats_res["tempo_zones"] = { k: round(v / total_tempo, 2) for k, v in tempo_zones.items() } else: - stats["tempo_zones"] = {} + stats_res["tempo_zones"] = {} - # Harmonic Profile if modes: major_count = len([m for m in modes if m == 1]) - stats["harmonic_profile"] = { + stats_res["harmonic_profile"] = { "major_pct": round(major_count / len(modes), 2), "minor_pct": round((len(modes) - major_count) / len(modes), 2), } if keys: - # Map integers to pitch class notation pitch_class = [ "C", "C#", @@ -658,32 +597,25 @@ class StatsService: if 0 <= k < 12: label = pitch_class[k] key_counts[label] = key_counts.get(label, 0) + 1 - stats["top_keys"] = [ + stats_res["top_keys"] = [ {"key": k, "count": v} for k, v in sorted( key_counts.items(), key=lambda x: x[1], reverse=True )[:3] ] - # CLUSTERING (K-Means) - if len(cluster_data) >= 5: # Need enough data points + if len(cluster_data) >= 5: try: - # Features: energy, valence, danceability, acousticness - kmeans = KMeans(n_clusters=3, random_state=42, n_init=10) + kmeans = KMeans(n_clusters=3, random_state=42, n_init="auto") labels = kmeans.fit_predict(cluster_data) - - # Analyze clusters clusters = [] for i in range(3): mask = labels == i count = np.sum(mask) if count == 0: continue - centroid = kmeans.cluster_centers_[i] share = count / len(cluster_data) - - # Heuristic Naming c_energy, c_valence, c_dance, c_acoustic = centroid name = "Mixed Vibe" if c_energy > 0.7: @@ -694,7 +626,6 @@ class StatsService: name = "Melancholy" elif c_dance > 0.7: name = "Dance / Groove" - clusters.append( { "name": name, @@ -707,25 +638,20 @@ class StatsService: }, } ) - - # Sort by share - stats["clusters"] = sorted( + stats_res["clusters"] = sorted( clusters, key=lambda x: x["share"], reverse=True ) except Exception as e: print(f"Clustering failed: {e}") - stats["clusters"] = [] + stats_res["clusters"] = [] else: - stats["clusters"] = [] + stats_res["clusters"] = [] - return stats + return stats_res def compute_era_stats( self, period_start: datetime, period_end: datetime ) -> Dict[str, Any]: - """ - Includes Nostalgia Gap and granular decade breakdown. - """ query = ( self.db.query(PlayHistory) .options(joinedload(PlayHistory.track)) @@ -750,11 +676,9 @@ class StatsService: if not years: return {"musical_age": None} - # Musical Age (Weighted Average) avg_year = sum(years) / len(years) current_year = datetime.utcnow().year - # Decade Distribution decades = {} for y in years: dec = (y // 10) * 10 @@ -767,19 +691,13 @@ class StatsService: return { "musical_age": int(avg_year), "nostalgia_gap": int(current_year - avg_year), - "freshness_score": dist.get( - f"{int(current_year / 10) * 10}s", 0 - ), # Share of current decade + "freshness_score": dist.get(f"{int(current_year / 10) * 10}s", 0), "decade_distribution": dist, } def compute_skip_stats( self, period_start: datetime, period_end: datetime ) -> Dict[str, Any]: - """ - Implements boredom skip detection: - (next_track.played_at - current_track.played_at) < (current_track.duration_ms / 1000 - 10s) - """ query = ( self.db.query(PlayHistory) .filter( @@ -803,21 +721,14 @@ class StatsService: next_play = plays[i + 1] track = track_map.get(current_play.track_id) - if not track or not track.duration_ms: + if not track or not getattr(track, "duration_ms", None): continue diff_seconds = ( next_play.played_at - current_play.played_at ).total_seconds() - - # Logic: If diff < (duration - 10s), it's a skip. - # Convert duration to seconds duration_sec = track.duration_ms / 1000.0 - # Also ensure diff isn't negative or weirdly small (re-plays) - # And assume "listening" means diff > 30s at least? - # Spec says "Spotify only returns 30s+". - if diff_seconds < (duration_sec - 10): skips += 1 @@ -826,9 +737,6 @@ class StatsService: def compute_context_stats( self, period_start: datetime, period_end: datetime ) -> Dict[str, Any]: - """ - Analyzes context_uri to determine if user listens to Playlists, Albums, or Artists. - """ query = self.db.query(PlayHistory).filter( PlayHistory.played_at >= period_start, PlayHistory.played_at <= period_end ) @@ -851,7 +759,6 @@ class StatsService: context_counts["unknown"] += 1 continue - # Count distinct contexts for loyalty unique_contexts[p.context_uri] = unique_contexts.get(p.context_uri, 0) + 1 if "playlist" in p.context_uri: @@ -861,15 +768,12 @@ class StatsService: elif "artist" in p.context_uri: context_counts["artist"] += 1 elif "collection" in p.context_uri: - # "Liked Songs" usually shows up as collection context_counts["collection"] += 1 else: context_counts["unknown"] += 1 total = len(plays) breakdown = {k: round(v / total, 2) for k, v in context_counts.items()} - - # Top 5 Contexts (Requires resolving URI to name, possibly missing metadata here) sorted_contexts = sorted( unique_contexts.items(), key=lambda x: x[1], reverse=True )[:5] @@ -887,9 +791,6 @@ class StatsService: def compute_taste_stats( self, period_start: datetime, period_end: datetime ) -> Dict[str, Any]: - """ - Mainstream vs. Hipster analysis based on Track.popularity (0-100). - """ query = self.db.query(PlayHistory).filter( PlayHistory.played_at >= period_start, PlayHistory.played_at <= period_end ) @@ -904,15 +805,13 @@ class StatsService: pop_values = [] for p in plays: t = track_map.get(p.track_id) - if t and t.popularity is not None: + if t and getattr(t, "popularity", None) is not None: pop_values.append(t.popularity) if not pop_values: return {"avg_popularity": 0, "hipster_score": 0} avg_pop = float(np.mean(pop_values)) - - # Hipster Score: Percentage of tracks with popularity < 30 underground_plays = len([x for x in pop_values if x < 30]) mainstream_plays = len([x for x in pop_values if x > 70]) @@ -926,10 +825,6 @@ class StatsService: def compute_lifecycle_stats( self, period_start: datetime, period_end: datetime ) -> Dict[str, Any]: - """ - Determines if tracks are 'New Discoveries' or 'Old Favorites'. - """ - # 1. Get tracks played in this period current_plays = ( self.db.query(PlayHistory) .filter( @@ -943,20 +838,14 @@ class StatsService: return {} current_track_ids = set([p.track_id for p in current_plays]) - - # 2. Check if these tracks were played BEFORE period_start - # We find which of the current_track_ids exist in history < period_start old_tracks_query = self.db.query(distinct(PlayHistory.track_id)).filter( PlayHistory.track_id.in_(current_track_ids), PlayHistory.played_at < period_start, ) old_track_ids = set([r[0] for r in old_tracks_query.all()]) - # 3. Calculate Discovery new_discoveries = current_track_ids - old_track_ids discovery_count = len(new_discoveries) - - # Calculate plays on new discoveries plays_on_new = len([p for p in current_plays if p.track_id in new_discoveries]) total_plays = len(current_plays) @@ -973,9 +862,6 @@ class StatsService: def compute_explicit_stats( self, period_start: datetime, period_end: datetime ) -> Dict[str, Any]: - """ - Analyzes explicit content consumption. - """ query = ( self.db.query(PlayHistory) .options(joinedload(PlayHistory.track)) @@ -987,7 +873,7 @@ class StatsService: plays = query.all() if not plays: - return {"explicit_rate": 0, "hourly_explicit_rate": []} + return {"explicit_rate": 0, "hourly_explicit_distribution": []} total_plays = len(plays) explicit_count = 0 @@ -997,18 +883,11 @@ class StatsService: for p in plays: h = p.played_at.hour hourly_total[h] += 1 - - # Check raw_data for explicit flag t = p.track - is_explicit = False - if t.raw_data and t.raw_data.get("explicit"): - is_explicit = True - - if is_explicit: + if t and t.raw_data and t.raw_data.get("explicit"): explicit_count += 1 hourly_explicit[h] += 1 - # Calculate hourly percentages hourly_rates = [] for i in range(24): if hourly_total[i] > 0: @@ -1025,7 +904,6 @@ class StatsService: def generate_full_report( self, period_start: datetime, period_end: datetime ) -> Dict[str, Any]: - # 1. Calculate all current stats current_stats = { "period": { "start": period_start.isoformat(), @@ -1043,7 +921,6 @@ class StatsService: "skips": self.compute_skip_stats(period_start, period_end), } - # 2. Calculate Comparison current_stats["comparison"] = self.compute_comparison( current_stats, period_start, period_end ) @@ -1064,7 +941,53 @@ class StatsService: "top_genres": [], "repeat_rate": 0, "one_and_done_rate": 0, - "concentration": {}, + "concentration": { + "hhi": 0, + "gini": 0, + "top_1_share": 0, + "top_5_share": 0, + "genre_entropy": 0, + }, + } + + def _empty_time_stats(self): + return { + "heatmap": [], + "heatmap_compressed": [], + "block_labels": [], + "hourly_distribution": [0] * 24, + "peak_hour": None, + "weekday_distribution": [0] * 7, + "daily_distribution": [0] * 7, + "weekend_share": 0, + "part_of_day": {"morning": 0, "afternoon": 0, "evening": 0, "night": 0}, + "listening_streak": 0, + "longest_streak": 0, + "active_days": 0, + "avg_plays_per_active_day": 0, + } + + def _empty_session_stats(self): + return { + "count": 0, + "avg_tracks": 0, + "avg_minutes": 0, + "median_minutes": 0, + "longest_session_minutes": 0, + "sessions_per_day": 0, + "start_hour_distribution": [0] * 24, + "micro_session_rate": 0, + "marathon_session_rate": 0, + "energy_arcs": {"rising": 0, "falling": 0, "flat": 0, "unknown": 0}, + "session_list": [], + } + + def _empty_vibe_stats(self): + return { + "avg_energy": 0, + "avg_valence": 0, + "mood_quadrant": {"x": 0, "y": 0}, + "clusters": [], } def _pct_change(self, curr, prev): diff --git a/docker-compose.template.yml b/docker-compose.template.yml index 941c535..fae9cca 100644 --- a/docker-compose.template.yml +++ b/docker-compose.template.yml @@ -25,6 +25,9 @@ services: - GEMINI_API_KEY=your_gemini_api_key_here # Optional: Genius for lyrics - GENIUS_ACCESS_TOKEN=your_genius_token_here + # Optional: Spotify Playlist IDs (will be created if not provided) + - SIX_HOUR_PLAYLIST_ID=your_playlist_id_here + - DAILY_PLAYLIST_ID=your_playlist_id_here ports: - '8000:8000' networks: diff --git a/docker-compose.yml b/docker-compose.yml index ceb43a1..5835589 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,8 @@ services: - GENIUS_ACCESS_TOKEN=${GENIUS_ACCESS_TOKEN} - OPENAI_API_KEY=${OPENAI_API_KEY} - OPENAI_APIKEY=${OPENAI_APIKEY} + - SIX_HOUR_PLAYLIST_ID=${SIX_HOUR_PLAYLIST_ID} + - DAILY_PLAYLIST_ID=${DAILY_PLAYLIST_ID} ports: - '8000:8000' networks: diff --git a/frontend/src/components/AGENTS.md b/frontend/src/components/AGENTS.md new file mode 100644 index 0000000..248033c --- /dev/null +++ b/frontend/src/components/AGENTS.md @@ -0,0 +1,34 @@ +# FRONTEND COMPONENTS KNOWLEDGE BASE + +**Directory:** `frontend/src/components` + +## OVERVIEW + +This directory contains the primary UI components for the MusicAnalyser dashboard. The architecture follows a **Presentational & Container pattern**, where `Dashboard.jsx` acts as the main container orchestrating data fetching and state, while sub-components handle specific visualizations and data displays. + +The UI is built with **React (Vite)**, utilizing **Tailwind CSS** for custom layouts/styling and **Ant Design** for basic UI primitives. Data visualization is powered by **Recharts** and custom SVG/Tailwind grid implementations. + +## WHERE TO LOOK + +| Component | Role | Complexity | +|-----------|------|------------| +| `Dashboard.jsx` | Main entry point. Handles API interaction (`/api/snapshots`), data caching (`localStorage`), and layout. | High | +| `VibeRadar.jsx` | Uses `Recharts` RadarChart to visualize "Sonic DNA" (acousticness, energy, valence, etc.). | High | +| `HeatMap.jsx` | Custom grid implementation for "Chronobiology" (listening density across days/time blocks). | Medium | +| `StatsGrid.jsx` | Renders high-level metrics (Minutes Listened, "Obsession" Track, Hipster Score) in a responsive grid. | Medium | +| `ListeningLog.jsx` | Displays a detailed list of recently played tracks. | Low | +| `NarrativeSection.jsx` | Renders AI-generated narratives, "vibe checks", and "roasts". | Low | +| `TopRotation.jsx` | Displays top artists and tracks with counts and popularity bars. | Medium | + +## CONVENTIONS + +- **Styling**: Leverages Tailwind utility classes. + - **Key Colors**: `primary` (#256af4), `card-dark` (#1e293b), `card-darker` (#0f172a). + - **Glassmorphism**: Use `glass-panel` for semi-transparent headers and panels. +- **Icons**: Standardized on **Google Material Symbols** (`material-symbols-outlined`). +- **Data Flow**: Unidirectional. `Dashboard.jsx` fetches data and passes specific slices down to sub-components via props. +- **Caching**: API responses are cached in `localStorage` with a date-based key (`sonicstats_v2_YYYY-MM-DD`) to minimize redundant requests. +- **Visualizations**: + - Use `Recharts` for standard charts (Radar, Line). + - Use Tailwind grid and relative/absolute positioning for custom visualizations (HeatMap, Mood Clusters). +- **Responsiveness**: Use responsive grid prefixes (`grid-cols-1 md:grid-cols-2 lg:grid-cols-4`) to ensure dashboard works across devices. diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index 38e6d10..f422cd8 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import axios from 'axios'; import NarrativeSection from './NarrativeSection'; import StatsGrid from './StatsGrid'; +import PlaylistsSection from './PlaylistsSection'; import VibeRadar from './VibeRadar'; import HeatMap from './HeatMap'; import TopRotation from './TopRotation'; @@ -105,6 +106,8 @@ const Dashboard = () => { + +
diff --git a/frontend/src/components/PlaylistsSection.jsx b/frontend/src/components/PlaylistsSection.jsx new file mode 100644 index 0000000..60f94ac --- /dev/null +++ b/frontend/src/components/PlaylistsSection.jsx @@ -0,0 +1,164 @@ +import React, { useState, useEffect } from 'react'; +import axios from 'axios'; +import { Card, Button, Typography, Space, Spin, message, Tooltip as AntTooltip } from 'antd'; +import { + PlayCircleOutlined, + ReloadOutlined, + HistoryOutlined, + InfoCircleOutlined, + CustomerServiceOutlined +} from '@ant-design/icons'; +import Tooltip from './Tooltip'; + +const { Title, Text, Paragraph } = Typography; + +const PlaylistsSection = () => { + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState({ sixHour: false, daily: false }); + const [playlists, setPlaylists] = useState(null); + + const fetchPlaylists = async () => { + try { + const response = await axios.get('/api/playlists'); + setPlaylists(response.data); + } catch (error) { + console.error('Failed to fetch playlists:', error); + message.error('Failed to load playlist metadata'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchPlaylists(); + }, []); + + const handleRefresh = async (type) => { + const isSixHour = type === 'six-hour'; + setRefreshing(prev => ({ ...prev, [isSixHour ? 'sixHour' : 'daily']: true })); + + try { + const endpoint = isSixHour ? '/api/playlists/refresh/six-hour' : '/api/playlists/refresh/daily'; + await axios.post(endpoint); + message.success(`${isSixHour ? '6-Hour' : 'Daily'} playlist refreshed!`); + await fetchPlaylists(); + } catch (error) { + console.error(`Refresh failed for ${type}:`, error); + message.error(`Failed to refresh ${type} playlist`); + } finally { + setRefreshing(prev => ({ ...prev, [isSixHour ? 'sixHour' : 'daily']: false })); + } + }; + + if (loading) return
; + + return ( +
+
+ + <CustomerServiceOutlined className="mr-2 text-blue-400" /> + AI Curated Playlists + + + + +
+ +
+ {/* 6-Hour Playlist */} + Short & Sweet (6h)} + extra={ + +
+
+ + + {/* Daily Playlist */} + Proof of Commitment (24h)} + extra={ + +
+
+ + + + ); +}; + +export default PlaylistsSection; diff --git a/frontend/src/components/StatsGrid.jsx b/frontend/src/components/StatsGrid.jsx index fb47a84..80a5f14 100644 --- a/frontend/src/components/StatsGrid.jsx +++ b/frontend/src/components/StatsGrid.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import Tooltip from './Tooltip'; const StatsGrid = ({ metrics }) => { if (!metrics) return null; @@ -14,8 +15,9 @@ const StatsGrid = ({ metrics }) => { const uniqueArtists = metrics.volume?.unique_artists || 0; - const hipsterScore = metrics.taste?.hipster_score || 0; - const obscurityRating = metrics.taste?.obscurity_rating || 0; + const concentration = metrics.volume?.concentration?.hhi || 0; + const diversity = metrics.volume?.concentration?.gini || 0; + const peakHour = metrics.time_habits?.peak_hour !== undefined ? `${metrics.time_habits.peak_hour}:00` : "N/A"; return (
@@ -56,31 +58,32 @@ const StatsGrid = ({ metrics }) => {
-
- visibility -
{uniqueArtists}
+
+
+
{uniqueArtists}
+ + info + +
Unique Artists
-
-
- - - - -
- {hipsterScore.toFixed(0)}% +
+
+
+ +
{(1 - concentration).toFixed(2)}
+
Variety
+
+
+
+
+ +
{peakHour}
+
Peak Time
+
-
Hipster Score
-
Obscurity: {obscurityRating.toFixed(0)}%
diff --git a/frontend/src/components/Tooltip.jsx b/frontend/src/components/Tooltip.jsx new file mode 100644 index 0000000..4b33bf1 --- /dev/null +++ b/frontend/src/components/Tooltip.jsx @@ -0,0 +1,25 @@ +import React, { useState } from 'react'; + +const Tooltip = ({ text, children }) => { + const [isVisible, setIsVisible] = useState(false); + + return ( +
setIsVisible(true)} + onMouseLeave={() => setIsVisible(false)} + onFocus={() => setIsVisible(true)} + onBlur={() => setIsVisible(false)} + > + {children} + {isVisible && ( +
+ {text} +
+
+ )} +
+ ); +}; + +export default Tooltip;