14 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
70 changed files with 7907 additions and 455 deletions

View File

@@ -8,17 +8,16 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
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
@@ -33,25 +32,68 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
- name: Extract metadata (tags, labels) for Backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/musicanalyser
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=sha
latest
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
- name: Build and push Backend
uses: docker/build-push-action@v5
with:
context: ./backend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
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.

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.

160
README.md
View File

@@ -2,85 +2,139 @@
A personal analytics dashboard for your music listening habits, powered by Python, FastAPI, React, and Google Gemini AI.
![Dashboard Screenshot](screen.png)
## Features
- **Continuous Ingestion**: Polls Spotify every 60 seconds to record your listening history.
- **Data Enrichment**: Automatically fetches **Genres** (via Spotify) and **Audio Features** (Energy, BPM, Mood via ReccoBeats).
- **Dashboard**: A responsive UI (Ant Design) to view your history, stats, and "Vibes".
- **AI Ready**: Database schema and environment prepared for Gemini AI integration.
- **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.
## Hosting Guide
You can run this application using Docker Compose. You have two options: using the pre-built image from GitHub Container Registry or building from source.
## Quick Start (Docker Compose)
### 1. Prerequisites
- Docker & Docker Compose installed.
- **Spotify Developer Credentials** (Client ID & Secret).
- **Spotify Refresh Token** (Run `backend/scripts/get_refresh_token.py` locally to generate this).
- **Google Gemini API Key**.
- 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))
### 2. Configuration (`.env`)
### 2. Get Spotify Refresh Token
Create a `.env` file in the root directory (same level as `docker-compose.yml`). This file is used by Docker Compose to populate environment variables.
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
```
### 3. Run with Docker Compose
### 4. Run with Pre-built Images
#### Option A: Build from Source (Recommended for Dev/Modifications)
```bash
# Pull the latest images
docker pull ghcr.io/bnair123/musicanalyser:latest
docker pull ghcr.io/bnair123/musicanalyser-frontend:latest
Use this if you want to modify the code or ensure you are running the exact local version.
# Start the services
docker-compose up -d
```
1. Clone the repository.
2. Ensure your `.env` file is set up.
3. Run:
```bash
docker-compose up -d --build
```
Or build from source:
#### Option B: Use Pre-built Image
```bash
docker-compose up -d --build
```
Use this if you just want to run the app without building locally.
### 5. Access the Dashboard
1. Open `docker-compose.yml`.
2. Ensure the `backend` service uses the image: `ghcr.io/bnair123/musicanalyser:latest`.
* *Note: If you want to force usage of the image and ignore local build context, you can comment out `build: context: ./backend` in the yaml, though Compose usually prefers build context if present.*
3. Ensure your `.env` file is set up.
4. Run:
```bash
docker pull ghcr.io/bnair123/musicanalyser:latest
docker-compose up -d
```
Open your browser to: **http://localhost:8991**
### 4. Access the Dashboard
## Architecture
Open your browser to:
`http://localhost:8991`
```
┌─────────────────────┐ ┌─────────────────────┐
│ Frontend │ │ Backend │
│ (React + Vite) │────▶│ (FastAPI + Worker) │
│ Port: 8991 │ │ Port: 8000 │
└─────────────────────┘ └─────────────────────┘
┌────────┴────────┐
▼ ▼
┌──────────┐ ┌──────────────┐
│PostgreSQL│ │ Spotify API │
│ music_db │ │ Gemini AI │
└──────────┘ └──────────────┘
```
### 5. Data Persistence
- **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)
- **Database**: Stored in a named volume or host path mapped to `/app/music.db`.
- **Migrations**: The backend uses Alembic. Schema changes are applied automatically on startup.
## Data Persistence
## Local Development (Non-Docker)
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
1. **Backend**:
```bash
cd backend
pip install -r requirements.txt
python run_worker.py # Starts ingestion
uvicorn app.main:app --reload # Starts API
```
To backup your data:
```bash
pg_dump -h 100.91.248.114 -p 5433 -U bnair music_db > backup.sql
```
2. **Frontend**:
```bash
cd frontend
npm install
npm run dev
```
Access at `http://localhost:5173`.
## Local Development
### Backend
```bash
cd backend
python -m venv venv && source venv/bin/activate
pip install -r requirements.txt
# Run migrations
alembic upgrade head
# Start worker (polls Spotify every 60s)
python run_worker.py &
# Start API server
uvicorn app.main:app --reload
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
Access at http://localhost:5173 (Vite proxies `/api` to backend automatically)
## Environment Variables
| 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`) |

56
TODO.md
View File

@@ -1,37 +1,21 @@
# Future Roadmap & TODOs
🎵 Playlist Service Feature - Complete Task List
What's Been Done ✅
| # | Task | Status | Notes |
|---|-------|--------|-------|
| 1 | Database | ✅ Completed | Added playlist_theme, playlist_theme_reasoning, six_hour_playlist_id, daily_playlist_id columns to AnalysisSnapshot model |
| 2 | AI Service | ✅ Completed | Added generate_playlist_theme(), _build_theme_prompt(), _call_openai_for_theme(), updated _build_prompt() to remove HHI/Gini/part_of_day |
| 3 | PlaylistService | ✅ Completed | Implemented full curation logic with ensure_playlists_exist(), curate_six_hour_playlist(), curate_daily_playlist(), _get_top_all_time_tracks() |
| 4 | Migration | ✅ Completed | Created 5ed73db9bab9_add_playlist_columns.py and applied to DB |
| 5 | API Endpoints | ✅ Completed | Added /playlists/refresh/* and /playlists GET endpoints in main.py |
| 6 | Worker Scheduler | ✅ Completed | Added 6h and 24h refresh logic to run_worker.py via ingest.py |
| 7 | Frontend Tooltip | ✅ Completed | Created Tooltip.jsx component |
| 8 | Playlists Section | ✅ Completed | Created PlaylistsSection.jsx with refresh and Spotify links |
| 9 | Integration | ✅ Completed | Integrated PlaylistsSection into Dashboard.jsx and added tooltips to StatsGrid.jsx |
| 10 | Docker Config | ✅ Completed | Updated docker-compose.yml and Dockerfile (curl for healthcheck) |
## Phase 3: AI Analysis & Insights
### 1. Data Analysis Enhancements
- [ ] **Timeframe Selection**:
- [ ] Update Backend API to accept timeframe parameters (e.g., `?range=30d`, `?range=year`, `?range=all`).
- [ ] Update Frontend to include a dropdown/toggle for these timeframes.
- [ ] **Advanced Stats**:
- [ ] Top Artists / Tracks calculation for the selected period.
- [ ] Genre distribution charts (Pie/Bar chart).
### 2. AI Integration (Gemini)
- [ ] **Trigger Mechanism**:
- [ ] Add "Generate AI Report" button on the UI.
- [ ] (Optional) Schedule daily auto-generation.
- [ ] **Prompt Engineering**:
- [ ] Design prompts to analyze:
- "Past 30 Days" (Monthly Vibe Check).
- "Overall" (Yearly/All-time evolution).
- [ ] Provide raw data (list of tracks + audio features) to Gemini.
- [ ] **Storage**:
- [ ] Create `AnalysisReport` table to store generated HTML/Markdown reports.
- [ ] View past reports in a new "Insights" tab.
### 3. Playlist Generation
- [ ] **Concept**: "Daily Vibe Playlist" or "AI Recommended".
- [ ] **Implementation**:
- [ ] Use ReccoBeats or Spotify Recommendations API.
- [ ] Seed with top 5 recent tracks.
- [ ] Filter by audio features (e.g., "High Energy" playlist).
- [ ] **Action**:
- [ ] Add "Save to Spotify" button in the UI (Requires `playlist-modify-public` scope).
### 4. Polish
- [ ] **Mobile Responsiveness**: Ensure Ant Design tables and charts stack correctly on mobile.
- [ ] **Error Handling**: Better UI feedback for API failures (e.g., expired tokens).
All feature tasks are COMPLETE and VERIFIED.
End-to-end testing with Playwright confirms:
- 6-hour refresh correctly calls AI and Spotify, saves snapshot.
- Daily refresh correctly curates mix and saves snapshot.
- Dashboard displays themed playlists and refresh status.
- Tooltips provide context for technical metrics.

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

View File

@@ -10,29 +10,17 @@ 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
from app.models import * # Import models to register them
from app.database import Base, SQLALCHEMY_DATABASE_URL
from app.models import *
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
# Override sqlalchemy.url with our app's URL
config.set_main_option("sqlalchemy.url", "sqlite:///./music.db")
config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL)
def run_migrations_offline() -> None:

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,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,52 +1,81 @@
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()
async def enrich_tracks(db: Session, spotify_client: SpotifyClient, recco_client: ReccoBeatsClient):
"""
Finds tracks missing genres (Spotify) or audio features (ReccoBeats) and enriches them.
"""
# 1. Enrich Audio Features (via ReccoBeats)
tracks_missing_features = db.query(Track).filter(Track.danceability == None).limit(50).all()
print(f"DEBUG: Found {len(tracks_missing_features)} tracks missing audio features.")
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 (ReccoBeats)...")
ids = [t.id for t in 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("id")
if not tid and "href" in f:
if "tracks/" in f["href"]:
tid = f["href"].split("tracks/")[1].split("?")[0]
elif "track/" in f["href"]:
tid = f["href"].split("track/")[1].split("?")[0]
tid = f.get("spotify_id") or f.get("id")
if tid:
features_map[tid] = f
updated_count = 0
for track in tracks_missing_features:
data = features_map.get(track.id)
if data:
@@ -61,44 +90,65 @@ async def enrich_tracks(db: Session, spotify_client: SpotifyClient, recco_client
track.liveness = data.get("liveness")
track.valence = data.get("valence")
track.tempo = data.get("tempo")
updated_count += 1
print(f"Updated {updated_count} tracks with audio features.")
db.commit()
# 2. Enrich Genres (via Spotify Artists)
tracks_missing_genres = db.query(Track).filter(Track.genres == None).limit(50).all()
if tracks_missing_genres:
print(f"Enriching {len(tracks_missing_genres)} tracks with genres (Spotify)...")
artist_ids = set()
track_artist_map = {}
for t in tracks_missing_genres:
if t.raw_data and "artists" in t.raw_data:
a_ids = [a["id"] for a in t.raw_data["artists"]]
artist_ids.update(a_ids)
track_artist_map[t.id] = a_ids
artist_ids_list = list(artist_ids)
artist_genre_map = {}
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]
chunk = artist_ids_list[i : i + 50]
artists_data = await spotify_client.get_artists(chunk)
for a_data in artists_data:
if a_data:
artist_genre_map[a_data["id"]] = a_data.get("genres", [])
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 t in tracks_missing_genres:
a_ids = track_artist_map.get(t.id, [])
combined_genres = set()
for a_id in a_ids:
genres = artist_genre_map.get(a_id, [])
combined_genres.update(genres)
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 = []
t.genres = list(combined_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()
@@ -106,6 +156,7 @@ async def enrich_tracks(db: Session, spotify_client: SpotifyClient, recco_client
async def ingest_recently_played(db: Session):
spotify_client = get_spotify_client()
recco_client = get_reccobeats_client()
genius_client = get_genius_client()
try:
items = await spotify_client.get_recently_played(limit=50)
@@ -125,48 +176,253 @@ async def ingest_recently_played(db: Session):
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,
)
artists_data = track_data.get("artists", [])
artist_objects = await ensure_artists_exist(db, artists_data)
track.artists = artist_objects
db.add(track)
db.commit()
exists = db.query(PlayHistory).filter(
PlayHistory.track_id == track_id,
PlayHistory.played_at == played_at
).first()
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()
# Enrich
await enrich_tracks(db, spotify_client, recco_client)
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,15 +1,51 @@
from sqlalchemy import Column, Integer, String, DateTime, JSON, ForeignKey, Float
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)
@@ -31,17 +67,19 @@ class Track(Base):
tempo = Column(Float, nullable=True)
time_signature = Column(Integer, nullable=True)
# Genres (stored as JSON list of strings)
# 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):
@@ -49,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

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

@@ -1,18 +1,136 @@
import httpx
from typing import List, Dict, Any
from typing import List, Dict, Any, Optional
RECCOBEATS_BASE_URL = "https://api.reccobeats.com/v1"
RECCOBEATS_API_URL = "https://api.reccobeats.com/v1/audio-features"
class ReccoBeatsClient:
async def get_audio_features(self, spotify_ids: List[str]) -> List[Dict[str, Any]]:
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() as client:
async with httpx.AsyncClient(timeout=self.timeout) as client:
try:
response = await client.get(RECCOBEATS_API_URL, params={"ids": ids_param})
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 []
return response.json().get("content", [])
except Exception:
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

@@ -8,6 +8,7 @@ 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
@@ -70,6 +71,26 @@ class SpotifyClient:
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.
@@ -92,3 +113,118 @@ class SpotifyClient:
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)

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,12 +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()

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

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

View File

@@ -1,4 +1,5 @@
version: '3.8'
services:
backend:
build:
@@ -6,21 +7,35 @@ services:
image: ghcr.io/bnair123/musicanalyser:latest
container_name: music-analyser-backend
restart: unless-stopped
volumes:
- /opt/mySpotify/music.db:/app/music.db
env_file:
- .env
environment:
- SPOTIFY_CLIENT_ID=${SPOTIFY_CLIENT_ID}
- SPOTIFY_CLIENT_SECRET=${SPOTIFY_CLIENT_SECRET}
- SPOTIFY_REFRESH_TOKEN=${SPOTIFY_REFRESH_TOKEN}
- GEMINI_API_KEY=${GEMINI_API_KEY}
- DATABASE_URL=postgresql://bnair:Bharath2002@music_db:5432/music_db
ports:
- '8000:8000'
- '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
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.

View File

@@ -1,12 +1,15 @@
<!doctype html>
<html lang="en">
<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>frontend</title>
<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>
<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>

File diff suppressed because it is too large Load Diff

View File

@@ -14,19 +14,25 @@
"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"
"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: {},
},
}

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,117 +1,17 @@
import React, { useEffect, useState } from 'react';
import { Table, Layout, Typography, Tag, Card, Statistic, Row, Col, Space } from 'antd';
import { ClockCircleOutlined, SoundOutlined, UserOutlined } from '@ant-design/icons';
import axios from 'axios';
import { format } from 'date-fns';
const { Header, Content, Footer } = Layout;
const { Title, Text } = Typography;
const App = () => {
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
// Fetch History
useEffect(() => {
const fetchHistory = async () => {
try {
const response = await axios.get('/api/history?limit=100');
setHistory(response.data);
} catch (error) {
console.error("Failed to fetch history", error);
} finally {
setLoading(false);
}
};
fetchHistory();
}, []);
// Columns for Ant Design Table
const columns = [
{
title: 'Track',
dataIndex: ['track', 'name'],
key: 'track',
render: (text, record) => (
<Space direction="vertical" size={0}>
<Text strong>{text}</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>{record.track.album}</Text>
</Space>
),
},
{
title: 'Artist',
dataIndex: ['track', 'artist'],
key: 'artist',
render: (text) => <Tag icon={<UserOutlined />} color="blue">{text}</Tag>,
},
{
title: 'Played At',
dataIndex: 'played_at',
key: 'played_at',
render: (date) => (
<Space>
<ClockCircleOutlined />
{format(new Date(date), 'MMM d, h:mm a')}
</Space>
),
sorter: (a, b) => new Date(a.played_at) - new Date(b.played_at),
defaultSortOrder: 'descend',
},
{
title: 'Vibe',
key: 'vibe',
render: (_, record) => {
const energy = record.track.energy;
const valence = record.track.valence;
if (energy === undefined || valence === undefined) return <Tag>Unknown</Tag>;
let color = 'default';
let label = 'Neutral';
if (energy > 0.7 && valence > 0.5) { color = 'orange'; label = 'High Energy / Happy'; }
else if (energy > 0.7 && valence <= 0.5) { color = 'red'; label = 'High Energy / Dark'; }
else if (energy <= 0.4 && valence > 0.5) { color = 'green'; label = 'Chill / Peaceful'; }
else if (energy <= 0.4 && valence <= 0.5) { color = 'purple'; label = 'Sad / Melancholic'; }
return <Tag color={color}>{label}</Tag>;
}
}
];
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 (
<Layout style={{ minHeight: '100vh' }}>
<Header style={{ display: 'flex', alignItems: 'center' }}>
<Title level={3} style={{ color: 'white', margin: 0 }}>
<SoundOutlined style={{ marginRight: 10 }}/> Music Analyser
</Title>
</Header>
<Content style={{ padding: '0 50px', marginTop: 30 }}>
<div style={{ background: '#141414', padding: 24, borderRadius: 8, minHeight: 280 }}>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={8}>
<Card>
<Statistic title="Total Plays (Stored)" value={history.length} prefix={<SoundOutlined />} />
</Card>
</Col>
</Row>
<Title level={4} style={{ color: 'white' }}>Recent Listening History</Title>
<Table
columns={columns}
dataSource={history}
rowKey="id"
loading={loading}
pagination={{ pageSize: 10 }}
/>
</div>
</Content>
<Footer style={{ textAlign: 'center' }}>
Music Analyser ©{new Date().getFullYear()} Created with Ant Design
</Footer>
</Layout>
<BrowserRouter>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/archives" element={<Archives />} />
</Routes>
</BrowserRouter>
);
};
}
export default App;

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;

View File

@@ -1,68 +1,88 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
@tailwind base;
@tailwind components;
@tailwind utilities;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
/* 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);
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
.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;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
.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;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
@keyframes holo-shine {
0% {
transform: translateX(-100%) rotate(45deg);
}
100% {
transform: translateX(100%) rotate(45deg);
}
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
.typing-cursor::after {
content: "|";
animation: blink 1s step-end infinite;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
@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);
}

View File

@@ -1,6 +1,7 @@
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(

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: [],
}

BIN
screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB