20 Commits

Author SHA1 Message Date
bnair123
272148c5bf feat: migrate to PostgreSQL and enhance playlist curation
- Migrate database from SQLite to PostgreSQL (100.91.248.114:5433)
- Fix playlist curation to use actual top tracks instead of AI name matching
- Add /playlists/history endpoint for historical playlist viewing
- Add Playlist Archives section to frontend with expandable history
- Add playlist-modify-* scopes to Spotify OAuth for playlist creation
- Rewrite Genius client to use official API (fixes 403 scraping blocks)
- Ensure playlists are created on Spotify before curation attempts
- Add DATABASE.md documentation for PostgreSQL schema
- Add migrations for PlaylistConfig and composition storage
2025-12-30 22:24:56 +04:00
bnair123
26b4895695 feat(frontend): add Archives, update Playlists with composition, add Navbar 2025-12-30 10:46:13 +04:00
bnair123
93e7c13f3d 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
2025-12-30 09:45:19 +04:00
bnair123
fa28b98c1a fix: ReccoBeats audio features and OpenAI narrative generation
- Add reccobeats_id column to Track model for API mapping
- Fix ReccoBeats batch size limit (max 40 IDs per request)
- Extract spotify_id from href field in ReccoBeats responses
- Fix OpenAI API: remove unsupported temperature param, increase max_completion_tokens to 4000
- Add playlist/user management methods to spotify_client for future auto-playlist feature
2025-12-30 01:15:49 +04:00
bnair123
887e78bf47 Add skip tracking, compressed heatmap, listening log, docs, tests, and OpenAI support
Major changes:
- Add skip tracking: poll currently-playing every 15s, detect skips (<30s listened)
- Add listening-log and sessions API endpoints
- Fix ReccoBeats client to extract spotify_id from href response
- Compress heatmap from 24 hours to 6 x 4-hour blocks
- Add OpenAI support in narrative service (use max_completion_tokens for new models)
- Add ListeningLog component with timeline and list views
- Update all frontend components to use real data (album art, play counts)
- Add docker-compose external network (dockernet) support
- Add comprehensive documentation (API, DATA_MODEL, ARCHITECTURE, FRONTEND)
- Add unit tests for ingest and API endpoints
2025-12-30 00:15:01 +04:00
bnair123
faee830545 Added ss 2025-12-27 15:25:45 +04:00
bnair123
56b7e2a5ba Rebuild frontend with Tailwind CSS + fix Python 3.14 compatibility
- Upgrade SQLAlchemy 2.0.27→2.0.45, google-genai SDK for Python 3.14
- Replace google-generativeai with google-genai in narrative_service.py
- Fix HTTPException handling in main.py (was wrapping as 500)
- Rebuild all frontend components with Tailwind CSS v3:
  - Dashboard, NarrativeSection, StatsGrid, VibeRadar, HeatMap, TopRotation
  - Custom color palette (background-dark, card-dark, accent-neon, etc.)
  - Add glass-panel, holographic-badge CSS effects
- Docker improvements:
  - Combined backend container (API + worker in entrypoint.sh)
  - DATABASE_URL configurable via env var
  - CI workflow builds both backend and frontend images
- Update README with clearer docker-compose instructions
2025-12-26 20:25:44 +04:00
bnair123
9b8f7355fb Fixed and added all the stats_service.py methods 2025-12-25 22:17:21 +04:00
bnair123
e7980cc706 Complete Stats & Narrative Engine + Testing Suite
- Stats: Added K-Means clustering, Tempo Zones, Harmonic Profile.
- Narrative: Optimized for Gemini tokens + JSON robustness.
- Testing: Added comprehensive backend/TESTING.md and standalone test script.
- Setup: Improved get_refresh_token.py for user onboarding.
2025-12-25 18:50:38 +04:00
bnair123
af0d985253 Refactor Stats and Narrative services to match spec
- StatsService: Fixed N+1 queries, added missing metrics (whiplash, entropy, lifecycle), and improved correctness (boundary checks, null handling).
- NarrativeService: Added payload shaping for token efficiency, improved JSON robustness, and updated prompts to align with persona specs.
- Documentation: Added backend/TECHNICAL_DOCS.md detailing the logic.
2025-12-25 18:12:05 +04:00
bnair123
508d001d7e Fixed and added all the stats_service.py methods 2025-12-25 17:48:41 +04:00
bnair123
d63a05fb72 Merge pull request #4 from bnair123/phase-3-analytics-12399556543681998668
Phase 3: Music Analysis Engine & LLM Integration
2025-12-25 03:21:20 +04:00
google-labs-jules[bot]
f4432154b6 Implement Phase 3 Music Analysis and LLM Engine
- Refactor Database: Add `Artist` model, M2M relationship, and `AnalysisSnapshot` model.
- Backend Services: Implement `StatsService` for computable metrics and `NarrativeService` for Gemini LLM integration.
- Fix Ingestion: Correctly handle multiple artists per track and backfill existing data.
- Testing: Add unit tests for statistics logic and live verification scripts.
- Documentation: Add `PHASE_4_FRONTEND_GUIDE.md`.
2025-12-24 23:16:32 +00:00
bnair123
ab47dd62ca Merge pull request #3 from bnair123/frontend-phase2-ant-design-2702419047852121330
Implement Phase 2 Frontend (Ant Design) & Fix Data Enrichment
2025-12-25 02:52:58 +04:00
google-labs-jules[bot]
6e80e97960 Implement Phase 2 Frontend with Ant Design and verify Data Ingestion
- Created `frontend/` React+Vite app using Ant Design (Dark Theme).
- Implemented `App.jsx` to display listening history and calculated "Vibes".
- Updated `backend/app/ingest.py` to fix ReccoBeats ID parsing.
- Updated `backend/app/schemas.py` to expose audio features to the API.
- Updated `README.md` with detailed Docker hosting instructions.
- Added `TODO.md` for Phase 3 roadmap.
- Cleaned up test scripts.
2025-12-24 22:51:53 +00:00
bnair123
f034b3eb43 Merge pull request #2 from bnair123/phase2-frontend-enrichment-14969504762303104643
Phase 2: Frontend & Enrichment Implementation
2025-12-25 01:51:12 +04:00
google-labs-jules[bot]
0ca9893c68 Implement Phase 2 Frontend and Phase 3 Data Enrichment
- Initialize React+Vite Frontend with Ant Design Dashboard.
- Implement Data Enrichment: ReccoBeats (Audio Features) and Spotify (Genres).
- Update Database Schema via Alembic Migrations.
- Add Docker support (Dockerfile, docker-compose.yml).
- Update README with hosting instructions.
2025-12-24 21:34:36 +00:00
bnair123
3a424d15a5 Add project context and documentation for Music Analyser
This document outlines the vision, technical decisions, current architecture, and future roadmap for the Music Analyser project. It serves as a guide for future AI agents or developers.
2025-12-24 22:03:18 +04:00
bnair123
4ca4c7befd Enhance Docker publish workflow with metadata and caching
Added environment variables for registry and image name. Updated Docker build and push steps to include metadata extraction and caching.
2025-12-24 21:54:04 +04:00
bnair123
b502e95652 Merge pull request #1 from bnair123/setup-initial-backend-8149240771439055261
Initial Backend Setup
2025-12-24 21:30:32 +04:00
86 changed files with 12939 additions and 112 deletions

View File

@@ -6,11 +6,18 @@ on:
pull_request:
branches: [ "main" ]
env:
REGISTRY: ghcr.io
jobs:
build:
build-backend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -18,9 +25,75 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/musicanalyser
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Backend
uses: docker/build-push-action@v5
with:
context: ./backend
push: false
tags: user/app:latest
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
build-frontend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/musicanalyser-frontend
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Frontend
uses: docker/build-push-action@v5
with:
context: ./frontend
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

85
AGENTS.md Normal file
View File

@@ -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, PostgreSQL) + 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).
- **PostgreSQL Persistence**: Production uses PostgreSQL on internal server (100.91.248.114:5433, database: music_db).
- **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.

114
Context.md Normal file
View File

@@ -0,0 +1,114 @@
# Music Analyser - Project Context & Documentation
This document serves as a comprehensive guide to the **Music Analyser** project. It outlines the vision, technical decisions, current architecture, and future roadmap. **Use this document to provide context to future AI agents or developers.**
## 1. Project Vision
The goal of this project is to build a personal analytics dashboard that:
1. **Regularly queries** the Spotify API (24/7) to collect a complete history of listening habits.
2. Stores this data locally (or in a private database) to ensure ownership and completeness.
3. Provides **rich analysis** and visualizations (similar to "Spotify Wrapped" but on-demand and more detailed).
4. Integrates **AI (Google Gemini)** to provide qualitative insights, summaries, and trend analysis (e.g., "You started the week with high-energy pop but shifted to lo-fi study beats by Friday").
## 2. Roadmap & Phases
### Phase 1: Foundation & Data Collection (Current Status: ✅ COMPLETED)
- **Goal:** reliable data ingestion and storage.
- **Deliverables:**
- FastAPI Backend.
- SQLite Database (with SQLAlchemy).
- Spotify OAuth logic (Refresh Token flow).
- Background Worker for 24/7 polling.
- Docker containerization + GitHub Actions (Multi-arch build).
### Phase 2: Visualization (Next Step)
- **Goal:** View the raw data.
- **Deliverables:**
- Frontend (React + Vite).
- Basic Data Table / List View of listening history.
- Basic filtering (by date, artist).
### Phase 3: Analysis & AI
- **Goal:** Deep insights.
- **Deliverables:**
- Advanced charts/graphs.
- AI Integration (Gemini 2.5/3 Flash) to generate text summaries of listening trends.
- Email reports (optional).
## 3. Technical Architecture
### Backend
- **Language:** Python 3.11+
- **Framework:** FastAPI (High performance, easy to use).
- **Dependencies:** `httpx` (Async HTTP), `sqlalchemy` (ORM), `pydantic` (Validation).
### Database
- **Current:** SQLite (`music.db`).
- *Decision:* Chosen for simplicity in Phase 1.
- **Future path:** The code uses SQLAlchemy, so migrating to **PostgreSQL** (e.g., Supabase) only requires changing the connection string in `database.py`.
### Database Schema
1. **`Track` Table:**
- Stores unique tracks.
- Columns: `id` (Spotify ID), `name`, `artist`, `album`, `duration_ms`, `metadata_json` (Stores the *entire* raw Spotify JSON response for future-proofing).
2. **`PlayHistory` Table:**
- Stores the instances of listening.
- Columns: `id`, `track_id` (FK), `played_at` (Timestamp), `context_uri`.
### Authentication Strategy
- **Challenge:** The background worker runs headless (no user to click "Login").
- **Solution:** We use the **Authorization Code Flow with Refresh Tokens**.
1. User runs the local helper script (`backend/scripts/get_refresh_token.py`) once.
2. This generates a long-lived `SPOTIFY_REFRESH_TOKEN`.
3. The backend uses this token to automatically request new short-lived Access Tokens whenever needed.
### Background Worker Logic
- **File:** `backend/run_worker.py` -> `backend/app/ingest.py`
- **Process:**
1. Worker wakes up every 60 seconds.
2. Calls Spotify `recently-played` endpoint (limit 50).
3. Iterates through tracks.
4. **Deduplication:** Checks `(track_id, played_at)` against the DB. If it exists, skip. If not, insert.
5. **Metadata:** If the track is new to the system, it saves the full metadata immediately.
### AI Integration
- **Model:** Google Gemini (Target: 2.5 Flash or 3 Flash).
- **Status:** Service class exists (`AIService`) but is not yet fully wired into the daily workflow.
### Deployment
- **Docker:** Multi-stage build (python-slim).
- **CI/CD:** GitHub Actions workflow (`docker-publish.yml`).
- Builds for `linux/amd64` and `linux/arm64`.
- Pushes to GitHub Container Registry (ghcr.io).
## 4. How to Run
### Prerequisites
- Spotify Client ID & Secret.
- Google Gemini API Key.
- Docker (optional).
### Local Development
1. **Setup Env:**
```bash
cp backend/.env.example backend/.env
# Fill in details
```
2. **Install:**
```bash
cd backend
pip install -r requirements.txt
```
3. **Run API:**
```bash
uvicorn app.main:app --reload
```
4. **Run Worker:**
```bash
python run_worker.py
```
### Docker
```bash
docker build -t music-analyser-backend ./backend
docker run --env-file backend/.env music-analyser-backend
```

84
PHASE_4_FRONTEND_GUIDE.md Normal file
View File

@@ -0,0 +1,84 @@
# Phase 4 Frontend Implementation Guide
This guide details how to consume the data generated by the Phase 3 Backend (Analysis & LLM Engine) and how to display it in the frontend.
## 1. Data Source
The backend now produces **Analysis Snapshots**. You should create an API endpoint (e.g., `GET /api/analysis/latest`) that returns the most recent snapshot.
### JSON Payload Structure
The response object contains two main keys: `metrics_payload` (calculated numbers) and `narrative_report` (LLM text).
```json
{
"id": 1,
"date": "2024-12-25T12:00:00Z",
"period_label": "last_30_days",
"metrics_payload": {
"volume": { ... },
"time_habits": { ... },
"sessions": { ... },
"vibe": { ... },
"era": { ... },
"skips": { ... }
},
"narrative_report": {
"vibe_check": "...",
"patterns": ["..."],
"persona": "...",
"roast": "..."
}
}
```
---
## 2. UI Components & Display Strategy
### A. Hero Section ("The Vibe Check")
**Data Source:** `narrative_report`
- **Headline:** Display `narrative_report.persona` as a large badge/title (e.g., "The Focused Fanatic").
- **Narrative:** Display `narrative_report.vibe_check` as the main text.
- **Roast:** Add a small, dismissible "Roast Me" alert box containing `narrative_report.roast`.
### B. "The Vibe" Radar Chart
**Data Source:** `metrics_payload.vibe`
- Use a **Radar Chart** (Spider Chart) with the following axes (0.0 - 1.0):
- Energy (`avg_energy`)
- Valence (`avg_valence`)
- Danceability (`avg_danceability`)
- Acousticness (`avg_acousticness`)
- Instrumentalness (`avg_instrumentalness`)
- **Tooltip:** Show the exact value.
### C. Listening Habits (Time & Sessions)
**Data Source:** `metrics_payload.time_habits` & `metrics_payload.sessions`
- **Hourly Heatmap:** Use a bar chart for `metrics_payload.time_habits.hourly_distribution` (0-23 hours). Highlight the `peak_hour`.
- **Session Stats:** Display "Average Session" stats:
- `sessions.avg_minutes` (mins)
- `sessions.avg_tracks` (tracks)
- `sessions.count` (total sessions)
### D. Top Favorites
**Data Source:** `metrics_payload.volume`
- **Lists:** Display Top 5 Tracks, Artists, and Genres.
- **Images:** You will need to fetch Artist/Track images from Spotify API using the IDs provided in the lists (the current snapshot only stores names/counts for simplicity, but the IDs are available in the backend if you expand the serializer). *Note: Phase 3 backend currently returns names. For Phase 4, ensure the API endpoint enriches these with Spotify Image URLs.*
### E. Era Analysis
**Data Source:** `metrics_payload.era`
- **Musical Age:** Display `musical_age` (e.g., "1998") prominently.
- **Distribution:** Pie chart for `decade_distribution`.
### F. Attention Span (Skips)
**Data Source:** `metrics_payload.skips`
- **Metric:** Display "Skip Rate" (`skip_rate`) as a percentage.
- **Insight:** "You skipped X tracks this month."
---
## 3. Integration Tips
- **Caching:** The backend stores snapshots. You do NOT need to trigger a calculation on page load. Just fetch the latest snapshot.
- **Theme:** The app uses Ant Design Dark Mode. Stick to Spotify colors (Black/Green/White) but add accent colors based on the "Vibe" (e.g., High Energy = Red/Orange, Low Energy = Blue/Purple).
- **Expansion:** Future snapshots allow for "Trend" views. You can graph `metrics_payload.volume.total_plays` over the last 6 snapshots to show activity trends.

140
README.md
View File

@@ -1,72 +1,140 @@
# Music Analyser
A personal analytics dashboard for your music listening habits, powered by Python, FastAPI, and Google Gemini AI.
A personal analytics dashboard for your music listening habits, powered by Python, FastAPI, React, and Google Gemini AI.
## Project Structure
![Dashboard Screenshot](screen.png)
- `backend/`: FastAPI backend for data ingestion and API.
- `app/ingest.py`: Background worker that polls Spotify.
- `app/services/`: Logic for Spotify and Gemini APIs.
- `app/models.py`: Database schema (Tracks, PlayHistory).
- `frontend/`: (Coming Soon) React/Vite frontend.
## Features
## Getting Started
- **Continuous Ingestion**: Polls Spotify every 60 seconds to record your listening history.
- **Data Enrichment**:
- **Genres & Images** (via Spotify)
- **Audio Features** (Energy, BPM, Mood via ReccoBeats)
- **Lyrics & Metadata** (via Genius)
- **Dashboard**: A responsive UI with Tailwind CSS, featuring AI-generated narrative insights.
- **AI Powered**: Google Gemini generates personalized listening narratives and roasts.
### Prerequisites
## Quick Start (Docker Compose)
- Docker & Docker Compose (optional, for containerization)
- Python 3.11+ (for local dev)
- A Spotify Developer App (Client ID & Secret)
- A Google Gemini API Key
### 1. Prerequisites
- Docker & Docker Compose installed
- Spotify Developer Credentials ([Create App](https://developer.spotify.com/dashboard))
- Google Gemini API Key ([Get Key](https://aistudio.google.com/app/apikey))
- Genius API Token (Optional, for lyrics - [Get Token](https://genius.com/api-clients))
### 1. Setup Environment Variables
### 2. Get Spotify Refresh Token
Create a `.env` file in the `backend/` directory:
Run this one-time script locally to authorize your Spotify account:
```bash
cd backend
pip install httpx
python scripts/get_refresh_token.py
```
Follow the prompts. Copy the `refresh_token` value for your `.env` file.
### 3. Create `.env` File
Create a `.env` file in the project root:
```bash
SPOTIFY_CLIENT_ID="your_client_id"
SPOTIFY_CLIENT_SECRET="your_client_secret"
SPOTIFY_REFRESH_TOKEN="your_refresh_token"
GEMINI_API_KEY="your_gemini_key"
GENIUS_ACCESS_TOKEN="your_genius_token" # Optional
```
To get the `SPOTIFY_REFRESH_TOKEN`, run the helper script:
### 4. Run with Pre-built Images
```bash
python backend/scripts/get_refresh_token.py
# Pull the latest images
docker pull ghcr.io/bnair123/musicanalyser:latest
docker pull ghcr.io/bnair123/musicanalyser-frontend:latest
# Start the services
docker-compose up -d
```
### 2. Run Locally
Or build from source:
Install dependencies:
```bash
docker-compose up -d --build
```
### 5. Access the Dashboard
Open your browser to: **http://localhost:8991**
## Architecture
```
┌─────────────────────┐ ┌─────────────────────┐
│ Frontend │ │ Backend │
│ (React + Vite) │────▶│ (FastAPI + Worker) │
│ Port: 8991 │ │ Port: 8000 │
└─────────────────────┘ └─────────────────────┘
┌────────┴────────┐
▼ ▼
┌──────────┐ ┌──────────────┐
│PostgreSQL│ │ Spotify API │
│ music_db │ │ Gemini AI │
└──────────┘ └──────────────┘
```
- **Backend Container**: Runs both the FastAPI server AND the background Spotify polling worker
- **Frontend Container**: Nginx serving the React build, proxies `/api/` to backend
- **Database**: PostgreSQL hosted on internal server (100.91.248.114:5433)
## Data Persistence
Your listening history is stored in PostgreSQL:
- Host: `100.91.248.114:5433`
- Database: `music_db`
- Data Location (on server): `/opt/DB/MusicDB/pgdata`
- Migrations run automatically on container startup via Alembic
To backup your data:
```bash
pg_dump -h 100.91.248.114 -p 5433 -U bnair music_db > backup.sql
```
## Local Development
### Backend
```bash
cd backend
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
```
Run the server:
# Run migrations
alembic upgrade head
```bash
# Start worker (polls Spotify every 60s)
python run_worker.py &
# Start API server
uvicorn app.main:app --reload
```
The API will be available at `http://localhost:8000`.
### 3. Run Ingestion (Manually)
You can trigger the ingestion process via the API:
### Frontend
```bash
curl -X POST http://localhost:8000/trigger-ingest
cd frontend
npm install
npm run dev
```
Or run the ingestion logic directly via python shell (see `app/ingest.py`).
Access at http://localhost:5173 (Vite proxies `/api` to backend automatically)
### 4. Docker Build
## Environment Variables
To build the image locally:
```bash
docker build -t music-analyser-backend ./backend
```
| Variable | Required | Description |
|----------|----------|-------------|
| `SPOTIFY_CLIENT_ID` | Yes | Spotify app client ID |
| `SPOTIFY_CLIENT_SECRET` | Yes | Spotify app client secret |
| `SPOTIFY_REFRESH_TOKEN` | Yes | Long-lived refresh token from OAuth |
| `GEMINI_API_KEY` | Yes | Google Gemini API key |
| `GENIUS_ACCESS_TOKEN` | No | Genius API token for lyrics |
| `DATABASE_URL` | No | PostgreSQL URL (default: `postgresql://bnair:Bharath2002@100.91.248.114:5433/music_db`) |

21
TODO.md Normal file
View File

@@ -0,0 +1,21 @@
🎵 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) |
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.

View File

@@ -3,9 +3,22 @@ FROM python:3.11-slim
WORKDIR /app
# Install system dependencies (including PostgreSQL client libs for psycopg2)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
# Make entrypoint executable
RUN chmod +x entrypoint.sh
# Expose API port
EXPOSE 8000
# Use entrypoint script to run migrations, worker, and API
CMD ["./entrypoint.sh"]

114
backend/TECHNICAL_DOCS.md Normal file
View File

@@ -0,0 +1,114 @@
# Technical Documentation: Stats & Narrative Services
## Overview
This document details the implementation of the core analysis engine (`StatsService`) and the AI narration layer (`NarrativeService`). These services transform raw Spotify listening data into computable metrics and human-readable insights.
## 1. StatsService (`backend/app/services/stats_service.py`)
The `StatsService` is a deterministic calculation engine. It takes a time range (`period_start` to `period_end`) and aggregates `PlayHistory` records.
### Core Architecture
- **Input:** SQLAlchemy Session, Start Datetime, End Datetime.
- **Output:** A structured JSON dictionary containing discrete analysis blocks (Volume, Time, Sessions, Vibe, etc.).
- **Optimization:** Uses `joinedload` to eagerly fetch `Track` and `Artist` relations, preventing N+1 query performance issues during iteration.
### Metric Logic
#### A. Volume & Consumption
- **Top Tracks/Artists:** Aggregated by ID, not name, to handle artist renames or duplicates.
- **Concentration Metrics:**
- **HHI (HerfindahlHirschman Index):** Measures diversity. `SUM(share^2)`. Close to 0 = diverse, close to 1 = repetitive.
- **Gini Coefficient:** Measures inequality of play distribution.
- **Genre Entropy:** `-SUM(p * log(p))` for genre probabilities. Higher = more diverse genre consumption.
- **Artists:** Parsed from the `Track.artists` relationship (Many-to-Many) rather than the flat string, ensuring accurate counts for collaborations (e.g., "Drake, Future" counts for both).
#### B. Time & Habits
- **Part of Day:** Fixed buckets:
- Morning: 06:00 - 12:00
- Afternoon: 12:00 - 18:00
- Evening: 18:00 - 23:59
- Night: 00:00 - 06:00
- **Streaks:** Calculates consecutive days with at least one play.
- **Active Days:** Count of unique dates with activity.
#### C. Session Analytics
- **Session Definition:** A sequence of plays where the gap between any two consecutive tracks is ≤ 20 minutes. A gap > 20 minutes starts a new session.
- **Energy Arcs:** Compares the `energy` feature of the first and last track in a session.
- Rising: Delta > +0.1
- Falling: Delta < -0.1
- Flat: Otherwise
#### D. The "Vibe" (Audio Features)
- **Aggregation:** Calculates Mean, Standard Deviation, and Percentiles (P10, P50/Median, P90) for all Spotify audio features (Energy, Valence, Danceability, etc.).
- **Whiplash Score:** Measures the "volatility" of a listening session. Calculated as the average absolute difference in a feature (Tempo, Energy, Valence) between consecutive tracks.
- High Whiplash (> 15-20 for BPM) = Chaotic playlist shuffling.
- Low Whiplash = Smooth transitions.
- **Profiles:**
- **Mood Quadrant:** (Avg Valence, Avg Energy) coordinates.
- **Texture:** Acousticness vs. Instrumentalness.
#### E. Context & Behavior
- **Context URI:** Parsed to determine source (Playlist vs. Album vs. Artist).
- **Context Switching:** Percentage of track transitions where the `context_uri` changes. High rate = user is jumping between playlists or albums frequently.
#### F. Lifecycle & Discovery
- **Discovery:** Tracks played in the current period that were *never* played before `period_start`.
- **Obsession:** Tracks with ≥ 5 plays in the current period.
- **Skip Detection (Boredom Skips):**
- Logic: `(next_start - current_start) < (current_duration - 10s)`
- Only counts if the listening time was > 30s (to filter accidental clicks).
- Proxy for "User got bored and hit next."
---
## 2. NarrativeService (`backend/app/services/narrative_service.py`)
The `NarrativeService` acts as an interpreter. It feeds the raw JSON from `StatsService` into Google's Gemini LLM to generate text.
### Payload Shaping
To ensure reliability and manage token costs, the service **does not** send the raw full database dump. It pre-processes the stats:
- Truncates top lists to Top 5.
- Removes raw transition arrays.
- Simplifies nested structures.
### LLM Prompt Engineering
The system uses a strict persona ("Witty Music Critic") and enforces specific constraints:
- **Output:** Strict JSON.
- **Safety:** Explicitly forbidden from making mental health diagnoses (e.g., no "You seem depressed").
- **Content:** Must reference specific numbers from the input stats (e.g., "Your 85% Mainstream Score...").
### Output Schema
The LLM returns a JSON object with:
- `vibe_check`: 2-3 paragraph summary.
- `patterns`: List of specific observations.
- `persona`: A creative 2-3 word label (e.g., "The Genre Chameleon").
- `roast`: A playful critique.
- `era_insight`: Commentary on the user's "Musical Age" (weighted avg release year).
## 3. Data Models (`backend/app/models.py`)
- **Track:** Stores static metadata and audio features.
- `lyrics`: Full lyrics from Genius (Text).
- `image_url`: Album art URL (String).
- `raw_data`: The full Spotify JSON for future-proofing.
- **Artist:** Normalized artist entities.
- `image_url`: Artist profile image (String).
- **PlayHistory:** The timeseries ledger. Links `Track` to a timestamp and context.
- **AnalysisSnapshot:** Stores the final output of these services.
- `metrics_payload`: The JSON output of `StatsService`.
- `narrative_report`: The JSON output of `NarrativeService`.
## 4. External Integrations
### Spotify
- **Ingestion:** Polls `recently-played` endpoint every 60s.
- **Enrichment:** Fetches Artist genres and images.
### Genius
- **Client:** `backend/app/services/genius_client.py`.
- **Function:** Searches for lyrics and high-res album art if missing from Spotify data.
- **Trigger:** Runs during the ingestion loop for new tracks.
### ReccoBeats
- **Function:** Fetches audio features (Danceability, Energy, Valence) for tracks.

76
backend/TESTING.md Normal file
View File

@@ -0,0 +1,76 @@
# Testing Guide
This project includes a comprehensive test suite to verify the calculation engine (`StatsService`) and the AI narrative generation (`NarrativeService`).
## 1. Quick Start (Standalone Test)
You can run the full stats verification script without installing `pytest`. This script uses an in-memory SQLite database, seeds it with synthetic listening history (including skips, sessions, and specific genres), and prints the computed analysis JSON.
```bash
# Ensure you are in the root directory
# If you are using the virtual environment:
source backend/venv/bin/activate
# Run the test
python backend/tests/test_stats_full.py
```
### What does this verify?
- **Volume Metrics:** Total plays, unique tracks/artists.
- **Session Logic:** Correctly groups plays into sessions based on 20-minute gaps.
- **Skip Detection:** Identifies "boredom skips" based on timestamp deltas.
- **Vibe Analysis:** Verifies K-Means clustering, tempo zones, and harmonic profiles.
- **Context Analysis:** Checks if plays are correctly attributed to Playlists/Albums.
## 2. Generating a Spotify Refresh Token
To run the actual application, you need a Spotify Refresh Token. We provide a script to automate the OAuth flow.
1. **Prerequisites:**
* Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/).
* Create an App.
* In settings, add `http://localhost:8888/callback` to "Redirect URIs".
* Get your **Client ID** and **Client Secret**.
2. **Run the Script:**
```bash
python backend/scripts/get_refresh_token.py
```
3. **Follow Instructions:**
* Enter your Client ID/Secret when prompted.
* The script will open your browser.
* Log in to Spotify and authorize the app.
* The script will print your `SPOTIFY_REFRESH_TOKEN` in the terminal.
4. **Save to .env:**
Copy the output into your `.env` file.
## 3. Full Test Suite (Pytest)
If you wish to run the full suite using `pytest` (recommended for CI/CD), install the dev dependencies:
```bash
pip install pytest
```
Then run:
```bash
pytest backend/tests
```
## 4. Manual Verification
To verify the system end-to-end with real data:
1. Start the backend:
```bash
python backend/run_worker.py
```
2. Wait for a few minutes for data to ingest (check logs).
3. Run the analysis manually:
```bash
python backend/run_analysis.py
```
4. Check the database or logs for the generated `AnalysisSnapshot`.

147
backend/alembic.ini Normal file
View File

@@ -0,0 +1,147 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

75
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,75 @@
from logging.config import fileConfig
import os
import sys
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# Add app to path to import models
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.database import Base, SQLALCHEMY_DATABASE_URL
from app.models import *
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL)
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,32 @@
"""add_composition_to_snapshot
Revision ID: 24fafb6f6e98
Revises: 86ea83950f3d
Create Date: 2025-12-30 10:43:05.933962
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '24fafb6f6e98'
down_revision: Union[str, Sequence[str], None] = '86ea83950f3d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('analysis_snapshots', sa.Column('playlist_composition', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('analysis_snapshots', 'playlist_composition')
# ### end Alembic commands ###

View File

@@ -0,0 +1,63 @@
"""Add Artist and Snapshot models
Revision ID: 4401cb416661
Revises: 707387fe1be2
Create Date: 2025-12-24 23:06:59.235445
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4401cb416661'
down_revision: Union[str, Sequence[str], None] = '707387fe1be2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('analysis_snapshots',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('date', sa.DateTime(), nullable=True),
sa.Column('period_start', sa.DateTime(), nullable=True),
sa.Column('period_end', sa.DateTime(), nullable=True),
sa.Column('period_label', sa.String(), nullable=True),
sa.Column('metrics_payload', sa.JSON(), nullable=True),
sa.Column('narrative_report', sa.JSON(), nullable=True),
sa.Column('model_used', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_analysis_snapshots_date'), 'analysis_snapshots', ['date'], unique=False)
op.create_index(op.f('ix_analysis_snapshots_id'), 'analysis_snapshots', ['id'], unique=False)
op.create_table('artists',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('genres', sa.JSON(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_artists_id'), 'artists', ['id'], unique=False)
op.create_table('track_artists',
sa.Column('track_id', sa.String(), nullable=False),
sa.Column('artist_id', sa.String(), nullable=False),
sa.ForeignKeyConstraint(['artist_id'], ['artists.id'], ),
sa.ForeignKeyConstraint(['track_id'], ['tracks.id'], ),
sa.PrimaryKeyConstraint('track_id', 'artist_id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('track_artists')
op.drop_index(op.f('ix_artists_id'), table_name='artists')
op.drop_table('artists')
op.drop_index(op.f('ix_analysis_snapshots_id'), table_name='analysis_snapshots')
op.drop_index(op.f('ix_analysis_snapshots_date'), table_name='analysis_snapshots')
op.drop_table('analysis_snapshots')
# ### end Alembic commands ###

View File

@@ -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 ###

View File

@@ -0,0 +1,73 @@
"""Initial Schema Complete
Revision ID: 707387fe1be2
Revises:
Create Date: 2025-12-24 21:23:43.744292
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '707387fe1be2'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('tracks',
sa.Column('id', sa.String(), nullable=False),
sa.Column('name', sa.String(), nullable=True),
sa.Column('artist', sa.String(), nullable=True),
sa.Column('album', sa.String(), nullable=True),
sa.Column('duration_ms', sa.Integer(), nullable=True),
sa.Column('popularity', sa.Integer(), nullable=True),
sa.Column('raw_data', sa.JSON(), nullable=True),
sa.Column('danceability', sa.Float(), nullable=True),
sa.Column('energy', sa.Float(), nullable=True),
sa.Column('key', sa.Integer(), nullable=True),
sa.Column('loudness', sa.Float(), nullable=True),
sa.Column('mode', sa.Integer(), nullable=True),
sa.Column('speechiness', sa.Float(), nullable=True),
sa.Column('acousticness', sa.Float(), nullable=True),
sa.Column('instrumentalness', sa.Float(), nullable=True),
sa.Column('liveness', sa.Float(), nullable=True),
sa.Column('valence', sa.Float(), nullable=True),
sa.Column('tempo', sa.Float(), nullable=True),
sa.Column('time_signature', sa.Integer(), nullable=True),
sa.Column('genres', sa.JSON(), nullable=True),
sa.Column('lyrics_summary', sa.String(), nullable=True),
sa.Column('genre_tags', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tracks_id'), 'tracks', ['id'], unique=False)
op.create_table('play_history',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('track_id', sa.String(), nullable=True),
sa.Column('played_at', sa.DateTime(), nullable=True),
sa.Column('context_uri', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['track_id'], ['tracks.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_play_history_id'), 'play_history', ['id'], unique=False)
op.create_index(op.f('ix_play_history_played_at'), 'play_history', ['played_at'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_play_history_played_at'), table_name='play_history')
op.drop_index(op.f('ix_play_history_id'), table_name='play_history')
op.drop_table('play_history')
op.drop_index(op.f('ix_tracks_id'), table_name='tracks')
op.drop_table('tracks')
# ### end Alembic commands ###

View File

@@ -0,0 +1,41 @@
"""add_playlist_config_table
Revision ID: 7e28cc511ef8
Revises: 5ed73db9bab9
Create Date: 2025-12-30 10:30:36.775553
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '7e28cc511ef8'
down_revision: Union[str, Sequence[str], None] = '5ed73db9bab9'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('playlist_config',
sa.Column('key', sa.String(), nullable=False),
sa.Column('spotify_id', sa.String(), nullable=False),
sa.Column('last_updated', sa.DateTime(), nullable=True),
sa.Column('current_theme', sa.String(), nullable=True),
sa.Column('description', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('key')
)
op.create_index(op.f('ix_playlist_config_key'), 'playlist_config', ['key'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_playlist_config_key'), table_name='playlist_config')
op.drop_table('playlist_config')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""add_composition_to_playlist_config
Revision ID: 86ea83950f3d
Revises: 7e28cc511ef8
Create Date: 2025-12-30 10:39:27.121477
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '86ea83950f3d'
down_revision: Union[str, Sequence[str], None] = '7e28cc511ef8'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('playlist_config', sa.Column('composition', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('playlist_config', 'composition')
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""Add skip tracking columns to play_history
Revision ID: a1b2c3d4e5f6
Revises: f92d8a9264d3
Create Date: 2025-12-29 22:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, Sequence[str], None] = "f92d8a9264d3"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add listened_ms, skipped, and source columns to play_history."""
op.add_column("play_history", sa.Column("listened_ms", sa.Integer(), nullable=True))
op.add_column("play_history", sa.Column("skipped", sa.Boolean(), nullable=True))
op.add_column("play_history", sa.Column("source", sa.String(), nullable=True))
# source can be: 'recently_played', 'currently_playing', 'inferred'
def downgrade() -> None:
"""Remove skip tracking columns."""
op.drop_column("play_history", "source")
op.drop_column("play_history", "skipped")
op.drop_column("play_history", "listened_ms")

View File

@@ -0,0 +1,25 @@
"""Add reccobeats_id column to tracks table
Revision ID: b2c3d4e5f6g7
Revises: a1b2c3d4e5f6
Create Date: 2025-12-30
"""
from alembic import op
import sqlalchemy as sa
revision = "b2c3d4e5f6g7"
down_revision = "a1b2c3d4e5f6"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("tracks", sa.Column("reccobeats_id", sa.String(), nullable=True))
op.create_index("ix_tracks_reccobeats_id", "tracks", ["reccobeats_id"])
def downgrade() -> None:
op.drop_index("ix_tracks_reccobeats_id", "tracks")
op.drop_column("tracks", "reccobeats_id")

View File

@@ -0,0 +1,36 @@
"""Add image_url and lyrics columns
Revision ID: f92d8a9264d3
Revises: 4401cb416661
Create Date: 2025-12-25 22:06:05.841447
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f92d8a9264d3'
down_revision: Union[str, Sequence[str], None] = '4401cb416661'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('artists', sa.Column('image_url', sa.String(), nullable=True))
op.add_column('tracks', sa.Column('image_url', sa.String(), nullable=True))
op.add_column('tracks', sa.Column('lyrics', sa.Text(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('tracks', 'lyrics')
op.drop_column('tracks', 'image_url')
op.drop_column('artists', 'image_url')
# ### end Alembic commands ###

View File

@@ -1,15 +1,34 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
SQLALCHEMY_DATABASE_URL = "sqlite:///./music.db"
# PostgreSQL connection configuration
# Uses docker hostname 'music_db' when running in container, falls back to external IP for local dev
POSTGRES_HOST = os.getenv("POSTGRES_HOST", "music_db")
POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432")
POSTGRES_USER = os.getenv("POSTGRES_USER", "bnair")
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "Bharath2002")
POSTGRES_DB = os.getenv("POSTGRES_DB", "music_db")
# Build the PostgreSQL URL
# Format: postgresql://user:password@host:port/database
SQLALCHEMY_DATABASE_URL = os.getenv(
"DATABASE_URL",
f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DB}",
)
# PostgreSQL connection pool settings for production
engine = create_engine(
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
SQLALCHEMY_DATABASE_URL,
pool_size=5, # Maintain 5 connections in the pool
max_overflow=10, # Allow up to 10 additional connections
pool_pre_ping=True, # Verify connection health before using
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:

View File

@@ -1,24 +1,165 @@
from .services.stats_service import StatsService
from .services.narrative_service import NarrativeService
from .services.playlist_service import PlaylistService
import asyncio
import os
from datetime import datetime
import time
from datetime import datetime, timedelta
from sqlalchemy.orm import Session
from .models import Track, PlayHistory
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
# Initialize Spotify Client (env vars will be populated later)
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=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 ""),
)
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):
client = get_spotify_client()
spotify_client = get_spotify_client()
recco_client = get_reccobeats_client()
genius_client = get_genius_client()
try:
items = await client.get_recently_played(limit=50)
items = await spotify_client.get_recently_played(limit=50)
except Exception as e:
print(f"Error connecting to Spotify: {e}")
return
@@ -30,52 +171,258 @@ async def ingest_recently_played(db: Session):
played_at_str = item["played_at"]
played_at = parser.isoparse(played_at_str)
# 1. Check if track exists, if not create it
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
raw_data=track_data,
)
db.add(track)
db.commit() # Commit immediately so ID exists for foreign key
# 2. Check if this specific play instance exists
# We assume (track_id, played_at) is unique enough
exists = db.query(PlayHistory).filter(
PlayHistory.track_id == track_id,
PlayHistory.played_at == played_at
).first()
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
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():
"""Simulates a background worker loop."""
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:
print("Worker: Polling Spotify...")
await ingest_recently_played(db)
print("Worker: Sleeping for 60 seconds...")
await asyncio.sleep(60)
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()

View File

@@ -1,36 +1,369 @@
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session
from .database import engine, Base, get_db
from .models import PlayHistory as PlayHistoryModel, Track as TrackModel
from . import schemas
from .ingest import ingest_recently_played
import asyncio
from typing import List
import os
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks, Query
from sqlalchemy.orm import Session, joinedload
from datetime import datetime, timedelta
from typing import List, Optional
from dotenv import load_dotenv
from .database import engine, Base, get_db
from .models import (
PlayHistory as PlayHistoryModel,
Track as TrackModel,
AnalysisSnapshot,
PlaylistConfig,
)
from . import schemas
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()
# Create tables
Base.metadata.create_all(bind=engine)
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI(title="Music Analyser Backend")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://localhost:8991"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/")
def read_root():
return {"status": "ok", "message": "Music Analyser API is running"}
@app.get("/history", response_model=List[schemas.PlayHistory])
def get_history(limit: int = 50, db: Session = Depends(get_db)):
history = db.query(PlayHistoryModel).order_by(PlayHistoryModel.played_at.desc()).limit(limit).all()
history = (
db.query(PlayHistoryModel)
.order_by(PlayHistoryModel.played_at.desc())
.limit(limit)
.all()
)
return history
@app.post("/trigger-ingest")
async def trigger_ingest(db: Session = Depends(get_db)):
"""Manually trigger the ingestion process (useful for testing)"""
await ingest_recently_played(db)
return {"status": "Ingestion triggered"}
@app.get("/tracks", response_model=List[schemas.Track])
def get_tracks(limit: int = 50, db: Session = Depends(get_db)):
tracks = db.query(TrackModel).limit(limit).all()
return tracks
@app.post("/trigger-ingest")
async def trigger_ingest(
background_tasks: BackgroundTasks, db: Session = Depends(get_db)
):
"""Triggers Spotify ingestion in the background."""
background_tasks.add_task(ingest_recently_played, db)
return {"status": "Ingestion started in background"}
@app.post("/trigger-analysis")
def trigger_analysis(
days: int = 30,
model_name: str = "gpt-5-mini-2025-08-07",
db: Session = Depends(get_db),
):
"""
Runs the full analysis pipeline (Stats + LLM) for the last X days.
Returns the computed metrics and narrative immediately.
"""
try:
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days)
# 1. Compute Stats
stats_service = StatsService(db)
stats_json = stats_service.generate_full_report(start_date, end_date)
if stats_json["volume"]["total_plays"] == 0:
raise HTTPException(
status_code=404, detail="No plays found in the specified period."
)
narrative_service = NarrativeService(model_name=model_name)
narrative_json = narrative_service.generate_full_narrative(stats_json)
# 3. Save Snapshot
snapshot = AnalysisSnapshot(
period_start=start_date,
period_end=end_date,
period_label=f"last_{days}_days",
metrics_payload=stats_json,
narrative_report=narrative_json,
model_used=model_name,
)
db.add(snapshot)
db.commit()
db.refresh(snapshot)
return {
"status": "success",
"snapshot_id": snapshot.id,
"period": {"start": start_date, "end": end_date},
"metrics": stats_json,
"narrative": narrative_json,
}
except HTTPException:
raise # Re-raise HTTPExceptions as-is (404, etc.)
except Exception as e:
print(f"Analysis Failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/snapshots")
def get_snapshots(limit: int = 10, db: Session = Depends(get_db)):
return (
db.query(AnalysisSnapshot)
.order_by(AnalysisSnapshot.date.desc())
.limit(limit)
.all()
)
@app.get("/listening-log")
def get_listening_log(
days: int = Query(default=7, ge=1, le=365),
limit: int = Query(default=200, ge=1, le=1000),
db: Session = Depends(get_db),
):
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days)
plays = (
db.query(PlayHistoryModel)
.options(joinedload(PlayHistoryModel.track))
.filter(
PlayHistoryModel.played_at >= start_date,
PlayHistoryModel.played_at <= end_date,
)
.order_by(PlayHistoryModel.played_at.desc())
.limit(limit)
.all()
)
result = []
for i, play in enumerate(plays):
track = play.track
listened_ms = play.listened_ms
skipped = play.skipped
if listened_ms is None and i < len(plays) - 1:
next_play = plays[i + 1]
diff_seconds = (play.played_at - next_play.played_at).total_seconds()
if track and track.duration_ms:
duration_sec = track.duration_ms / 1000.0
listened_ms = int(min(diff_seconds, duration_sec) * 1000)
skipped = diff_seconds < 30
result.append(
{
"id": play.id,
"track_id": play.track_id,
"track_name": track.name if track else "Unknown",
"artist": track.artist if track else "Unknown",
"album": track.album if track else "Unknown",
"image": track.image_url if track else None,
"played_at": play.played_at.isoformat(),
"duration_ms": track.duration_ms if track else 0,
"listened_ms": listened_ms,
"skipped": skipped,
"context_uri": play.context_uri,
"source": play.source,
}
)
return {
"plays": result,
"period": {"start": start_date.isoformat(), "end": end_date.isoformat()},
}
@app.get("/sessions")
def get_sessions(
days: int = Query(default=7, ge=1, le=365), db: Session = Depends(get_db)
):
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days)
stats_service = StatsService(db)
session_stats = stats_service.compute_session_stats(start_date, end_date)
return {
"sessions": session_stats.get("session_list", []),
"summary": {
"count": session_stats.get("count", 0),
"avg_minutes": session_stats.get("avg_minutes", 0),
"micro_rate": session_stats.get("micro_session_rate", 0),
"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)
spotify_client = get_spotify_client()
playlist_service = PlaylistService(
db=db,
spotify_client=spotify_client,
recco_client=get_reccobeats_client(),
narrative_service=NarrativeService(),
)
# Ensure playlists exist (creates on Spotify if needed)
user_id = await spotify_client.get_current_user_id()
await playlist_service.ensure_playlists_exist(user_id)
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"),
playlist_composition=result.get("composition"),
)
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)
spotify_client = get_spotify_client()
playlist_service = PlaylistService(
db=db,
spotify_client=spotify_client,
recco_client=get_reccobeats_client(),
narrative_service=NarrativeService(),
)
# Ensure playlists exist (creates on Spotify if needed)
user_id = await spotify_client.get_current_user_id()
await playlist_service.ensure_playlists_exist(user_id)
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"),
playlist_composition=result.get("composition"),
)
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."""
six_hour_config = (
db.query(PlaylistConfig).filter(PlaylistConfig.key == "six_hour").first()
)
daily_config = (
db.query(PlaylistConfig).filter(PlaylistConfig.key == "daily").first()
)
return {
"six_hour": {
"id": six_hour_config.spotify_id
if six_hour_config
else os.getenv("SIX_HOUR_PLAYLIST_ID"),
"theme": six_hour_config.current_theme if six_hour_config else "N/A",
"reasoning": six_hour_config.description if six_hour_config else "N/A",
"last_refresh": six_hour_config.last_updated.isoformat()
if six_hour_config
else None,
"composition": six_hour_config.composition if six_hour_config else [],
},
"daily": {
"id": daily_config.spotify_id
if daily_config
else os.getenv("DAILY_PLAYLIST_ID"),
"theme": daily_config.current_theme if daily_config else "N/A",
"reasoning": daily_config.description if daily_config else "N/A",
"last_refresh": daily_config.last_updated.isoformat()
if daily_config
else None,
"composition": daily_config.composition if daily_config else [],
},
}
@app.get("/playlists/history")
def get_playlist_history(
limit: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
):
"""Returns historical playlist snapshots."""
snapshots = (
db.query(AnalysisSnapshot)
.filter(
(AnalysisSnapshot.playlist_theme.isnot(None))
| (AnalysisSnapshot.six_hour_playlist_id.isnot(None))
| (AnalysisSnapshot.daily_playlist_id.isnot(None))
)
.order_by(AnalysisSnapshot.date.desc())
.limit(limit)
.all()
)
result = []
for snap in snapshots:
result.append(
{
"id": snap.id,
"date": snap.date.isoformat() if snap.date else None,
"period_label": snap.period_label,
"theme": snap.playlist_theme,
"reasoning": snap.playlist_theme_reasoning,
"six_hour_id": snap.six_hour_playlist_id,
"daily_id": snap.daily_playlist_id,
"composition": snap.playlist_composition or [],
}
)
return {"history": result}

View File

@@ -1,29 +1,85 @@
from sqlalchemy import Column, Integer, String, DateTime, JSON, ForeignKey, Boolean
from sqlalchemy import (
Boolean,
Column,
Integer,
String,
DateTime,
JSON,
ForeignKey,
Float,
Table,
Text,
)
from sqlalchemy.orm import relationship
from datetime import datetime
from .database import Base
# Association Table for Many-to-Many Relationship between Track and Artist
track_artists = Table(
"track_artists",
Base.metadata,
Column("track_id", String, ForeignKey("tracks.id"), primary_key=True),
Column("artist_id", String, ForeignKey("artists.id"), primary_key=True),
)
class Artist(Base):
__tablename__ = "artists"
id = Column(String, primary_key=True, index=True) # Spotify ID
name = Column(String)
genres = Column(JSON, nullable=True) # List of genre strings
image_url = Column(String, nullable=True) # Artist profile image
# Relationships
tracks = relationship("Track", secondary=track_artists, back_populates="artists")
class Track(Base):
__tablename__ = "tracks"
id = Column(String, primary_key=True, index=True) # Spotify ID
id = Column(String, primary_key=True, index=True) # Spotify ID
reccobeats_id = Column(String, nullable=True, index=True) # ReccoBeats UUID
name = Column(String)
artist = Column(String)
artist = Column(
String
) # Display string (e.g. "Drake, Future") - kept for convenience
album = Column(String)
image_url = Column(String, nullable=True) # Album art
duration_ms = Column(Integer)
popularity = Column(Integer, nullable=True)
# Store raw full JSON response for future-proofing analysis
raw_data = Column(JSON, nullable=True)
# Enriched Data (Phase 3 Prep)
# Audio Features
danceability = Column(Float, nullable=True)
energy = Column(Float, nullable=True)
key = Column(Integer, nullable=True)
loudness = Column(Float, nullable=True)
mode = Column(Integer, nullable=True)
speechiness = Column(Float, nullable=True)
acousticness = Column(Float, nullable=True)
instrumentalness = Column(Float, nullable=True)
liveness = Column(Float, nullable=True)
valence = Column(Float, nullable=True)
tempo = Column(Float, nullable=True)
time_signature = Column(Integer, nullable=True)
# Genres (stored as JSON list of strings) - DEPRECATED in favor of Artist.genres but kept for now
genres = Column(JSON, nullable=True)
# AI Analysis fields
lyrics = Column(Text, nullable=True) # Full lyrics from Genius
lyrics_summary = Column(String, nullable=True)
genre_tags = Column(String, nullable=True) # JSON list stored as string or just raw JSON
genre_tags = Column(String, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
plays = relationship("PlayHistory", back_populates="track")
artists = relationship("Artist", secondary=track_artists, back_populates="tracks")
class PlayHistory(Base):
@@ -31,9 +87,61 @@ class PlayHistory(Base):
id = Column(Integer, primary_key=True, index=True)
track_id = Column(String, ForeignKey("tracks.id"))
played_at = Column(DateTime, index=True) # The timestamp from Spotify
# Context (album, playlist, etc.)
played_at = Column(DateTime, index=True)
context_uri = Column(String, nullable=True)
listened_ms = Column(Integer, nullable=True)
skipped = Column(Boolean, nullable=True)
source = Column(String, nullable=True)
track = relationship("Track", back_populates="plays")
class AnalysisSnapshot(Base):
"""
Stores the computed statistics and LLM analysis for a given period.
Allows for trend analysis over time.
"""
__tablename__ = "analysis_snapshots"
id = Column(Integer, primary_key=True, index=True)
date = Column(
DateTime, default=datetime.utcnow, index=True
) # When the analysis was run
period_start = Column(DateTime)
period_end = Column(DateTime)
period_label = Column(String) # e.g., "last_30_days", "monthly_nov_2023"
# The heavy lifting: stored as JSON blobs
metrics_payload = Column(JSON) # The input to the LLM (StatsService output)
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
playlist_composition = Column(JSON, nullable=True)
playlist_composition = Column(
JSON, nullable=True
) # Store the track list at this snapshot
class PlaylistConfig(Base):
__tablename__ = "playlist_config"
key = Column(String, primary_key=True, index=True) # e.g., "six_hour", "daily"
spotify_id = Column(String, nullable=False)
last_updated = Column(DateTime, default=datetime.utcnow)
current_theme = Column(String, nullable=True)
description = Column(String, nullable=True)
composition = Column(JSON, nullable=True)

View File

@@ -12,6 +12,19 @@ class TrackBase(BaseModel):
lyrics_summary: Optional[str] = None
genre_tags: Optional[str] = None
# Audio Features
danceability: Optional[float] = None
energy: Optional[float] = None
valence: Optional[float] = None
tempo: Optional[float] = None
key: Optional[int] = None
mode: Optional[int] = None
acousticness: Optional[float] = None
instrumentalness: Optional[float] = None
liveness: Optional[float] = None
speechiness: Optional[float] = None
loudness: Optional[float] = None
class Track(TrackBase):
created_at: datetime
updated_at: datetime

View File

@@ -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.

View File

@@ -0,0 +1,103 @@
import os
import requests
from typing import Optional, Dict, Any
import re
class GeniusClient:
def __init__(self):
self.access_token = os.getenv("GENIUS_ACCESS_TOKEN")
self.base_url = "https://api.genius.com"
self.headers = (
{"Authorization": f"Bearer {self.access_token}"}
if self.access_token
else {}
)
if not self.access_token:
print(
"WARNING: GENIUS_ACCESS_TOKEN not found. Lyrics enrichment will be skipped."
)
self.genius = None
else:
self.genius = True
def search_song(self, title: str, artist: str) -> Optional[Dict[str, Any]]:
if not self.genius:
return None
try:
clean_title = title.split(" - ")[0].split("(")[0].strip()
query = f"{clean_title} {artist}"
response = requests.get(
f"{self.base_url}/search",
headers=self.headers,
params={"q": query},
timeout=10,
)
if response.status_code != 200:
print(f"Genius API Error: {response.status_code}")
return None
data = response.json()
hits = data.get("response", {}).get("hits", [])
if not hits:
return None
song = hits[0]["result"]
lyrics = self._scrape_lyrics(song.get("url")) if song.get("url") else None
return {
"lyrics": lyrics,
"image_url": song.get("song_art_image_url")
or song.get("header_image_url"),
"artist_image_url": song.get("primary_artist", {}).get("image_url"),
}
except Exception as e:
print(f"Genius Search Error for {title} by {artist}: {e}")
return None
def _scrape_lyrics(self, url: str) -> Optional[str]:
try:
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.5",
}
response = requests.get(url, headers=headers, timeout=10)
if response.status_code != 200:
return None
html = response.text
lyrics_divs = re.findall(
r'<div[^>]*data-lyrics-container="true"[^>]*>(.*?)</div>',
html,
re.DOTALL,
)
if not lyrics_divs:
return None
lyrics = ""
for div in lyrics_divs:
text = re.sub(r"<br\s*/?>", "\n", div)
text = re.sub(r"<[^>]+>", "", text)
text = (
text.replace("&amp;", "&")
.replace("&quot;", '"')
.replace("&#x27;", "'")
)
lyrics += text + "\n"
return lyrics.strip() if lyrics.strip() else None
except Exception as e:
print(f"Lyrics scrape error: {e}")
return None

View File

@@ -0,0 +1,276 @@
import os
import json
import re
from typing import Dict, Any
try:
from openai import OpenAI
except ImportError:
OpenAI = None
try:
from google import genai
except ImportError:
genai = None
class NarrativeService:
def __init__(self, model_name: str = "gpt-5-mini-2025-08-07"):
self.model_name = model_name
self.provider = self._detect_provider()
self.client = self._init_client()
def _detect_provider(self) -> str:
openai_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENAI_APIKEY")
gemini_key = os.getenv("GEMINI_API_KEY")
if self.model_name.startswith("gpt") and openai_key and OpenAI:
return "openai"
elif gemini_key and genai:
return "gemini"
elif openai_key and OpenAI:
return "openai"
elif gemini_key and genai:
return "gemini"
return "none"
def _init_client(self):
if self.provider == "openai":
api_key = os.getenv("OPENAI_API_KEY") or os.getenv("OPENAI_APIKEY")
return OpenAI(api_key=api_key)
elif self.provider == "gemini":
api_key = os.getenv("GEMINI_API_KEY")
return genai.Client(api_key=api_key)
return None
def generate_full_narrative(self, stats_json: Dict[str, Any]) -> Dict[str, Any]:
if not self.client:
print("WARNING: No LLM client available")
return self._get_fallback_narrative()
clean_stats = self._shape_payload(stats_json)
prompt = self._build_prompt(clean_stats)
try:
if self.provider == "openai":
return self._call_openai(prompt)
elif self.provider == "gemini":
return self._call_gemini(prompt)
except Exception as e:
print(f"LLM Generation Error: {e}")
return self._get_fallback_narrative()
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.
5. Do NOT output internal variable names (e.g. 'part_of_day', 'avg_valence') in the description. Translate them to natural language (e.g. 'morning listens', 'happy vibe').
**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,
messages=[
{
"role": "system",
"content": "You are a witty music critic. Output only valid JSON.",
},
{"role": "user", "content": prompt},
],
response_format={"type": "json_object"},
max_completion_tokens=4000,
)
return self._clean_and_parse_json(response.choices[0].message.content)
def _call_gemini(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_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:**
1. NO mental health diagnoses. Use behavioral descriptors only.
2. Be specific - reference actual metrics from the data.
3. Be playful but not cruel.
4. Return ONLY valid JSON.
5. Translate all technical metrics (e.g. 'discovery_rate', 'valence', 'hhi') into natural language descriptions. Do NOT use the variable names themselves.
**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)}
**REQUIRED JSON:**
{{
"vibe_check_short": "1-2 sentence hook for the hero banner.",
"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 ({era_label}).",
"roast": "1-2 sentence playful roast.",
"comparison": "Compare to previous period if data exists."
}}"""
def _shape_payload(self, stats: Dict[str, Any]) -> Dict[str, Any]:
s = stats.copy()
if "volume" in s:
volume_copy = {
k: v
for k, v in s["volume"].items()
if k not in ["top_tracks", "top_artists", "top_albums", "top_genres"]
}
volume_copy["top_tracks"] = [
t["name"] for t in stats["volume"].get("top_tracks", [])[:5]
]
volume_copy["top_artists"] = [
a["name"] for a in stats["volume"].get("top_artists", [])[:5]
]
volume_copy["top_genres"] = [
g["name"] for g in stats["volume"].get("top_genres", [])[:5]
]
s["volume"] = volume_copy
if "time_habits" in s:
s["time_habits"] = {
k: v for k, v in s["time_habits"].items() if k != "heatmap"
}
if "sessions" in s:
s["sessions"] = {
k: v for k, v in s["sessions"].items() if k != "session_list"
}
return s
def _clean_and_parse_json(self, raw_text: str) -> Dict[str, Any]:
try:
return json.loads(raw_text)
except json.JSONDecodeError:
pass
try:
match = re.search(r"\{.*\}", raw_text, re.DOTALL)
if match:
return json.loads(match.group(0))
except:
pass
return self._get_fallback_narrative()
def _get_fallback_narrative(self) -> Dict[str, Any]:
return {
"vibe_check_short": "Your taste is... interesting.",
"vibe_check": "Data processing error. You're too mysterious to analyze right now.",
"patterns": [],
"persona": "The Enigma",
"era_insight": "Time is a flat circle.",
"roast": "You broke the machine. Congratulations.",
"comparison": "N/A",
}

View File

@@ -0,0 +1,396 @@
import os
from typing import Dict, Any, List
from datetime import datetime
from sqlalchemy.orm import Session
from .spotify_client import SpotifyClient
from .reccobeats_client import ReccoBeatsClient
from .narrative_service import NarrativeService
class PlaylistService:
def __init__(
self,
db: Session,
spotify_client: SpotifyClient,
recco_client: ReccoBeatsClient,
narrative_service: NarrativeService,
) -> None:
self.db = db
self.spotify = spotify_client
self.recco = recco_client
self.narrative = narrative_service
async def ensure_playlists_exist(self, user_id: str) -> Dict[str, str]:
"""Check/create playlists. Returns {six_hour_id, daily_id}."""
from app.models import PlaylistConfig
six_hour_config = (
self.db.query(PlaylistConfig)
.filter(PlaylistConfig.key == "six_hour")
.first()
)
daily_config = (
self.db.query(PlaylistConfig).filter(PlaylistConfig.key == "daily").first()
)
six_hour_id = six_hour_config.spotify_id if six_hour_config else None
daily_id = daily_config.spotify_id if daily_config else None
if not six_hour_id:
six_hour_id = os.getenv("SIX_HOUR_PLAYLIST_ID")
if not six_hour_id:
six_hour_data = await self.spotify.create_playlist(
user_id=user_id,
name="Short and Sweet",
description="AI-curated 6-hour playlists based on your listening habits",
)
six_hour_id = str(six_hour_data["id"])
self._save_playlist_config("six_hour", six_hour_id, "Short and Sweet")
if not daily_id:
daily_id = os.getenv("DAILY_PLAYLIST_ID")
if not daily_id:
daily_data = await self.spotify.create_playlist(
user_id=user_id,
name="Proof of Commitment",
description="Your daily 24-hour mix showing your music journey",
)
daily_id = str(daily_data["id"])
self._save_playlist_config("daily", daily_id, "Proof of Commitment")
return {"six_hour_id": six_hour_id, "daily_id": daily_id}
def _save_playlist_config(
self,
key: str,
spotify_id: str,
description: str = None,
theme: str = None,
composition: List[Dict[str, Any]] = None,
):
from app.models import PlaylistConfig
config = self.db.query(PlaylistConfig).filter(PlaylistConfig.key == key).first()
if not config:
config = PlaylistConfig(key=key, spotify_id=spotify_id)
self.db.add(config)
else:
config.spotify_id = spotify_id
if description:
config.description = description
if theme:
config.current_theme = theme
if composition:
config.composition = composition
config.last_updated = datetime.utcnow()
self.db.commit()
async def _hydrate_tracks(
self, track_ids: List[str], sources: Dict[str, str]
) -> List[Dict[str, Any]]:
"""Fetch full track details for a list of IDs."""
from app.models import Track
db_tracks = self.db.query(Track).filter(Track.id.in_(track_ids)).all()
track_map = {t.id: t for t in db_tracks}
missing_ids = [tid for tid in track_ids if tid not in track_map]
if missing_ids:
spotify_tracks = await self.spotify.get_tracks(missing_ids)
for st in spotify_tracks:
if not st:
continue
track_map[st["id"]] = {
"id": st["id"],
"name": st["name"],
"artist": ", ".join([a["name"] for a in st["artists"]]),
"image": st["album"]["images"][0]["url"]
if st["album"]["images"]
else None,
"uri": st["uri"],
}
result = []
for tid in track_ids:
track = track_map.get(tid)
if not track:
continue
if hasattr(track, "name") and not isinstance(track, dict):
track_data = {
"id": track.id,
"name": track.name,
"artist": track.artist,
"image": track.image_url,
"uri": f"spotify:track:{track.id}",
}
else:
track_data = track
track_data["source"] = sources.get(tid, "unknown")
result.append(track_data)
return result
async def curate_six_hour_playlist(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
"""Generate 6-hour playlist (15 curated + 15 recommendations)."""
from app.models import Track, PlayHistory
from app.services.stats_service import StatsService
from sqlalchemy import func
stats = StatsService(self.db)
data = stats.generate_full_report(period_start, period_end)
top_tracks_period = [t["id"] for t in data["volume"].get("top_tracks", [])][:15]
if len(top_tracks_period) < 5:
fallback_tracks = (
self.db.query(Track.id, func.count(PlayHistory.id).label("cnt"))
.join(PlayHistory, Track.id == PlayHistory.track_id)
.group_by(Track.id)
.order_by(func.count(PlayHistory.id).desc())
.limit(15)
.all()
)
top_tracks_period = [tid for tid, _ in fallback_tracks]
listening_data = {
"peak_hour": data["time_habits"].get("peak_hour", 12),
"avg_energy": data["vibe"].get("avg_energy", 0.5),
"avg_valence": data["vibe"].get("avg_valence", 0.5),
"total_plays": data["volume"].get("total_plays", 0),
"top_artists": data["volume"].get("top_artists", [])[:10],
}
theme_result = self.narrative.generate_playlist_theme(listening_data)
curated_details = []
for tid in top_tracks_period:
track_obj = self.db.query(Track).filter(Track.id == tid).first()
if track_obj:
curated_details.append(
{
"id": str(track_obj.id),
"energy": track_obj.energy,
"source": "history",
}
)
rec_details = []
seed_ids = top_tracks_period[:5] if top_tracks_period else []
if seed_ids:
raw_recs = await self.recco.get_recommendations(
seed_ids=seed_ids,
size=15,
)
for r in raw_recs:
rec_id = str(r.get("spotify_id") or r.get("id"))
if rec_id:
rec_details.append(
{
"id": rec_id,
"energy": r.get("energy"),
"source": "recommendation",
}
)
all_candidates = curated_details[:15] + rec_details[:15]
optimized_tracks = self._optimize_playlist_flow(all_candidates)
final_track_ids = [t["id"] for t in optimized_tracks]
sources = {t["id"]: t["source"] for t in optimized_tracks}
# Hydrate for persistence/display
full_tracks = await self._hydrate_tracks(final_track_ids, sources)
playlist_id = None
from app.models import PlaylistConfig
config = (
self.db.query(PlaylistConfig)
.filter(PlaylistConfig.key == "six_hour")
.first()
)
if config:
playlist_id = config.spotify_id
if not playlist_id:
playlist_id = os.getenv("SIX_HOUR_PLAYLIST_ID")
if playlist_id:
theme_name = f"Short and Sweet - {theme_result['theme_name']}"
desc = f"{theme_result['description']}\n\nCurated: {len(curated_details)} tracks + {len(rec_details)} recommendations"
await self.spotify.update_playlist_details(
playlist_id=playlist_id,
name=theme_name,
description=desc,
)
await self.spotify.replace_playlist_tracks(
playlist_id=playlist_id,
track_uris=[f"spotify:track:{tid}" for tid in final_track_ids],
)
self._save_playlist_config(
"six_hour",
playlist_id,
description=desc,
theme=theme_result["theme_name"],
composition=full_tracks,
)
return {
"playlist_id": playlist_id,
"theme_name": theme_result["theme_name"],
"description": theme_result["description"],
"track_count": len(final_track_ids),
"sources": sources,
"composition": full_tracks,
"curated_count": len(curated_details),
"rec_count": len(rec_details),
"refreshed_at": datetime.utcnow().isoformat(),
}
async def curate_daily_playlist(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
"""Generate 24-hour playlist (30 favorites + 20 discoveries)."""
from app.models import Track
from app.services.stats_service import StatsService
stats = StatsService(self.db)
data = stats.generate_full_report(period_start, period_end)
top_all_time_ids = self._get_top_all_time_tracks(limit=30)
recent_tracks_ids = [track["id"] for track in data["volume"]["top_tracks"][:20]]
favorites_details = []
for tid in top_all_time_ids:
track_obj = self.db.query(Track).filter(Track.id == tid).first()
if track_obj:
favorites_details.append(
{
"id": str(track_obj.id),
"energy": track_obj.energy,
"source": "favorite_all_time",
}
)
discovery_details = []
for tid in recent_tracks_ids:
track_obj = self.db.query(Track).filter(Track.id == tid).first()
if track_obj:
discovery_details.append(
{
"id": str(track_obj.id),
"energy": track_obj.energy,
"source": "recent_discovery",
}
)
all_candidates = favorites_details + discovery_details
optimized_tracks = self._optimize_playlist_flow(all_candidates)
final_track_ids = [t["id"] for t in optimized_tracks]
sources = {t["id"]: t["source"] for t in optimized_tracks}
# Hydrate for persistence/display
full_tracks = await self._hydrate_tracks(final_track_ids, sources)
playlist_id = None
from app.models import PlaylistConfig
config = (
self.db.query(PlaylistConfig).filter(PlaylistConfig.key == "daily").first()
)
if config:
playlist_id = config.spotify_id
if not playlist_id:
playlist_id = os.getenv("DAILY_PLAYLIST_ID")
theme_name = f"Proof of Commitment - {datetime.utcnow().date().isoformat()}"
if playlist_id:
desc = (
f"{theme_name} reflects the past 24 hours plus your all-time devotion."
)
await self.spotify.update_playlist_details(
playlist_id=playlist_id,
name=theme_name,
description=desc,
)
await self.spotify.replace_playlist_tracks(
playlist_id=playlist_id,
track_uris=[f"spotify:track:{tid}" for tid in final_track_ids],
)
self._save_playlist_config(
"daily",
playlist_id,
description=desc,
theme=theme_name,
composition=full_tracks,
)
return {
"playlist_id": playlist_id,
"theme_name": theme_name,
"description": "Daily mix refreshed with your favorites and discoveries.",
"track_count": len(final_track_ids),
"sources": sources,
"composition": full_tracks,
"favorites_count": len(favorites_details),
"recent_discoveries_count": len(discovery_details),
"refreshed_at": datetime.utcnow().isoformat(),
}
def _get_top_all_time_tracks(self, limit: int = 30) -> List[str]:
"""Get top tracks by play count from all-time history."""
from app.models import PlayHistory, Track
from sqlalchemy import func
result = (
self.db.query(Track.id, func.count(PlayHistory.id).label("play_count"))
.join(PlayHistory, Track.id == PlayHistory.track_id)
.group_by(Track.id)
.order_by(func.count(PlayHistory.id).desc())
.limit(limit)
.all()
)
return [track_id for track_id, _ in result]
def _optimize_playlist_flow(
self, tracks: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""
Sort tracks to create a smooth flow (Energy Ramp).
Strategy: Sort by energy (Low -> High -> Medium).
"""
if not any("energy" in t for t in tracks):
return tracks
for t in tracks:
if "energy" not in t or t["energy"] is None:
t["energy"] = 0.5
sorted_tracks = sorted(tracks, key=lambda x: x["energy"])
n = len(sorted_tracks)
low_end = int(n * 0.3)
high_start = int(n * 0.7)
low_energy = sorted_tracks[:low_end]
medium_energy = sorted_tracks[low_end:high_start]
high_energy = sorted_tracks[high_start:]
return low_energy + high_energy + medium_energy

View File

@@ -0,0 +1,136 @@
import httpx
from typing import List, Dict, Any, Optional
RECCOBEATS_BASE_URL = "https://api.reccobeats.com/v1"
class ReccoBeatsClient:
def __init__(self):
self.timeout = 30.0
async def get_tracks(self, spotify_ids: List[str]) -> List[Dict[str, Any]]:
if not spotify_ids:
return []
ids_param = ",".join(spotify_ids)
async with httpx.AsyncClient(timeout=self.timeout) as client:
try:
response = await client.get(
f"{RECCOBEATS_BASE_URL}/track", params={"ids": ids_param}
)
if response.status_code != 200:
print(f"ReccoBeats /track returned status {response.status_code}")
return []
content = response.json().get("content", [])
for item in content:
href = item.get("href", "")
if "spotify.com/track/" in href:
item["spotify_id"] = href.split("/track/")[-1].split("?")[0]
return content
except Exception as e:
print(f"ReccoBeats /track error: {e}")
return []
async def get_audio_features(self, spotify_ids: List[str]) -> List[Dict[str, Any]]:
"""Fetch audio features for tracks. Batches in chunks of 40 (API limit)."""
if not spotify_ids:
return []
all_results = []
batch_size = 40 # ReccoBeats API returns 400 for 50+ IDs
async with httpx.AsyncClient(timeout=self.timeout) as client:
for i in range(0, len(spotify_ids), batch_size):
batch = spotify_ids[i : i + batch_size]
ids_param = ",".join(batch)
try:
response = await client.get(
f"{RECCOBEATS_BASE_URL}/audio-features",
params={"ids": ids_param},
)
if response.status_code != 200:
print(
f"ReccoBeats /audio-features returned status {response.status_code}"
)
continue
content = response.json().get("content", [])
for item in content:
href = item.get("href", "")
if "spotify.com/track/" in href:
item["spotify_id"] = href.split("/track/")[-1].split("?")[0]
all_results.extend(content)
except Exception as e:
print(f"ReccoBeats /audio-features error: {e}")
return all_results
async def get_recommendations(
self,
seed_ids: List[str],
size: int = 20,
negative_seeds: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
if not seed_ids:
return []
if len(seed_ids) > 5:
seed_ids = seed_ids[:5]
params = {"seeds": ",".join(seed_ids), "size": size}
if negative_seeds:
if len(negative_seeds) > 5:
negative_seeds = negative_seeds[:5]
params["negativeSeeds"] = ",".join(negative_seeds)
async with httpx.AsyncClient(timeout=self.timeout) as client:
try:
response = await client.get(
f"{RECCOBEATS_BASE_URL}/track/recommendation", params=params
)
if response.status_code != 200:
print(
f"ReccoBeats /recommendation returned status {response.status_code}"
)
return []
content = response.json().get("content", [])
for item in content:
href = item.get("href", "")
if "spotify.com/track/" in href:
item["spotify_id"] = href.split("/track/")[-1].split("?")[0]
return content
except Exception as e:
print(f"ReccoBeats /recommendation error: {e}")
return []
async def get_audio_features_by_reccobeats_ids(
self, reccobeats_ids: List[str]
) -> List[Dict[str, Any]]:
if not reccobeats_ids:
return []
results = []
async with httpx.AsyncClient(timeout=self.timeout) as client:
for rb_id in reccobeats_ids:
try:
response = await client.get(
f"{RECCOBEATS_BASE_URL}/track/{rb_id}/audio-features"
)
if response.status_code == 200:
data = response.json()
data["reccobeats_id"] = rb_id
results.append(data)
except Exception as e:
print(f"ReccoBeats audio-features for {rb_id} error: {e}")
return results

View File

@@ -3,10 +3,12 @@ import base64
import time
import httpx
from fastapi import HTTPException
from typing import List, Dict, Any
SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
SPOTIFY_API_BASE = "https://api.spotify.com/v1"
class SpotifyClient:
def __init__(self, client_id: str, client_secret: str, refresh_token: str):
self.client_id = client_id
@@ -68,3 +70,161 @@ class SpotifyClient:
if response.status_code != 200:
return None
return response.json()
async def get_tracks(self, track_ids: List[str]) -> List[Dict[str, Any]]:
"""Fetch multiple tracks by ID."""
if not track_ids:
return []
token = await self.get_access_token()
ids_param = ",".join(track_ids[:50])
async with httpx.AsyncClient() as client:
response = await client.get(
f"{SPOTIFY_API_BASE}/tracks",
params={"ids": ids_param},
headers={"Authorization": f"Bearer {token}"},
)
if response.status_code != 200:
print(f"Error fetching tracks: {response.text}")
return []
return response.json().get("tracks", [])
async def get_artists(self, artist_ids: List[str]) -> List[Dict[str, Any]]:
"""
Fetches artist details (including genres) for a list of artist IDs.
Spotify allows up to 50 IDs per request.
"""
if not artist_ids:
return []
token = await self.get_access_token()
ids_param = ",".join(artist_ids)
async with httpx.AsyncClient() as client:
response = await client.get(
f"{SPOTIFY_API_BASE}/artists",
params={"ids": ids_param},
headers={"Authorization": f"Bearer {token}"},
)
if response.status_code != 200:
print(f"Error fetching artists: {response.text}")
return []
return response.json().get("artists", [])
async def get_currently_playing(self) -> Dict[str, Any] | None:
token = await self.get_access_token()
async with httpx.AsyncClient() as client:
response = await client.get(
f"{SPOTIFY_API_BASE}/me/player/currently-playing",
headers={"Authorization": f"Bearer {token}"},
)
if response.status_code == 204:
return None
if response.status_code != 200:
print(f"Error fetching currently playing: {response.text}")
return None
return response.json()
async def get_current_user_id(self) -> str:
token = await self.get_access_token()
async with httpx.AsyncClient() as client:
response = await client.get(
f"{SPOTIFY_API_BASE}/me",
headers={"Authorization": f"Bearer {token}"},
)
if response.status_code != 200:
raise Exception(f"Failed to get user profile: {response.text}")
return response.json().get("id")
async def create_playlist(
self, user_id: str, name: str, description: str = "", public: bool = False
) -> Dict[str, Any]:
token = await self.get_access_token()
async with httpx.AsyncClient() as client:
response = await client.post(
f"{SPOTIFY_API_BASE}/users/{user_id}/playlists",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json={"name": name, "description": description, "public": public},
)
if response.status_code not in [200, 201]:
raise Exception(f"Failed to create playlist: {response.text}")
return response.json()
async def update_playlist_details(
self, playlist_id: str, name: str = None, description: str = None
) -> bool:
token = await self.get_access_token()
data = {}
if name:
data["name"] = name
if description:
data["description"] = description
if not data:
return True
async with httpx.AsyncClient() as client:
response = await client.put(
f"{SPOTIFY_API_BASE}/playlists/{playlist_id}",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json=data,
)
return response.status_code == 200
async def replace_playlist_tracks(
self, playlist_id: str, track_uris: List[str]
) -> bool:
token = await self.get_access_token()
async with httpx.AsyncClient() as client:
response = await client.put(
f"{SPOTIFY_API_BASE}/playlists/{playlist_id}/tracks",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
json={"uris": track_uris[:100]},
)
return response.status_code in [200, 201]
async def get_recommendations(
self,
seed_tracks: List[str] = None,
seed_artists: List[str] = None,
seed_genres: List[str] = None,
limit: int = 20,
target_energy: float = None,
target_valence: float = None,
) -> List[Dict[str, Any]]:
token = await self.get_access_token()
params = {"limit": limit}
if seed_tracks:
params["seed_tracks"] = ",".join(seed_tracks[:5])
if seed_artists:
params["seed_artists"] = ",".join(seed_artists[:5])
if seed_genres:
params["seed_genres"] = ",".join(seed_genres[:5])
if target_energy is not None:
params["target_energy"] = target_energy
if target_valence is not None:
params["target_valence"] = target_valence
async with httpx.AsyncClient() as client:
response = await client.get(
f"{SPOTIFY_API_BASE}/recommendations",
params=params,
headers={"Authorization": f"Bearer {token}"},
)
if response.status_code != 200:
print(f"Error fetching recommendations: {response.text}")
return []
return response.json().get("tracks", [])

View File

@@ -0,0 +1,996 @@
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import func, distinct
from datetime import datetime, timedelta
from typing import Dict, Any, List, Optional
import math
import numpy as np
from sklearn.cluster import KMeans
from ..models import PlayHistory, Track, Artist
class StatsService:
def __init__(self, db: Session):
self.db = db
def compute_comparison(
self,
current_stats: Dict[str, Any],
period_start: datetime,
period_end: datetime,
) -> Dict[str, Any]:
duration = period_end - period_start
prev_end = period_start
prev_start = prev_end - duration
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 = {}
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)
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"]
deltas["energy_delta"] = round(curr_e - prev_e, 2)
curr_v = current_stats["vibe"]["mood_quadrant"]["x"]
prev_v = prev_vibe["mood_quadrant"]["x"]
deltas["valence_delta"] = round(curr_v - prev_v, 2)
if (
"avg_popularity" in current_stats["taste"]
and "avg_popularity" in prev_taste
):
deltas["popularity_delta"] = round(
current_stats["taste"]["avg_popularity"] - prev_taste["avg_popularity"],
1,
)
return {
"previous_period": {
"start": prev_start.isoformat(),
"end": prev_end.isoformat(),
},
"deltas": deltas,
}
def compute_volume_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
query = (
self.db.query(PlayHistory)
.options(joinedload(PlayHistory.track).joinedload(Track.artists))
.filter(
PlayHistory.played_at >= period_start,
PlayHistory.played_at < period_end,
)
)
plays = query.all()
total_plays = len(plays)
if total_plays == 0:
return self._empty_volume_stats()
total_ms = 0
track_counts = {}
artist_counts = {}
genre_counts = {}
album_counts = {}
track_map = {}
artist_map = {}
album_map = {}
def get_track_image(t):
if t.image_url:
return t.image_url
if t.raw_data and "album" in t.raw_data and "images" in t.raw_data["album"]:
imgs = t.raw_data["album"]["images"]
if imgs:
return imgs[0].get("url")
return None
for p in plays:
t = p.track
if not t:
continue
total_ms += t.duration_ms if t.duration_ms else 0
track_counts[t.id] = track_counts.get(t.id, 0) + 1
track_map[t.id] = t
album_id = t.album
album_name = t.album
if t.raw_data and "album" in t.raw_data:
album_id = t.raw_data["album"].get("id", t.album)
album_name = t.raw_data["album"].get("name", t.album)
album_counts[album_id] = album_counts.get(album_id, 0) + 1
if album_id not in album_map:
album_map[album_id] = {"name": album_name, "image": get_track_image(t)}
for artist in t.artists:
artist_counts[artist.id] = artist_counts.get(artist.id, 0) + 1
if artist.id not in artist_map:
artist_map[artist.id] = {
"name": artist.name,
"image": artist.image_url,
}
if artist.genres:
for g in artist.genres:
genre_counts[g] = genre_counts.get(g, 0) + 1
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_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]),
"count": c,
}
for tid, c in sorted(
track_counts.items(), key=lambda x: x[1], reverse=True
)[:5]
]
top_artists = [
{
"name": artist_map[aid]["name"],
"id": aid,
"image": artist_map[aid]["image"],
"count": c,
}
for aid, c in sorted(
artist_counts.items(), key=lambda x: x[1], reverse=True
)[:5]
]
top_albums = [
{
"name": album_map[aid]["name"],
"image": album_map[aid]["image"],
"count": c,
}
for aid, c in sorted(
album_counts.items(), key=lambda x: x[1], reverse=True
)[:5]
]
top_genres = [
{"name": k, "count": v}
for k, v in sorted(genre_counts.items(), key=lambda x: x[1], reverse=True)[
:5
]
]
hhi = sum([s**2 for s in shares])
sorted_shares = sorted(shares)
n = len(shares)
gini = 0
if n > 0:
gini = (2 * sum((i + 1) * x for i, x in enumerate(sorted_shares))) / (
n * sum(sorted_shares)
) - (n + 1) / n
total_genre_occurrences = sum(genre_counts.values())
genre_entropy = 0
if total_genre_occurrences > 0:
genre_probs = [
count / total_genre_occurrences for count in genre_counts.values()
]
genre_entropy = -sum([p * math.log(p) for p in genre_probs if p > 0])
top_5_plays = sum([t["count"] for t in top_tracks])
top_5_share = top_5_plays / total_plays if total_plays else 0
return {
"total_plays": total_plays,
"estimated_minutes": int(total_ms / 60000),
"unique_tracks": unique_tracks,
"unique_artists": len(artist_counts),
"unique_albums": len(album_counts),
"unique_genres": len(genre_counts),
"top_tracks": top_tracks,
"top_artists": top_artists,
"top_albums": top_albums,
"top_genres": top_genres,
"repeat_rate": round((total_plays - unique_tracks) / total_plays, 3)
if total_plays
else 0,
"one_and_done_rate": round(one_and_done / unique_tracks, 3)
if unique_tracks
else 0,
"concentration": {
"hhi": round(hhi, 4),
"gini": round(gini, 4),
"top_1_share": round(max(shares), 3) if shares else 0,
"top_5_share": round(top_5_share, 3),
"genre_entropy": round(genre_entropy, 2),
},
}
def compute_time_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
query = (
self.db.query(PlayHistory)
.filter(
PlayHistory.played_at >= period_start,
PlayHistory.played_at < period_end,
)
.order_by(PlayHistory.played_at.asc())
)
plays = query.all()
if not plays:
return self._empty_time_stats()
heatmap = [[0 for _ in range(24)] for _ in range(7)]
heatmap_compressed = [[0 for _ in range(6)] for _ in range(7)]
block_labels = [
"12am-4am",
"4am-8am",
"8am-12pm",
"12pm-4pm",
"4pm-8pm",
"8pm-12am",
]
hourly_counts = [0] * 24
weekday_counts = [0] * 7
part_of_day = {"morning": 0, "afternoon": 0, "evening": 0, "night": 0}
active_dates = set()
for p in plays:
h = p.played_at.hour
d = p.played_at.weekday()
heatmap[d][h] += 1
block_idx = h // 4
heatmap_compressed[d][block_idx] += 1
hourly_counts[h] += 1
weekday_counts[d] += 1
active_dates.add(p.played_at.date())
if 6 <= h < 12:
part_of_day["morning"] += 1
elif 12 <= h < 18:
part_of_day["afternoon"] += 1
elif 18 <= h <= 23:
part_of_day["evening"] += 1
else:
part_of_day["night"] += 1
sorted_dates = sorted(list(active_dates))
current_streak = 0
longest_streak = 0
if sorted_dates:
current_streak = 1
longest_streak = 1
for i in range(1, len(sorted_dates)):
delta = (sorted_dates[i] - sorted_dates[i - 1]).days
if delta == 1:
current_streak += 1
else:
longest_streak = max(longest_streak, current_streak)
current_streak = 1
longest_streak = max(longest_streak, current_streak)
weekend_plays = weekday_counts[5] + weekday_counts[6]
active_days_count = len(active_dates)
return {
"heatmap": heatmap,
"heatmap_compressed": heatmap_compressed,
"block_labels": block_labels,
"hourly_distribution": hourly_counts,
"peak_hour": hourly_counts.index(max(hourly_counts)),
"weekday_distribution": weekday_counts,
"daily_distribution": weekday_counts,
"weekend_share": round(weekend_plays / len(plays), 2),
"part_of_day": part_of_day,
"listening_streak": current_streak,
"longest_streak": longest_streak,
"active_days": active_days_count,
"avg_plays_per_active_day": round(len(plays) / active_days_count, 1)
if active_days_count
else 0,
}
def compute_session_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
query = (
self.db.query(PlayHistory)
.options(joinedload(PlayHistory.track))
.filter(
PlayHistory.played_at >= period_start,
PlayHistory.played_at < period_end,
)
.order_by(PlayHistory.played_at.asc())
)
plays = query.all()
if not plays:
return self._empty_session_stats()
sessions = []
current_session = [plays[0]]
for i in range(1, len(plays)):
diff = (plays[i].played_at - plays[i - 1].played_at).total_seconds() / 60
if diff > 20:
sessions.append(current_session)
current_session = []
current_session.append(plays[i])
sessions.append(current_session)
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 = []
for sess in sessions:
start_t = sess[0].played_at
end_t = sess[-1].played_at
start_hour_dist[start_t.hour] += 1
if len(sess) > 1:
duration = (end_t - start_t).total_seconds() / 60
lengths_min.append(duration)
else:
duration = 3.0
lengths_min.append(duration)
sess_type = "Standard"
if len(sess) <= 3:
micro_sessions += 1
sess_type = "Micro"
elif len(sess) >= 20:
marathon_sessions += 1
sess_type = "Marathon"
session_list.append(
{
"start_time": start_t.isoformat(),
"end_time": end_t.isoformat(),
"duration_minutes": round(duration, 1),
"track_count": len(sess),
"type": sess_type,
}
)
first_t = sess[0].track
last_t = sess[-1].track
if (
first_t
and last_t
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:
energy_arcs["rising"] += 1
elif diff < -0.1:
energy_arcs["falling"] += 1
else:
energy_arcs["flat"] += 1
else:
energy_arcs["unknown"] += 1
avg_min = np.mean(lengths_min) if lengths_min else 0
median_min = np.median(lengths_min) if lengths_min else 0
active_days = len(set(p.played_at.date() for p in plays))
sessions_per_day = len(sessions) / active_days if active_days else 0
return {
"count": len(sessions),
"avg_tracks": round(len(plays) / len(sessions), 1),
"avg_minutes": round(float(avg_min), 1),
"median_minutes": round(float(median_min), 1),
"longest_session_minutes": round(max(lengths_min), 1) if lengths_min else 0,
"sessions_per_day": round(sessions_per_day, 1),
"start_hour_distribution": start_hour_dist,
"micro_session_rate": round(micro_sessions / len(sessions), 2),
"marathon_session_rate": round(marathon_sessions / len(sessions), 2),
"energy_arcs": energy_arcs,
"session_list": session_list,
}
def compute_vibe_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
plays = (
self.db.query(PlayHistory)
.filter(
PlayHistory.played_at >= period_start,
PlayHistory.played_at < period_end,
)
.order_by(PlayHistory.played_at.asc())
.all()
)
if not plays:
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}
feature_keys = [
"energy",
"valence",
"danceability",
"tempo",
"acousticness",
"instrumentalness",
"liveness",
"speechiness",
"loudness",
]
features = {k: [] for k in feature_keys}
cluster_data = []
keys = []
modes = []
tempo_zones = {"chill": 0, "groove": 0, "hype": 0}
transitions = {"tempo": [], "energy": [], "valence": []}
previous_track = None
for i, p in enumerate(plays):
t = track_map.get(p.track_id)
if not t:
continue
for key in feature_keys:
val = getattr(t, key, None)
if val is not None:
features[key].append(val)
if all(
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]
)
if getattr(t, "key", None) is not None:
keys.append(t.key)
if getattr(t, "mode", None) is not None:
modes.append(t.mode)
if getattr(t, "tempo", None) is not None:
if t.tempo < 100:
tempo_zones["chill"] += 1
elif t.tempo < 130:
tempo_zones["groove"] += 1
else:
tempo_zones["hype"] += 1
if i > 0 and previous_track:
time_diff = (p.played_at - plays[i - 1].played_at).total_seconds()
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 (
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 (
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
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_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_res[key] = 0.0
stats_res[f"avg_{key}"] = None
if (
stats_res.get("avg_energy") is not None
and stats_res.get("avg_valence") is not None
):
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_res.get("avg_acousticness") is not None
and stats_res.get("avg_instrumentalness") is not None
):
stats_res["texture_profile"] = {
"acousticness": round(stats_res["avg_acousticness"], 2),
"instrumentalness": round(stats_res["avg_instrumentalness"], 2),
}
stats_res["whiplash"] = {}
for k in ["tempo", "energy", "valence"]:
if transitions[k]:
stats_res["whiplash"][k] = round(float(np.mean(transitions[k])), 2)
else:
stats_res["whiplash"][k] = 0
total_tempo = sum(tempo_zones.values())
if total_tempo > 0:
stats_res["tempo_zones"] = {
k: round(v / total_tempo, 2) for k, v in tempo_zones.items()
}
else:
stats_res["tempo_zones"] = {}
if modes:
major_count = len([m for m in modes if m == 1])
stats_res["harmonic_profile"] = {
"major_pct": round(major_count / len(modes), 2),
"minor_pct": round((len(modes) - major_count) / len(modes), 2),
}
if keys:
pitch_class = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
]
key_counts = {}
for k in keys:
if 0 <= k < 12:
label = pitch_class[k]
key_counts[label] = key_counts.get(label, 0) + 1
stats_res["top_keys"] = [
{"key": k, "count": v}
for k, v in sorted(
key_counts.items(), key=lambda x: x[1], reverse=True
)[:3]
]
if len(cluster_data) >= 5:
try:
kmeans = KMeans(n_clusters=3, random_state=42, n_init="auto")
labels = kmeans.fit_predict(cluster_data)
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)
c_energy, c_valence, c_dance, c_acoustic = centroid
name = "Mixed Vibe"
if c_energy > 0.7:
name = "High Energy"
elif c_acoustic > 0.7:
name = "Acoustic / Chill"
elif c_valence < 0.3:
name = "Melancholy"
elif c_dance > 0.7:
name = "Dance / Groove"
clusters.append(
{
"name": name,
"share": round(share, 2),
"features": {
"energy": round(c_energy, 2),
"valence": round(c_valence, 2),
"danceability": round(c_dance, 2),
"acousticness": round(c_acoustic, 2),
},
}
)
stats_res["clusters"] = sorted(
clusters, key=lambda x: x["share"], reverse=True
)
except Exception as e:
print(f"Clustering failed: {e}")
stats_res["clusters"] = []
else:
stats_res["clusters"] = []
return stats_res
def compute_era_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
query = (
self.db.query(PlayHistory)
.options(joinedload(PlayHistory.track))
.filter(
PlayHistory.played_at >= period_start,
PlayHistory.played_at < period_end,
)
)
plays = query.all()
years = []
for p in plays:
t = p.track
if t and t.raw_data and "album" in t.raw_data:
rd = t.raw_data["album"].get("release_date")
if rd:
try:
years.append(int(rd.split("-")[0]))
except:
pass
if not years:
return {"musical_age": None}
avg_year = sum(years) / len(years)
current_year = datetime.utcnow().year
decades = {}
for y in years:
dec = (y // 10) * 10
label = f"{dec}s"
decades[label] = decades.get(label, 0) + 1
total = len(years)
dist = {k: round(v / total, 3) for k, v in decades.items()}
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),
"decade_distribution": dist,
}
def compute_skip_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
query = (
self.db.query(PlayHistory)
.filter(
PlayHistory.played_at >= period_start,
PlayHistory.played_at <= period_end,
)
.order_by(PlayHistory.played_at.asc())
)
plays = query.all()
if len(plays) < 2:
return {"skip_rate": 0, "total_skips": 0}
skips = 0
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}
for i in range(len(plays) - 1):
current_play = plays[i]
next_play = plays[i + 1]
track = track_map.get(current_play.track_id)
if not track or not getattr(track, "duration_ms", None):
continue
diff_seconds = (
next_play.played_at - current_play.played_at
).total_seconds()
duration_sec = track.duration_ms / 1000.0
if diff_seconds < (duration_sec - 10):
skips += 1
return {"total_skips": skips, "skip_rate": round(skips / len(plays), 3)}
def compute_context_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
query = self.db.query(PlayHistory).filter(
PlayHistory.played_at >= period_start, PlayHistory.played_at <= period_end
)
plays = query.all()
if not plays:
return {}
context_counts = {
"playlist": 0,
"album": 0,
"artist": 0,
"collection": 0,
"unknown": 0,
}
unique_contexts = {}
for p in plays:
if not p.context_uri:
context_counts["unknown"] += 1
continue
unique_contexts[p.context_uri] = unique_contexts.get(p.context_uri, 0) + 1
if "playlist" in p.context_uri:
context_counts["playlist"] += 1
elif "album" in p.context_uri:
context_counts["album"] += 1
elif "artist" in p.context_uri:
context_counts["artist"] += 1
elif "collection" in p.context_uri:
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()}
sorted_contexts = sorted(
unique_contexts.items(), key=lambda x: x[1], reverse=True
)[:5]
return {
"type_breakdown": breakdown,
"album_purist_score": breakdown.get("album", 0),
"playlist_dependency": breakdown.get("playlist", 0),
"context_loyalty": round(len(plays) / len(unique_contexts), 2)
if unique_contexts
else 0,
"top_context_uris": [{"uri": k, "count": v} for k, v in sorted_contexts],
}
def compute_taste_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
query = self.db.query(PlayHistory).filter(
PlayHistory.played_at >= period_start, PlayHistory.played_at <= period_end
)
plays = query.all()
if not plays:
return {}
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}
pop_values = []
for p in plays:
t = track_map.get(p.track_id)
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))
underground_plays = len([x for x in pop_values if x < 30])
mainstream_plays = len([x for x in pop_values if x > 70])
return {
"avg_popularity": round(avg_pop, 1),
"hipster_score": round((underground_plays / len(pop_values)) * 100, 1),
"mainstream_score": round((mainstream_plays / len(pop_values)) * 100, 1),
"obscurity_rating": round(100 - avg_pop, 1),
}
def compute_lifecycle_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
current_plays = (
self.db.query(PlayHistory)
.filter(
PlayHistory.played_at >= period_start,
PlayHistory.played_at <= period_end,
)
.all()
)
if not current_plays:
return {}
current_track_ids = set([p.track_id for p in current_plays])
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()])
new_discoveries = current_track_ids - old_track_ids
discovery_count = len(new_discoveries)
plays_on_new = len([p for p in current_plays if p.track_id in new_discoveries])
total_plays = len(current_plays)
return {
"discovery_count": discovery_count,
"discovery_rate": round(plays_on_new / total_plays, 3)
if total_plays > 0
else 0,
"recurrence_rate": round((total_plays - plays_on_new) / total_plays, 3)
if total_plays > 0
else 0,
}
def compute_explicit_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
query = (
self.db.query(PlayHistory)
.options(joinedload(PlayHistory.track))
.filter(
PlayHistory.played_at >= period_start,
PlayHistory.played_at <= period_end,
)
)
plays = query.all()
if not plays:
return {"explicit_rate": 0, "hourly_explicit_distribution": []}
total_plays = len(plays)
explicit_count = 0
hourly_explicit = [0] * 24
hourly_total = [0] * 24
for p in plays:
h = p.played_at.hour
hourly_total[h] += 1
t = p.track
if t and t.raw_data and t.raw_data.get("explicit"):
explicit_count += 1
hourly_explicit[h] += 1
hourly_rates = []
for i in range(24):
if hourly_total[i] > 0:
hourly_rates.append(round(hourly_explicit[i] / hourly_total[i], 2))
else:
hourly_rates.append(0.0)
return {
"explicit_rate": round(explicit_count / total_plays, 3),
"total_explicit_plays": explicit_count,
"hourly_explicit_distribution": hourly_rates,
}
def generate_full_report(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
current_stats = {
"period": {
"start": period_start.isoformat(),
"end": period_end.isoformat(),
},
"volume": self.compute_volume_stats(period_start, period_end),
"time_habits": self.compute_time_stats(period_start, period_end),
"sessions": self.compute_session_stats(period_start, period_end),
"context": self.compute_context_stats(period_start, period_end),
"vibe": self.compute_vibe_stats(period_start, period_end),
"era": self.compute_era_stats(period_start, period_end),
"taste": self.compute_taste_stats(period_start, period_end),
"lifecycle": self.compute_lifecycle_stats(period_start, period_end),
"flags": self.compute_explicit_stats(period_start, period_end),
"skips": self.compute_skip_stats(period_start, period_end),
}
current_stats["comparison"] = self.compute_comparison(
current_stats, period_start, period_end
)
return current_stats
def _empty_volume_stats(self):
return {
"total_plays": 0,
"estimated_minutes": 0,
"unique_tracks": 0,
"unique_artists": 0,
"unique_albums": 0,
"unique_genres": 0,
"top_tracks": [],
"top_artists": [],
"top_albums": [],
"top_genres": [],
"repeat_rate": 0,
"one_and_done_rate": 0,
"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):
if prev == 0:
return 100.0 if curr > 0 else 0.0
return round(((curr - prev) / prev) * 100, 1)

10
backend/backend.log Normal file
View File

@@ -0,0 +1,10 @@
INFO: Started server process [9223]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
INFO: 127.0.0.1:35326 - "GET /history?limit=100 HTTP/1.1" 200 OK
INFO: 127.0.0.1:35342 - "GET /history?limit=100 HTTP/1.1" 200 OK
INFO: Shutting down
INFO: Waiting for application shutdown.
INFO: Application shutdown complete.
INFO: Finished server process [9223]

17
backend/entrypoint.sh Normal file
View File

@@ -0,0 +1,17 @@
#!/bin/bash
set -e
echo "=== MusicAnalyser Backend Starting ==="
# Run Alembic migrations
echo "Running database migrations..."
alembic upgrade head
# Start the worker in background (polls Spotify every 60s)
echo "Starting Spotify ingestion worker..."
python run_worker.py &
WORKER_PID=$!
# Start the API server in foreground
echo "Starting API server on port 8000..."
exec uvicorn app.main:app --host 0.0.0.0 --port 8000

View File

@@ -1,11 +1,16 @@
fastapi==0.109.2
uvicorn==0.27.1
sqlalchemy==2.0.27
httpx==0.26.0
sqlalchemy==2.0.45
httpx==0.28.1
python-dotenv==1.0.1
pydantic==2.6.1
pydantic-settings==2.1.0
google-generativeai==0.3.2
pydantic==2.12.5
pydantic-core==2.41.5
pydantic-settings==2.12.0
tenacity==8.2.3
python-dateutil==2.9.0.post0
requests==2.31.0
alembic==1.13.1
psycopg2-binary==2.9.9
scikit-learn==1.4.0
google-genai==1.56.0
openai>=1.0.0

82
backend/run_analysis.py Normal file
View File

@@ -0,0 +1,82 @@
import os
import sys
import json
from datetime import datetime, timedelta
from app.database import SessionLocal
from app.services.stats_service import StatsService
from app.services.narrative_service import NarrativeService
from app.models import AnalysisSnapshot
def run_analysis_pipeline(days: int = 30, model_name: str = "gemini-2.5-flash"):
db = SessionLocal()
try:
end_date = datetime.utcnow()
start_date = end_date - timedelta(days=days)
print(f"--- Starting Analysis for period: {start_date} to {end_date} ---")
# 1. Compute Stats
print("Calculating metrics...")
stats_service = StatsService(db)
stats_json = stats_service.generate_full_report(start_date, end_date)
# Check if we have enough data
if stats_json["volume"]["total_plays"] == 0:
print("No plays found in this period. Skipping LLM analysis.")
return
print(f"Stats computed. Total Plays: {stats_json['volume']['total_plays']}")
print(f"Top Artist: {stats_json['volume']['top_artists'][0]['name'] if stats_json['volume']['top_artists'] else 'N/A'}")
# 2. Generate Narrative
print(f"Generating Narrative with {model_name}...")
narrative_service = NarrativeService(model_name=model_name)
narrative_json = narrative_service.generate_full_narrative(stats_json)
if "error" in narrative_json:
print(f"LLM Error: {narrative_json['error']}")
else:
print("Narrative generated successfully.")
print(f"Persona: {narrative_json.get('persona')}")
# 3. Save Snapshot
print("Saving snapshot to database...")
snapshot = AnalysisSnapshot(
period_start=start_date,
period_end=end_date,
period_label=f"last_{days}_days",
metrics_payload=stats_json,
narrative_report=narrative_json,
model_used=model_name
)
db.add(snapshot)
db.commit()
print(f"Snapshot saved with ID: {snapshot.id}")
# 4. Output to file for easy inspection
output = {
"snapshot_id": snapshot.id,
"metrics": stats_json,
"narrative": narrative_json
}
with open("latest_analysis.json", "w") as f:
json.dump(output, f, indent=2)
print("Full report saved to latest_analysis.json")
except Exception as e:
print(f"Pipeline Failed: {e}")
import traceback
traceback.print_exc()
finally:
db.close()
if __name__ == "__main__":
# Allow arguments?
days = 30
if len(sys.argv) > 1:
try:
days = int(sys.argv[1])
except:
pass
run_analysis_pipeline(days=days)

16
backend/run_scheduler.py Normal file
View File

@@ -0,0 +1,16 @@
import schedule
import time
from run_analysis import run_analysis_pipeline
def job():
print("Running daily analysis...")
# Analyze last 24 hours
run_analysis_pipeline(days=1)
# Schedule for 03:00 AM
schedule.every().day.at("03:00").do(job)
print("Scheduler started...")
while True:
schedule.run_pending()
time.sleep(60)

View File

@@ -16,8 +16,9 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
# CONFIGURATION - You can hardcode these or input them when prompted
SPOTIFY_CLIENT_ID = input("Enter your Spotify Client ID: ").strip()
SPOTIFY_CLIENT_SECRET = input("Enter your Spotify Client Secret: ").strip()
REDIRECT_URI = "http://localhost:8888/callback"
SCOPE = "user-read-recently-played user-read-playback-state"
REDIRECT_URI = "http://127.0.0.1:8888/callback"
SCOPE = "user-read-recently-played user-read-playback-state playlist-modify-public playlist-modify-private"
class RequestHandler(BaseHTTPRequestHandler):
def do_GET(self):
@@ -36,6 +37,7 @@ class RequestHandler(BaseHTTPRequestHandler):
# Shut down server
raise KeyboardInterrupt
def get_token(code):
url = "https://accounts.spotify.com/api/token"
payload = {
@@ -49,24 +51,27 @@ def get_token(code):
response = requests.post(url, data=payload)
if response.status_code == 200:
data = response.json()
print("\n" + "="*50)
print("\n" + "=" * 50)
print("SUCCESS! HERE ARE YOUR CREDENTIALS")
print("="*50)
print("=" * 50)
print(f"\nSPOTIFY_REFRESH_TOKEN={data['refresh_token']}")
print(f"SPOTIFY_CLIENT_ID={SPOTIFY_CLIENT_ID}")
print(f"SPOTIFY_CLIENT_SECRET={SPOTIFY_CLIENT_SECRET}")
print("\nSave these in your .env file or share them with the agent.")
print("="*50 + "\n")
print("=" * 50 + "\n")
else:
print("Error getting token:", response.text)
def start_auth():
auth_url = "https://accounts.spotify.com/authorize?" + urllib.parse.urlencode({
"response_type": "code",
"client_id": SPOTIFY_CLIENT_ID,
"scope": SCOPE,
"redirect_uri": REDIRECT_URI,
})
auth_url = "https://accounts.spotify.com/authorize?" + urllib.parse.urlencode(
{
"response_type": "code",
"client_id": SPOTIFY_CLIENT_ID,
"scope": SCOPE,
"redirect_uri": REDIRECT_URI,
}
)
print(f"Opening browser to: {auth_url}")
try:
@@ -74,7 +79,7 @@ def start_auth():
except:
print(f"Could not open browser. Please manually visit: {auth_url}")
server_address = ('', 8888)
server_address = ("", 8888)
httpd = HTTPServer(server_address, RequestHandler)
print("Listening on port 8888...")
try:
@@ -83,5 +88,6 @@ def start_auth():
pass
httpd.server_close()
if __name__ == "__main__":
start_auth()

View File

@@ -0,0 +1,31 @@
from sqlalchemy.orm import Session
from app.database import SessionLocal, engine, Base
from app.models import Track, PlayHistory
from datetime import datetime, timedelta
Base.metadata.create_all(bind=engine)
db = SessionLocal()
# clear
db.query(PlayHistory).delete()
db.query(Track).delete()
db.commit()
# Create tracks
t1 = Track(id="t1", name="Midnight City", artist="M83", album="Hurry Up, We're Dreaming", duration_ms=243000, danceability=0.6, energy=0.8, valence=0.5, raw_data={})
t2 = Track(id="t2", name="Weightless", artist="Marconi Union", album="Weightless", duration_ms=480000, danceability=0.2, energy=0.1, valence=0.1, raw_data={})
t3 = Track(id="t3", name="Levitating", artist="Dua Lipa", album="Future Nostalgia", duration_ms=203000, danceability=0.8, energy=0.9, valence=0.9, raw_data={})
db.add_all([t1, t2, t3])
db.commit()
# Create history
ph1 = PlayHistory(track_id="t1", played_at=datetime.utcnow() - timedelta(minutes=10))
ph2 = PlayHistory(track_id="t2", played_at=datetime.utcnow() - timedelta(minutes=30))
ph3 = PlayHistory(track_id="t3", played_at=datetime.utcnow() - timedelta(minutes=60))
db.add_all([ph1, ph2, ph3])
db.commit()
print("Data populated")
db.close()

78
backend/seed_data.py Normal file
View File

@@ -0,0 +1,78 @@
from datetime import datetime, timedelta
import random
from app.database import SessionLocal
from app.models import Track, Artist, PlayHistory
from app.services.stats_service import StatsService
def seed_db():
db = SessionLocal()
# 1. Create Artists
artists = []
for i in range(10):
a = Artist(
id=f"artist_{i}",
name=f"Artist {i}",
genres=[random.choice(["pop", "rock", "jazz", "edm", "hip-hop"]) for _ in range(2)]
)
db.merge(a) # merge handles insert/update
artists.append(a)
db.commit()
print(f"Seeded {len(artists)} artists.")
# 2. Create Tracks
tracks = []
for i in range(50):
# Random artist
artist = random.choice(artists)
t = Track(
id=f"track_{i}",
name=f"Track {i}",
artist=artist.name, # Legacy
album=f"Album {i % 10}",
duration_ms=random.randint(180000, 300000), # 3-5 mins
popularity=random.randint(10, 90),
danceability=random.uniform(0.3, 0.9),
energy=random.uniform(0.3, 0.9),
valence=random.uniform(0.1, 0.9),
tempo=random.uniform(80, 160),
raw_data={"album": {"id": f"album_{i%10}", "release_date": f"{random.randint(2000, 2023)}-01-01"}}
)
# Link artist
t.artists.append(artist)
db.merge(t)
tracks.append(t)
db.commit()
print(f"Seeded {len(tracks)} tracks.")
# 3. Create Play History (Last 30 days)
plays = []
base_time = datetime.utcnow() - timedelta(days=25)
for i in range(200):
# Create sessions
# 80% chance next play is soon (2-5 mins), 20% chance gap (30-600 mins)
gap = random.randint(2, 6) if random.random() > 0.2 else random.randint(30, 600)
base_time += timedelta(minutes=gap)
if base_time > datetime.utcnow():
break
track = random.choice(tracks)
p = PlayHistory(
track_id=track.id,
played_at=base_time,
context_uri="spotify:playlist:fake"
)
db.add(p)
db.commit()
print(f"Seeded play history until {base_time}.")
db.close()
if __name__ == "__main__":
seed_db()

View File

@@ -0,0 +1,5 @@
import pytest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))

View File

@@ -0,0 +1,113 @@
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
from datetime import datetime, timedelta
from app.ingest import PlaybackTracker, finalize_track
class TestPlaybackTracker:
def test_initial_state(self):
tracker = PlaybackTracker()
assert tracker.current_track_id is None
assert tracker.track_start_time is None
assert tracker.accumulated_listen_ms == 0
assert tracker.last_progress_ms == 0
assert tracker.is_paused is False
class TestFinalizeTrack:
def test_finalize_creates_play_history_when_not_exists(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
tracker = PlaybackTracker()
tracker.current_track_id = "track123"
tracker.track_start_time = datetime(2024, 1, 1, 10, 0, 0)
tracker.accumulated_listen_ms = 60000
finalize_track(mock_db, tracker)
mock_db.add.assert_called_once()
mock_db.commit.assert_called_once()
assert tracker.current_track_id is None
assert tracker.accumulated_listen_ms == 0
def test_finalize_marks_skip_when_under_30s(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
tracker = PlaybackTracker()
tracker.current_track_id = "track123"
tracker.track_start_time = datetime(2024, 1, 1, 10, 0, 0)
tracker.accumulated_listen_ms = 15000
finalize_track(mock_db, tracker)
call_args = mock_db.add.call_args[0][0]
assert call_args.skipped is True
def test_finalize_updates_existing_play(self):
mock_existing = MagicMock()
mock_existing.listened_ms = None
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = (
mock_existing
)
tracker = PlaybackTracker()
tracker.current_track_id = "track123"
tracker.track_start_time = datetime(2024, 1, 1, 10, 0, 0)
tracker.accumulated_listen_ms = 120000
finalize_track(mock_db, tracker)
assert mock_existing.listened_ms == 120000
assert mock_existing.skipped is False
mock_db.commit.assert_called_once()
class TestReccoBeatsClient:
@pytest.mark.asyncio
async def test_extracts_spotify_id_from_href(self):
from app.services.reccobeats_client import ReccoBeatsClient
with patch("httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"content": [
{
"id": "uuid-here",
"href": "https://open.spotify.com/track/abc123xyz",
"energy": 0.8,
"valence": 0.6,
}
]
}
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
client = ReccoBeatsClient()
result = await client.get_audio_features(["abc123xyz"])
assert len(result) == 1
assert result[0]["spotify_id"] == "abc123xyz"
assert result[0]["energy"] == 0.8
@pytest.mark.asyncio
async def test_returns_empty_on_error(self):
from app.services.reccobeats_client import ReccoBeatsClient
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
side_effect=Exception("Network error")
)
client = ReccoBeatsClient()
result = await client.get_audio_features(["test123"])
assert result == []

View File

@@ -0,0 +1,49 @@
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from datetime import datetime
@pytest.fixture
def mock_db():
return MagicMock()
class TestSnapshotsEndpoint:
def test_snapshots_endpoint_exists(self, mock_db):
with patch("app.main.SessionLocal", return_value=mock_db):
from fastapi.testclient import TestClient
from app.main import app
mock_db.query.return_value.order_by.return_value.limit.return_value.all.return_value = []
with TestClient(app) as client:
response = client.get("/snapshots?limit=1")
assert response.status_code == 200
class TestListeningLogEndpoint:
def test_listening_log_endpoint_exists(self, mock_db):
with patch("app.main.SessionLocal", return_value=mock_db):
from fastapi.testclient import TestClient
from app.main import app
mock_db.query.return_value.options.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = []
with TestClient(app) as client:
response = client.get("/listening-log?days=7&limit=100")
assert response.status_code == 200
class TestSessionsEndpoint:
def test_sessions_endpoint_exists(self, mock_db):
with patch("app.main.SessionLocal", return_value=mock_db):
from fastapi.testclient import TestClient
from app.main import app
mock_db.query.return_value.options.return_value.filter.return_value.order_by.return_value.all.return_value = []
with TestClient(app) as client:
response = client.get("/sessions?days=7")
assert response.status_code == 200
data = response.json()
assert "session_list" in data

View File

@@ -0,0 +1,126 @@
import pytest
from unittest.mock import Mock, AsyncMock, MagicMock
from datetime import datetime
from app.services.playlist_service import PlaylistService
from app.models import PlaylistConfig, Track
@pytest.fixture
def mock_db():
session = MagicMock()
# Mock query return values
session.query.return_value.filter.return_value.first.return_value = None
return session
@pytest.fixture
def mock_spotify():
client = AsyncMock()
client.create_playlist.return_value = {"id": "new_playlist_id"}
client.get_tracks.return_value = []
return client
@pytest.fixture
def mock_recco():
client = AsyncMock()
return client
@pytest.fixture
def mock_narrative():
service = Mock()
service.generate_playlist_theme.return_value = {
"theme_name": "Test Theme",
"description": "Test Description",
"curated_tracks": [],
}
return service
@pytest.fixture
def playlist_service(mock_db, mock_spotify, mock_recco, mock_narrative):
return PlaylistService(mock_db, mock_spotify, mock_recco, mock_narrative)
@pytest.mark.asyncio
async def test_ensure_playlists_exist_creates_new(
playlist_service, mock_db, mock_spotify
):
# Setup: DB empty, Env vars assumed empty (or mocked)
mock_db.query.return_value.filter.return_value.first.return_value = None
result = await playlist_service.ensure_playlists_exist("user123")
assert result["six_hour_id"] == "new_playlist_id"
assert result["daily_id"] == "new_playlist_id"
assert mock_spotify.create_playlist.call_count == 2
# Verify persistence call
assert mock_db.add.call_count == 2 # Once for each
assert mock_db.commit.call_count == 2
@pytest.mark.asyncio
async def test_ensure_playlists_exist_loads_from_db(
playlist_service, mock_db, mock_spotify
):
# Setup: DB has configs
mock_six = PlaylistConfig(key="six_hour", spotify_id="db_six_id")
mock_daily = PlaylistConfig(key="daily", spotify_id="db_daily_id")
# Mock return values for separate queries
# This is tricky with MagicMock chains.
# Simpler approach: Assuming the service calls query(PlaylistConfig).filter(...)
# We can just check the result logic without complex DB mocking if we abstract the DB access.
# But let's try to mock the specific return values based on call order if possible.
mock_query = mock_db.query.return_value
mock_filter = mock_query.filter
# Configure filter().first() to return mock_six then mock_daily
# But ensure_playlists_exist calls filter twice.
# mock_filter.return_value is the same object.
# mock_filter.return_value.first.side_effect = [mock_six, mock_daily]
# This assumes sequential execution order which is fragile but works for unit test.
# IMPORTANT: Ensure filter side_effect is cleared if set previously
mock_filter.side_effect = None
mock_filter.return_value.first.side_effect = [mock_six, mock_daily]
result = await playlist_service.ensure_playlists_exist("user123")
assert result["six_hour_id"] == "db_six_id"
assert result["daily_id"] == "db_daily_id"
mock_spotify.create_playlist.assert_not_called()
def test_optimize_playlist_flow(playlist_service):
tracks = [
{"id": "1", "energy": 0.8}, # High
{"id": "2", "energy": 0.2}, # Low
{"id": "3", "energy": 0.5}, # Medium
{"id": "4", "energy": 0.9}, # High
{"id": "5", "energy": 0.3}, # Low
]
# Expected sort: Low, Low, Medium, High, High
# Then split:
# Sorted: 2(0.2), 5(0.3), 3(0.5), 1(0.8), 4(0.9)
# Len 5.
# Low end: 5 * 0.3 = 1.5 -> 1. (Index 1) -> [2]
# High start: 5 * 0.7 = 3.5 -> 3. (Index 3) -> [1, 4]
# Medium: [5, 3]
# Result: Low + High + Medium = [2] + [1, 4] + [5, 3]
# Order: 2, 1, 4, 5, 3
# Energies: 0.2, 0.8, 0.9, 0.3, 0.5
optimized = playlist_service._optimize_playlist_flow(tracks)
ids = [t["id"] for t in optimized]
# Check if High energy tracks are in the middle/early part (Ramp Up)
# The current logic is Low -> High -> Medium.
# So we expect High energy block (1, 4) to be in the middle?
# Wait, code was: low_energy + high_energy + medium_energy
assert ids == ["2", "1", "4", "5", "3"]
assert optimized[0]["energy"] == 0.2
assert optimized[1]["energy"] == 0.8

View File

@@ -0,0 +1,69 @@
import unittest
from datetime import datetime, timedelta
from unittest.mock import MagicMock
from app.services.stats_service import StatsService
from app.models import PlayHistory, Track, Artist
class TestStatsService(unittest.TestCase):
def setUp(self):
self.mock_db = MagicMock()
self.service = StatsService(self.mock_db)
def test_compute_volume_stats_empty(self):
# Mock empty query result
self.mock_db.query.return_value.filter.return_value.all.return_value = []
start = datetime.utcnow()
end = datetime.utcnow()
stats = self.service.compute_volume_stats(start, end)
self.assertEqual(stats["total_plays"], 0)
self.assertEqual(stats["unique_tracks"], 0)
def test_compute_session_stats(self):
# Create dummy plays
t1 = datetime(2023, 1, 1, 10, 0, 0)
t2 = datetime(2023, 1, 1, 10, 5, 0) # 5 min gap (same session)
t3 = datetime(2023, 1, 1, 12, 0, 0) # 1h 55m gap (new session)
plays = [
PlayHistory(played_at=t1, track_id="1"),
PlayHistory(played_at=t2, track_id="2"),
PlayHistory(played_at=t3, track_id="3"),
]
# Mock the query chain
# service.db.query().filter().order_by().all()
query_mock = self.mock_db.query.return_value.filter.return_value.order_by.return_value
query_mock.all.return_value = plays
stats = self.service.compute_session_stats(datetime.utcnow(), datetime.utcnow())
# Expected: 2 sessions ([t1, t2], [t3])
self.assertEqual(stats["count"], 2)
# Avg tracks: 3 plays / 2 sessions = 1.5
self.assertEqual(stats["avg_tracks"], 1.5)
def test_compute_skip_stats(self):
# Track duration = 30s
track = Track(id="t1", duration_ms=30000)
# Play 1: 10:00:00
# Play 2: 10:00:10 (Diff 10s. Duration 30s. 10 < 20 (30-10) -> Skip)
p1 = PlayHistory(played_at=datetime(2023, 1, 1, 10, 0, 0), track_id="t1")
p2 = PlayHistory(played_at=datetime(2023, 1, 1, 10, 0, 10), track_id="t1")
plays = [p1, p2]
query_mock = self.mock_db.query.return_value.filter.return_value.order_by.return_value
query_mock.all.return_value = plays
# Mock track lookup
self.mock_db.query.return_value.filter.return_value.all.return_value = [track]
stats = self.service.compute_skip_stats(datetime.utcnow(), datetime.utcnow())
self.assertEqual(stats["total_skips"], 1)
if __name__ == '__main__':
unittest.main()

View File

View File

@@ -0,0 +1,67 @@
# MusicAnalyser Docker Compose Template
# Copy this file to docker-compose.yml and fill in your values
# Or use environment variables / .env file
version: '3.8'
services:
backend:
build:
context: ./backend
image: ghcr.io/bnair123/musicanalyser:latest
container_name: music-analyser-backend
restart: unless-stopped
volumes:
- music_data:/app/data
environment:
- DATABASE_URL=sqlite:////app/data/music.db
# Required: Spotify API credentials
- SPOTIFY_CLIENT_ID=your_spotify_client_id_here
- SPOTIFY_CLIENT_SECRET=your_spotify_client_secret_here
- SPOTIFY_REFRESH_TOKEN=your_spotify_refresh_token_here
# Required: AI API key (choose one)
- OPENAI_API_KEY=your_openai_api_key_here
# OR
- 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:
- dockernet
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/snapshots?limit=1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
frontend:
build:
context: ./frontend
image: ghcr.io/bnair123/musicanalyser-frontend:latest
container_name: music-analyser-frontend
restart: unless-stopped
ports:
- '8991:80'
networks:
- dockernet
depends_on:
backend:
condition: service_healthy
volumes:
music_data:
driver: local
networks:
dockernet:
external: true
# If you don't have an external dockernet, create it with:
# docker network create dockernet
# Or change to:
# dockernet:
# driver: bridge

41
docker-compose.yml Normal file
View File

@@ -0,0 +1,41 @@
version: '3.8'
services:
backend:
build:
context: ./backend
image: ghcr.io/bnair123/musicanalyser:latest
container_name: music-analyser-backend
restart: unless-stopped
env_file:
- .env
environment:
- DATABASE_URL=postgresql://bnair:Bharath2002@music_db:5432/music_db
ports:
- '8088:8000'
networks:
- dockernet
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/snapshots?limit=1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
frontend:
build:
context: ./frontend
image: ghcr.io/bnair123/musicanalyser-frontend:latest
container_name: music-analyser-frontend
restart: unless-stopped
ports:
- '8991:80'
networks:
- dockernet
depends_on:
backend:
condition: service_healthy
networks:
dockernet:
external: true

125
docs/API.md Normal file
View File

@@ -0,0 +1,125 @@
# API Documentation
The MusicAnalyser Backend is built with FastAPI. It provides endpoints for data ingestion, listening history retrieval, and AI-powered analysis.
## Base URL
Default local development: `http://localhost:8000`
Docker environment: Proxied via Nginx at `http://localhost:8991/api`
---
## Endpoints
### 1. Root / Health Check
- **URL**: `/`
- **Method**: `GET`
- **Response**:
```json
{
"status": "ok",
"message": "Music Analyser API is running"
}
```
### 2. Get Recent History
Returns a flat list of recently played tracks.
- **URL**: `/history`
- **Method**: `GET`
- **Query Parameters**:
- `limit` (int, default=50): Number of items to return.
- **Response**: List of PlayHistory objects with nested Track data.
### 3. Get Tracks
Returns a list of unique tracks in the database.
- **URL**: `/tracks`
- **Method**: `GET`
- **Query Parameters**:
- `limit` (int, default=50): Number of tracks to return.
### 4. Trigger Spotify Ingestion
Manually triggers a background task to poll Spotify for recently played tracks.
- **URL**: `/trigger-ingest`
- **Method**: `POST`
- **Response**:
```json
{
"status": "Ingestion started in background"
}
```
### 5. Trigger Analysis Pipeline
Runs the full stats calculation and AI narrative generation for a specific timeframe.
- **URL**: `/trigger-analysis`
- **Method**: `POST`
- **Query Parameters**:
- `days` (int, default=30): Number of past days to analyze.
- `model_name` (str): LLM model to use.
- **Response**:
```json
{
"status": "success",
"snapshot_id": 1,
"period": { "start": "...", "end": "..." },
"metrics": { ... },
"narrative": { ... }
}
```
### 6. Get Analysis Snapshots
Retrieves previously saved analysis reports.
- **URL**: `/snapshots`
- **Method**: `GET`
- **Query Parameters**:
- `limit` (int, default=10): Number of snapshots to return.
### 7. Detailed Listening Log
Returns a refined listening log with skip detection and listening duration calculations.
- **URL**: `/listening-log`
- **Method**: `GET`
- **Query Parameters**:
- `days` (int, 1-365, default=7): Timeframe.
- `limit` (int, 1-1000, default=200): Max plays to return.
- **Response**:
```json
{
"plays": [
{
"id": 123,
"track_name": "Song Name",
"artist": "Artist Name",
"played_at": "ISO-TIMESTAMP",
"listened_ms": 180000,
"skipped": false,
"image": "..."
}
],
"period": { "start": "...", "end": "..." }
}
```
### 8. Session Statistics
Groups plays into listening sessions (Marathon, Standard, Micro).
- **URL**: `/sessions`
- **Method**: `GET`
- **Query Parameters**:
- `days` (int, 1-365, default=7): Timeframe.
- **Response**:
```json
{
"sessions": [
{
"start_time": "...",
"end_time": "...",
"duration_minutes": 45,
"track_count": 12,
"type": "Standard"
}
],
"summary": {
"count": 10,
"avg_minutes": 35,
"micro_rate": 0.1,
"marathon_rate": 0.05
}
}
```

43
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,43 @@
# Architecture Overview
MusicAnalyser is a full-stack personal analytics platform designed to collect, store, and analyze music listening habits using the Spotify API and Google Gemini AI.
## System Components
### 1. Backend (FastAPI)
- **API Layer**: Handles requests from the frontend, manages the database, and triggers analysis.
- **Database**: SQLite used for local storage of listening history, track metadata, and AI snapshots.
- **ORM**: SQLAlchemy manages the data models and relationships.
- **Services**:
- `SpotifyClient`: Handles OAuth2 flow and API requests.
- `StatsService`: Computes complex metrics (heatmaps, sessions, top tracks, hipster scores).
- `NarrativeService`: Interfaces with Google Gemini to generate text-based insights.
- `IngestService`: Manages the logic of fetching and deduplicating Spotify "recently played" data.
### 2. Background Worker
- A standalone Python script (`run_worker.py`) that polls the Spotify API every 60 seconds.
- Ensures a continuous record of listening history even when the dashboard is not open.
### 3. Frontend (React)
- **Framework**: Vite + React.
- **Styling**: Tailwind CSS for a modern, dark-themed dashboard.
- **Visualizations**: Recharts for radar and heatmaps; Framer Motion for animations.
- **State**: Managed via standard React hooks (`useState`, `useEffect`) and local storage for caching.
### 4. External Integrations
- **Spotify API**: Primary data source for tracks, artists, and listening history.
- **ReccoBeats API**: Used for fetching audio features (BPM, Energy, Mood) for tracks.
- **Genius API**: Used for fetching song lyrics to provide deep content analysis.
- **Google Gemini**: Large Language Model used to "roast" the user's taste and generate personas.
## Data Flow
1. **Ingestion**: `Background Worker``Spotify API``Database (PlayHistory)`.
2. **Enrichment**: `Ingest Logic``ReccoBeats/Genius/Spotify``Database (Track/Artist)`.
3. **Analysis**: `Frontend``Backend API``StatsService``NarrativeService (Gemini)``Database (Snapshot)`.
4. **Visualization**: `Frontend``Backend API``Database (Snapshot/Log)`.
## Deployment
- **Containerization**: Both Backend and Frontend are containerized using Docker.
- **Docker Compose**: Orchestrates the backend (including worker) and frontend (Nginx proxy) services.
- **CI/CD**: GitHub Actions builds multi-arch images (amd64/arm64) and pushes to GHCR.

271
docs/DATABASE.md Normal file
View File

@@ -0,0 +1,271 @@
# Database Documentation
## PostgreSQL Connection Details
| Property | Value |
|----------|-------|
| Host | `100.91.248.114` |
| Port | `5433` |
| User | `bnair` |
| Password | `Bharath2002` |
| Database | `music_db` |
| Data Location (on server) | `/opt/DB/MusicDB/pgdata` |
### Connection String
```
postgresql://bnair:Bharath2002@100.91.248.114:5433/music_db
```
## Schema Overview
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ artists │ │ track_artists │ │ tracks │
├─────────────────┤ ├──────────────────┤ ├─────────────────┤
│ id (PK) │◄────┤ artist_id (FK) │ │ id (PK) │
│ name │ │ track_id (FK) │────►│ reccobeats_id │
│ genres (JSON) │ └──────────────────┘ │ name │
│ image_url │ │ artist │
└─────────────────┘ │ album │
│ image_url │
│ duration_ms │
│ popularity │
│ raw_data (JSON) │
│ danceability │
│ energy │
│ key │
│ ... (audio) │
│ genres (JSON) │
│ lyrics │
│ created_at │
│ updated_at │
└─────────────────┘
┌─────────────────────┐ ┌─────────────────┐
│ analysis_snapshots │ │ play_history │
├─────────────────────┤ ├─────────────────┤
│ id (PK) │ │ id (PK) │
│ date │ │ track_id (FK) │
│ period_start │ │ played_at │
│ period_end │ │ context_uri │
│ period_label │ │ listened_ms │
│ metrics_payload │ │ skipped │
│ narrative_report │ │ source │
│ model_used │ └─────────────────┘
│ playlist_theme │
│ ... (playlist) │
│ playlist_composition│
└─────────────────────┘
┌─────────────────────┐
│ playlist_config │
├─────────────────────┤
│ key (PK) │
│ spotify_id │
│ last_updated │
│ current_theme │
│ description │
│ composition (JSON) │
└─────────────────────┘
```
## Tables
### `tracks`
Central entity storing Spotify track metadata and enriched audio features.
| Column | Type | Description |
|--------|------|-------------|
| `id` | VARCHAR | Spotify track ID (primary key) |
| `reccobeats_id` | VARCHAR | ReccoBeats UUID for audio features |
| `name` | VARCHAR | Track title |
| `artist` | VARCHAR | Display artist string (e.g., "Drake, Future") |
| `album` | VARCHAR | Album name |
| `image_url` | VARCHAR | Album art URL |
| `duration_ms` | INTEGER | Track duration in milliseconds |
| `popularity` | INTEGER | Spotify popularity score (0-100) |
| `raw_data` | JSON | Full Spotify API response |
| `danceability` | FLOAT | Audio feature (0.0-1.0) |
| `energy` | FLOAT | Audio feature (0.0-1.0) |
| `key` | INTEGER | Musical key (0-11) |
| `loudness` | FLOAT | Audio feature (dB) |
| `mode` | INTEGER | Major (1) or minor (0) |
| `speechiness` | FLOAT | Audio feature (0.0-1.0) |
| `acousticness` | FLOAT | Audio feature (0.0-1.0) |
| `instrumentalness` | FLOAT | Audio feature (0.0-1.0) |
| `liveness` | FLOAT | Audio feature (0.0-1.0) |
| `valence` | FLOAT | Audio feature (0.0-1.0) |
| `tempo` | FLOAT | BPM |
| `time_signature` | INTEGER | Beats per bar |
| `genres` | JSON | Genre tags (deprecated, use Artist.genres) |
| `lyrics` | TEXT | Full lyrics from Genius |
| `lyrics_summary` | VARCHAR | AI-generated summary |
| `genre_tags` | VARCHAR | AI-generated tags |
| `created_at` | TIMESTAMP | Record creation time |
| `updated_at` | TIMESTAMP | Last update time |
### `artists`
Artist entities with genre information.
| Column | Type | Description |
|--------|------|-------------|
| `id` | VARCHAR | Spotify artist ID (primary key) |
| `name` | VARCHAR | Artist name |
| `genres` | JSON | List of genre strings |
| `image_url` | VARCHAR | Artist profile image URL |
### `track_artists`
Many-to-many relationship between tracks and artists.
| Column | Type | Description |
|--------|------|-------------|
| `track_id` | VARCHAR | Foreign key to tracks.id |
| `artist_id` | VARCHAR | Foreign key to artists.id |
### `play_history`
Immutable log of listening events.
| Column | Type | Description |
|--------|------|-------------|
| `id` | INTEGER | Auto-increment primary key |
| `track_id` | VARCHAR | Foreign key to tracks.id |
| `played_at` | TIMESTAMP | When the track was played |
| `context_uri` | VARCHAR | Spotify context (playlist, album, etc.) |
| `listened_ms` | INTEGER | Duration actually listened |
| `skipped` | BOOLEAN | Whether track was skipped |
| `source` | VARCHAR | Source of the play event |
### `analysis_snapshots`
Stores computed statistics and AI-generated narratives.
| Column | Type | Description |
|--------|------|-------------|
| `id` | INTEGER | Auto-increment primary key |
| `date` | TIMESTAMP | When analysis was run |
| `period_start` | TIMESTAMP | Analysis period start |
| `period_end` | TIMESTAMP | Analysis period end |
| `period_label` | VARCHAR | Label (e.g., "last_30_days") |
| `metrics_payload` | JSON | StatsService output |
| `narrative_report` | JSON | NarrativeService output |
| `model_used` | VARCHAR | LLM model name |
| `playlist_theme` | VARCHAR | AI-generated theme name |
| `playlist_theme_reasoning` | TEXT | AI explanation for theme |
| `six_hour_playlist_id` | VARCHAR | Spotify playlist ID |
| `daily_playlist_id` | VARCHAR | Spotify playlist ID |
| `playlist_composition` | JSON | Track list at snapshot time |
### `playlist_config`
Configuration for managed Spotify playlists.
| Column | Type | Description |
|--------|------|-------------|
| `key` | VARCHAR | Config key (primary key, e.g., "six_hour") |
| `spotify_id` | VARCHAR | Spotify playlist ID |
| `last_updated` | TIMESTAMP | Last update time |
| `current_theme` | VARCHAR | Current playlist theme |
| `description` | VARCHAR | Playlist description |
| `composition` | JSON | Current track list |
## Schema Modifications (Alembic)
All schema changes MUST go through Alembic migrations.
### Creating a New Migration
```bash
cd backend
source venv/bin/activate
# Auto-generate migration from model changes
alembic revision --autogenerate -m "description_of_change"
# Or create empty migration for manual SQL
alembic revision -m "description_of_change"
```
### Applying Migrations
```bash
# Apply all pending migrations
alembic upgrade head
# Apply specific migration
alembic upgrade <revision_id>
# Rollback one migration
alembic downgrade -1
# Rollback to specific revision
alembic downgrade <revision_id>
```
### Migration Best Practices
1. **Test locally first** - Always test migrations on a dev database
2. **Backup before migrating** - `pg_dump -h 100.91.248.114 -p 5433 -U bnair music_db > backup.sql`
3. **One change per migration** - Keep migrations atomic
4. **Include rollback logic** - Implement `downgrade()` function
5. **Review autogenerated migrations** - They may miss nuances
### Example Migration
```python
# alembic/versions/xxxx_add_new_column.py
from alembic import op
import sqlalchemy as sa
revision = 'xxxx'
down_revision = 'yyyy'
def upgrade():
op.add_column('tracks', sa.Column('new_column', sa.String(), nullable=True))
def downgrade():
op.drop_column('tracks', 'new_column')
```
## Direct Database Access
### Using psql
```bash
psql -h 100.91.248.114 -p 5433 -U bnair -d music_db
```
### Using Python
```python
import psycopg2
conn = psycopg2.connect(
host='100.91.248.114',
port=5433,
user='bnair',
password='Bharath2002',
dbname='music_db'
)
```
### Common Queries
```sql
-- Recent plays
SELECT t.name, t.artist, ph.played_at
FROM play_history ph
JOIN tracks t ON ph.track_id = t.id
ORDER BY ph.played_at DESC
LIMIT 10;
-- Top tracks by play count
SELECT t.name, t.artist, COUNT(*) as plays
FROM play_history ph
JOIN tracks t ON ph.track_id = t.id
GROUP BY t.id, t.name, t.artist
ORDER BY plays DESC
LIMIT 10;
-- Genre distribution
SELECT genre, COUNT(*)
FROM artists, jsonb_array_elements_text(genres::jsonb) AS genre
GROUP BY genre
ORDER BY count DESC;
```

89
docs/DATA_MODEL.md Normal file
View File

@@ -0,0 +1,89 @@
# Data Model Documentation
This document describes the database schema for the MusicAnalyser project. The project uses SQLite with SQLAlchemy as the ORM.
## Entity Relationship Diagram Overview
- **Artist** (Many-to-Many) **Track**
- **Track** (One-to-Many) **PlayHistory**
- **AnalysisSnapshot** (Independent)
---
## Tables
### `artists`
Stores unique artists retrieved from Spotify.
| Field | Type | Description |
|-------|------|-------------|
| `id` | String | Spotify ID (Primary Key) |
| `name` | String | Artist name |
| `genres` | JSON | List of genre strings |
| `image_url` | String | URL to artist profile image |
### `tracks`
Stores unique tracks retrieved from Spotify, enriched with audio features and lyrics.
| Field | Type | Description |
|-------|------|-------------|
| `id` | String | Spotify ID (Primary Key) |
| `name` | String | Track name |
| `artist` | String | Display string for artists (e.g., "Artist A, Artist B") |
| `album` | String | Album name |
| `image_url` | String | URL to album art |
| `duration_ms` | Integer | Track duration in milliseconds |
| `popularity` | Integer | Spotify popularity score (0-100) |
| `raw_data` | JSON | Full raw response from Spotify API for future-proofing |
| `danceability` | Float | Audio feature: Danceability (0.0 to 1.0) |
| `energy` | Float | Audio feature: Energy (0.0 to 1.0) |
| `key` | Integer | Audio feature: Key |
| `loudness` | Float | Audio feature: Loudness in dB |
| `mode` | Integer | Audio feature: Mode (0 for Minor, 1 for Major) |
| `speechiness` | Float | Audio feature: Speechiness (0.0 to 1.0) |
| `acousticness` | Float | Audio feature: Acousticness (0.0 to 1.0) |
| `instrumentalness` | Float | Audio feature: Instrumentalness (0.0 to 1.0) |
| `liveness` | Float | Audio feature: Liveness (0.0 to 1.0) |
| `valence` | Float | Audio feature: Valence (0.0 to 1.0) |
| `tempo` | Float | Audio feature: Tempo in BPM |
| `time_signature` | Integer | Audio feature: Time signature |
| `lyrics` | Text | Full lyrics retrieved from Genius |
| `lyrics_summary` | String | AI-generated summary of lyrics |
| `genre_tags` | String | Combined genre tags for the track |
| `created_at` | DateTime | Timestamp of record creation |
| `updated_at` | DateTime | Timestamp of last update |
### `play_history`
Stores individual listening instances.
| Field | Type | Description |
|-------|------|-------------|
| `id` | Integer | Primary Key (Auto-increment) |
| `track_id` | String | Foreign Key to `tracks.id` |
| `played_at` | DateTime | Timestamp when the track was played |
| `context_uri` | String | Spotify context URI (e.g., playlist or album URI) |
| `listened_ms` | Integer | Computed duration the track was actually heard |
| `skipped` | Boolean | Whether the track was likely skipped |
| `source` | String | Ingestion source (e.g., "spotify_recently_played") |
### `analysis_snapshots`
Stores periodic analysis results generated by the AI service.
| Field | Type | Description |
|-------|------|-------------|
| `id` | Integer | Primary Key |
| `date` | DateTime | When the analysis was performed |
| `period_start` | DateTime | Start of the analyzed period |
| `period_end` | DateTime | End of the analyzed period |
| `period_label` | String | Label for the period (e.g., "last_30_days") |
| `metrics_payload` | JSON | Computed statistics used as input for the AI |
| `narrative_report` | JSON | AI-generated narrative and persona |
| `model_used` | String | LLM model identifier (e.g., "gemini-1.5-flash") |
### `track_artists` (Association Table)
Facilitates the many-to-many relationship between tracks and artists.
| Field | Type | Description |
|-------|------|-------------|
| `track_id` | String | Foreign Key to `tracks.id` |
| `artist_id` | String | Foreign Key to `artists.id` |

61
docs/FRONTEND.md Normal file
View File

@@ -0,0 +1,61 @@
# Frontend Documentation
The frontend is a React application built with Vite and Tailwind CSS. It uses Ant Design for some UI components and Recharts for data visualization.
## Main Components
### `Dashboard.jsx`
The primary layout component that manages data fetching and state.
- **Features**:
- Handles API calls to `/snapshots` and `/trigger-analysis`.
- Implements local storage caching to reduce API load.
- Displays a global loading state during analysis.
- Contains the main header with a refresh trigger.
### `NarrativeSection.jsx`
Displays the AI-generated qualitative analysis.
- **Props**:
- `narrative`: Object containing `persona`, `vibe_check_short`, and `roast`.
- `vibe`: Object containing audio features used to generate dynamic tags.
- **Purpose**: Gives the user a "identity" based on their music taste (e.g., "THE MELANCHOLIC ARCHITECT").
### `StatsGrid.jsx`
A grid of high-level metric cards.
- **Props**:
- `metrics`: The `metrics_payload` from a snapshot.
- **Displays**:
- **Minutes Listened**: Total listening time converted to days.
- **Obsession**: The #1 most played track with album art background.
- **Unique Artists**: Count of different artists encountered.
- **Hipster Score**: A percentage indicating how obscure the user's taste is.
### `VibeRadar.jsx`
Visualizes the "Sonic DNA" of the user.
- **Props**:
- `vibe`: Audio feature averages (acousticness, danceability, energy, etc.).
- **Visuals**:
- **Radar Chart**: Shows the balance of audio features.
- **Mood Clusters**: Floating bubbles representing "Party", "Focus", and "Chill" percentages.
- **Whiplash Meter**: Shows volatility in tempo, energy, and valence between consecutive tracks.
### `TopRotation.jsx`
A horizontal scrolling list of the most played tracks.
- **Props**:
- `volume`: Object containing `top_tracks` array.
- **Purpose**: Quick view of recent favorites.
### `HeatMap.jsx`
Visualizes when the user listens to music.
- **Props**:
- `timeHabits`: Compressed heatmap data (7x6 grid for days/time blocks).
- `sessions`: List of recent listening sessions.
- **Visuals**:
- **Grid**: Days of the week vs. Time blocks (12am, 4am, etc.).
- **Session Timeline**: Vertical list of recent listening bouts with session type (Marathon vs. Micro).
### `ListeningLog.jsx`
A detailed view of individual plays.
- **Features**:
- **Timeline View**: Visualizes listening sessions across the day for the last 7 days.
- **List View**: A table of individual plays with skip status detection.
- **Timeframe Filter**: Toggle between 24h, 7d, 14d, and 30d views.

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
# Stage 1: Build the React app
FROM node:18 as build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
# Stage 2: Serve with Nginx
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

16
frontend/README.md Normal file
View File

@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

29
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

16
frontend/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SonicStats - Your Vibe Report</title>
<!-- Google Fonts & Icons -->
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
</head>
<body class="bg-background-light dark:bg-background-dark text-slate-900 dark:text-white font-display overflow-x-hidden min-h-screen flex flex-col">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

22
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,22 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/ {
# 'backend' is the service name in docker-compose
# We strip the /api/ prefix if the backend doesn't expect it,
# but in this setup the backend routes are /history, /tracks etc.
# It's cleaner to keep /api prefix in frontend and rewrite here or configure backend to serve on /api
# For simplicity, let's proxy /api/ to /
rewrite ^/api/(.*) /$1 break;
proxy_pass http://backend:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}

5508
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
frontend/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.1.0",
"antd": "^6.1.2",
"axios": "^1.13.2",
"date-fns": "^4.1.0",
"framer-motion": "^12.23.26",
"lucide-react": "^0.562.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.11.0",
"recharts": "^3.6.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"vite": "^7.2.4"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

17
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Dashboard from './components/Dashboard';
import Archives from './components/Archives';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/archives" element={<Archives />} />
</Routes>
</BrowserRouter>
);
}
export default App;

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -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.

View File

@@ -0,0 +1,209 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { Card, Typography, Spin, Drawer, Empty, Tag, Button } from 'antd';
import { CalendarOutlined, RightOutlined, HistoryOutlined, RobotOutlined } from '@ant-design/icons';
import NarrativeSection from './NarrativeSection';
import StatsGrid from './StatsGrid';
import TrackList from './TrackList';
import Navbar from './Navbar';
const { Title, Text } = Typography;
const Archives = () => {
const [snapshots, setSnapshots] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedSnapshot, setSelectedSnapshot] = useState(null);
const [drawerVisible, setDrawerVisible] = useState(false);
const fetchSnapshots = async () => {
setLoading(true);
try {
const response = await axios.get('/api/snapshots');
setSnapshots(response.data);
} catch (error) {
console.error('Failed to fetch snapshots:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSnapshots();
}, []);
const handleSnapshotClick = (snapshot) => {
setSelectedSnapshot(snapshot);
setDrawerVisible(true);
};
const closeDrawer = () => {
setDrawerVisible(false);
setSelectedSnapshot(null);
};
// Helper to safely parse JSON if it comes as string (though axios usually handles it)
const safeParse = (data) => {
if (typeof data === 'string') {
try {
return JSON.parse(data);
} catch (e) {
return null;
}
}
return data;
};
return (
<>
<Navbar showRefresh={false} />
<main className="w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
<div className="flex items-center justify-between">
<Title level={2} className="text-white !mb-0 flex items-center gap-3">
<HistoryOutlined className="text-primary" />
Archives
</Title>
<Text className="text-slate-400">
{snapshots.length} Snapshot{snapshots.length !== 1 && 's'} Found
</Text>
</div>
{loading ? (
<div className="flex justify-center p-12">
<Spin size="large" />
</div>
) : snapshots.length === 0 ? (
<div className="glass-panel p-12 text-center rounded-xl border border-dashed border-slate-700">
<Empty description={<span className="text-slate-400">No archives found yet.</span>} />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{snapshots.map((snap) => {
const narrative = safeParse(snap.narrative_report);
const metrics = safeParse(snap.metrics_payload);
const date = new Date(snap.created_at);
return (
<Card
key={snap.id}
hoverable
className="bg-slate-800 border-slate-700 shadow-xl transition-all duration-300 hover:border-primary/50 group"
onClick={() => handleSnapshotClick(snap)}
>
<div className="flex items-start justify-between mb-4">
<Tag icon={<CalendarOutlined />} color="blue">
{date.toLocaleDateString(undefined, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })}
</Tag>
<Text className="text-slate-500 text-xs font-mono">
{date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}
</Text>
</div>
<div className="space-y-3">
<div>
<Text className="text-slate-400 text-xs uppercase tracking-wider block mb-1">Vibe</Text>
<Title level={4} className="!mt-0 !mb-1 text-white truncate">
{metrics?.vibe || 'Unknown Vibe'}
</Title>
</div>
<div>
<Text className="text-slate-400 text-xs uppercase tracking-wider block mb-1">Musical Era</Text>
<div className="flex items-center gap-2">
<span className="text-primary font-bold">
{metrics?.era?.musical_age || 'N/A'}
</span>
</div>
</div>
<div className="pt-4 border-t border-slate-700/50 flex justify-end">
<Button type="text" className="text-slate-400 group-hover:text-primary flex items-center gap-1 pl-0">
View Details <RightOutlined className="text-xs" />
</Button>
</div>
</div>
</Card>
);
})}
</div>
)}
<Drawer
title={
<div className="flex items-center gap-3">
<CalendarOutlined className="text-primary" />
<span className="text-white">
Snapshot: {selectedSnapshot && new Date(selectedSnapshot.created_at).toLocaleDateString()}
</span>
</div>
}
placement="right"
width={800} // Wide drawer
onClose={closeDrawer}
open={drawerVisible}
className="bg-[#0f172a] text-white"
styles={{
header: { background: '#1e293b', borderBottom: '1px solid #334155' },
body: { background: '#0f172a', padding: '24px' },
mask: { backdropFilter: 'blur(4px)' }
}}
>
{selectedSnapshot && (
<div className="space-y-8">
{/* Reuse components but pass the specific snapshot data */}
<NarrativeSection
narrative={safeParse(selectedSnapshot.narrative_report)}
vibe={safeParse(selectedSnapshot.metrics_payload)?.vibe}
/>
<StatsGrid metrics={safeParse(selectedSnapshot.metrics_payload)} />
{/* Playlist Compositions if available */}
{selectedSnapshot.playlist_composition && (
<div className="space-y-6">
<div className="flex items-center gap-2 mb-4">
<HistoryOutlined className="text-blue-400 text-xl" />
<Title level={3} className="!mb-0 text-white">Archived Playlists</Title>
</div>
{/* We need to parse playlist composition if it's stored as JSON string */}
{(() => {
const playlists = safeParse(selectedSnapshot.playlist_composition);
if (!playlists) return <Text className="text-slate-500">No playlist data archived.</Text>;
return (
<div className="grid grid-cols-1 gap-6">
{playlists.six_hour && (
<Card className="bg-slate-800 border-slate-700" title={<span className="text-blue-400">Short & Sweet (6h)</span>}>
<div className="mb-4">
<Text className="text-gray-400 text-xs uppercase tracking-wider block mb-1">Theme</Text>
<Text className="text-white font-medium block">{playlists.six_hour.theme}</Text>
<Paragraph className="text-gray-400 text-sm italic mt-1">{playlists.six_hour.reasoning}</Paragraph>
</div>
<TrackList tracks={playlists.six_hour.composition} />
</Card>
)}
{playlists.daily && (
<Card className="bg-slate-800 border-slate-700" title={<span className="text-purple-400">Daily Devotion</span>}>
<div className="mb-4">
<Text className="text-gray-400 text-xs uppercase tracking-wider block mb-1">Strategy</Text>
<Text className="text-white font-medium block">Daily Mix</Text>
</div>
<TrackList tracks={playlists.daily.composition} />
</Card>
)}
</div>
);
})()}
</div>
)}
</div>
)}
</Drawer>
</main>
</>
);
};
export default Archives;

View File

@@ -0,0 +1,167 @@
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';
import ListeningLog from './ListeningLog';
import Navbar from './Navbar';
import { Spin } from 'antd';
const API_BASE_URL = '/api';
const Dashboard = () => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const getTodayKey = () => `sonicstats_v2_${new Date().toISOString().split('T')[0]}`;
const fetchData = async (forceRefresh = false) => {
setLoading(true);
const todayKey = getTodayKey();
if (!forceRefresh) {
const cached = localStorage.getItem(todayKey);
if (cached) {
console.log("Loading from cache");
setData(JSON.parse(cached));
setLoading(false);
return;
}
}
try {
let payload;
if (forceRefresh) {
const res = await axios.post(`${API_BASE_URL}/trigger-analysis?days=30`);
payload = res.data;
} else {
const snapRes = await axios.get(`${API_BASE_URL}/snapshots?limit=1`);
if (snapRes.data && snapRes.data.length > 0) {
const latest = snapRes.data[0];
payload = {
metrics: latest.metrics_payload,
narrative: latest.narrative_report
};
} else {
const res = await axios.post(`${API_BASE_URL}/trigger-analysis?days=30`);
payload = res.data;
}
}
if (payload) {
setData(payload);
localStorage.setItem(todayKey, JSON.stringify(payload));
}
} catch (error) {
console.error("Failed to fetch data", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
if (loading && !data) {
return (
<div className="min-h-screen bg-background-dark flex flex-col items-center justify-center text-white">
<Spin size="large" />
<p className="mt-4 text-slate-400 animate-pulse">Analyzing your auditory aura...</p>
</div>
);
}
const vibeCheckFull = data?.narrative?.vibe_check || "";
const patterns = data?.narrative?.patterns || [];
return (
<>
<Navbar onRefresh={() => fetchData(true)} />
<main className="flex-grow w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
<NarrativeSection narrative={data?.narrative} vibe={data?.metrics?.vibe} />
<StatsGrid metrics={data?.metrics} />
<PlaylistsSection />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-8">
<VibeRadar vibe={data?.metrics?.vibe} />
<TopRotation volume={data?.metrics?.volume} />
</div>
<div className="lg:col-span-1 space-y-8">
<HeatMap timeHabits={data?.metrics?.time_habits} sessions={data?.metrics?.sessions} />
</div>
</div>
<ListeningLog />
{(vibeCheckFull || patterns.length > 0) && (
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6">
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
<span className="material-symbols-outlined text-primary">psychology</span>
Full Analysis
</h3>
{vibeCheckFull && (
<div className="prose prose-invert max-w-none mb-6">
<p className="text-slate-300 leading-relaxed whitespace-pre-line">{vibeCheckFull}</p>
</div>
)}
{patterns.length > 0 && (
<div className="mt-4">
<h4 className="text-sm text-slate-400 uppercase font-medium mb-3">Patterns Detected</h4>
<ul className="space-y-2">
{patterns.map((pattern, idx) => (
<li key={idx} className="flex items-start gap-2 text-slate-300">
<span className="material-symbols-outlined text-primary text-sm mt-0.5">insights</span>
{pattern}
</li>
))}
</ul>
</div>
)}
</div>
)}
{data?.narrative?.roast && (
<footer className="pb-8">
<div className="paper-texture rounded-xl p-8 border border-white/10 relative overflow-hidden group">
<div className="relative z-10 flex flex-col md:flex-row gap-8 items-start md:items-center justify-between">
<div className="max-w-md">
<div className="text-xs font-mono text-gray-500 mb-2 uppercase tracking-widest">Analysis Complete</div>
<h2 className="text-3xl font-black text-gray-900 leading-tight">
Your Musical Age is <span className="text-primary underline decoration-wavy">{data?.metrics?.era?.musical_age || "Unknown"}</span>.
</h2>
<p className="text-gray-600 mt-2 font-medium">
{data?.narrative?.era_insight || "You are timeless."}
</p>
</div>
<div className="flex-1 bg-white p-6 rounded-lg shadow-sm border border-gray-200 transform rotate-1 md:group-hover:rotate-0 transition-transform duration-300">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-gray-400">smart_toy</span>
<div>
<p className="font-mono text-sm text-gray-800 leading-relaxed">
"{data.narrative.roast}"
</p>
</div>
</div>
</div>
</div>
</div>
</footer>
)}
</main>
</>
);
};
export default Dashboard;

View File

@@ -0,0 +1,117 @@
import React from 'react';
import { format, parseISO } from 'date-fns';
const HeatMap = ({ timeHabits, sessions }) => {
if (!timeHabits) return null;
const heatmapCompressed = timeHabits.heatmap_compressed || timeHabits.heatmap || [];
const blockLabels = timeHabits.block_labels || ["12am-4am", "4am-8am", "8am-12pm", "12pm-4pm", "4pm-8pm", "8pm-12am"];
const sessionList = sessions?.session_list || [];
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const blocks = blockLabels.length > 0 ? blockLabels : Array.from({ length: 6 }, (_, i) => `${i*4}h-${(i+1)*4}h`);
const maxVal = Math.max(...heatmapCompressed.flat(), 1);
const getIntensityClass = (val) => {
if (val === 0) return "bg-[#1e293b]";
const ratio = val / maxVal;
if (ratio > 0.8) return "bg-primary";
if (ratio > 0.6) return "bg-primary/80";
if (ratio > 0.4) return "bg-primary/60";
if (ratio > 0.2) return "bg-primary/40";
return "bg-primary/20";
};
const recentSessions = sessionList.slice(-5).reverse();
const formatSessionTime = (isoString) => {
try {
return format(parseISO(isoString), 'MMM d, h:mm a');
} catch {
return isoString;
}
};
const getSessionTypeColor = (type) => {
if (type === "Marathon") return "bg-primary";
if (type === "Micro") return "bg-slate-600";
return "bg-primary/50";
};
return (
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6 h-full">
<h3 className="text-xl font-bold text-white mb-6 flex items-center gap-2">
<span className="material-symbols-outlined text-primary">history</span>
Chronobiology
</h3>
<div className="mb-8">
<h4 className="text-sm text-slate-400 mb-3 font-medium">Listening Heatmap</h4>
<div className="flex gap-2">
<div className="flex flex-col justify-between text-[10px] text-slate-500 pr-1 py-1">
{blocks.map((label, i) => (
<span key={i} className="leading-tight">{label.split('-')[0]}</span>
))}
</div>
<div className="flex-1">
<div className="grid grid-cols-7 gap-1 mb-2">
{days.map((d, i) => (
<div key={i} className="text-[10px] text-center text-slate-500 font-medium">{d}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{blocks.map((block, blockIdx) => (
days.map((day, dayIdx) => {
const val = heatmapCompressed[dayIdx]?.[blockIdx] || 0;
return (
<div
key={`${dayIdx}-${blockIdx}`}
className={`h-6 rounded ${getIntensityClass(val)} transition-colors hover:ring-2 hover:ring-primary/50`}
title={`${day} ${block} - ${val} plays`}
/>
);
})
)).flat()}
</div>
</div>
</div>
<div className="flex justify-end gap-1 mt-3 items-center">
<span className="text-[9px] text-slate-500">Less</span>
<div className="w-3 h-3 rounded-sm bg-primary/20"></div>
<div className="w-3 h-3 rounded-sm bg-primary/40"></div>
<div className="w-3 h-3 rounded-sm bg-primary/60"></div>
<div className="w-3 h-3 rounded-sm bg-primary/80"></div>
<div className="w-3 h-3 rounded-sm bg-primary"></div>
<span className="text-[9px] text-slate-500">More</span>
</div>
</div>
<div>
<h4 className="text-sm text-slate-400 mb-4 font-medium">Recent Sessions</h4>
{recentSessions.length > 0 ? (
<div className="relative pl-4 border-l border-[#334155] space-y-4">
{recentSessions.map((session, idx) => (
<div key={idx} className="relative">
<span className={`absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full ${getSessionTypeColor(session.type)} ring-4 ring-card-dark`}></span>
<p className="text-xs text-slate-400">{formatSessionTime(session.start_time)}</p>
<p className="text-white font-bold text-sm">{session.type} Session</p>
<p className="text-xs text-primary mt-0.5">
{session.duration_minutes}m · {session.track_count} tracks
</p>
</div>
))}
</div>
) : (
<p className="text-sm text-slate-500">No session data yet</p>
)}
</div>
</div>
);
};
export default HeatMap;

View File

@@ -0,0 +1,202 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { format, parseISO, differenceInMinutes, startOfDay, endOfDay } from 'date-fns';
const API_BASE_URL = '/api';
const ListeningLog = () => {
const [plays, setPlays] = useState([]);
const [sessions, setSessions] = useState([]);
const [days, setDays] = useState(7);
const [loading, setLoading] = useState(true);
const [view, setView] = useState('timeline');
useEffect(() => {
fetchData();
}, [days]);
const fetchData = async () => {
setLoading(true);
try {
const [logRes, sessRes] = await Promise.all([
axios.get(`${API_BASE_URL}/listening-log?days=${days}&limit=500`),
axios.get(`${API_BASE_URL}/sessions?days=${days}`)
]);
setPlays(logRes.data.plays || []);
setSessions(sessRes.data.sessions || []);
} catch (error) {
console.error("Failed to fetch listening log", error);
} finally {
setLoading(false);
}
};
const formatTime = (isoString) => {
try {
return format(parseISO(isoString), 'MMM d, h:mm a');
} catch {
return isoString;
}
};
const formatDuration = (ms) => {
if (!ms) return '-';
const mins = Math.round(ms / 60000);
return `${mins}m`;
};
const groupSessionsByDay = () => {
const dayMap = {};
sessions.forEach(session => {
const dayKey = format(parseISO(session.start_time), 'yyyy-MM-dd');
if (!dayMap[dayKey]) dayMap[dayKey] = [];
dayMap[dayKey].push(session);
});
return dayMap;
};
const sessionsByDay = groupSessionsByDay();
const sortedDays = Object.keys(sessionsByDay).sort().reverse().slice(0, 7);
const getSessionPosition = (session) => {
const start = parseISO(session.start_time);
const startMinutes = start.getHours() * 60 + start.getMinutes();
const leftPct = (startMinutes / 1440) * 100;
const widthPct = Math.max((session.duration_minutes / 1440) * 100, 1);
return { left: `${leftPct}%`, width: `${Math.min(widthPct, 100 - leftPct)}%` };
};
const getSessionColor = (type) => {
if (type === 'Marathon') return 'bg-primary';
if (type === 'Micro') return 'bg-slate-500';
return 'bg-primary/70';
};
return (
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-white flex items-center gap-2">
<span className="material-symbols-outlined text-primary">library_music</span>
Listening Log
</h3>
<div className="flex gap-2">
<select
value={days}
onChange={(e) => setDays(Number(e.target.value))}
className="bg-card-darker border border-[#334155] rounded px-3 py-1 text-sm text-white"
>
<option value={1}>Last 24h</option>
<option value={7}>Last 7 days</option>
<option value={14}>Last 14 days</option>
<option value={30}>Last 30 days</option>
</select>
<div className="flex border border-[#334155] rounded overflow-hidden">
<button
onClick={() => setView('timeline')}
className={`px-3 py-1 text-sm ${view === 'timeline' ? 'bg-primary text-white' : 'bg-card-darker text-slate-400'}`}
>
Timeline
</button>
<button
onClick={() => setView('list')}
className={`px-3 py-1 text-sm ${view === 'list' ? 'bg-primary text-white' : 'bg-card-darker text-slate-400'}`}
>
List
</button>
</div>
</div>
</div>
{loading ? (
<div className="text-slate-400 text-center py-8">Loading...</div>
) : view === 'timeline' ? (
<div className="space-y-4">
<div className="flex text-[10px] text-slate-500 mb-2">
<div className="w-20"></div>
<div className="flex-1 flex justify-between">
<span>12am</span>
<span>6am</span>
<span>12pm</span>
<span>6pm</span>
<span>12am</span>
</div>
</div>
{sortedDays.map(day => (
<div key={day} className="flex items-center gap-2">
<div className="w-20 text-xs text-slate-400 shrink-0">
{format(parseISO(day), 'EEE, MMM d')}
</div>
<div className="flex-1 h-8 bg-card-darker rounded relative">
{sessionsByDay[day]?.map((session, idx) => {
const pos = getSessionPosition(session);
return (
<div
key={idx}
className={`absolute h-full rounded ${getSessionColor(session.type)} opacity-80 hover:opacity-100 cursor-pointer transition-opacity`}
style={{ left: pos.left, width: pos.width }}
title={`${session.type}: ${session.track_count} tracks, ${session.duration_minutes}m`}
/>
);
})}
</div>
</div>
))}
<div className="flex gap-4 mt-4 text-xs text-slate-400">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-primary"></div>
<span>Marathon (20+ tracks)</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-primary/70"></div>
<span>Standard</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-slate-500"></div>
<span>Micro (1-3 tracks)</span>
</div>
</div>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-slate-400 border-b border-[#334155]">
<th className="pb-2 font-medium">Track</th>
<th className="pb-2 font-medium">Artist</th>
<th className="pb-2 font-medium">Played</th>
<th className="pb-2 font-medium">Listened</th>
<th className="pb-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{plays.slice(0, 50).map((play, idx) => (
<tr key={idx} className="border-b border-[#222f49] hover:bg-card-darker/50">
<td className="py-3">
<div className="flex items-center gap-3">
{play.image && (
<img src={play.image} alt="" className="w-10 h-10 rounded object-cover" />
)}
<span className="text-white font-medium truncate max-w-[200px]">{play.track_name}</span>
</div>
</td>
<td className="py-3 text-slate-400 truncate max-w-[150px]">{play.artist}</td>
<td className="py-3 text-slate-400">{formatTime(play.played_at)}</td>
<td className="py-3 text-slate-400">{formatDuration(play.listened_ms)}</td>
<td className="py-3">
{play.skipped ? (
<span className="px-2 py-0.5 rounded text-xs bg-red-500/20 text-red-400">Skipped</span>
) : (
<span className="px-2 py-0.5 rounded text-xs bg-green-500/20 text-green-400">Played</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
};
export default ListeningLog;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { motion } from 'framer-motion';
const NarrativeSection = ({ narrative, vibe }) => {
if (!narrative) return null;
const persona = narrative.persona || "THE UNKNOWN LISTENER";
const vibeCheckShort = narrative.vibe_check_short || narrative.vibe_check?.substring(0, 120) + "..." || "Analyzing auditory aura...";
const getTags = () => {
if (!vibe) return [];
const tags = [];
const valence = vibe.valence || 0;
const energy = vibe.energy || 0;
const danceability = vibe.danceability || 0;
if (valence > 0.6) tags.push({ text: "HIGH VALENCE", color: "primary" });
else if (valence < 0.4) tags.push({ text: "MELANCHOLIC", color: "accent-purple" });
if (energy > 0.6) tags.push({ text: "HIGH ENERGY", color: "accent-neon" });
else if (energy < 0.4) tags.push({ text: "CHILL VIBES", color: "accent-purple" });
if (danceability > 0.7) tags.push({ text: "DANCEABLE", color: "primary" });
return tags.slice(0, 3);
};
const tags = getTags();
if (tags.length === 0) {
tags.push({ text: "ECLECTIC", color: "primary" });
tags.push({ text: "MYSTERIOUS", color: "accent-purple" });
}
return (
<section className="relative rounded-2xl overflow-hidden min-h-[300px] flex items-center justify-center p-8 bg-card-dark border border-[#222f49]">
<div className="absolute inset-0 mood-gradient"></div>
<div className="relative z-10 flex flex-col items-center text-center max-w-2xl gap-4">
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5 }}
className="holographic-badge px-8 py-4 rounded-full border border-primary/30"
>
<h1 className="text-3xl md:text-5xl font-black tracking-tight text-white drop-shadow-[0_0_15px_rgba(37,106,244,0.5)] uppercase">
{persona}
</h1>
</motion.div>
<div className="font-mono text-primary/80 text-base md:text-lg font-medium tracking-wide max-w-lg">
<span className="typing-cursor">{vibeCheckShort}</span>
</div>
<div className="mt-2 flex gap-3 flex-wrap justify-center">
{tags.map((tag, i) => (
<span key={i} className={`px-3 py-1 rounded-full text-xs font-bold bg-${tag.color}/20 text-${tag.color} border border-${tag.color}/20`}>
{tag.text}
</span>
))}
</div>
</div>
</section>
);
};
export default NarrativeSection;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { NavLink } from 'react-router-dom';
const Navbar = ({ onRefresh, showRefresh = true }) => {
return (
<header className="sticky top-0 z-50 glass-panel border-b border-[#222f49] backdrop-blur-md bg-[#0f172a]/80">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-8">
<NavLink to="/" className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<div className="size-8 text-primary flex items-center justify-center">
<span className="material-symbols-outlined !text-3xl">equalizer</span>
</div>
<h2 className="text-xl font-bold tracking-tight text-white">SonicStats</h2>
</NavLink>
<nav className="hidden md:flex items-center gap-1 bg-slate-800/50 p-1 rounded-lg border border-white/5">
<NavLink
to="/"
className={({ isActive }) =>
`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
isActive
? 'bg-primary text-white shadow-lg shadow-primary/25'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`
}
>
Dashboard
</NavLink>
<NavLink
to="/archives"
className={({ isActive }) =>
`px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
isActive
? 'bg-primary text-white shadow-lg shadow-primary/25'
: 'text-slate-400 hover:text-white hover:bg-white/5'
}`
}
>
Archives
</NavLink>
</nav>
</div>
<div className="flex items-center gap-4">
{showRefresh && (
<button
onClick={onRefresh}
className="size-10 flex items-center justify-center rounded-xl bg-card-dark hover:bg-card-darker transition-colors text-white border border-[#222f49]"
title="Refresh Data"
>
<span className="material-symbols-outlined text-[20px]">refresh</span>
</button>
)}
<div className="size-10 rounded-full bg-cover bg-center border-2 border-[#222f49]" style={{ backgroundImage: "url('https://api.dicebear.com/7.x/avataaars/svg?seed=Sonic')" }}></div>
</div>
</div>
</header>
);
};
export default Navbar;

View File

@@ -0,0 +1,255 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { Card, Button, Typography, Space, Spin, message, Tooltip as AntTooltip, Collapse, Empty } from 'antd';
import {
PlayCircleOutlined,
ReloadOutlined,
HistoryOutlined,
InfoCircleOutlined,
CustomerServiceOutlined,
CalendarOutlined,
DownOutlined
} from '@ant-design/icons';
import Tooltip from './Tooltip';
import TrackList from './TrackList';
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 [history, setHistory] = useState([]);
const [loadingHistory, setLoadingHistory] = useState(false);
const [showHistory, setShowHistory] = useState(false);
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);
}
};
const fetchHistory = async () => {
if (loadingHistory) return;
setLoadingHistory(true);
try {
const response = await axios.get('/api/playlists/history');
setHistory(response.data.history || []);
} catch (error) {
console.error('Failed to fetch playlist history:', error);
message.error('Failed to load playlist history');
} finally {
setLoadingHistory(false);
}
};
useEffect(() => {
fetchPlaylists();
}, []);
useEffect(() => {
if (showHistory && history.length === 0) {
fetchHistory();
}
}, [showHistory]);
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();
if (showHistory) fetchHistory();
} 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 <div className="flex justify-center p-8"><Spin size="large" /></div>;
const historyItems = history.map((item) => ({
key: item.id,
label: (
<div className="flex justify-between items-center w-full">
<div className="flex items-center space-x-3">
<Text className="text-gray-400 font-mono text-xs">
{new Date(item.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</Text>
<Text className="text-white font-medium">{item.theme}</Text>
<span className="px-2 py-0.5 rounded text-[10px] bg-slate-700 text-blue-300 border border-slate-600">
{item.period_label || '6h'}
</span>
</div>
<Text className="text-gray-500 text-xs">{item.composition?.length || 0} tracks</Text>
</div>
),
children: (
<div className="pl-2">
<Paragraph className="text-gray-300 text-sm italic mb-2 border-l-2 border-blue-500 pl-3 py-1">
"{item.reasoning}"
</Paragraph>
<TrackList tracks={item.composition} maxHeight="max-h-96" />
</div>
),
}));
return (
<div className="mt-8 space-y-6">
<div className="flex items-center space-x-2">
<Title level={3} className="!mb-0 text-white flex items-center">
<CustomerServiceOutlined className="mr-2 text-blue-400" />
AI Curated Playlists
</Title>
<Tooltip text="Dynamic playlists that evolve with your taste. Refreshed automatically, or trigger manually here.">
<InfoCircleOutlined className="text-gray-400 cursor-help" />
</Tooltip>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 6-Hour Playlist */}
<Card
className="bg-slate-800 border-slate-700 shadow-xl"
title={<span className="text-blue-400 flex items-center"><HistoryOutlined className="mr-2" /> Short & Sweet (6h)</span>}
extra={
<Button
type="text"
icon={<ReloadOutlined spin={refreshing.sixHour} />}
onClick={() => handleRefresh('six-hour')}
className="text-gray-400 hover:text-white"
disabled={refreshing.sixHour}
/>
}
>
<div className="space-y-4">
<div>
<Text className="text-gray-400 text-xs uppercase tracking-wider block mb-1">Current Theme</Text>
<Title level={4} className="!mt-0 !mb-1 text-white">{playlists?.six_hour?.theme || 'Calculating...'}</Title>
<Paragraph className="text-gray-300 text-sm italic mb-0">
"{playlists?.six_hour?.reasoning || 'Analyzing your recent listening patterns to find the perfect vibe.'}"
</Paragraph>
<TrackList tracks={playlists?.six_hour?.composition} />
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-700">
<div className="flex flex-col">
<Text className="text-gray-500 text-xs">Last Updated</Text>
<Text className="text-gray-300 text-xs font-mono">
{playlists?.six_hour?.last_refresh ? new Date(playlists.six_hour.last_refresh).toLocaleString() : 'Never'}
</Text>
</div>
<Button
type="primary"
shape="round"
icon={<PlayCircleOutlined />}
href={`https://open.spotify.com/playlist/${playlists?.six_hour?.id}`}
target="_blank"
disabled={!playlists?.six_hour?.id}
className="bg-blue-600 hover:bg-blue-500 border-none"
>
Open Spotify
</Button>
</div>
</div>
</Card>
{/* Daily Playlist */}
<Card
className="bg-slate-800 border-slate-700 shadow-xl"
title={<span className="text-purple-400 flex items-center"><PlayCircleOutlined className="mr-2" /> Proof of Commitment (24h)</span>}
extra={
<Button
type="text"
icon={<ReloadOutlined spin={refreshing.daily} />}
onClick={() => handleRefresh('daily')}
className="text-gray-400 hover:text-white"
disabled={refreshing.daily}
/>
}
>
<div className="space-y-4">
<div>
<Text className="text-gray-400 text-xs uppercase tracking-wider block mb-1">Daily Mix Strategy</Text>
<Title level={4} className="!mt-0 !mb-1 text-white">Daily Devotion Mix</Title>
<Paragraph className="text-gray-300 text-sm mb-0">
A blend of 30 all-time favorites and 20 recent discoveries to keep your rotation fresh but familiar.
</Paragraph>
<TrackList tracks={playlists?.daily?.composition} />
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-700">
<div className="flex flex-col">
<Text className="text-gray-500 text-xs">Last Updated</Text>
<Text className="text-gray-300 text-xs font-mono">
{playlists?.daily?.last_refresh ? new Date(playlists.daily.last_refresh).toLocaleString() : 'Never'}
</Text>
</div>
<Button
type="primary"
shape="round"
icon={<PlayCircleOutlined />}
href={`https://open.spotify.com/playlist/${playlists?.daily?.id}`}
target="_blank"
disabled={!playlists?.daily?.id}
className="bg-purple-600 hover:bg-purple-500 border-none"
>
Open Spotify
</Button>
</div>
</div>
</Card>
</div>
<div className="mt-8 border-t border-slate-700 pt-6">
<Button
type="text"
onClick={() => setShowHistory(!showHistory)}
className="flex items-center text-gray-400 hover:text-white p-0 text-lg font-medium mb-4 transition-colors"
>
<CalendarOutlined className="mr-2" />
Playlist Archives
<DownOutlined className={`ml-2 text-xs transition-transform duration-300 ${showHistory ? 'rotate-180' : ''}`} />
</Button>
{showHistory && (
<div className="animate-fade-in">
{loadingHistory ? (
<div className="flex justify-center p-8"><Spin /></div>
) : history.length > 0 ? (
<Collapse
items={historyItems}
bordered={false}
className="bg-transparent"
expandIconPosition="end"
ghost
theme="dark"
itemLayout="horizontal"
style={{ background: 'transparent' }}
/>
) : (
<Empty description={<span className="text-gray-500">No playlist history available yet</span>} />
)}
</div>
)}
</div>
</div>
);
};
export default PlaylistsSection;

View File

@@ -0,0 +1,93 @@
import React from 'react';
import Tooltip from './Tooltip';
const StatsGrid = ({ metrics }) => {
if (!metrics) return null;
const totalMinutes = Math.round((metrics.volume?.estimated_minutes || 0));
const daysListened = (totalMinutes / (24 * 60)).toFixed(1);
const obsessionTrack = metrics.volume?.top_tracks?.[0];
const obsessionName = obsessionTrack ? obsessionTrack.name : "N/A";
const obsessionArtist = obsessionTrack ? obsessionTrack.artist : "N/A";
const obsessionCount = obsessionTrack ? obsessionTrack.count : 0;
const obsessionImage = obsessionTrack?.image || "https://images.unsplash.com/photo-1614613535308-eb5fbd3d2c17?q=80&w=400&auto=format&fit=crop";
const uniqueArtists = metrics.volume?.unique_artists || 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 (
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6 flex flex-col justify-between h-full min-h-[200px] group hover:border-primary/50 transition-colors">
<div className="flex items-start justify-between">
<span className="text-slate-400 text-sm font-medium uppercase tracking-wider">Minutes Listened</span>
<span className="material-symbols-outlined text-primary">schedule</span>
</div>
<div>
<div className="text-4xl lg:text-5xl font-bold text-white mb-2">{totalMinutes.toLocaleString()}</div>
<div className="text-accent-neon text-sm font-medium flex items-center gap-1">
<span className="material-symbols-outlined text-sm">trending_up</span>
That's {daysListened} days straight
</div>
</div>
</div>
<div className="bg-card-dark border border-[#222f49] rounded-xl relative overflow-hidden h-full min-h-[200px] group lg:col-span-2">
<div
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
style={{ backgroundImage: `url('${obsessionImage}')` }}
>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"></div>
</div>
<div className="relative z-10 p-6 flex flex-col justify-end h-full">
<div className="flex justify-between items-end">
<div>
<span className="inline-block px-2 py-1 rounded bg-primary/80 text-white text-[10px] font-bold tracking-widest mb-2">OBSESSION</span>
<h3 className="text-2xl font-bold text-white leading-tight truncate max-w-[200px] md:max-w-[300px]">{obsessionName}</h3>
<p className="text-slate-300">{obsessionArtist}</p>
</div>
<div className="text-right hidden sm:block">
<div className="text-3xl font-bold text-white">{obsessionCount}</div>
<div className="text-xs text-slate-400 uppercase">Plays this month</div>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-4 h-full">
<div className="bg-card-dark border border-[#222f49] rounded-xl p-5 flex-1 flex flex-col justify-center items-center text-center group">
<div className="flex items-center gap-2 mb-1">
<div className="text-3xl font-bold text-white">{uniqueArtists}</div>
<Tooltip text="The number of unique artists you've listened to in this period.">
<span className="material-symbols-outlined text-slate-500 text-sm cursor-help">info</span>
</Tooltip>
</div>
<div className="text-slate-400 text-xs uppercase tracking-wider">Unique Artists</div>
</div>
<div className="bg-card-dark border border-[#222f49] rounded-xl p-5 flex-1 flex flex-col justify-center items-center group">
<div className="flex items-center gap-4">
<div className="text-center">
<Tooltip text="Concentration score (HHI). High means you focus on few artists, low means you spread your listening.">
<div className="text-xl font-bold text-white">{(1 - concentration).toFixed(2)}</div>
<div className="text-slate-500 text-[9px] uppercase tracking-tighter">Variety</div>
</Tooltip>
</div>
<div className="w-px h-8 bg-slate-700"></div>
<div className="text-center">
<Tooltip text={`Your peak listening time is around ${peakHour}.`}>
<div className="text-xl font-bold text-white">{peakHour}</div>
<div className="text-slate-500 text-[9px] uppercase tracking-tighter">Peak Time</div>
</Tooltip>
</div>
</div>
</div>
</div>
</section>
);
};
export default StatsGrid;

View File

@@ -0,0 +1,25 @@
import React, { useState } from 'react';
const Tooltip = ({ text, children }) => {
const [isVisible, setIsVisible] = useState(false);
return (
<div
className="relative flex items-center group"
onMouseEnter={() => setIsVisible(true)}
onMouseLeave={() => setIsVisible(false)}
onFocus={() => setIsVisible(true)}
onBlur={() => setIsVisible(false)}
>
{children}
{isVisible && (
<div className="absolute z-50 px-3 py-2 text-sm font-medium text-white transition-opacity duration-300 bg-gray-900 rounded-lg shadow-sm opacity-100 -top-12 left-1/2 -translate-x-1/2 whitespace-nowrap dark:bg-gray-700">
{text}
<div className="absolute w-2 h-2 bg-gray-900 rotate-45 -bottom-1 left-1/2 -translate-x-1/2 dark:bg-gray-700"></div>
</div>
)}
</div>
);
};
export default Tooltip;

View File

@@ -0,0 +1,41 @@
import React from 'react';
const TopRotation = ({ volume }) => {
if (!volume || !volume.top_tracks) return null;
const fallbackImage = "https://images.unsplash.com/photo-1619983081563-430f63602796?q=80&w=200&auto=format&fit=crop";
return (
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6 overflow-hidden">
<div className="flex items-center justify-between mb-4">
<h3 className="text-xl font-bold text-white">Top Rotation</h3>
<div className="flex gap-2">
<span className="size-2 rounded-full bg-primary"></span>
<span className="size-2 rounded-full bg-slate-600"></span>
</div>
</div>
<div className="flex gap-4 overflow-x-auto no-scrollbar pb-2">
{volume.top_tracks.slice(0, 5).map((track, i) => {
const name = track.name || "Unknown";
const artist = track.artist || "Unknown";
const image = track.image || fallbackImage;
return (
<div key={i} className={`min-w-[140px] flex flex-col gap-2 group cursor-pointer ${i === 0 ? 'min-w-[180px]' : 'opacity-80 hover:opacity-100 transition-opacity pt-4'}`}>
<div
className={`w-full aspect-square rounded-lg bg-cover bg-center ${i === 0 ? 'shadow-lg shadow-black/50 transition-transform group-hover:scale-105' : ''}`}
style={{ backgroundImage: `url('${image}')` }}
></div>
<p className={`text-white font-medium truncate ${i === 0 ? 'font-bold' : 'text-sm'}`}>{name}</p>
<p className="text-xs text-slate-400 truncate">{artist}</p>
<p className="text-xs text-primary">{track.count} plays</p>
</div>
);
})}
</div>
</div>
);
};
export default TopRotation;

View File

@@ -0,0 +1,61 @@
import React from 'react';
import { Button, Typography, Tooltip as AntTooltip, Avatar } from 'antd';
import {
PlayCircleOutlined,
HistoryOutlined,
CustomerServiceOutlined,
RobotOutlined
} from '@ant-design/icons';
const { Text } = Typography;
const TrackList = ({ tracks, maxHeight = "max-h-60" }) => {
if (!tracks || tracks.length === 0) return <div className="text-gray-500 text-sm py-4 text-center">No tracks available</div>;
return (
<div className={`${maxHeight} overflow-y-auto custom-scrollbar pr-2 -mr-2 my-4 space-y-2`}>
{tracks.map((track, idx) => (
<div key={`${track.id}-${idx}`} className="flex items-center p-2 rounded-lg bg-slate-700/50 hover:bg-slate-700 transition-colors group">
<div className="flex-shrink-0 relative">
<Avatar shape="square" size={40} src={track.image_url} icon={<CustomerServiceOutlined />} />
<div className="absolute -top-1 -right-1">
{track.source === 'recommendation' ? (
<AntTooltip title="AI Recommendation">
<div className="bg-purple-500 rounded-full p-0.5 w-4 h-4 flex items-center justify-center">
<RobotOutlined className="text-white text-[10px]" />
</div>
</AntTooltip>
) : (
<AntTooltip title="From History">
<div className="bg-blue-500 rounded-full p-0.5 w-4 h-4 flex items-center justify-center">
<HistoryOutlined className="text-white text-[10px]" />
</div>
</AntTooltip>
)}
</div>
</div>
<div className="ml-3 flex-grow min-w-0">
<Text className="text-white text-sm font-medium truncate block" title={track.name}>
{track.name}
</Text>
<Text className="text-gray-400 text-xs truncate block" title={track.artist}>
{track.artist}
</Text>
</div>
<div className="ml-2 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
type="text"
size="small"
icon={<PlayCircleOutlined />}
href={`https://open.spotify.com/track/${track.id}`}
target="_blank"
className="text-gray-400 hover:text-white"
/>
</div>
</div>
))}
</div>
);
};
export default TrackList;

View File

@@ -0,0 +1,128 @@
import React from 'react';
import { Radar, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, ResponsiveContainer } from 'recharts';
const VibeRadar = ({ vibe }) => {
if (!vibe) return null;
const data = [
{ subject: 'Acoustic', A: vibe.acousticness || 0, fullMark: 1 },
{ subject: 'Dance', A: vibe.danceability || 0, fullMark: 1 },
{ subject: 'Energy', A: vibe.energy || 0, fullMark: 1 },
{ subject: 'Instrumental', A: vibe.instrumentalness || 0, fullMark: 1 },
{ subject: 'Valence', A: vibe.valence || 0, fullMark: 1 },
{ subject: 'Live', A: vibe.liveness || 0, fullMark: 1 },
];
const energy = vibe.energy || 0;
const danceability = vibe.danceability || 0;
const instrumentalness = vibe.instrumentalness || 0;
const valence = vibe.valence || 0;
const acousticness = vibe.acousticness || 0;
const partyScore = Math.round(((energy + danceability) / 2) * 100);
const focusScore = Math.round(((instrumentalness + (1 - valence)) / 2) * 100);
const chillScore = Math.round(((acousticness + (1 - energy)) / 2) * 100);
const total = partyScore + focusScore + chillScore || 1;
const partyPct = Math.round((partyScore / total) * 100);
const focusPct = Math.round((focusScore / total) * 100);
const chillPct = 100 - partyPct - focusPct;
const whiplash = vibe.whiplash || {};
const maxWhiplash = Math.max(
whiplash.tempo || 0,
(whiplash.energy || 0) * 100,
(whiplash.valence || 0) * 100
);
const volatilityLevel = maxWhiplash > 25 ? "HIGH" : maxWhiplash > 12 ? "MEDIUM" : "LOW";
const volatilityColor = volatilityLevel === "HIGH" ? "text-red-400" : volatilityLevel === "MEDIUM" ? "text-yellow-400" : "text-green-400";
return (
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-xl font-bold text-white flex items-center gap-2">
<span className="material-symbols-outlined text-primary">fingerprint</span>
Sonic DNA
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<div className="aspect-square relative flex items-center justify-center bg-card-darker rounded-lg border border-[#222f49]/50 p-4">
<ResponsiveContainer width="100%" height={200} minHeight={200}>
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={data}>
<PolarGrid stroke="#334155" />
<PolarAngleAxis dataKey="subject" tick={{ fill: '#94a3b8', fontSize: 12 }} />
<PolarRadiusAxis angle={30} domain={[0, 1]} tick={false} axisLine={false} />
<Radar
name="My Vibe"
dataKey="A"
stroke="#256af4"
strokeWidth={2}
fill="rgba(37, 106, 244, 0.4)"
fillOpacity={0.4}
/>
</RadarChart>
</ResponsiveContainer>
</div>
<div className="flex flex-col gap-6">
<div className="flex-1 flex flex-col justify-center">
<h4 className="text-sm text-slate-400 mb-4 font-medium uppercase">Mood Clusters</h4>
<div className="relative h-40 w-full rounded-lg border border-dashed border-[#334155] bg-card-darker/50">
<div className="absolute top-1/2 left-1/4 -translate-x-1/2 -translate-y-1/2 size-20 rounded-full bg-primary/20 border border-primary text-primary flex items-center justify-center text-xs font-bold text-center p-1 cursor-pointer hover:bg-primary hover:text-white transition-colors z-10">
Party<br/>{partyPct}%
</div>
<div className="absolute top-1/3 right-1/4 size-14 rounded-full bg-accent-purple/20 border border-accent-purple text-accent-purple flex items-center justify-center text-[10px] font-bold text-center p-1 cursor-pointer hover:bg-accent-purple hover:text-white transition-colors">
Focus<br/>{focusPct}%
</div>
<div className="absolute bottom-4 right-1/3 size-12 rounded-full bg-accent-neon/20 border border-accent-neon text-accent-neon flex items-center justify-center text-[10px] font-bold text-center p-1 cursor-pointer hover:bg-accent-neon hover:text-black transition-colors">
Chill<br/>{chillPct}%
</div>
</div>
</div>
<div>
<div className="flex justify-between items-end mb-2">
<h4 className="text-sm text-slate-400 font-medium uppercase">Whiplash Meter</h4>
<span className={`text-xs font-bold ${volatilityColor}`}>{volatilityLevel} VOLATILITY</span>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-16">Tempo</span>
<div className="flex-1 h-2 bg-card-darker rounded overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary to-red-500 transition-all"
style={{ width: `${Math.min((whiplash.tempo || 0) / 40 * 100, 100)}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-12 text-right">{(whiplash.tempo || 0).toFixed(1)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-16">Energy</span>
<div className="flex-1 h-2 bg-card-darker rounded overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary to-yellow-500 transition-all"
style={{ width: `${Math.min((whiplash.energy || 0) * 100 / 0.4 * 100, 100)}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-12 text-right">{((whiplash.energy || 0) * 100).toFixed(0)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-16">Valence</span>
<div className="flex-1 h-2 bg-card-darker rounded overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary to-green-500 transition-all"
style={{ width: `${Math.min((whiplash.valence || 0) * 100 / 0.4 * 100, 100)}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-12 text-right">{((whiplash.valence || 0) * 100).toFixed(0)}%</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default VibeRadar;

88
frontend/src/index.css Normal file
View File

@@ -0,0 +1,88 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom Utilities from code.html */
.glass-panel {
background: rgba(24, 34, 52, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.holographic-badge {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
box-shadow: 0 0 20px rgba(37, 106, 244, 0.3), inset 0 0 0 1px rgba(255, 255, 255, 0.2);
backdrop-filter: blur(8px);
position: relative;
overflow: hidden;
}
.holographic-badge::before {
content: "";
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.1), transparent);
transform: rotate(45deg);
animation: holo-shine 3s infinite linear;
}
@keyframes holo-shine {
0% {
transform: translateX(-100%) rotate(45deg);
}
100% {
transform: translateX(100%) rotate(45deg);
}
}
.typing-cursor::after {
content: "|";
animation: blink 1s step-end infinite;
}
@keyframes blink {
50% {
opacity: 0;
}
}
.mood-gradient {
background: radial-gradient(circle at 50% 50%, rgba(37, 106, 244, 0.15), transparent 70%), radial-gradient(circle at 80% 20%, rgba(139, 92, 246, 0.1), transparent 50%);
}
/* Hide scrollbar for carousel */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.radar-grid circle {
fill: none;
stroke: #334155;
stroke-width: 1;
}
.radar-grid line {
stroke: #334155;
stroke-width: 1;
}
.radar-area {
fill: rgba(37, 106, 244, 0.3);
stroke: #256af4;
stroke-width: 2;
}
/* Paper texture */
.paper-texture {
background-color: #f0f0f0;
background-image: url(https://lh3.googleusercontent.com/aida-public/AB6AXuCxWgGFi3y5uU1Eo5AvX4bBjCZyqH_y2JcjejnbTD6deIOvWk3bplb-Bj1oFuS3P1LlYkmdnJOUkNL9g9L4yQd3Otfcz6qhp7psxQQqPTkZwV4myWl1ZoEp3ZQfBGYSI-nJnwMpWmwB1uO75co2eIFngOJE3Rn6JmLO_nOUKGhsut6iWdt_LKijBTH7SilsOX7HWTXfekHR2CwuUs4LJ6LkTMCVXS3R-aQTNfmsza_6PcRn40PTaBYS90sY9xtDPFcfgS2vzgPmPDZ6);
}

20
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,20 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css';
import { ConfigProvider, theme } from 'antd';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: '#1DB954', // Spotify Green
},
}}
>
<App />
</ConfigProvider>
</React.StrictMode>,
)

View File

@@ -0,0 +1,34 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: "class",
safelist: [
'bg-primary/20', 'text-primary', 'border-primary/20',
'bg-accent-purple/20', 'text-accent-purple', 'border-accent-purple/20',
'bg-accent-neon/20', 'text-accent-neon', 'border-accent-neon/20',
],
theme: {
extend: {
colors: {
"primary": "#256af4",
"background-light": "#f5f6f8",
"background-dark": "#101622",
"card-dark": "#182234",
"card-darker": "#111927",
"accent-neon": "#0bda5e",
"accent-purple": "#8b5cf6",
},
fontFamily: {
"display": ["Space Grotesk", "sans-serif"],
"mono": ["Space Grotesk", "monospace"],
},
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
}
},
},
plugins: [],
}

16
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
})

BIN
screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB