mirror of
https://github.com/bnair123/MusicAnalyser.git
synced 2026-02-25 19:56:06 +00:00
Compare commits
20 Commits
setup-init
...
272148c5bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
272148c5bf | ||
|
|
26b4895695 | ||
|
|
93e7c13f3d | ||
|
|
fa28b98c1a | ||
|
|
887e78bf47 | ||
|
|
faee830545 | ||
|
|
56b7e2a5ba | ||
|
|
9b8f7355fb | ||
|
|
e7980cc706 | ||
|
|
af0d985253 | ||
|
|
508d001d7e | ||
|
|
d63a05fb72 | ||
|
|
f4432154b6 | ||
|
|
ab47dd62ca | ||
|
|
6e80e97960 | ||
|
|
f034b3eb43 | ||
|
|
0ca9893c68 | ||
|
|
3a424d15a5 | ||
|
|
4ca4c7befd | ||
|
|
b502e95652 |
83
.github/workflows/docker-publish.yml
vendored
83
.github/workflows/docker-publish.yml
vendored
@@ -6,11 +6,18 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches: [ "main" ]
|
branches: [ "main" ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build-backend:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -18,9 +25,75 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build and push
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels) for Backend
|
||||||
|
id: meta-backend
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/musicanalyser
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=ref,event=pr
|
||||||
|
type=sha
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
|
- name: Build and push Backend
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
push: false
|
push: true
|
||||||
tags: user/app:latest
|
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
6
.idea/vcs.xml
generated
Normal 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
85
AGENTS.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# PROJECT KNOWLEDGE BASE
|
||||||
|
|
||||||
|
**Generated:** 2025-12-30
|
||||||
|
**Branch:** main
|
||||||
|
|
||||||
|
## OVERVIEW
|
||||||
|
|
||||||
|
Personal music analytics dashboard polling Spotify 24/7. Core stack: Python (FastAPI, SQLAlchemy, PostgreSQL) + React (Vite, Tailwind, AntD). Integrates AI (Gemini) for listening narratives.
|
||||||
|
|
||||||
|
## STRUCTURE
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── backend/ # FastAPI API & Spotify polling worker
|
||||||
|
│ ├── app/ # Core logic (services, models, schemas)
|
||||||
|
│ ├── alembic/ # DB migrations
|
||||||
|
│ └── tests/ # Pytest suite
|
||||||
|
├── frontend/ # React application
|
||||||
|
│ └── src/ # Components & application logic
|
||||||
|
├── docs/ # Technical & architecture documentation
|
||||||
|
└── docker-compose.yml # Production orchestration
|
||||||
|
```
|
||||||
|
|
||||||
|
## WHERE TO LOOK
|
||||||
|
|
||||||
|
| Task | Location | Notes |
|
||||||
|
|------|----------|-------|
|
||||||
|
| Modify API endpoints | `backend/app/main.py` | FastAPI routes |
|
||||||
|
| Update DB models | `backend/app/models.py` | SQLAlchemy ORM |
|
||||||
|
| Change polling logic | `backend/app/ingest.py` | Worker & ingestion logic |
|
||||||
|
| Add analysis features | `backend/app/services/stats_service.py` | Core metric computation |
|
||||||
|
| Update UI components | `frontend/src/components/` | React/AntD components |
|
||||||
|
| Adjust AI prompts | `backend/app/services/narrative_service.py` | LLM integration |
|
||||||
|
|
||||||
|
## CODE MAP (KEY SYMBOLS)
|
||||||
|
|
||||||
|
| Symbol | Type | Location | Role |
|
||||||
|
|--------|------|----------|------|
|
||||||
|
| `SpotifyClient` | Class | `backend/app/services/spotify_client.py` | API wrapper & token management |
|
||||||
|
| `StatsService` | Class | `backend/app/services/stats_service.py` | Metric computation & report generation |
|
||||||
|
| `NarrativeService` | Class | `backend/app/services/narrative_service.py` | LLM (Gemini/OpenAI) integration |
|
||||||
|
| `ingest_recently_played` | Function | `backend/app/ingest.py` | Primary data ingestion entry |
|
||||||
|
| `Track` | Model | `backend/app/models.py` | Central track entity with metadata |
|
||||||
|
| `PlayHistory` | Model | `backend/app/models.py` | Immutable log of listening events |
|
||||||
|
|
||||||
|
### Module Dependencies
|
||||||
|
|
||||||
|
```
|
||||||
|
[run_worker.py] ───> [ingest.py] ───> [spotify_client.py]
|
||||||
|
└───> [reccobeats_client.py]
|
||||||
|
[main.py] ─────────> [services/] ───> [models.py]
|
||||||
|
```
|
||||||
|
|
||||||
|
## CONVENTIONS
|
||||||
|
|
||||||
|
- **Single Container Multi-Process**: `backend/entrypoint.sh` starts worker + API (Docker anti-pattern, project-specific).
|
||||||
|
- **PostgreSQL Persistence**: Production uses PostgreSQL on internal server (100.91.248.114:5433, database: music_db).
|
||||||
|
- **Deduplication**: Ingestion checks `(track_id, played_at)` unique constraint before insert.
|
||||||
|
- **Frontend State**: Minimal global state; primarily local component state and API fetching.
|
||||||
|
|
||||||
|
## ANTI-PATTERNS (THIS PROJECT)
|
||||||
|
|
||||||
|
- **Manual DB Edits**: Always use Alembic migrations for schema changes.
|
||||||
|
- **Sync in Async**: Avoid blocking I/O in FastAPI routes (GeniusClient is currently synchronous).
|
||||||
|
- **Hardcoded IDs**: Avoid hardcoding Spotify/Playlist IDs; use `.env` configuration.
|
||||||
|
|
||||||
|
## COMMANDS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend
|
||||||
|
cd backend && uvicorn app.main:app --reload
|
||||||
|
python backend/run_worker.py
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
# Tests
|
||||||
|
cd backend && pytest tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## NOTES
|
||||||
|
|
||||||
|
- Multi-arch Docker builds (`amd64`, `arm64`) automated via GHA.
|
||||||
|
- `ReccoBeats` service used for supplemental audio features (energy, valence).
|
||||||
|
- Genius API used as fallback for lyrics and artist images.
|
||||||
114
Context.md
Normal file
114
Context.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Music Analyser - Project Context & Documentation
|
||||||
|
|
||||||
|
This document serves as a comprehensive guide to the **Music Analyser** project. It outlines the vision, technical decisions, current architecture, and future roadmap. **Use this document to provide context to future AI agents or developers.**
|
||||||
|
|
||||||
|
## 1. Project Vision
|
||||||
|
The goal of this project is to build a personal analytics dashboard that:
|
||||||
|
1. **Regularly queries** the Spotify API (24/7) to collect a complete history of listening habits.
|
||||||
|
2. Stores this data locally (or in a private database) to ensure ownership and completeness.
|
||||||
|
3. Provides **rich analysis** and visualizations (similar to "Spotify Wrapped" but on-demand and more detailed).
|
||||||
|
4. Integrates **AI (Google Gemini)** to provide qualitative insights, summaries, and trend analysis (e.g., "You started the week with high-energy pop but shifted to lo-fi study beats by Friday").
|
||||||
|
|
||||||
|
## 2. Roadmap & Phases
|
||||||
|
|
||||||
|
### Phase 1: Foundation & Data Collection (Current Status: ✅ COMPLETED)
|
||||||
|
- **Goal:** reliable data ingestion and storage.
|
||||||
|
- **Deliverables:**
|
||||||
|
- FastAPI Backend.
|
||||||
|
- SQLite Database (with SQLAlchemy).
|
||||||
|
- Spotify OAuth logic (Refresh Token flow).
|
||||||
|
- Background Worker for 24/7 polling.
|
||||||
|
- Docker containerization + GitHub Actions (Multi-arch build).
|
||||||
|
|
||||||
|
### Phase 2: Visualization (Next Step)
|
||||||
|
- **Goal:** View the raw data.
|
||||||
|
- **Deliverables:**
|
||||||
|
- Frontend (React + Vite).
|
||||||
|
- Basic Data Table / List View of listening history.
|
||||||
|
- Basic filtering (by date, artist).
|
||||||
|
|
||||||
|
### Phase 3: Analysis & AI
|
||||||
|
- **Goal:** Deep insights.
|
||||||
|
- **Deliverables:**
|
||||||
|
- Advanced charts/graphs.
|
||||||
|
- AI Integration (Gemini 2.5/3 Flash) to generate text summaries of listening trends.
|
||||||
|
- Email reports (optional).
|
||||||
|
|
||||||
|
## 3. Technical Architecture
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Language:** Python 3.11+
|
||||||
|
- **Framework:** FastAPI (High performance, easy to use).
|
||||||
|
- **Dependencies:** `httpx` (Async HTTP), `sqlalchemy` (ORM), `pydantic` (Validation).
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- **Current:** SQLite (`music.db`).
|
||||||
|
- *Decision:* Chosen for simplicity in Phase 1.
|
||||||
|
- **Future path:** The code uses SQLAlchemy, so migrating to **PostgreSQL** (e.g., Supabase) only requires changing the connection string in `database.py`.
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
1. **`Track` Table:**
|
||||||
|
- Stores unique tracks.
|
||||||
|
- Columns: `id` (Spotify ID), `name`, `artist`, `album`, `duration_ms`, `metadata_json` (Stores the *entire* raw Spotify JSON response for future-proofing).
|
||||||
|
2. **`PlayHistory` Table:**
|
||||||
|
- Stores the instances of listening.
|
||||||
|
- Columns: `id`, `track_id` (FK), `played_at` (Timestamp), `context_uri`.
|
||||||
|
|
||||||
|
### Authentication Strategy
|
||||||
|
- **Challenge:** The background worker runs headless (no user to click "Login").
|
||||||
|
- **Solution:** We use the **Authorization Code Flow with Refresh Tokens**.
|
||||||
|
1. User runs the local helper script (`backend/scripts/get_refresh_token.py`) once.
|
||||||
|
2. This generates a long-lived `SPOTIFY_REFRESH_TOKEN`.
|
||||||
|
3. The backend uses this token to automatically request new short-lived Access Tokens whenever needed.
|
||||||
|
|
||||||
|
### Background Worker Logic
|
||||||
|
- **File:** `backend/run_worker.py` -> `backend/app/ingest.py`
|
||||||
|
- **Process:**
|
||||||
|
1. Worker wakes up every 60 seconds.
|
||||||
|
2. Calls Spotify `recently-played` endpoint (limit 50).
|
||||||
|
3. Iterates through tracks.
|
||||||
|
4. **Deduplication:** Checks `(track_id, played_at)` against the DB. If it exists, skip. If not, insert.
|
||||||
|
5. **Metadata:** If the track is new to the system, it saves the full metadata immediately.
|
||||||
|
|
||||||
|
### AI Integration
|
||||||
|
- **Model:** Google Gemini (Target: 2.5 Flash or 3 Flash).
|
||||||
|
- **Status:** Service class exists (`AIService`) but is not yet fully wired into the daily workflow.
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
- **Docker:** Multi-stage build (python-slim).
|
||||||
|
- **CI/CD:** GitHub Actions workflow (`docker-publish.yml`).
|
||||||
|
- Builds for `linux/amd64` and `linux/arm64`.
|
||||||
|
- Pushes to GitHub Container Registry (ghcr.io).
|
||||||
|
|
||||||
|
## 4. How to Run
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Spotify Client ID & Secret.
|
||||||
|
- Google Gemini API Key.
|
||||||
|
- Docker (optional).
|
||||||
|
|
||||||
|
### Local Development
|
||||||
|
1. **Setup Env:**
|
||||||
|
```bash
|
||||||
|
cp backend/.env.example backend/.env
|
||||||
|
# Fill in details
|
||||||
|
```
|
||||||
|
2. **Install:**
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
3. **Run API:**
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
4. **Run Worker:**
|
||||||
|
```bash
|
||||||
|
python run_worker.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
```bash
|
||||||
|
docker build -t music-analyser-backend ./backend
|
||||||
|
docker run --env-file backend/.env music-analyser-backend
|
||||||
|
```
|
||||||
84
PHASE_4_FRONTEND_GUIDE.md
Normal file
84
PHASE_4_FRONTEND_GUIDE.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# Phase 4 Frontend Implementation Guide
|
||||||
|
|
||||||
|
This guide details how to consume the data generated by the Phase 3 Backend (Analysis & LLM Engine) and how to display it in the frontend.
|
||||||
|
|
||||||
|
## 1. Data Source
|
||||||
|
|
||||||
|
The backend now produces **Analysis Snapshots**. You should create an API endpoint (e.g., `GET /api/analysis/latest`) that returns the most recent snapshot.
|
||||||
|
|
||||||
|
### JSON Payload Structure
|
||||||
|
|
||||||
|
The response object contains two main keys: `metrics_payload` (calculated numbers) and `narrative_report` (LLM text).
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"date": "2024-12-25T12:00:00Z",
|
||||||
|
"period_label": "last_30_days",
|
||||||
|
"metrics_payload": {
|
||||||
|
"volume": { ... },
|
||||||
|
"time_habits": { ... },
|
||||||
|
"sessions": { ... },
|
||||||
|
"vibe": { ... },
|
||||||
|
"era": { ... },
|
||||||
|
"skips": { ... }
|
||||||
|
},
|
||||||
|
"narrative_report": {
|
||||||
|
"vibe_check": "...",
|
||||||
|
"patterns": ["..."],
|
||||||
|
"persona": "...",
|
||||||
|
"roast": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. UI Components & Display Strategy
|
||||||
|
|
||||||
|
### A. Hero Section ("The Vibe Check")
|
||||||
|
**Data Source:** `narrative_report`
|
||||||
|
- **Headline:** Display `narrative_report.persona` as a large badge/title (e.g., "The Focused Fanatic").
|
||||||
|
- **Narrative:** Display `narrative_report.vibe_check` as the main text.
|
||||||
|
- **Roast:** Add a small, dismissible "Roast Me" alert box containing `narrative_report.roast`.
|
||||||
|
|
||||||
|
### B. "The Vibe" Radar Chart
|
||||||
|
**Data Source:** `metrics_payload.vibe`
|
||||||
|
- Use a **Radar Chart** (Spider Chart) with the following axes (0.0 - 1.0):
|
||||||
|
- Energy (`avg_energy`)
|
||||||
|
- Valence (`avg_valence`)
|
||||||
|
- Danceability (`avg_danceability`)
|
||||||
|
- Acousticness (`avg_acousticness`)
|
||||||
|
- Instrumentalness (`avg_instrumentalness`)
|
||||||
|
- **Tooltip:** Show the exact value.
|
||||||
|
|
||||||
|
### C. Listening Habits (Time & Sessions)
|
||||||
|
**Data Source:** `metrics_payload.time_habits` & `metrics_payload.sessions`
|
||||||
|
- **Hourly Heatmap:** Use a bar chart for `metrics_payload.time_habits.hourly_distribution` (0-23 hours). Highlight the `peak_hour`.
|
||||||
|
- **Session Stats:** Display "Average Session" stats:
|
||||||
|
- `sessions.avg_minutes` (mins)
|
||||||
|
- `sessions.avg_tracks` (tracks)
|
||||||
|
- `sessions.count` (total sessions)
|
||||||
|
|
||||||
|
### D. Top Favorites
|
||||||
|
**Data Source:** `metrics_payload.volume`
|
||||||
|
- **Lists:** Display Top 5 Tracks, Artists, and Genres.
|
||||||
|
- **Images:** You will need to fetch Artist/Track images from Spotify API using the IDs provided in the lists (the current snapshot only stores names/counts for simplicity, but the IDs are available in the backend if you expand the serializer). *Note: Phase 3 backend currently returns names. For Phase 4, ensure the API endpoint enriches these with Spotify Image URLs.*
|
||||||
|
|
||||||
|
### E. Era Analysis
|
||||||
|
**Data Source:** `metrics_payload.era`
|
||||||
|
- **Musical Age:** Display `musical_age` (e.g., "1998") prominently.
|
||||||
|
- **Distribution:** Pie chart for `decade_distribution`.
|
||||||
|
|
||||||
|
### F. Attention Span (Skips)
|
||||||
|
**Data Source:** `metrics_payload.skips`
|
||||||
|
- **Metric:** Display "Skip Rate" (`skip_rate`) as a percentage.
|
||||||
|
- **Insight:** "You skipped X tracks this month."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Integration Tips
|
||||||
|
|
||||||
|
- **Caching:** The backend stores snapshots. You do NOT need to trigger a calculation on page load. Just fetch the latest snapshot.
|
||||||
|
- **Theme:** The app uses Ant Design Dark Mode. Stick to Spotify colors (Black/Green/White) but add accent colors based on the "Vibe" (e.g., High Energy = Red/Orange, Low Energy = Blue/Purple).
|
||||||
|
- **Expansion:** Future snapshots allow for "Trend" views. You can graph `metrics_payload.volume.total_plays` over the last 6 snapshots to show activity trends.
|
||||||
140
README.md
140
README.md
@@ -1,72 +1,140 @@
|
|||||||
# Music Analyser
|
# Music Analyser
|
||||||
|
|
||||||
A personal analytics dashboard for your music listening habits, powered by Python, FastAPI, and Google Gemini AI.
|
A personal analytics dashboard for your music listening habits, powered by Python, FastAPI, React, and Google Gemini AI.
|
||||||
|
|
||||||
## Project Structure
|

|
||||||
|
|
||||||
- `backend/`: FastAPI backend for data ingestion and API.
|
## Features
|
||||||
- `app/ingest.py`: Background worker that polls Spotify.
|
|
||||||
- `app/services/`: Logic for Spotify and Gemini APIs.
|
|
||||||
- `app/models.py`: Database schema (Tracks, PlayHistory).
|
|
||||||
- `frontend/`: (Coming Soon) React/Vite frontend.
|
|
||||||
|
|
||||||
## Getting Started
|
- **Continuous Ingestion**: Polls Spotify every 60 seconds to record your listening history.
|
||||||
|
- **Data Enrichment**:
|
||||||
|
- **Genres & Images** (via Spotify)
|
||||||
|
- **Audio Features** (Energy, BPM, Mood via ReccoBeats)
|
||||||
|
- **Lyrics & Metadata** (via Genius)
|
||||||
|
- **Dashboard**: A responsive UI with Tailwind CSS, featuring AI-generated narrative insights.
|
||||||
|
- **AI Powered**: Google Gemini generates personalized listening narratives and roasts.
|
||||||
|
|
||||||
### Prerequisites
|
## Quick Start (Docker Compose)
|
||||||
|
|
||||||
- Docker & Docker Compose (optional, for containerization)
|
### 1. Prerequisites
|
||||||
- Python 3.11+ (for local dev)
|
- Docker & Docker Compose installed
|
||||||
- A Spotify Developer App (Client ID & Secret)
|
- Spotify Developer Credentials ([Create App](https://developer.spotify.com/dashboard))
|
||||||
- A Google Gemini API Key
|
- Google Gemini API Key ([Get Key](https://aistudio.google.com/app/apikey))
|
||||||
|
- Genius API Token (Optional, for lyrics - [Get Token](https://genius.com/api-clients))
|
||||||
|
|
||||||
### 1. Setup Environment Variables
|
### 2. Get Spotify Refresh Token
|
||||||
|
|
||||||
Create a `.env` file in the `backend/` directory:
|
Run this one-time script locally to authorize your Spotify account:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pip install httpx
|
||||||
|
python scripts/get_refresh_token.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Follow the prompts. Copy the `refresh_token` value for your `.env` file.
|
||||||
|
|
||||||
|
### 3. Create `.env` File
|
||||||
|
|
||||||
|
Create a `.env` file in the project root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
SPOTIFY_CLIENT_ID="your_client_id"
|
SPOTIFY_CLIENT_ID="your_client_id"
|
||||||
SPOTIFY_CLIENT_SECRET="your_client_secret"
|
SPOTIFY_CLIENT_SECRET="your_client_secret"
|
||||||
SPOTIFY_REFRESH_TOKEN="your_refresh_token"
|
SPOTIFY_REFRESH_TOKEN="your_refresh_token"
|
||||||
GEMINI_API_KEY="your_gemini_key"
|
GEMINI_API_KEY="your_gemini_key"
|
||||||
|
GENIUS_ACCESS_TOKEN="your_genius_token" # Optional
|
||||||
```
|
```
|
||||||
|
|
||||||
To get the `SPOTIFY_REFRESH_TOKEN`, run the helper script:
|
### 4. Run with Pre-built Images
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python backend/scripts/get_refresh_token.py
|
# Pull the latest images
|
||||||
|
docker pull ghcr.io/bnair123/musicanalyser:latest
|
||||||
|
docker pull ghcr.io/bnair123/musicanalyser-frontend:latest
|
||||||
|
|
||||||
|
# Start the services
|
||||||
|
docker-compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Run Locally
|
Or build from source:
|
||||||
|
|
||||||
Install dependencies:
|
```bash
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Access the Dashboard
|
||||||
|
|
||||||
|
Open your browser to: **http://localhost:8991**
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────┐ ┌─────────────────────┐
|
||||||
|
│ Frontend │ │ Backend │
|
||||||
|
│ (React + Vite) │────▶│ (FastAPI + Worker) │
|
||||||
|
│ Port: 8991 │ │ Port: 8000 │
|
||||||
|
└─────────────────────┘ └─────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────┴────────┐
|
||||||
|
▼ ▼
|
||||||
|
┌──────────┐ ┌──────────────┐
|
||||||
|
│PostgreSQL│ │ Spotify API │
|
||||||
|
│ music_db │ │ Gemini AI │
|
||||||
|
└──────────┘ └──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Backend Container**: Runs both the FastAPI server AND the background Spotify polling worker
|
||||||
|
- **Frontend Container**: Nginx serving the React build, proxies `/api/` to backend
|
||||||
|
- **Database**: PostgreSQL hosted on internal server (100.91.248.114:5433)
|
||||||
|
|
||||||
|
## Data Persistence
|
||||||
|
|
||||||
|
Your listening history is stored in PostgreSQL:
|
||||||
|
- Host: `100.91.248.114:5433`
|
||||||
|
- Database: `music_db`
|
||||||
|
- Data Location (on server): `/opt/DB/MusicDB/pgdata`
|
||||||
|
- Migrations run automatically on container startup via Alembic
|
||||||
|
|
||||||
|
To backup your data:
|
||||||
|
```bash
|
||||||
|
pg_dump -h 100.91.248.114 -p 5433 -U bnair music_db > backup.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
### Backend
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
|
python -m venv venv && source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
|
||||||
|
|
||||||
Run the server:
|
# Run migrations
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
```bash
|
# Start worker (polls Spotify every 60s)
|
||||||
|
python run_worker.py &
|
||||||
|
|
||||||
|
# Start API server
|
||||||
uvicorn app.main:app --reload
|
uvicorn app.main:app --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
The API will be available at `http://localhost:8000`.
|
### Frontend
|
||||||
|
|
||||||
### 3. Run Ingestion (Manually)
|
|
||||||
|
|
||||||
You can trigger the ingestion process via the API:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8000/trigger-ingest
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Or run the ingestion logic directly via python shell (see `app/ingest.py`).
|
Access at http://localhost:5173 (Vite proxies `/api` to backend automatically)
|
||||||
|
|
||||||
### 4. Docker Build
|
## Environment Variables
|
||||||
|
|
||||||
To build the image locally:
|
| Variable | Required | Description |
|
||||||
|
|----------|----------|-------------|
|
||||||
```bash
|
| `SPOTIFY_CLIENT_ID` | Yes | Spotify app client ID |
|
||||||
docker build -t music-analyser-backend ./backend
|
| `SPOTIFY_CLIENT_SECRET` | Yes | Spotify app client secret |
|
||||||
```
|
| `SPOTIFY_REFRESH_TOKEN` | Yes | Long-lived refresh token from OAuth |
|
||||||
|
| `GEMINI_API_KEY` | Yes | Google Gemini API key |
|
||||||
|
| `GENIUS_ACCESS_TOKEN` | No | Genius API token for lyrics |
|
||||||
|
| `DATABASE_URL` | No | PostgreSQL URL (default: `postgresql://bnair:Bharath2002@100.91.248.114:5433/music_db`) |
|
||||||
|
|||||||
21
TODO.md
Normal file
21
TODO.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
🎵 Playlist Service Feature - Complete Task List
|
||||||
|
What's Been Done ✅
|
||||||
|
| # | Task | Status | Notes |
|
||||||
|
|---|-------|--------|-------|
|
||||||
|
| 1 | Database | ✅ Completed | Added playlist_theme, playlist_theme_reasoning, six_hour_playlist_id, daily_playlist_id columns to AnalysisSnapshot model |
|
||||||
|
| 2 | AI Service | ✅ Completed | Added generate_playlist_theme(), _build_theme_prompt(), _call_openai_for_theme(), updated _build_prompt() to remove HHI/Gini/part_of_day |
|
||||||
|
| 3 | PlaylistService | ✅ Completed | Implemented full curation logic with ensure_playlists_exist(), curate_six_hour_playlist(), curate_daily_playlist(), _get_top_all_time_tracks() |
|
||||||
|
| 4 | Migration | ✅ Completed | Created 5ed73db9bab9_add_playlist_columns.py and applied to DB |
|
||||||
|
| 5 | API Endpoints | ✅ Completed | Added /playlists/refresh/* and /playlists GET endpoints in main.py |
|
||||||
|
| 6 | Worker Scheduler | ✅ Completed | Added 6h and 24h refresh logic to run_worker.py via ingest.py |
|
||||||
|
| 7 | Frontend Tooltip | ✅ Completed | Created Tooltip.jsx component |
|
||||||
|
| 8 | Playlists Section | ✅ Completed | Created PlaylistsSection.jsx with refresh and Spotify links |
|
||||||
|
| 9 | Integration | ✅ Completed | Integrated PlaylistsSection into Dashboard.jsx and added tooltips to StatsGrid.jsx |
|
||||||
|
| 10 | Docker Config | ✅ Completed | Updated docker-compose.yml and Dockerfile (curl for healthcheck) |
|
||||||
|
|
||||||
|
All feature tasks are COMPLETE and VERIFIED.
|
||||||
|
End-to-end testing with Playwright confirms:
|
||||||
|
- 6-hour refresh correctly calls AI and Spotify, saves snapshot.
|
||||||
|
- Daily refresh correctly curates mix and saves snapshot.
|
||||||
|
- Dashboard displays themed playlists and refresh status.
|
||||||
|
- Tooltips provide context for technical metrics.
|
||||||
@@ -3,9 +3,22 @@ FROM python:3.11-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
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 .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
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
114
backend/TECHNICAL_DOCS.md
Normal 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 (Herfindahl–Hirschman 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
76
backend/TESTING.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Testing Guide
|
||||||
|
|
||||||
|
This project includes a comprehensive test suite to verify the calculation engine (`StatsService`) and the AI narrative generation (`NarrativeService`).
|
||||||
|
|
||||||
|
## 1. Quick Start (Standalone Test)
|
||||||
|
|
||||||
|
You can run the full stats verification script without installing `pytest`. This script uses an in-memory SQLite database, seeds it with synthetic listening history (including skips, sessions, and specific genres), and prints the computed analysis JSON.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ensure you are in the root directory
|
||||||
|
# If you are using the virtual environment:
|
||||||
|
source backend/venv/bin/activate
|
||||||
|
|
||||||
|
# Run the test
|
||||||
|
python backend/tests/test_stats_full.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### What does this verify?
|
||||||
|
- **Volume Metrics:** Total plays, unique tracks/artists.
|
||||||
|
- **Session Logic:** Correctly groups plays into sessions based on 20-minute gaps.
|
||||||
|
- **Skip Detection:** Identifies "boredom skips" based on timestamp deltas.
|
||||||
|
- **Vibe Analysis:** Verifies K-Means clustering, tempo zones, and harmonic profiles.
|
||||||
|
- **Context Analysis:** Checks if plays are correctly attributed to Playlists/Albums.
|
||||||
|
|
||||||
|
## 2. Generating a Spotify Refresh Token
|
||||||
|
|
||||||
|
To run the actual application, you need a Spotify Refresh Token. We provide a script to automate the OAuth flow.
|
||||||
|
|
||||||
|
1. **Prerequisites:**
|
||||||
|
* Go to [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/).
|
||||||
|
* Create an App.
|
||||||
|
* In settings, add `http://localhost:8888/callback` to "Redirect URIs".
|
||||||
|
* Get your **Client ID** and **Client Secret**.
|
||||||
|
|
||||||
|
2. **Run the Script:**
|
||||||
|
```bash
|
||||||
|
python backend/scripts/get_refresh_token.py
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Follow Instructions:**
|
||||||
|
* Enter your Client ID/Secret when prompted.
|
||||||
|
* The script will open your browser.
|
||||||
|
* Log in to Spotify and authorize the app.
|
||||||
|
* The script will print your `SPOTIFY_REFRESH_TOKEN` in the terminal.
|
||||||
|
|
||||||
|
4. **Save to .env:**
|
||||||
|
Copy the output into your `.env` file.
|
||||||
|
|
||||||
|
## 3. Full Test Suite (Pytest)
|
||||||
|
|
||||||
|
If you wish to run the full suite using `pytest` (recommended for CI/CD), install the dev dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest backend/tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Manual Verification
|
||||||
|
|
||||||
|
To verify the system end-to-end with real data:
|
||||||
|
|
||||||
|
1. Start the backend:
|
||||||
|
```bash
|
||||||
|
python backend/run_worker.py
|
||||||
|
```
|
||||||
|
2. Wait for a few minutes for data to ingest (check logs).
|
||||||
|
3. Run the analysis manually:
|
||||||
|
```bash
|
||||||
|
python backend/run_analysis.py
|
||||||
|
```
|
||||||
|
4. Check the database or logs for the generated `AnalysisSnapshot`.
|
||||||
147
backend/alembic.ini
Normal file
147
backend/alembic.ini
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# A generic, single database configuration.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# path to migration scripts.
|
||||||
|
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||||
|
# format, relative to the token %(here)s which refers to the location of this
|
||||||
|
# ini file
|
||||||
|
script_location = %(here)s/alembic
|
||||||
|
|
||||||
|
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||||
|
# Uncomment the line below if you want the files to be prepended with date and time
|
||||||
|
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||||
|
# for all available tokens
|
||||||
|
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# sys.path path, will be prepended to sys.path if present.
|
||||||
|
# defaults to the current working directory. for multiple paths, the path separator
|
||||||
|
# is defined by "path_separator" below.
|
||||||
|
prepend_sys_path = .
|
||||||
|
|
||||||
|
|
||||||
|
# timezone to use when rendering the date within the migration file
|
||||||
|
# as well as the filename.
|
||||||
|
# If specified, requires the tzdata library which can be installed by adding
|
||||||
|
# `alembic[tz]` to the pip requirements.
|
||||||
|
# string value is passed to ZoneInfo()
|
||||||
|
# leave blank for localtime
|
||||||
|
# timezone =
|
||||||
|
|
||||||
|
# max length of characters to apply to the "slug" field
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# set to 'true' to run the environment during
|
||||||
|
# the 'revision' command, regardless of autogenerate
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# set to 'true' to allow .pyc and .pyo files without
|
||||||
|
# a source .py file to be detected as revisions in the
|
||||||
|
# versions/ directory
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# version location specification; This defaults
|
||||||
|
# to <script_location>/versions. When using multiple version
|
||||||
|
# directories, initial revisions must be specified with --version-path.
|
||||||
|
# The path separator used here should be the separator specified by "path_separator"
|
||||||
|
# below.
|
||||||
|
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||||
|
|
||||||
|
# path_separator; This indicates what character is used to split lists of file
|
||||||
|
# paths, including version_locations and prepend_sys_path within configparser
|
||||||
|
# files such as alembic.ini.
|
||||||
|
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||||
|
# to provide os-dependent path splitting.
|
||||||
|
#
|
||||||
|
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||||
|
# take place if path_separator is not present in alembic.ini. If this
|
||||||
|
# option is omitted entirely, fallback logic is as follows:
|
||||||
|
#
|
||||||
|
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||||
|
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||||
|
# behavior of splitting on spaces and/or commas.
|
||||||
|
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||||
|
# behavior of splitting on spaces, commas, or colons.
|
||||||
|
#
|
||||||
|
# Valid values for path_separator are:
|
||||||
|
#
|
||||||
|
# path_separator = :
|
||||||
|
# path_separator = ;
|
||||||
|
# path_separator = space
|
||||||
|
# path_separator = newline
|
||||||
|
#
|
||||||
|
# Use os.pathsep. Default configuration used for new projects.
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# set to 'true' to search source files recursively
|
||||||
|
# in each "version_locations" directory
|
||||||
|
# new in Alembic version 1.10
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# the output encoding used when revision files
|
||||||
|
# are written from script.py.mako
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# database URL. This is consumed by the user-maintained env.py script only.
|
||||||
|
# other means of configuring database URLs may be customized within the env.py
|
||||||
|
# file.
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# post_write_hooks defines scripts or Python functions that are run
|
||||||
|
# on newly generated revision scripts. See the documentation for further
|
||||||
|
# detail and examples
|
||||||
|
|
||||||
|
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||||
|
# hooks = black
|
||||||
|
# black.type = console_scripts
|
||||||
|
# black.entrypoint = black
|
||||||
|
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = module
|
||||||
|
# ruff.module = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = ruff
|
||||||
|
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration. This is also consumed by the user-maintained
|
||||||
|
# env.py script only.
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Generic single-database configuration.
|
||||||
75
backend/alembic/env.py
Normal file
75
backend/alembic/env.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from sqlalchemy import engine_from_config
|
||||||
|
from sqlalchemy import pool
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Add app to path to import models
|
||||||
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from app.database import Base, SQLALCHEMY_DATABASE_URL
|
||||||
|
from app.models import *
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
config.set_main_option("sqlalchemy.url", SQLALCHEMY_DATABASE_URL)
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(
|
||||||
|
connection=connection, target_metadata=target_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -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 ###
|
||||||
@@ -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 ###
|
||||||
@@ -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 ###
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Initial Schema Complete
|
||||||
|
|
||||||
|
Revision ID: 707387fe1be2
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-12-24 21:23:43.744292
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '707387fe1be2'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('tracks',
|
||||||
|
sa.Column('id', sa.String(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(), nullable=True),
|
||||||
|
sa.Column('artist', sa.String(), nullable=True),
|
||||||
|
sa.Column('album', sa.String(), nullable=True),
|
||||||
|
sa.Column('duration_ms', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('popularity', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('raw_data', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('danceability', sa.Float(), nullable=True),
|
||||||
|
sa.Column('energy', sa.Float(), nullable=True),
|
||||||
|
sa.Column('key', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('loudness', sa.Float(), nullable=True),
|
||||||
|
sa.Column('mode', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('speechiness', sa.Float(), nullable=True),
|
||||||
|
sa.Column('acousticness', sa.Float(), nullable=True),
|
||||||
|
sa.Column('instrumentalness', sa.Float(), nullable=True),
|
||||||
|
sa.Column('liveness', sa.Float(), nullable=True),
|
||||||
|
sa.Column('valence', sa.Float(), nullable=True),
|
||||||
|
sa.Column('tempo', sa.Float(), nullable=True),
|
||||||
|
sa.Column('time_signature', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('genres', sa.JSON(), nullable=True),
|
||||||
|
sa.Column('lyrics_summary', sa.String(), nullable=True),
|
||||||
|
sa.Column('genre_tags', sa.String(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_tracks_id'), 'tracks', ['id'], unique=False)
|
||||||
|
op.create_table('play_history',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('track_id', sa.String(), nullable=True),
|
||||||
|
sa.Column('played_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('context_uri', sa.String(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['track_id'], ['tracks.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_play_history_id'), 'play_history', ['id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_play_history_played_at'), 'play_history', ['played_at'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_play_history_played_at'), table_name='play_history')
|
||||||
|
op.drop_index(op.f('ix_play_history_id'), table_name='play_history')
|
||||||
|
op.drop_table('play_history')
|
||||||
|
op.drop_index(op.f('ix_tracks_id'), table_name='tracks')
|
||||||
|
op.drop_table('tracks')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -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 ###
|
||||||
@@ -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 ###
|
||||||
@@ -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")
|
||||||
25
backend/alembic/versions/b2c3d4e5f6g7_add_reccobeats_id.py
Normal file
25
backend/alembic/versions/b2c3d4e5f6g7_add_reccobeats_id.py
Normal 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")
|
||||||
@@ -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 ###
|
||||||
@@ -1,15 +1,34 @@
|
|||||||
|
import os
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker, declarative_base
|
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(
|
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)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
def get_db():
|
def get_db():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,24 +1,165 @@
|
|||||||
|
from .services.stats_service import StatsService
|
||||||
|
from .services.narrative_service import NarrativeService
|
||||||
|
from .services.playlist_service import PlaylistService
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from .models import Track, PlayHistory
|
from .models import Track, PlayHistory, Artist, AnalysisSnapshot
|
||||||
from .database import SessionLocal
|
from .database import SessionLocal
|
||||||
from .services.spotify_client import SpotifyClient
|
from .services.spotify_client import SpotifyClient
|
||||||
|
from .services.reccobeats_client import ReccoBeatsClient
|
||||||
|
from .services.genius_client import GeniusClient
|
||||||
from dateutil import parser
|
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():
|
def get_spotify_client():
|
||||||
return SpotifyClient(
|
return SpotifyClient(
|
||||||
client_id=os.getenv("SPOTIFY_CLIENT_ID"),
|
client_id=str(os.getenv("SPOTIFY_CLIENT_ID") or ""),
|
||||||
client_secret=os.getenv("SPOTIFY_CLIENT_SECRET"),
|
client_secret=str(os.getenv("SPOTIFY_CLIENT_SECRET") or ""),
|
||||||
refresh_token=os.getenv("SPOTIFY_REFRESH_TOKEN"),
|
refresh_token=str(os.getenv("SPOTIFY_REFRESH_TOKEN") or ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_reccobeats_client():
|
||||||
|
return ReccoBeatsClient()
|
||||||
|
|
||||||
|
|
||||||
|
def get_genius_client():
|
||||||
|
return GeniusClient()
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_artists_exist(db: Session, artists_data: list):
|
||||||
|
artist_objects = []
|
||||||
|
for a_data in artists_data:
|
||||||
|
artist_id = a_data["id"]
|
||||||
|
artist = db.query(Artist).filter(Artist.id == artist_id).first()
|
||||||
|
if not artist:
|
||||||
|
img = None
|
||||||
|
if "images" in a_data and a_data["images"]:
|
||||||
|
img = a_data["images"][0]["url"]
|
||||||
|
|
||||||
|
artist = Artist(id=artist_id, name=a_data["name"], genres=[], image_url=img)
|
||||||
|
db.add(artist)
|
||||||
|
artist_objects.append(artist)
|
||||||
|
return artist_objects
|
||||||
|
|
||||||
|
|
||||||
|
async def enrich_tracks(
|
||||||
|
db: Session,
|
||||||
|
spotify_client: SpotifyClient,
|
||||||
|
recco_client: ReccoBeatsClient,
|
||||||
|
genius_client: GeniusClient,
|
||||||
|
):
|
||||||
|
tracks_missing_features = (
|
||||||
|
db.query(Track).filter(Track.danceability == None).limit(50).all()
|
||||||
|
)
|
||||||
|
if tracks_missing_features:
|
||||||
|
print(f"Enriching {len(tracks_missing_features)} tracks with audio features...")
|
||||||
|
ids = [str(t.id) for t in tracks_missing_features]
|
||||||
|
features_list = await recco_client.get_audio_features(ids)
|
||||||
|
|
||||||
|
features_map = {}
|
||||||
|
for f in features_list:
|
||||||
|
tid = f.get("spotify_id") or f.get("id")
|
||||||
|
if tid:
|
||||||
|
features_map[tid] = f
|
||||||
|
|
||||||
|
for track in tracks_missing_features:
|
||||||
|
data = features_map.get(track.id)
|
||||||
|
if data:
|
||||||
|
track.danceability = data.get("danceability")
|
||||||
|
track.energy = data.get("energy")
|
||||||
|
track.key = data.get("key")
|
||||||
|
track.loudness = data.get("loudness")
|
||||||
|
track.mode = data.get("mode")
|
||||||
|
track.speechiness = data.get("speechiness")
|
||||||
|
track.acousticness = data.get("acousticness")
|
||||||
|
track.instrumentalness = data.get("instrumentalness")
|
||||||
|
track.liveness = data.get("liveness")
|
||||||
|
track.valence = data.get("valence")
|
||||||
|
track.tempo = data.get("tempo")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
artists_missing_data = (
|
||||||
|
db.query(Artist)
|
||||||
|
.filter((Artist.genres == None) | (Artist.image_url == None))
|
||||||
|
.limit(50)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if artists_missing_data:
|
||||||
|
print(f"Enriching {len(artists_missing_data)} artists with genres/images...")
|
||||||
|
artist_ids_list = [str(a.id) for a in artists_missing_data]
|
||||||
|
|
||||||
|
artist_data_map = {}
|
||||||
|
for i in range(0, len(artist_ids_list), 50):
|
||||||
|
chunk = artist_ids_list[i : i + 50]
|
||||||
|
artists_data = await spotify_client.get_artists(chunk)
|
||||||
|
for a_data in artists_data:
|
||||||
|
if a_data:
|
||||||
|
img = a_data["images"][0]["url"] if a_data.get("images") else None
|
||||||
|
artist_data_map[a_data["id"]] = {
|
||||||
|
"genres": a_data.get("genres", []),
|
||||||
|
"image_url": img,
|
||||||
|
}
|
||||||
|
|
||||||
|
for artist in artists_missing_data:
|
||||||
|
data = artist_data_map.get(artist.id)
|
||||||
|
if data:
|
||||||
|
if artist.genres is None:
|
||||||
|
artist.genres = data["genres"]
|
||||||
|
if artist.image_url is None:
|
||||||
|
artist.image_url = data["image_url"]
|
||||||
|
elif artist.genres is None:
|
||||||
|
artist.genres = []
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
tracks_missing_lyrics = (
|
||||||
|
db.query(Track)
|
||||||
|
.filter(Track.lyrics == None)
|
||||||
|
.order_by(Track.updated_at.desc())
|
||||||
|
.limit(10)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if tracks_missing_lyrics and genius_client.genius:
|
||||||
|
print(f"Enriching {len(tracks_missing_lyrics)} tracks with lyrics (Genius)...")
|
||||||
|
for track in tracks_missing_lyrics:
|
||||||
|
artist_name = str(track.artist).split(",")[0]
|
||||||
|
|
||||||
|
print(f"Searching Genius for: {track.name} by {artist_name}")
|
||||||
|
data = genius_client.search_song(str(track.name), artist_name)
|
||||||
|
|
||||||
|
if data:
|
||||||
|
track.lyrics = data["lyrics"]
|
||||||
|
if not track.image_url and data.get("image_url"):
|
||||||
|
track.image_url = data["image_url"]
|
||||||
|
else:
|
||||||
|
track.lyrics = ""
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def ingest_recently_played(db: Session):
|
async def ingest_recently_played(db: Session):
|
||||||
client = get_spotify_client()
|
spotify_client = get_spotify_client()
|
||||||
|
recco_client = get_reccobeats_client()
|
||||||
|
genius_client = get_genius_client()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
items = await client.get_recently_played(limit=50)
|
items = await spotify_client.get_recently_played(limit=50)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error connecting to Spotify: {e}")
|
print(f"Error connecting to Spotify: {e}")
|
||||||
return
|
return
|
||||||
@@ -30,52 +171,258 @@ async def ingest_recently_played(db: Session):
|
|||||||
played_at_str = item["played_at"]
|
played_at_str = item["played_at"]
|
||||||
played_at = parser.isoparse(played_at_str)
|
played_at = parser.isoparse(played_at_str)
|
||||||
|
|
||||||
# 1. Check if track exists, if not create it
|
|
||||||
track_id = track_data["id"]
|
track_id = track_data["id"]
|
||||||
track = db.query(Track).filter(Track.id == track_id).first()
|
track = db.query(Track).filter(Track.id == track_id).first()
|
||||||
|
|
||||||
if not track:
|
if not track:
|
||||||
print(f"New track found: {track_data['name']}")
|
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(
|
track = Track(
|
||||||
id=track_id,
|
id=track_id,
|
||||||
name=track_data["name"],
|
name=track_data["name"],
|
||||||
artist=", ".join([a["name"] for a in track_data["artists"]]),
|
artist=", ".join([a["name"] for a in track_data["artists"]]),
|
||||||
album=track_data["album"]["name"],
|
album=track_data["album"]["name"],
|
||||||
|
image_url=image_url,
|
||||||
duration_ms=track_data["duration_ms"],
|
duration_ms=track_data["duration_ms"],
|
||||||
popularity=track_data["popularity"],
|
popularity=track_data["popularity"],
|
||||||
raw_data=track_data
|
raw_data=track_data,
|
||||||
)
|
)
|
||||||
db.add(track)
|
|
||||||
db.commit() # Commit immediately so ID exists for foreign key
|
|
||||||
|
|
||||||
# 2. Check if this specific play instance exists
|
artists_data = track_data.get("artists", [])
|
||||||
# We assume (track_id, played_at) is unique enough
|
artist_objects = await ensure_artists_exist(db, artists_data)
|
||||||
exists = db.query(PlayHistory).filter(
|
track.artists = artist_objects
|
||||||
PlayHistory.track_id == track_id,
|
|
||||||
PlayHistory.played_at == played_at
|
db.add(track)
|
||||||
).first()
|
db.commit()
|
||||||
|
|
||||||
|
if not track.artists and track.raw_data and "artists" in track.raw_data:
|
||||||
|
artist_objects = await ensure_artists_exist(db, track.raw_data["artists"])
|
||||||
|
track.artists = artist_objects
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
exists = (
|
||||||
|
db.query(PlayHistory)
|
||||||
|
.filter(
|
||||||
|
PlayHistory.track_id == track_id, PlayHistory.played_at == played_at
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
if not exists:
|
if not exists:
|
||||||
print(f" recording play: {track_data['name']} at {played_at}")
|
print(f" recording play: {track_data['name']} at {played_at}")
|
||||||
play = PlayHistory(
|
play = PlayHistory(
|
||||||
track_id=track_id,
|
track_id=track_id,
|
||||||
played_at=played_at,
|
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.add(play)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
await enrich_tracks(db, spotify_client, recco_client, genius_client)
|
||||||
|
|
||||||
|
|
||||||
async def run_worker():
|
async def run_worker():
|
||||||
"""Simulates a background worker loop."""
|
|
||||||
db = SessionLocal()
|
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:
|
try:
|
||||||
while True:
|
while True:
|
||||||
print("Worker: Polling Spotify...")
|
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)
|
await ingest_recently_played(db)
|
||||||
print("Worker: Sleeping for 60 seconds...")
|
|
||||||
await asyncio.sleep(60)
|
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:
|
except Exception as e:
|
||||||
print(f"Worker crashed: {e}")
|
print(f"Worker crashed: {e}")
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
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()
|
||||||
|
|||||||
@@ -1,36 +1,369 @@
|
|||||||
from fastapi import FastAPI, Depends
|
import os
|
||||||
from sqlalchemy.orm import Session
|
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks, Query
|
||||||
from .database import engine, Base, get_db
|
from sqlalchemy.orm import Session, joinedload
|
||||||
from .models import PlayHistory as PlayHistoryModel, Track as TrackModel
|
from datetime import datetime, timedelta
|
||||||
from . import schemas
|
from typing import List, Optional
|
||||||
from .ingest import ingest_recently_played
|
|
||||||
import asyncio
|
|
||||||
from typing import List
|
|
||||||
from dotenv import load_dotenv
|
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()
|
load_dotenv()
|
||||||
|
|
||||||
# Create tables
|
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
app = FastAPI(title="Music Analyser Backend")
|
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("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
return {"status": "ok", "message": "Music Analyser API is running"}
|
return {"status": "ok", "message": "Music Analyser API is running"}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/history", response_model=List[schemas.PlayHistory])
|
@app.get("/history", response_model=List[schemas.PlayHistory])
|
||||||
def get_history(limit: int = 50, db: Session = Depends(get_db)):
|
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
|
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])
|
@app.get("/tracks", response_model=List[schemas.Track])
|
||||||
def get_tracks(limit: int = 50, db: Session = Depends(get_db)):
|
def get_tracks(limit: int = 50, db: Session = Depends(get_db)):
|
||||||
tracks = db.query(TrackModel).limit(limit).all()
|
tracks = db.query(TrackModel).limit(limit).all()
|
||||||
return tracks
|
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}
|
||||||
|
|||||||
@@ -1,29 +1,85 @@
|
|||||||
from sqlalchemy import Column, Integer, String, DateTime, JSON, ForeignKey, Boolean
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
DateTime,
|
||||||
|
JSON,
|
||||||
|
ForeignKey,
|
||||||
|
Float,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
)
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .database import Base
|
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):
|
class Track(Base):
|
||||||
__tablename__ = "tracks"
|
__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)
|
name = Column(String)
|
||||||
artist = Column(String)
|
artist = Column(
|
||||||
|
String
|
||||||
|
) # Display string (e.g. "Drake, Future") - kept for convenience
|
||||||
album = Column(String)
|
album = Column(String)
|
||||||
|
image_url = Column(String, nullable=True) # Album art
|
||||||
duration_ms = Column(Integer)
|
duration_ms = Column(Integer)
|
||||||
popularity = Column(Integer, nullable=True)
|
popularity = Column(Integer, nullable=True)
|
||||||
|
|
||||||
# Store raw full JSON response for future-proofing analysis
|
# Store raw full JSON response for future-proofing analysis
|
||||||
raw_data = Column(JSON, nullable=True)
|
raw_data = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Enriched Data (Phase 3 Prep)
|
||||||
|
# Audio Features
|
||||||
|
danceability = Column(Float, nullable=True)
|
||||||
|
energy = Column(Float, nullable=True)
|
||||||
|
key = Column(Integer, nullable=True)
|
||||||
|
loudness = Column(Float, nullable=True)
|
||||||
|
mode = Column(Integer, nullable=True)
|
||||||
|
speechiness = Column(Float, nullable=True)
|
||||||
|
acousticness = Column(Float, nullable=True)
|
||||||
|
instrumentalness = Column(Float, nullable=True)
|
||||||
|
liveness = Column(Float, nullable=True)
|
||||||
|
valence = Column(Float, nullable=True)
|
||||||
|
tempo = Column(Float, nullable=True)
|
||||||
|
time_signature = Column(Integer, nullable=True)
|
||||||
|
|
||||||
|
# Genres (stored as JSON list of strings) - DEPRECATED in favor of Artist.genres but kept for now
|
||||||
|
genres = Column(JSON, nullable=True)
|
||||||
|
|
||||||
# AI Analysis fields
|
# AI Analysis fields
|
||||||
|
lyrics = Column(Text, nullable=True) # Full lyrics from Genius
|
||||||
lyrics_summary = Column(String, nullable=True)
|
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)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
plays = relationship("PlayHistory", back_populates="track")
|
plays = relationship("PlayHistory", back_populates="track")
|
||||||
|
artists = relationship("Artist", secondary=track_artists, back_populates="tracks")
|
||||||
|
|
||||||
|
|
||||||
class PlayHistory(Base):
|
class PlayHistory(Base):
|
||||||
@@ -31,9 +87,61 @@ class PlayHistory(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
track_id = Column(String, ForeignKey("tracks.id"))
|
track_id = Column(String, ForeignKey("tracks.id"))
|
||||||
played_at = Column(DateTime, index=True) # The timestamp from Spotify
|
played_at = Column(DateTime, index=True)
|
||||||
|
|
||||||
# Context (album, playlist, etc.)
|
|
||||||
context_uri = Column(String, nullable=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")
|
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)
|
||||||
|
|||||||
@@ -12,6 +12,19 @@ class TrackBase(BaseModel):
|
|||||||
lyrics_summary: Optional[str] = None
|
lyrics_summary: Optional[str] = None
|
||||||
genre_tags: Optional[str] = None
|
genre_tags: Optional[str] = None
|
||||||
|
|
||||||
|
# Audio Features
|
||||||
|
danceability: Optional[float] = None
|
||||||
|
energy: Optional[float] = None
|
||||||
|
valence: Optional[float] = None
|
||||||
|
tempo: Optional[float] = None
|
||||||
|
key: Optional[int] = None
|
||||||
|
mode: Optional[int] = None
|
||||||
|
acousticness: Optional[float] = None
|
||||||
|
instrumentalness: Optional[float] = None
|
||||||
|
liveness: Optional[float] = None
|
||||||
|
speechiness: Optional[float] = None
|
||||||
|
loudness: Optional[float] = None
|
||||||
|
|
||||||
class Track(TrackBase):
|
class Track(TrackBase):
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|||||||
40
backend/app/services/AGENTS.md
Normal file
40
backend/app/services/AGENTS.md
Normal 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.
|
||||||
103
backend/app/services/genius_client.py
Normal file
103
backend/app/services/genius_client.py
Normal 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("&", "&")
|
||||||
|
.replace(""", '"')
|
||||||
|
.replace("'", "'")
|
||||||
|
)
|
||||||
|
lyrics += text + "\n"
|
||||||
|
|
||||||
|
return lyrics.strip() if lyrics.strip() else None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Lyrics scrape error: {e}")
|
||||||
|
return None
|
||||||
276
backend/app/services/narrative_service.py
Normal file
276
backend/app/services/narrative_service.py
Normal 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",
|
||||||
|
}
|
||||||
396
backend/app/services/playlist_service.py
Normal file
396
backend/app/services/playlist_service.py
Normal 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
|
||||||
136
backend/app/services/reccobeats_client.py
Normal file
136
backend/app/services/reccobeats_client.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import httpx
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
RECCOBEATS_BASE_URL = "https://api.reccobeats.com/v1"
|
||||||
|
|
||||||
|
|
||||||
|
class ReccoBeatsClient:
|
||||||
|
def __init__(self):
|
||||||
|
self.timeout = 30.0
|
||||||
|
|
||||||
|
async def get_tracks(self, spotify_ids: List[str]) -> List[Dict[str, Any]]:
|
||||||
|
if not spotify_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
ids_param = ",".join(spotify_ids)
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
f"{RECCOBEATS_BASE_URL}/track", params={"ids": ids_param}
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"ReccoBeats /track returned status {response.status_code}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
content = response.json().get("content", [])
|
||||||
|
|
||||||
|
for item in content:
|
||||||
|
href = item.get("href", "")
|
||||||
|
if "spotify.com/track/" in href:
|
||||||
|
item["spotify_id"] = href.split("/track/")[-1].split("?")[0]
|
||||||
|
|
||||||
|
return content
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ReccoBeats /track error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_audio_features(self, spotify_ids: List[str]) -> List[Dict[str, Any]]:
|
||||||
|
"""Fetch audio features for tracks. Batches in chunks of 40 (API limit)."""
|
||||||
|
if not spotify_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
all_results = []
|
||||||
|
batch_size = 40 # ReccoBeats API returns 400 for 50+ IDs
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
for i in range(0, len(spotify_ids), batch_size):
|
||||||
|
batch = spotify_ids[i : i + batch_size]
|
||||||
|
ids_param = ",".join(batch)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
f"{RECCOBEATS_BASE_URL}/audio-features",
|
||||||
|
params={"ids": ids_param},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(
|
||||||
|
f"ReccoBeats /audio-features returned status {response.status_code}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
content = response.json().get("content", [])
|
||||||
|
|
||||||
|
for item in content:
|
||||||
|
href = item.get("href", "")
|
||||||
|
if "spotify.com/track/" in href:
|
||||||
|
item["spotify_id"] = href.split("/track/")[-1].split("?")[0]
|
||||||
|
|
||||||
|
all_results.extend(content)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ReccoBeats /audio-features error: {e}")
|
||||||
|
|
||||||
|
return all_results
|
||||||
|
|
||||||
|
async def get_recommendations(
|
||||||
|
self,
|
||||||
|
seed_ids: List[str],
|
||||||
|
size: int = 20,
|
||||||
|
negative_seeds: Optional[List[str]] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
if not seed_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if len(seed_ids) > 5:
|
||||||
|
seed_ids = seed_ids[:5]
|
||||||
|
|
||||||
|
params = {"seeds": ",".join(seed_ids), "size": size}
|
||||||
|
|
||||||
|
if negative_seeds:
|
||||||
|
if len(negative_seeds) > 5:
|
||||||
|
negative_seeds = negative_seeds[:5]
|
||||||
|
params["negativeSeeds"] = ",".join(negative_seeds)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
f"{RECCOBEATS_BASE_URL}/track/recommendation", params=params
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(
|
||||||
|
f"ReccoBeats /recommendation returned status {response.status_code}"
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
|
||||||
|
content = response.json().get("content", [])
|
||||||
|
|
||||||
|
for item in content:
|
||||||
|
href = item.get("href", "")
|
||||||
|
if "spotify.com/track/" in href:
|
||||||
|
item["spotify_id"] = href.split("/track/")[-1].split("?")[0]
|
||||||
|
|
||||||
|
return content
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ReccoBeats /recommendation error: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_audio_features_by_reccobeats_ids(
|
||||||
|
self, reccobeats_ids: List[str]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
if not reccobeats_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results = []
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
for rb_id in reccobeats_ids:
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
f"{RECCOBEATS_BASE_URL}/track/{rb_id}/audio-features"
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
data["reccobeats_id"] = rb_id
|
||||||
|
results.append(data)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ReccoBeats audio-features for {rb_id} error: {e}")
|
||||||
|
|
||||||
|
return results
|
||||||
@@ -3,10 +3,12 @@ import base64
|
|||||||
import time
|
import time
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
|
SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||||
SPOTIFY_API_BASE = "https://api.spotify.com/v1"
|
SPOTIFY_API_BASE = "https://api.spotify.com/v1"
|
||||||
|
|
||||||
|
|
||||||
class SpotifyClient:
|
class SpotifyClient:
|
||||||
def __init__(self, client_id: str, client_secret: str, refresh_token: str):
|
def __init__(self, client_id: str, client_secret: str, refresh_token: str):
|
||||||
self.client_id = client_id
|
self.client_id = client_id
|
||||||
@@ -68,3 +70,161 @@ class SpotifyClient:
|
|||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
return None
|
return None
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
|
async def get_tracks(self, track_ids: List[str]) -> List[Dict[str, Any]]:
|
||||||
|
"""Fetch multiple tracks by ID."""
|
||||||
|
if not track_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
token = await self.get_access_token()
|
||||||
|
ids_param = ",".join(track_ids[:50])
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{SPOTIFY_API_BASE}/tracks",
|
||||||
|
params={"ids": ids_param},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"Error fetching tracks: {response.text}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
return response.json().get("tracks", [])
|
||||||
|
|
||||||
|
async def get_artists(self, artist_ids: List[str]) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Fetches artist details (including genres) for a list of artist IDs.
|
||||||
|
Spotify allows up to 50 IDs per request.
|
||||||
|
"""
|
||||||
|
if not artist_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
token = await self.get_access_token()
|
||||||
|
ids_param = ",".join(artist_ids)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{SPOTIFY_API_BASE}/artists",
|
||||||
|
params={"ids": ids_param},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"Error fetching artists: {response.text}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
return response.json().get("artists", [])
|
||||||
|
|
||||||
|
async def get_currently_playing(self) -> Dict[str, Any] | None:
|
||||||
|
token = await self.get_access_token()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{SPOTIFY_API_BASE}/me/player/currently-playing",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
if response.status_code == 204:
|
||||||
|
return None
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"Error fetching currently playing: {response.text}")
|
||||||
|
return None
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def get_current_user_id(self) -> str:
|
||||||
|
token = await self.get_access_token()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{SPOTIFY_API_BASE}/me",
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise Exception(f"Failed to get user profile: {response.text}")
|
||||||
|
return response.json().get("id")
|
||||||
|
|
||||||
|
async def create_playlist(
|
||||||
|
self, user_id: str, name: str, description: str = "", public: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
token = await self.get_access_token()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{SPOTIFY_API_BASE}/users/{user_id}/playlists",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={"name": name, "description": description, "public": public},
|
||||||
|
)
|
||||||
|
if response.status_code not in [200, 201]:
|
||||||
|
raise Exception(f"Failed to create playlist: {response.text}")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def update_playlist_details(
|
||||||
|
self, playlist_id: str, name: str = None, description: str = None
|
||||||
|
) -> bool:
|
||||||
|
token = await self.get_access_token()
|
||||||
|
data = {}
|
||||||
|
if name:
|
||||||
|
data["name"] = name
|
||||||
|
if description:
|
||||||
|
data["description"] = description
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.put(
|
||||||
|
f"{SPOTIFY_API_BASE}/playlists/{playlist_id}",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json=data,
|
||||||
|
)
|
||||||
|
return response.status_code == 200
|
||||||
|
|
||||||
|
async def replace_playlist_tracks(
|
||||||
|
self, playlist_id: str, track_uris: List[str]
|
||||||
|
) -> bool:
|
||||||
|
token = await self.get_access_token()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.put(
|
||||||
|
f"{SPOTIFY_API_BASE}/playlists/{playlist_id}/tracks",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={"uris": track_uris[:100]},
|
||||||
|
)
|
||||||
|
return response.status_code in [200, 201]
|
||||||
|
|
||||||
|
async def get_recommendations(
|
||||||
|
self,
|
||||||
|
seed_tracks: List[str] = None,
|
||||||
|
seed_artists: List[str] = None,
|
||||||
|
seed_genres: List[str] = None,
|
||||||
|
limit: int = 20,
|
||||||
|
target_energy: float = None,
|
||||||
|
target_valence: float = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
token = await self.get_access_token()
|
||||||
|
params = {"limit": limit}
|
||||||
|
|
||||||
|
if seed_tracks:
|
||||||
|
params["seed_tracks"] = ",".join(seed_tracks[:5])
|
||||||
|
if seed_artists:
|
||||||
|
params["seed_artists"] = ",".join(seed_artists[:5])
|
||||||
|
if seed_genres:
|
||||||
|
params["seed_genres"] = ",".join(seed_genres[:5])
|
||||||
|
if target_energy is not None:
|
||||||
|
params["target_energy"] = target_energy
|
||||||
|
if target_valence is not None:
|
||||||
|
params["target_valence"] = target_valence
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{SPOTIFY_API_BASE}/recommendations",
|
||||||
|
params=params,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
if response.status_code != 200:
|
||||||
|
print(f"Error fetching recommendations: {response.text}")
|
||||||
|
return []
|
||||||
|
return response.json().get("tracks", [])
|
||||||
|
|||||||
996
backend/app/services/stats_service.py
Normal file
996
backend/app/services/stats_service.py
Normal file
@@ -0,0 +1,996 @@
|
|||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
from sqlalchemy import func, distinct
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
import math
|
||||||
|
import numpy as np
|
||||||
|
from sklearn.cluster import KMeans
|
||||||
|
|
||||||
|
from ..models import PlayHistory, Track, Artist
|
||||||
|
|
||||||
|
|
||||||
|
class StatsService:
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def compute_comparison(
|
||||||
|
self,
|
||||||
|
current_stats: Dict[str, Any],
|
||||||
|
period_start: datetime,
|
||||||
|
period_end: datetime,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
duration = period_end - period_start
|
||||||
|
prev_end = period_start
|
||||||
|
prev_start = prev_end - duration
|
||||||
|
|
||||||
|
prev_volume = self.compute_volume_stats(prev_start, prev_end)
|
||||||
|
prev_vibe = self.compute_vibe_stats(prev_start, prev_end)
|
||||||
|
prev_taste = self.compute_taste_stats(prev_start, prev_end)
|
||||||
|
|
||||||
|
deltas = {}
|
||||||
|
|
||||||
|
curr_plays = current_stats["volume"]["total_plays"]
|
||||||
|
prev_plays_count = prev_volume["total_plays"]
|
||||||
|
deltas["plays_delta"] = curr_plays - prev_plays_count
|
||||||
|
deltas["plays_pct_change"] = self._pct_change(curr_plays, prev_plays_count)
|
||||||
|
|
||||||
|
if "mood_quadrant" in current_stats["vibe"] and "mood_quadrant" in prev_vibe:
|
||||||
|
curr_e = current_stats["vibe"]["mood_quadrant"]["y"]
|
||||||
|
prev_e = prev_vibe["mood_quadrant"]["y"]
|
||||||
|
deltas["energy_delta"] = round(curr_e - prev_e, 2)
|
||||||
|
|
||||||
|
curr_v = current_stats["vibe"]["mood_quadrant"]["x"]
|
||||||
|
prev_v = prev_vibe["mood_quadrant"]["x"]
|
||||||
|
deltas["valence_delta"] = round(curr_v - prev_v, 2)
|
||||||
|
|
||||||
|
if (
|
||||||
|
"avg_popularity" in current_stats["taste"]
|
||||||
|
and "avg_popularity" in prev_taste
|
||||||
|
):
|
||||||
|
deltas["popularity_delta"] = round(
|
||||||
|
current_stats["taste"]["avg_popularity"] - prev_taste["avg_popularity"],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"previous_period": {
|
||||||
|
"start": prev_start.isoformat(),
|
||||||
|
"end": prev_end.isoformat(),
|
||||||
|
},
|
||||||
|
"deltas": deltas,
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute_volume_stats(
|
||||||
|
self, period_start: datetime, period_end: datetime
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
query = (
|
||||||
|
self.db.query(PlayHistory)
|
||||||
|
.options(joinedload(PlayHistory.track).joinedload(Track.artists))
|
||||||
|
.filter(
|
||||||
|
PlayHistory.played_at >= period_start,
|
||||||
|
PlayHistory.played_at < period_end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
plays = query.all()
|
||||||
|
total_plays = len(plays)
|
||||||
|
|
||||||
|
if total_plays == 0:
|
||||||
|
return self._empty_volume_stats()
|
||||||
|
|
||||||
|
total_ms = 0
|
||||||
|
track_counts = {}
|
||||||
|
artist_counts = {}
|
||||||
|
genre_counts = {}
|
||||||
|
album_counts = {}
|
||||||
|
|
||||||
|
track_map = {}
|
||||||
|
artist_map = {}
|
||||||
|
album_map = {}
|
||||||
|
|
||||||
|
def get_track_image(t):
|
||||||
|
if t.image_url:
|
||||||
|
return t.image_url
|
||||||
|
if t.raw_data and "album" in t.raw_data and "images" in t.raw_data["album"]:
|
||||||
|
imgs = t.raw_data["album"]["images"]
|
||||||
|
if imgs:
|
||||||
|
return imgs[0].get("url")
|
||||||
|
return None
|
||||||
|
|
||||||
|
for p in plays:
|
||||||
|
t = p.track
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
|
||||||
|
total_ms += t.duration_ms if t.duration_ms else 0
|
||||||
|
track_counts[t.id] = track_counts.get(t.id, 0) + 1
|
||||||
|
track_map[t.id] = t
|
||||||
|
|
||||||
|
album_id = t.album
|
||||||
|
album_name = t.album
|
||||||
|
if t.raw_data and "album" in t.raw_data:
|
||||||
|
album_id = t.raw_data["album"].get("id", t.album)
|
||||||
|
album_name = t.raw_data["album"].get("name", t.album)
|
||||||
|
|
||||||
|
album_counts[album_id] = album_counts.get(album_id, 0) + 1
|
||||||
|
if album_id not in album_map:
|
||||||
|
album_map[album_id] = {"name": album_name, "image": get_track_image(t)}
|
||||||
|
|
||||||
|
for artist in t.artists:
|
||||||
|
artist_counts[artist.id] = artist_counts.get(artist.id, 0) + 1
|
||||||
|
if artist.id not in artist_map:
|
||||||
|
artist_map[artist.id] = {
|
||||||
|
"name": artist.name,
|
||||||
|
"image": artist.image_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
if artist.genres:
|
||||||
|
for g in artist.genres:
|
||||||
|
genre_counts[g] = genre_counts.get(g, 0) + 1
|
||||||
|
|
||||||
|
unique_tracks = len(track_counts)
|
||||||
|
one_and_done = len([c for c in track_counts.values() if c == 1])
|
||||||
|
shares = [c / total_plays for c in track_counts.values()]
|
||||||
|
|
||||||
|
top_tracks = [
|
||||||
|
{
|
||||||
|
"id": tid,
|
||||||
|
"name": track_map[tid].name,
|
||||||
|
"artist": ", ".join([a.name for a in track_map[tid].artists]),
|
||||||
|
"image": get_track_image(track_map[tid]),
|
||||||
|
"count": c,
|
||||||
|
}
|
||||||
|
for tid, c in sorted(
|
||||||
|
track_counts.items(), key=lambda x: x[1], reverse=True
|
||||||
|
)[:5]
|
||||||
|
]
|
||||||
|
|
||||||
|
top_artists = [
|
||||||
|
{
|
||||||
|
"name": artist_map[aid]["name"],
|
||||||
|
"id": aid,
|
||||||
|
"image": artist_map[aid]["image"],
|
||||||
|
"count": c,
|
||||||
|
}
|
||||||
|
for aid, c in sorted(
|
||||||
|
artist_counts.items(), key=lambda x: x[1], reverse=True
|
||||||
|
)[:5]
|
||||||
|
]
|
||||||
|
|
||||||
|
top_albums = [
|
||||||
|
{
|
||||||
|
"name": album_map[aid]["name"],
|
||||||
|
"image": album_map[aid]["image"],
|
||||||
|
"count": c,
|
||||||
|
}
|
||||||
|
for aid, c in sorted(
|
||||||
|
album_counts.items(), key=lambda x: x[1], reverse=True
|
||||||
|
)[:5]
|
||||||
|
]
|
||||||
|
|
||||||
|
top_genres = [
|
||||||
|
{"name": k, "count": v}
|
||||||
|
for k, v in sorted(genre_counts.items(), key=lambda x: x[1], reverse=True)[
|
||||||
|
:5
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
hhi = sum([s**2 for s in shares])
|
||||||
|
|
||||||
|
sorted_shares = sorted(shares)
|
||||||
|
n = len(shares)
|
||||||
|
gini = 0
|
||||||
|
if n > 0:
|
||||||
|
gini = (2 * sum((i + 1) * x for i, x in enumerate(sorted_shares))) / (
|
||||||
|
n * sum(sorted_shares)
|
||||||
|
) - (n + 1) / n
|
||||||
|
|
||||||
|
total_genre_occurrences = sum(genre_counts.values())
|
||||||
|
genre_entropy = 0
|
||||||
|
if total_genre_occurrences > 0:
|
||||||
|
genre_probs = [
|
||||||
|
count / total_genre_occurrences for count in genre_counts.values()
|
||||||
|
]
|
||||||
|
genre_entropy = -sum([p * math.log(p) for p in genre_probs if p > 0])
|
||||||
|
|
||||||
|
top_5_plays = sum([t["count"] for t in top_tracks])
|
||||||
|
top_5_share = top_5_plays / total_plays if total_plays else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_plays": total_plays,
|
||||||
|
"estimated_minutes": int(total_ms / 60000),
|
||||||
|
"unique_tracks": unique_tracks,
|
||||||
|
"unique_artists": len(artist_counts),
|
||||||
|
"unique_albums": len(album_counts),
|
||||||
|
"unique_genres": len(genre_counts),
|
||||||
|
"top_tracks": top_tracks,
|
||||||
|
"top_artists": top_artists,
|
||||||
|
"top_albums": top_albums,
|
||||||
|
"top_genres": top_genres,
|
||||||
|
"repeat_rate": round((total_plays - unique_tracks) / total_plays, 3)
|
||||||
|
if total_plays
|
||||||
|
else 0,
|
||||||
|
"one_and_done_rate": round(one_and_done / unique_tracks, 3)
|
||||||
|
if unique_tracks
|
||||||
|
else 0,
|
||||||
|
"concentration": {
|
||||||
|
"hhi": round(hhi, 4),
|
||||||
|
"gini": round(gini, 4),
|
||||||
|
"top_1_share": round(max(shares), 3) if shares else 0,
|
||||||
|
"top_5_share": round(top_5_share, 3),
|
||||||
|
"genre_entropy": round(genre_entropy, 2),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute_time_stats(
|
||||||
|
self, period_start: datetime, period_end: datetime
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
query = (
|
||||||
|
self.db.query(PlayHistory)
|
||||||
|
.filter(
|
||||||
|
PlayHistory.played_at >= period_start,
|
||||||
|
PlayHistory.played_at < period_end,
|
||||||
|
)
|
||||||
|
.order_by(PlayHistory.played_at.asc())
|
||||||
|
)
|
||||||
|
plays = query.all()
|
||||||
|
|
||||||
|
if not plays:
|
||||||
|
return self._empty_time_stats()
|
||||||
|
|
||||||
|
heatmap = [[0 for _ in range(24)] for _ in range(7)]
|
||||||
|
heatmap_compressed = [[0 for _ in range(6)] for _ in range(7)]
|
||||||
|
block_labels = [
|
||||||
|
"12am-4am",
|
||||||
|
"4am-8am",
|
||||||
|
"8am-12pm",
|
||||||
|
"12pm-4pm",
|
||||||
|
"4pm-8pm",
|
||||||
|
"8pm-12am",
|
||||||
|
]
|
||||||
|
|
||||||
|
hourly_counts = [0] * 24
|
||||||
|
weekday_counts = [0] * 7
|
||||||
|
|
||||||
|
part_of_day = {"morning": 0, "afternoon": 0, "evening": 0, "night": 0}
|
||||||
|
active_dates = set()
|
||||||
|
|
||||||
|
for p in plays:
|
||||||
|
h = p.played_at.hour
|
||||||
|
d = p.played_at.weekday()
|
||||||
|
|
||||||
|
heatmap[d][h] += 1
|
||||||
|
block_idx = h // 4
|
||||||
|
heatmap_compressed[d][block_idx] += 1
|
||||||
|
|
||||||
|
hourly_counts[h] += 1
|
||||||
|
weekday_counts[d] += 1
|
||||||
|
active_dates.add(p.played_at.date())
|
||||||
|
|
||||||
|
if 6 <= h < 12:
|
||||||
|
part_of_day["morning"] += 1
|
||||||
|
elif 12 <= h < 18:
|
||||||
|
part_of_day["afternoon"] += 1
|
||||||
|
elif 18 <= h <= 23:
|
||||||
|
part_of_day["evening"] += 1
|
||||||
|
else:
|
||||||
|
part_of_day["night"] += 1
|
||||||
|
|
||||||
|
sorted_dates = sorted(list(active_dates))
|
||||||
|
current_streak = 0
|
||||||
|
longest_streak = 0
|
||||||
|
if sorted_dates:
|
||||||
|
current_streak = 1
|
||||||
|
longest_streak = 1
|
||||||
|
for i in range(1, len(sorted_dates)):
|
||||||
|
delta = (sorted_dates[i] - sorted_dates[i - 1]).days
|
||||||
|
if delta == 1:
|
||||||
|
current_streak += 1
|
||||||
|
else:
|
||||||
|
longest_streak = max(longest_streak, current_streak)
|
||||||
|
current_streak = 1
|
||||||
|
longest_streak = max(longest_streak, current_streak)
|
||||||
|
|
||||||
|
weekend_plays = weekday_counts[5] + weekday_counts[6]
|
||||||
|
active_days_count = len(active_dates)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"heatmap": heatmap,
|
||||||
|
"heatmap_compressed": heatmap_compressed,
|
||||||
|
"block_labels": block_labels,
|
||||||
|
"hourly_distribution": hourly_counts,
|
||||||
|
"peak_hour": hourly_counts.index(max(hourly_counts)),
|
||||||
|
"weekday_distribution": weekday_counts,
|
||||||
|
"daily_distribution": weekday_counts,
|
||||||
|
"weekend_share": round(weekend_plays / len(plays), 2),
|
||||||
|
"part_of_day": part_of_day,
|
||||||
|
"listening_streak": current_streak,
|
||||||
|
"longest_streak": longest_streak,
|
||||||
|
"active_days": active_days_count,
|
||||||
|
"avg_plays_per_active_day": round(len(plays) / active_days_count, 1)
|
||||||
|
if active_days_count
|
||||||
|
else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute_session_stats(
|
||||||
|
self, period_start: datetime, period_end: datetime
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
query = (
|
||||||
|
self.db.query(PlayHistory)
|
||||||
|
.options(joinedload(PlayHistory.track))
|
||||||
|
.filter(
|
||||||
|
PlayHistory.played_at >= period_start,
|
||||||
|
PlayHistory.played_at < period_end,
|
||||||
|
)
|
||||||
|
.order_by(PlayHistory.played_at.asc())
|
||||||
|
)
|
||||||
|
plays = query.all()
|
||||||
|
|
||||||
|
if not plays:
|
||||||
|
return self._empty_session_stats()
|
||||||
|
|
||||||
|
sessions = []
|
||||||
|
current_session = [plays[0]]
|
||||||
|
|
||||||
|
for i in range(1, len(plays)):
|
||||||
|
diff = (plays[i].played_at - plays[i - 1].played_at).total_seconds() / 60
|
||||||
|
if diff > 20:
|
||||||
|
sessions.append(current_session)
|
||||||
|
current_session = []
|
||||||
|
current_session.append(plays[i])
|
||||||
|
sessions.append(current_session)
|
||||||
|
|
||||||
|
lengths_min = []
|
||||||
|
micro_sessions = 0
|
||||||
|
marathon_sessions = 0
|
||||||
|
energy_arcs = {"rising": 0, "falling": 0, "flat": 0, "unknown": 0}
|
||||||
|
start_hour_dist = [0] * 24
|
||||||
|
|
||||||
|
session_list = []
|
||||||
|
|
||||||
|
for sess in sessions:
|
||||||
|
start_t = sess[0].played_at
|
||||||
|
end_t = sess[-1].played_at
|
||||||
|
start_hour_dist[start_t.hour] += 1
|
||||||
|
|
||||||
|
if len(sess) > 1:
|
||||||
|
duration = (end_t - start_t).total_seconds() / 60
|
||||||
|
lengths_min.append(duration)
|
||||||
|
else:
|
||||||
|
duration = 3.0
|
||||||
|
lengths_min.append(duration)
|
||||||
|
|
||||||
|
sess_type = "Standard"
|
||||||
|
if len(sess) <= 3:
|
||||||
|
micro_sessions += 1
|
||||||
|
sess_type = "Micro"
|
||||||
|
elif len(sess) >= 20:
|
||||||
|
marathon_sessions += 1
|
||||||
|
sess_type = "Marathon"
|
||||||
|
|
||||||
|
session_list.append(
|
||||||
|
{
|
||||||
|
"start_time": start_t.isoformat(),
|
||||||
|
"end_time": end_t.isoformat(),
|
||||||
|
"duration_minutes": round(duration, 1),
|
||||||
|
"track_count": len(sess),
|
||||||
|
"type": sess_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
first_t = sess[0].track
|
||||||
|
last_t = sess[-1].track
|
||||||
|
if (
|
||||||
|
first_t
|
||||||
|
and last_t
|
||||||
|
and getattr(first_t, "energy", None) is not None
|
||||||
|
and getattr(last_t, "energy", None) is not None
|
||||||
|
):
|
||||||
|
diff = last_t.energy - first_t.energy
|
||||||
|
if diff > 0.1:
|
||||||
|
energy_arcs["rising"] += 1
|
||||||
|
elif diff < -0.1:
|
||||||
|
energy_arcs["falling"] += 1
|
||||||
|
else:
|
||||||
|
energy_arcs["flat"] += 1
|
||||||
|
else:
|
||||||
|
energy_arcs["unknown"] += 1
|
||||||
|
|
||||||
|
avg_min = np.mean(lengths_min) if lengths_min else 0
|
||||||
|
median_min = np.median(lengths_min) if lengths_min else 0
|
||||||
|
active_days = len(set(p.played_at.date() for p in plays))
|
||||||
|
sessions_per_day = len(sessions) / active_days if active_days else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"count": len(sessions),
|
||||||
|
"avg_tracks": round(len(plays) / len(sessions), 1),
|
||||||
|
"avg_minutes": round(float(avg_min), 1),
|
||||||
|
"median_minutes": round(float(median_min), 1),
|
||||||
|
"longest_session_minutes": round(max(lengths_min), 1) if lengths_min else 0,
|
||||||
|
"sessions_per_day": round(sessions_per_day, 1),
|
||||||
|
"start_hour_distribution": start_hour_dist,
|
||||||
|
"micro_session_rate": round(micro_sessions / len(sessions), 2),
|
||||||
|
"marathon_session_rate": round(marathon_sessions / len(sessions), 2),
|
||||||
|
"energy_arcs": energy_arcs,
|
||||||
|
"session_list": session_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute_vibe_stats(
|
||||||
|
self, period_start: datetime, period_end: datetime
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
plays = (
|
||||||
|
self.db.query(PlayHistory)
|
||||||
|
.filter(
|
||||||
|
PlayHistory.played_at >= period_start,
|
||||||
|
PlayHistory.played_at < period_end,
|
||||||
|
)
|
||||||
|
.order_by(PlayHistory.played_at.asc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not plays:
|
||||||
|
return self._empty_vibe_stats()
|
||||||
|
|
||||||
|
track_ids = list(set([p.track_id for p in plays]))
|
||||||
|
tracks = self.db.query(Track).filter(Track.id.in_(track_ids)).all()
|
||||||
|
track_map = {t.id: t for t in tracks}
|
||||||
|
|
||||||
|
feature_keys = [
|
||||||
|
"energy",
|
||||||
|
"valence",
|
||||||
|
"danceability",
|
||||||
|
"tempo",
|
||||||
|
"acousticness",
|
||||||
|
"instrumentalness",
|
||||||
|
"liveness",
|
||||||
|
"speechiness",
|
||||||
|
"loudness",
|
||||||
|
]
|
||||||
|
features = {k: [] for k in feature_keys}
|
||||||
|
cluster_data = []
|
||||||
|
keys = []
|
||||||
|
modes = []
|
||||||
|
tempo_zones = {"chill": 0, "groove": 0, "hype": 0}
|
||||||
|
transitions = {"tempo": [], "energy": [], "valence": []}
|
||||||
|
previous_track = None
|
||||||
|
|
||||||
|
for i, p in enumerate(plays):
|
||||||
|
t = track_map.get(p.track_id)
|
||||||
|
if not t:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for key in feature_keys:
|
||||||
|
val = getattr(t, key, None)
|
||||||
|
if val is not None:
|
||||||
|
features[key].append(val)
|
||||||
|
|
||||||
|
if all(
|
||||||
|
getattr(t, k, None) is not None
|
||||||
|
for k in ["energy", "valence", "danceability", "acousticness"]
|
||||||
|
):
|
||||||
|
cluster_data.append(
|
||||||
|
[t.energy, t.valence, t.danceability, t.acousticness]
|
||||||
|
)
|
||||||
|
|
||||||
|
if getattr(t, "key", None) is not None:
|
||||||
|
keys.append(t.key)
|
||||||
|
if getattr(t, "mode", None) is not None:
|
||||||
|
modes.append(t.mode)
|
||||||
|
|
||||||
|
if getattr(t, "tempo", None) is not None:
|
||||||
|
if t.tempo < 100:
|
||||||
|
tempo_zones["chill"] += 1
|
||||||
|
elif t.tempo < 130:
|
||||||
|
tempo_zones["groove"] += 1
|
||||||
|
else:
|
||||||
|
tempo_zones["hype"] += 1
|
||||||
|
|
||||||
|
if i > 0 and previous_track:
|
||||||
|
time_diff = (p.played_at - plays[i - 1].played_at).total_seconds()
|
||||||
|
if time_diff < 300:
|
||||||
|
if (
|
||||||
|
getattr(t, "tempo", None) is not None
|
||||||
|
and getattr(previous_track, "tempo", None) is not None
|
||||||
|
):
|
||||||
|
transitions["tempo"].append(abs(t.tempo - previous_track.tempo))
|
||||||
|
if (
|
||||||
|
getattr(t, "energy", None) is not None
|
||||||
|
and getattr(previous_track, "energy", None) is not None
|
||||||
|
):
|
||||||
|
transitions["energy"].append(
|
||||||
|
abs(t.energy - previous_track.energy)
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
getattr(t, "valence", None) is not None
|
||||||
|
and getattr(previous_track, "valence", None) is not None
|
||||||
|
):
|
||||||
|
transitions["valence"].append(
|
||||||
|
abs(t.valence - previous_track.valence)
|
||||||
|
)
|
||||||
|
|
||||||
|
previous_track = t
|
||||||
|
|
||||||
|
stats_res = {}
|
||||||
|
for key, values in features.items():
|
||||||
|
valid = [v for v in values if v is not None]
|
||||||
|
if valid:
|
||||||
|
avg_val = float(np.mean(valid))
|
||||||
|
stats_res[key] = round(avg_val, 3)
|
||||||
|
stats_res[f"avg_{key}"] = avg_val
|
||||||
|
stats_res[f"std_{key}"] = float(np.std(valid))
|
||||||
|
stats_res[f"p10_{key}"] = float(np.percentile(valid, 10))
|
||||||
|
stats_res[f"p50_{key}"] = float(np.percentile(valid, 50))
|
||||||
|
stats_res[f"p90_{key}"] = float(np.percentile(valid, 90))
|
||||||
|
else:
|
||||||
|
stats_res[key] = 0.0
|
||||||
|
stats_res[f"avg_{key}"] = None
|
||||||
|
|
||||||
|
if (
|
||||||
|
stats_res.get("avg_energy") is not None
|
||||||
|
and stats_res.get("avg_valence") is not None
|
||||||
|
):
|
||||||
|
stats_res["mood_quadrant"] = {
|
||||||
|
"x": round(stats_res["avg_valence"], 2),
|
||||||
|
"y": round(stats_res["avg_energy"], 2),
|
||||||
|
}
|
||||||
|
avg_std = (
|
||||||
|
stats_res.get("std_energy", 0) + stats_res.get("std_valence", 0)
|
||||||
|
) / 2
|
||||||
|
stats_res["consistency_score"] = round(1.0 - avg_std, 2)
|
||||||
|
|
||||||
|
if (
|
||||||
|
stats_res.get("avg_tempo") is not None
|
||||||
|
and stats_res.get("avg_danceability") is not None
|
||||||
|
):
|
||||||
|
stats_res["rhythm_profile"] = {
|
||||||
|
"avg_tempo": round(stats_res["avg_tempo"], 1),
|
||||||
|
"avg_danceability": round(stats_res["avg_danceability"], 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
stats_res.get("avg_acousticness") is not None
|
||||||
|
and stats_res.get("avg_instrumentalness") is not None
|
||||||
|
):
|
||||||
|
stats_res["texture_profile"] = {
|
||||||
|
"acousticness": round(stats_res["avg_acousticness"], 2),
|
||||||
|
"instrumentalness": round(stats_res["avg_instrumentalness"], 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
stats_res["whiplash"] = {}
|
||||||
|
for k in ["tempo", "energy", "valence"]:
|
||||||
|
if transitions[k]:
|
||||||
|
stats_res["whiplash"][k] = round(float(np.mean(transitions[k])), 2)
|
||||||
|
else:
|
||||||
|
stats_res["whiplash"][k] = 0
|
||||||
|
|
||||||
|
total_tempo = sum(tempo_zones.values())
|
||||||
|
if total_tempo > 0:
|
||||||
|
stats_res["tempo_zones"] = {
|
||||||
|
k: round(v / total_tempo, 2) for k, v in tempo_zones.items()
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
stats_res["tempo_zones"] = {}
|
||||||
|
|
||||||
|
if modes:
|
||||||
|
major_count = len([m for m in modes if m == 1])
|
||||||
|
stats_res["harmonic_profile"] = {
|
||||||
|
"major_pct": round(major_count / len(modes), 2),
|
||||||
|
"minor_pct": round((len(modes) - major_count) / len(modes), 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
if keys:
|
||||||
|
pitch_class = [
|
||||||
|
"C",
|
||||||
|
"C#",
|
||||||
|
"D",
|
||||||
|
"D#",
|
||||||
|
"E",
|
||||||
|
"F",
|
||||||
|
"F#",
|
||||||
|
"G",
|
||||||
|
"G#",
|
||||||
|
"A",
|
||||||
|
"A#",
|
||||||
|
"B",
|
||||||
|
]
|
||||||
|
key_counts = {}
|
||||||
|
for k in keys:
|
||||||
|
if 0 <= k < 12:
|
||||||
|
label = pitch_class[k]
|
||||||
|
key_counts[label] = key_counts.get(label, 0) + 1
|
||||||
|
stats_res["top_keys"] = [
|
||||||
|
{"key": k, "count": v}
|
||||||
|
for k, v in sorted(
|
||||||
|
key_counts.items(), key=lambda x: x[1], reverse=True
|
||||||
|
)[:3]
|
||||||
|
]
|
||||||
|
|
||||||
|
if len(cluster_data) >= 5:
|
||||||
|
try:
|
||||||
|
kmeans = KMeans(n_clusters=3, random_state=42, n_init="auto")
|
||||||
|
labels = kmeans.fit_predict(cluster_data)
|
||||||
|
clusters = []
|
||||||
|
for i in range(3):
|
||||||
|
mask = labels == i
|
||||||
|
count = np.sum(mask)
|
||||||
|
if count == 0:
|
||||||
|
continue
|
||||||
|
centroid = kmeans.cluster_centers_[i]
|
||||||
|
share = count / len(cluster_data)
|
||||||
|
c_energy, c_valence, c_dance, c_acoustic = centroid
|
||||||
|
name = "Mixed Vibe"
|
||||||
|
if c_energy > 0.7:
|
||||||
|
name = "High Energy"
|
||||||
|
elif c_acoustic > 0.7:
|
||||||
|
name = "Acoustic / Chill"
|
||||||
|
elif c_valence < 0.3:
|
||||||
|
name = "Melancholy"
|
||||||
|
elif c_dance > 0.7:
|
||||||
|
name = "Dance / Groove"
|
||||||
|
clusters.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"share": round(share, 2),
|
||||||
|
"features": {
|
||||||
|
"energy": round(c_energy, 2),
|
||||||
|
"valence": round(c_valence, 2),
|
||||||
|
"danceability": round(c_dance, 2),
|
||||||
|
"acousticness": round(c_acoustic, 2),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
stats_res["clusters"] = sorted(
|
||||||
|
clusters, key=lambda x: x["share"], reverse=True
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Clustering failed: {e}")
|
||||||
|
stats_res["clusters"] = []
|
||||||
|
else:
|
||||||
|
stats_res["clusters"] = []
|
||||||
|
|
||||||
|
return stats_res
|
||||||
|
|
||||||
|
def compute_era_stats(
|
||||||
|
self, period_start: datetime, period_end: datetime
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
query = (
|
||||||
|
self.db.query(PlayHistory)
|
||||||
|
.options(joinedload(PlayHistory.track))
|
||||||
|
.filter(
|
||||||
|
PlayHistory.played_at >= period_start,
|
||||||
|
PlayHistory.played_at < period_end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
plays = query.all()
|
||||||
|
|
||||||
|
years = []
|
||||||
|
for p in plays:
|
||||||
|
t = p.track
|
||||||
|
if t and t.raw_data and "album" in t.raw_data:
|
||||||
|
rd = t.raw_data["album"].get("release_date")
|
||||||
|
if rd:
|
||||||
|
try:
|
||||||
|
years.append(int(rd.split("-")[0]))
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not years:
|
||||||
|
return {"musical_age": None}
|
||||||
|
|
||||||
|
avg_year = sum(years) / len(years)
|
||||||
|
current_year = datetime.utcnow().year
|
||||||
|
|
||||||
|
decades = {}
|
||||||
|
for y in years:
|
||||||
|
dec = (y // 10) * 10
|
||||||
|
label = f"{dec}s"
|
||||||
|
decades[label] = decades.get(label, 0) + 1
|
||||||
|
|
||||||
|
total = len(years)
|
||||||
|
dist = {k: round(v / total, 3) for k, v in decades.items()}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"musical_age": int(avg_year),
|
||||||
|
"nostalgia_gap": int(current_year - avg_year),
|
||||||
|
"freshness_score": dist.get(f"{int(current_year / 10) * 10}s", 0),
|
||||||
|
"decade_distribution": dist,
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute_skip_stats(
|
||||||
|
self, period_start: datetime, period_end: datetime
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
query = (
|
||||||
|
self.db.query(PlayHistory)
|
||||||
|
.filter(
|
||||||
|
PlayHistory.played_at >= period_start,
|
||||||
|
PlayHistory.played_at <= period_end,
|
||||||
|
)
|
||||||
|
.order_by(PlayHistory.played_at.asc())
|
||||||
|
)
|
||||||
|
plays = query.all()
|
||||||
|
|
||||||
|
if len(plays) < 2:
|
||||||
|
return {"skip_rate": 0, "total_skips": 0}
|
||||||
|
|
||||||
|
skips = 0
|
||||||
|
track_ids = list(set([p.track_id for p in plays]))
|
||||||
|
tracks = self.db.query(Track).filter(Track.id.in_(track_ids)).all()
|
||||||
|
track_map = {t.id: t for t in tracks}
|
||||||
|
|
||||||
|
for i in range(len(plays) - 1):
|
||||||
|
current_play = plays[i]
|
||||||
|
next_play = plays[i + 1]
|
||||||
|
track = track_map.get(current_play.track_id)
|
||||||
|
|
||||||
|
if not track or not getattr(track, "duration_ms", None):
|
||||||
|
continue
|
||||||
|
|
||||||
|
diff_seconds = (
|
||||||
|
next_play.played_at - current_play.played_at
|
||||||
|
).total_seconds()
|
||||||
|
duration_sec = track.duration_ms / 1000.0
|
||||||
|
|
||||||
|
if diff_seconds < (duration_sec - 10):
|
||||||
|
skips += 1
|
||||||
|
|
||||||
|
return {"total_skips": skips, "skip_rate": round(skips / len(plays), 3)}
|
||||||
|
|
||||||
|
def compute_context_stats(
|
||||||
|
self, period_start: datetime, period_end: datetime
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
query = self.db.query(PlayHistory).filter(
|
||||||
|
PlayHistory.played_at >= period_start, PlayHistory.played_at <= period_end
|
||||||
|
)
|
||||||
|
plays = query.all()
|
||||||
|
|
||||||
|
if not plays:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
context_counts = {
|
||||||
|
"playlist": 0,
|
||||||
|
"album": 0,
|
||||||
|
"artist": 0,
|
||||||
|
"collection": 0,
|
||||||
|
"unknown": 0,
|
||||||
|
}
|
||||||
|
unique_contexts = {}
|
||||||
|
|
||||||
|
for p in plays:
|
||||||
|
if not p.context_uri:
|
||||||
|
context_counts["unknown"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
unique_contexts[p.context_uri] = unique_contexts.get(p.context_uri, 0) + 1
|
||||||
|
|
||||||
|
if "playlist" in p.context_uri:
|
||||||
|
context_counts["playlist"] += 1
|
||||||
|
elif "album" in p.context_uri:
|
||||||
|
context_counts["album"] += 1
|
||||||
|
elif "artist" in p.context_uri:
|
||||||
|
context_counts["artist"] += 1
|
||||||
|
elif "collection" in p.context_uri:
|
||||||
|
context_counts["collection"] += 1
|
||||||
|
else:
|
||||||
|
context_counts["unknown"] += 1
|
||||||
|
|
||||||
|
total = len(plays)
|
||||||
|
breakdown = {k: round(v / total, 2) for k, v in context_counts.items()}
|
||||||
|
sorted_contexts = sorted(
|
||||||
|
unique_contexts.items(), key=lambda x: x[1], reverse=True
|
||||||
|
)[:5]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type_breakdown": breakdown,
|
||||||
|
"album_purist_score": breakdown.get("album", 0),
|
||||||
|
"playlist_dependency": breakdown.get("playlist", 0),
|
||||||
|
"context_loyalty": round(len(plays) / len(unique_contexts), 2)
|
||||||
|
if unique_contexts
|
||||||
|
else 0,
|
||||||
|
"top_context_uris": [{"uri": k, "count": v} for k, v in sorted_contexts],
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute_taste_stats(
|
||||||
|
self, period_start: datetime, period_end: datetime
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
query = self.db.query(PlayHistory).filter(
|
||||||
|
PlayHistory.played_at >= period_start, PlayHistory.played_at <= period_end
|
||||||
|
)
|
||||||
|
plays = query.all()
|
||||||
|
if not plays:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
track_ids = list(set([p.track_id for p in plays]))
|
||||||
|
tracks = self.db.query(Track).filter(Track.id.in_(track_ids)).all()
|
||||||
|
track_map = {t.id: t for t in tracks}
|
||||||
|
|
||||||
|
pop_values = []
|
||||||
|
for p in plays:
|
||||||
|
t = track_map.get(p.track_id)
|
||||||
|
if t and getattr(t, "popularity", None) is not None:
|
||||||
|
pop_values.append(t.popularity)
|
||||||
|
|
||||||
|
if not pop_values:
|
||||||
|
return {"avg_popularity": 0, "hipster_score": 0}
|
||||||
|
|
||||||
|
avg_pop = float(np.mean(pop_values))
|
||||||
|
underground_plays = len([x for x in pop_values if x < 30])
|
||||||
|
mainstream_plays = len([x for x in pop_values if x > 70])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"avg_popularity": round(avg_pop, 1),
|
||||||
|
"hipster_score": round((underground_plays / len(pop_values)) * 100, 1),
|
||||||
|
"mainstream_score": round((mainstream_plays / len(pop_values)) * 100, 1),
|
||||||
|
"obscurity_rating": round(100 - avg_pop, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute_lifecycle_stats(
|
||||||
|
self, period_start: datetime, period_end: datetime
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
current_plays = (
|
||||||
|
self.db.query(PlayHistory)
|
||||||
|
.filter(
|
||||||
|
PlayHistory.played_at >= period_start,
|
||||||
|
PlayHistory.played_at <= period_end,
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not current_plays:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
current_track_ids = set([p.track_id for p in current_plays])
|
||||||
|
old_tracks_query = self.db.query(distinct(PlayHistory.track_id)).filter(
|
||||||
|
PlayHistory.track_id.in_(current_track_ids),
|
||||||
|
PlayHistory.played_at < period_start,
|
||||||
|
)
|
||||||
|
old_track_ids = set([r[0] for r in old_tracks_query.all()])
|
||||||
|
|
||||||
|
new_discoveries = current_track_ids - old_track_ids
|
||||||
|
discovery_count = len(new_discoveries)
|
||||||
|
plays_on_new = len([p for p in current_plays if p.track_id in new_discoveries])
|
||||||
|
total_plays = len(current_plays)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"discovery_count": discovery_count,
|
||||||
|
"discovery_rate": round(plays_on_new / total_plays, 3)
|
||||||
|
if total_plays > 0
|
||||||
|
else 0,
|
||||||
|
"recurrence_rate": round((total_plays - plays_on_new) / total_plays, 3)
|
||||||
|
if total_plays > 0
|
||||||
|
else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute_explicit_stats(
|
||||||
|
self, period_start: datetime, period_end: datetime
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
query = (
|
||||||
|
self.db.query(PlayHistory)
|
||||||
|
.options(joinedload(PlayHistory.track))
|
||||||
|
.filter(
|
||||||
|
PlayHistory.played_at >= period_start,
|
||||||
|
PlayHistory.played_at <= period_end,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
plays = query.all()
|
||||||
|
|
||||||
|
if not plays:
|
||||||
|
return {"explicit_rate": 0, "hourly_explicit_distribution": []}
|
||||||
|
|
||||||
|
total_plays = len(plays)
|
||||||
|
explicit_count = 0
|
||||||
|
hourly_explicit = [0] * 24
|
||||||
|
hourly_total = [0] * 24
|
||||||
|
|
||||||
|
for p in plays:
|
||||||
|
h = p.played_at.hour
|
||||||
|
hourly_total[h] += 1
|
||||||
|
t = p.track
|
||||||
|
if t and t.raw_data and t.raw_data.get("explicit"):
|
||||||
|
explicit_count += 1
|
||||||
|
hourly_explicit[h] += 1
|
||||||
|
|
||||||
|
hourly_rates = []
|
||||||
|
for i in range(24):
|
||||||
|
if hourly_total[i] > 0:
|
||||||
|
hourly_rates.append(round(hourly_explicit[i] / hourly_total[i], 2))
|
||||||
|
else:
|
||||||
|
hourly_rates.append(0.0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"explicit_rate": round(explicit_count / total_plays, 3),
|
||||||
|
"total_explicit_plays": explicit_count,
|
||||||
|
"hourly_explicit_distribution": hourly_rates,
|
||||||
|
}
|
||||||
|
|
||||||
|
def generate_full_report(
|
||||||
|
self, period_start: datetime, period_end: datetime
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
current_stats = {
|
||||||
|
"period": {
|
||||||
|
"start": period_start.isoformat(),
|
||||||
|
"end": period_end.isoformat(),
|
||||||
|
},
|
||||||
|
"volume": self.compute_volume_stats(period_start, period_end),
|
||||||
|
"time_habits": self.compute_time_stats(period_start, period_end),
|
||||||
|
"sessions": self.compute_session_stats(period_start, period_end),
|
||||||
|
"context": self.compute_context_stats(period_start, period_end),
|
||||||
|
"vibe": self.compute_vibe_stats(period_start, period_end),
|
||||||
|
"era": self.compute_era_stats(period_start, period_end),
|
||||||
|
"taste": self.compute_taste_stats(period_start, period_end),
|
||||||
|
"lifecycle": self.compute_lifecycle_stats(period_start, period_end),
|
||||||
|
"flags": self.compute_explicit_stats(period_start, period_end),
|
||||||
|
"skips": self.compute_skip_stats(period_start, period_end),
|
||||||
|
}
|
||||||
|
|
||||||
|
current_stats["comparison"] = self.compute_comparison(
|
||||||
|
current_stats, period_start, period_end
|
||||||
|
)
|
||||||
|
|
||||||
|
return current_stats
|
||||||
|
|
||||||
|
def _empty_volume_stats(self):
|
||||||
|
return {
|
||||||
|
"total_plays": 0,
|
||||||
|
"estimated_minutes": 0,
|
||||||
|
"unique_tracks": 0,
|
||||||
|
"unique_artists": 0,
|
||||||
|
"unique_albums": 0,
|
||||||
|
"unique_genres": 0,
|
||||||
|
"top_tracks": [],
|
||||||
|
"top_artists": [],
|
||||||
|
"top_albums": [],
|
||||||
|
"top_genres": [],
|
||||||
|
"repeat_rate": 0,
|
||||||
|
"one_and_done_rate": 0,
|
||||||
|
"concentration": {
|
||||||
|
"hhi": 0,
|
||||||
|
"gini": 0,
|
||||||
|
"top_1_share": 0,
|
||||||
|
"top_5_share": 0,
|
||||||
|
"genre_entropy": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def _empty_time_stats(self):
|
||||||
|
return {
|
||||||
|
"heatmap": [],
|
||||||
|
"heatmap_compressed": [],
|
||||||
|
"block_labels": [],
|
||||||
|
"hourly_distribution": [0] * 24,
|
||||||
|
"peak_hour": None,
|
||||||
|
"weekday_distribution": [0] * 7,
|
||||||
|
"daily_distribution": [0] * 7,
|
||||||
|
"weekend_share": 0,
|
||||||
|
"part_of_day": {"morning": 0, "afternoon": 0, "evening": 0, "night": 0},
|
||||||
|
"listening_streak": 0,
|
||||||
|
"longest_streak": 0,
|
||||||
|
"active_days": 0,
|
||||||
|
"avg_plays_per_active_day": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _empty_session_stats(self):
|
||||||
|
return {
|
||||||
|
"count": 0,
|
||||||
|
"avg_tracks": 0,
|
||||||
|
"avg_minutes": 0,
|
||||||
|
"median_minutes": 0,
|
||||||
|
"longest_session_minutes": 0,
|
||||||
|
"sessions_per_day": 0,
|
||||||
|
"start_hour_distribution": [0] * 24,
|
||||||
|
"micro_session_rate": 0,
|
||||||
|
"marathon_session_rate": 0,
|
||||||
|
"energy_arcs": {"rising": 0, "falling": 0, "flat": 0, "unknown": 0},
|
||||||
|
"session_list": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _empty_vibe_stats(self):
|
||||||
|
return {
|
||||||
|
"avg_energy": 0,
|
||||||
|
"avg_valence": 0,
|
||||||
|
"mood_quadrant": {"x": 0, "y": 0},
|
||||||
|
"clusters": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def _pct_change(self, curr, prev):
|
||||||
|
if prev == 0:
|
||||||
|
return 100.0 if curr > 0 else 0.0
|
||||||
|
return round(((curr - prev) / prev) * 100, 1)
|
||||||
10
backend/backend.log
Normal file
10
backend/backend.log
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
INFO: Started server process [9223]
|
||||||
|
INFO: Waiting for application startup.
|
||||||
|
INFO: Application startup complete.
|
||||||
|
INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)
|
||||||
|
INFO: 127.0.0.1:35326 - "GET /history?limit=100 HTTP/1.1" 200 OK
|
||||||
|
INFO: 127.0.0.1:35342 - "GET /history?limit=100 HTTP/1.1" 200 OK
|
||||||
|
INFO: Shutting down
|
||||||
|
INFO: Waiting for application shutdown.
|
||||||
|
INFO: Application shutdown complete.
|
||||||
|
INFO: Finished server process [9223]
|
||||||
17
backend/entrypoint.sh
Normal file
17
backend/entrypoint.sh
Normal 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
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
fastapi==0.109.2
|
fastapi==0.109.2
|
||||||
uvicorn==0.27.1
|
uvicorn==0.27.1
|
||||||
sqlalchemy==2.0.27
|
sqlalchemy==2.0.45
|
||||||
httpx==0.26.0
|
httpx==0.28.1
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
pydantic==2.6.1
|
pydantic==2.12.5
|
||||||
pydantic-settings==2.1.0
|
pydantic-core==2.41.5
|
||||||
google-generativeai==0.3.2
|
pydantic-settings==2.12.0
|
||||||
tenacity==8.2.3
|
tenacity==8.2.3
|
||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
requests==2.31.0
|
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
82
backend/run_analysis.py
Normal 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
16
backend/run_scheduler.py
Normal 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)
|
||||||
@@ -16,8 +16,9 @@ from http.server import HTTPServer, BaseHTTPRequestHandler
|
|||||||
# CONFIGURATION - You can hardcode these or input them when prompted
|
# CONFIGURATION - You can hardcode these or input them when prompted
|
||||||
SPOTIFY_CLIENT_ID = input("Enter your Spotify Client ID: ").strip()
|
SPOTIFY_CLIENT_ID = input("Enter your Spotify Client ID: ").strip()
|
||||||
SPOTIFY_CLIENT_SECRET = input("Enter your Spotify Client Secret: ").strip()
|
SPOTIFY_CLIENT_SECRET = input("Enter your Spotify Client Secret: ").strip()
|
||||||
REDIRECT_URI = "http://localhost:8888/callback"
|
REDIRECT_URI = "http://127.0.0.1:8888/callback"
|
||||||
SCOPE = "user-read-recently-played user-read-playback-state"
|
SCOPE = "user-read-recently-played user-read-playback-state playlist-modify-public playlist-modify-private"
|
||||||
|
|
||||||
|
|
||||||
class RequestHandler(BaseHTTPRequestHandler):
|
class RequestHandler(BaseHTTPRequestHandler):
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
@@ -36,6 +37,7 @@ class RequestHandler(BaseHTTPRequestHandler):
|
|||||||
# Shut down server
|
# Shut down server
|
||||||
raise KeyboardInterrupt
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
|
||||||
def get_token(code):
|
def get_token(code):
|
||||||
url = "https://accounts.spotify.com/api/token"
|
url = "https://accounts.spotify.com/api/token"
|
||||||
payload = {
|
payload = {
|
||||||
@@ -49,24 +51,27 @@ def get_token(code):
|
|||||||
response = requests.post(url, data=payload)
|
response = requests.post(url, data=payload)
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
print("\n" + "="*50)
|
print("\n" + "=" * 50)
|
||||||
print("SUCCESS! HERE ARE YOUR CREDENTIALS")
|
print("SUCCESS! HERE ARE YOUR CREDENTIALS")
|
||||||
print("="*50)
|
print("=" * 50)
|
||||||
print(f"\nSPOTIFY_REFRESH_TOKEN={data['refresh_token']}")
|
print(f"\nSPOTIFY_REFRESH_TOKEN={data['refresh_token']}")
|
||||||
print(f"SPOTIFY_CLIENT_ID={SPOTIFY_CLIENT_ID}")
|
print(f"SPOTIFY_CLIENT_ID={SPOTIFY_CLIENT_ID}")
|
||||||
print(f"SPOTIFY_CLIENT_SECRET={SPOTIFY_CLIENT_SECRET}")
|
print(f"SPOTIFY_CLIENT_SECRET={SPOTIFY_CLIENT_SECRET}")
|
||||||
print("\nSave these in your .env file or share them with the agent.")
|
print("\nSave these in your .env file or share them with the agent.")
|
||||||
print("="*50 + "\n")
|
print("=" * 50 + "\n")
|
||||||
else:
|
else:
|
||||||
print("Error getting token:", response.text)
|
print("Error getting token:", response.text)
|
||||||
|
|
||||||
|
|
||||||
def start_auth():
|
def start_auth():
|
||||||
auth_url = "https://accounts.spotify.com/authorize?" + urllib.parse.urlencode({
|
auth_url = "https://accounts.spotify.com/authorize?" + urllib.parse.urlencode(
|
||||||
|
{
|
||||||
"response_type": "code",
|
"response_type": "code",
|
||||||
"client_id": SPOTIFY_CLIENT_ID,
|
"client_id": SPOTIFY_CLIENT_ID,
|
||||||
"scope": SCOPE,
|
"scope": SCOPE,
|
||||||
"redirect_uri": REDIRECT_URI,
|
"redirect_uri": REDIRECT_URI,
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
print(f"Opening browser to: {auth_url}")
|
print(f"Opening browser to: {auth_url}")
|
||||||
try:
|
try:
|
||||||
@@ -74,7 +79,7 @@ def start_auth():
|
|||||||
except:
|
except:
|
||||||
print(f"Could not open browser. Please manually visit: {auth_url}")
|
print(f"Could not open browser. Please manually visit: {auth_url}")
|
||||||
|
|
||||||
server_address = ('', 8888)
|
server_address = ("", 8888)
|
||||||
httpd = HTTPServer(server_address, RequestHandler)
|
httpd = HTTPServer(server_address, RequestHandler)
|
||||||
print("Listening on port 8888...")
|
print("Listening on port 8888...")
|
||||||
try:
|
try:
|
||||||
@@ -83,5 +88,6 @@ def start_auth():
|
|||||||
pass
|
pass
|
||||||
httpd.server_close()
|
httpd.server_close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
start_auth()
|
start_auth()
|
||||||
|
|||||||
31
backend/scripts/reset_db_with_dummy_data.py
Normal file
31
backend/scripts/reset_db_with_dummy_data.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.database import SessionLocal, engine, Base
|
||||||
|
from app.models import Track, PlayHistory
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
# clear
|
||||||
|
db.query(PlayHistory).delete()
|
||||||
|
db.query(Track).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Create tracks
|
||||||
|
t1 = Track(id="t1", name="Midnight City", artist="M83", album="Hurry Up, We're Dreaming", duration_ms=243000, danceability=0.6, energy=0.8, valence=0.5, raw_data={})
|
||||||
|
t2 = Track(id="t2", name="Weightless", artist="Marconi Union", album="Weightless", duration_ms=480000, danceability=0.2, energy=0.1, valence=0.1, raw_data={})
|
||||||
|
t3 = Track(id="t3", name="Levitating", artist="Dua Lipa", album="Future Nostalgia", duration_ms=203000, danceability=0.8, energy=0.9, valence=0.9, raw_data={})
|
||||||
|
|
||||||
|
db.add_all([t1, t2, t3])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Create history
|
||||||
|
ph1 = PlayHistory(track_id="t1", played_at=datetime.utcnow() - timedelta(minutes=10))
|
||||||
|
ph2 = PlayHistory(track_id="t2", played_at=datetime.utcnow() - timedelta(minutes=30))
|
||||||
|
ph3 = PlayHistory(track_id="t3", played_at=datetime.utcnow() - timedelta(minutes=60))
|
||||||
|
|
||||||
|
db.add_all([ph1, ph2, ph3])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print("Data populated")
|
||||||
|
db.close()
|
||||||
78
backend/seed_data.py
Normal file
78
backend/seed_data.py
Normal 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()
|
||||||
5
backend/tests/conftest.py
Normal file
5
backend/tests/conftest.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
113
backend/tests/test_ingest.py
Normal file
113
backend/tests/test_ingest.py
Normal 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 == []
|
||||||
49
backend/tests/test_main.py
Normal file
49
backend/tests/test_main.py
Normal 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
|
||||||
126
backend/tests/test_playlist_service.py
Normal file
126
backend/tests/test_playlist_service.py
Normal 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
|
||||||
69
backend/tests/test_stats.py
Normal file
69
backend/tests/test_stats.py
Normal 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()
|
||||||
67
docker-compose.template.yml
Normal file
67
docker-compose.template.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# MusicAnalyser Docker Compose Template
|
||||||
|
# Copy this file to docker-compose.yml and fill in your values
|
||||||
|
# Or use environment variables / .env file
|
||||||
|
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
image: ghcr.io/bnair123/musicanalyser:latest
|
||||||
|
container_name: music-analyser-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- music_data:/app/data
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=sqlite:////app/data/music.db
|
||||||
|
# Required: Spotify API credentials
|
||||||
|
- SPOTIFY_CLIENT_ID=your_spotify_client_id_here
|
||||||
|
- SPOTIFY_CLIENT_SECRET=your_spotify_client_secret_here
|
||||||
|
- SPOTIFY_REFRESH_TOKEN=your_spotify_refresh_token_here
|
||||||
|
# Required: AI API key (choose one)
|
||||||
|
- OPENAI_API_KEY=your_openai_api_key_here
|
||||||
|
# OR
|
||||||
|
- GEMINI_API_KEY=your_gemini_api_key_here
|
||||||
|
# Optional: Genius for lyrics
|
||||||
|
- GENIUS_ACCESS_TOKEN=your_genius_token_here
|
||||||
|
# Optional: Spotify Playlist IDs (will be created if not provided)
|
||||||
|
- SIX_HOUR_PLAYLIST_ID=your_playlist_id_here
|
||||||
|
- DAILY_PLAYLIST_ID=your_playlist_id_here
|
||||||
|
ports:
|
||||||
|
- '8000:8000'
|
||||||
|
networks:
|
||||||
|
- dockernet
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/snapshots?limit=1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
image: ghcr.io/bnair123/musicanalyser-frontend:latest
|
||||||
|
container_name: music-analyser-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '8991:80'
|
||||||
|
networks:
|
||||||
|
- dockernet
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
music_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dockernet:
|
||||||
|
external: true
|
||||||
|
# If you don't have an external dockernet, create it with:
|
||||||
|
# docker network create dockernet
|
||||||
|
# Or change to:
|
||||||
|
# dockernet:
|
||||||
|
# driver: bridge
|
||||||
41
docker-compose.yml
Normal file
41
docker-compose.yml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
image: ghcr.io/bnair123/musicanalyser:latest
|
||||||
|
container_name: music-analyser-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://bnair:Bharath2002@music_db:5432/music_db
|
||||||
|
ports:
|
||||||
|
- '8088:8000'
|
||||||
|
networks:
|
||||||
|
- dockernet
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8000/snapshots?limit=1"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
image: ghcr.io/bnair123/musicanalyser-frontend:latest
|
||||||
|
container_name: music-analyser-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- '8991:80'
|
||||||
|
networks:
|
||||||
|
- dockernet
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dockernet:
|
||||||
|
external: true
|
||||||
125
docs/API.md
Normal file
125
docs/API.md
Normal 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
43
docs/ARCHITECTURE.md
Normal 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
271
docs/DATABASE.md
Normal 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
89
docs/DATA_MODEL.md
Normal 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
61
docs/FRONTEND.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# Frontend Documentation
|
||||||
|
|
||||||
|
The frontend is a React application built with Vite and Tailwind CSS. It uses Ant Design for some UI components and Recharts for data visualization.
|
||||||
|
|
||||||
|
## Main Components
|
||||||
|
|
||||||
|
### `Dashboard.jsx`
|
||||||
|
The primary layout component that manages data fetching and state.
|
||||||
|
- **Features**:
|
||||||
|
- Handles API calls to `/snapshots` and `/trigger-analysis`.
|
||||||
|
- Implements local storage caching to reduce API load.
|
||||||
|
- Displays a global loading state during analysis.
|
||||||
|
- Contains the main header with a refresh trigger.
|
||||||
|
|
||||||
|
### `NarrativeSection.jsx`
|
||||||
|
Displays the AI-generated qualitative analysis.
|
||||||
|
- **Props**:
|
||||||
|
- `narrative`: Object containing `persona`, `vibe_check_short`, and `roast`.
|
||||||
|
- `vibe`: Object containing audio features used to generate dynamic tags.
|
||||||
|
- **Purpose**: Gives the user a "identity" based on their music taste (e.g., "THE MELANCHOLIC ARCHITECT").
|
||||||
|
|
||||||
|
### `StatsGrid.jsx`
|
||||||
|
A grid of high-level metric cards.
|
||||||
|
- **Props**:
|
||||||
|
- `metrics`: The `metrics_payload` from a snapshot.
|
||||||
|
- **Displays**:
|
||||||
|
- **Minutes Listened**: Total listening time converted to days.
|
||||||
|
- **Obsession**: The #1 most played track with album art background.
|
||||||
|
- **Unique Artists**: Count of different artists encountered.
|
||||||
|
- **Hipster Score**: A percentage indicating how obscure the user's taste is.
|
||||||
|
|
||||||
|
### `VibeRadar.jsx`
|
||||||
|
Visualizes the "Sonic DNA" of the user.
|
||||||
|
- **Props**:
|
||||||
|
- `vibe`: Audio feature averages (acousticness, danceability, energy, etc.).
|
||||||
|
- **Visuals**:
|
||||||
|
- **Radar Chart**: Shows the balance of audio features.
|
||||||
|
- **Mood Clusters**: Floating bubbles representing "Party", "Focus", and "Chill" percentages.
|
||||||
|
- **Whiplash Meter**: Shows volatility in tempo, energy, and valence between consecutive tracks.
|
||||||
|
|
||||||
|
### `TopRotation.jsx`
|
||||||
|
A horizontal scrolling list of the most played tracks.
|
||||||
|
- **Props**:
|
||||||
|
- `volume`: Object containing `top_tracks` array.
|
||||||
|
- **Purpose**: Quick view of recent favorites.
|
||||||
|
|
||||||
|
### `HeatMap.jsx`
|
||||||
|
Visualizes when the user listens to music.
|
||||||
|
- **Props**:
|
||||||
|
- `timeHabits`: Compressed heatmap data (7x6 grid for days/time blocks).
|
||||||
|
- `sessions`: List of recent listening sessions.
|
||||||
|
- **Visuals**:
|
||||||
|
- **Grid**: Days of the week vs. Time blocks (12am, 4am, etc.).
|
||||||
|
- **Session Timeline**: Vertical list of recent listening bouts with session type (Marathon vs. Micro).
|
||||||
|
|
||||||
|
### `ListeningLog.jsx`
|
||||||
|
A detailed view of individual plays.
|
||||||
|
- **Features**:
|
||||||
|
- **Timeline View**: Visualizes listening sessions across the day for the last 7 days.
|
||||||
|
- **List View**: A table of individual plays with skip status detection.
|
||||||
|
- **Timeframe Filter**: Toggle between 24h, 7d, 14d, and 30d views.
|
||||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
14
frontend/Dockerfile
Normal file
14
frontend/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Stage 1: Build the React app
|
||||||
|
FROM node:18 as build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Stage 2: Serve with Nginx
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
16
frontend/README.md
Normal file
16
frontend/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||||
29
frontend/eslint.config.js
Normal file
29
frontend/eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{js,jsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SonicStats - Your Vibe Report</title>
|
||||||
|
<!-- Google Fonts & Icons -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-background-light dark:bg-background-dark text-slate-900 dark:text-white font-display overflow-x-hidden min-h-screen flex flex-col">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
22
frontend/nginx.conf
Normal file
22
frontend/nginx.conf
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api/ {
|
||||||
|
# 'backend' is the service name in docker-compose
|
||||||
|
# We strip the /api/ prefix if the backend doesn't expect it,
|
||||||
|
# but in this setup the backend routes are /history, /tracks etc.
|
||||||
|
# It's cleaner to keep /api prefix in frontend and rewrite here or configure backend to serve on /api
|
||||||
|
# For simplicity, let's proxy /api/ to /
|
||||||
|
rewrite ^/api/(.*) /$1 break;
|
||||||
|
proxy_pass http://backend:8000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
}
|
||||||
5508
frontend/package-lock.json
generated
Normal file
5508
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^6.1.0",
|
||||||
|
"antd": "^6.1.2",
|
||||||
|
"axios": "^1.13.2",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"framer-motion": "^12.23.26",
|
||||||
|
"lucide-react": "^0.562.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.11.0",
|
||||||
|
"recharts": "^3.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
17
frontend/src/App.jsx
Normal file
17
frontend/src/App.jsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import Dashboard from './components/Dashboard';
|
||||||
|
import Archives from './components/Archives';
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/archives" element={<Archives />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
34
frontend/src/components/AGENTS.md
Normal file
34
frontend/src/components/AGENTS.md
Normal 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.
|
||||||
209
frontend/src/components/Archives.jsx
Normal file
209
frontend/src/components/Archives.jsx
Normal 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;
|
||||||
167
frontend/src/components/Dashboard.jsx
Normal file
167
frontend/src/components/Dashboard.jsx
Normal 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;
|
||||||
117
frontend/src/components/HeatMap.jsx
Normal file
117
frontend/src/components/HeatMap.jsx
Normal 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;
|
||||||
202
frontend/src/components/ListeningLog.jsx
Normal file
202
frontend/src/components/ListeningLog.jsx
Normal 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;
|
||||||
67
frontend/src/components/NarrativeSection.jsx
Normal file
67
frontend/src/components/NarrativeSection.jsx
Normal 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;
|
||||||
61
frontend/src/components/Navbar.jsx
Normal file
61
frontend/src/components/Navbar.jsx
Normal 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;
|
||||||
255
frontend/src/components/PlaylistsSection.jsx
Normal file
255
frontend/src/components/PlaylistsSection.jsx
Normal 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;
|
||||||
93
frontend/src/components/StatsGrid.jsx
Normal file
93
frontend/src/components/StatsGrid.jsx
Normal 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;
|
||||||
25
frontend/src/components/Tooltip.jsx
Normal file
25
frontend/src/components/Tooltip.jsx
Normal 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;
|
||||||
41
frontend/src/components/TopRotation.jsx
Normal file
41
frontend/src/components/TopRotation.jsx
Normal 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;
|
||||||
61
frontend/src/components/TrackList.jsx
Normal file
61
frontend/src/components/TrackList.jsx
Normal 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;
|
||||||
128
frontend/src/components/VibeRadar.jsx
Normal file
128
frontend/src/components/VibeRadar.jsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Radar, RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, ResponsiveContainer } from 'recharts';
|
||||||
|
|
||||||
|
const VibeRadar = ({ vibe }) => {
|
||||||
|
if (!vibe) return null;
|
||||||
|
|
||||||
|
const data = [
|
||||||
|
{ subject: 'Acoustic', A: vibe.acousticness || 0, fullMark: 1 },
|
||||||
|
{ subject: 'Dance', A: vibe.danceability || 0, fullMark: 1 },
|
||||||
|
{ subject: 'Energy', A: vibe.energy || 0, fullMark: 1 },
|
||||||
|
{ subject: 'Instrumental', A: vibe.instrumentalness || 0, fullMark: 1 },
|
||||||
|
{ subject: 'Valence', A: vibe.valence || 0, fullMark: 1 },
|
||||||
|
{ subject: 'Live', A: vibe.liveness || 0, fullMark: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const energy = vibe.energy || 0;
|
||||||
|
const danceability = vibe.danceability || 0;
|
||||||
|
const instrumentalness = vibe.instrumentalness || 0;
|
||||||
|
const valence = vibe.valence || 0;
|
||||||
|
const acousticness = vibe.acousticness || 0;
|
||||||
|
|
||||||
|
const partyScore = Math.round(((energy + danceability) / 2) * 100);
|
||||||
|
const focusScore = Math.round(((instrumentalness + (1 - valence)) / 2) * 100);
|
||||||
|
const chillScore = Math.round(((acousticness + (1 - energy)) / 2) * 100);
|
||||||
|
|
||||||
|
const total = partyScore + focusScore + chillScore || 1;
|
||||||
|
const partyPct = Math.round((partyScore / total) * 100);
|
||||||
|
const focusPct = Math.round((focusScore / total) * 100);
|
||||||
|
const chillPct = 100 - partyPct - focusPct;
|
||||||
|
|
||||||
|
const whiplash = vibe.whiplash || {};
|
||||||
|
const maxWhiplash = Math.max(
|
||||||
|
whiplash.tempo || 0,
|
||||||
|
(whiplash.energy || 0) * 100,
|
||||||
|
(whiplash.valence || 0) * 100
|
||||||
|
);
|
||||||
|
const volatilityLevel = maxWhiplash > 25 ? "HIGH" : maxWhiplash > 12 ? "MEDIUM" : "LOW";
|
||||||
|
const volatilityColor = volatilityLevel === "HIGH" ? "text-red-400" : volatilityLevel === "MEDIUM" ? "text-yellow-400" : "text-green-400";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
|
<span className="material-symbols-outlined text-primary">fingerprint</span>
|
||||||
|
Sonic DNA
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||||
|
<div className="aspect-square relative flex items-center justify-center bg-card-darker rounded-lg border border-[#222f49]/50 p-4">
|
||||||
|
<ResponsiveContainer width="100%" height={200} minHeight={200}>
|
||||||
|
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={data}>
|
||||||
|
<PolarGrid stroke="#334155" />
|
||||||
|
<PolarAngleAxis dataKey="subject" tick={{ fill: '#94a3b8', fontSize: 12 }} />
|
||||||
|
<PolarRadiusAxis angle={30} domain={[0, 1]} tick={false} axisLine={false} />
|
||||||
|
<Radar
|
||||||
|
name="My Vibe"
|
||||||
|
dataKey="A"
|
||||||
|
stroke="#256af4"
|
||||||
|
strokeWidth={2}
|
||||||
|
fill="rgba(37, 106, 244, 0.4)"
|
||||||
|
fillOpacity={0.4}
|
||||||
|
/>
|
||||||
|
</RadarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex-1 flex flex-col justify-center">
|
||||||
|
<h4 className="text-sm text-slate-400 mb-4 font-medium uppercase">Mood Clusters</h4>
|
||||||
|
<div className="relative h-40 w-full rounded-lg border border-dashed border-[#334155] bg-card-darker/50">
|
||||||
|
<div className="absolute top-1/2 left-1/4 -translate-x-1/2 -translate-y-1/2 size-20 rounded-full bg-primary/20 border border-primary text-primary flex items-center justify-center text-xs font-bold text-center p-1 cursor-pointer hover:bg-primary hover:text-white transition-colors z-10">
|
||||||
|
Party<br/>{partyPct}%
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-1/3 right-1/4 size-14 rounded-full bg-accent-purple/20 border border-accent-purple text-accent-purple flex items-center justify-center text-[10px] font-bold text-center p-1 cursor-pointer hover:bg-accent-purple hover:text-white transition-colors">
|
||||||
|
Focus<br/>{focusPct}%
|
||||||
|
</div>
|
||||||
|
<div className="absolute bottom-4 right-1/3 size-12 rounded-full bg-accent-neon/20 border border-accent-neon text-accent-neon flex items-center justify-center text-[10px] font-bold text-center p-1 cursor-pointer hover:bg-accent-neon hover:text-black transition-colors">
|
||||||
|
Chill<br/>{chillPct}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex justify-between items-end mb-2">
|
||||||
|
<h4 className="text-sm text-slate-400 font-medium uppercase">Whiplash Meter</h4>
|
||||||
|
<span className={`text-xs font-bold ${volatilityColor}`}>{volatilityLevel} VOLATILITY</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-500 w-16">Tempo</span>
|
||||||
|
<div className="flex-1 h-2 bg-card-darker rounded overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-primary to-red-500 transition-all"
|
||||||
|
style={{ width: `${Math.min((whiplash.tempo || 0) / 40 * 100, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400 w-12 text-right">{(whiplash.tempo || 0).toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-500 w-16">Energy</span>
|
||||||
|
<div className="flex-1 h-2 bg-card-darker rounded overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-primary to-yellow-500 transition-all"
|
||||||
|
style={{ width: `${Math.min((whiplash.energy || 0) * 100 / 0.4 * 100, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400 w-12 text-right">{((whiplash.energy || 0) * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-500 w-16">Valence</span>
|
||||||
|
<div className="flex-1 h-2 bg-card-darker rounded overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-primary to-green-500 transition-all"
|
||||||
|
style={{ width: `${Math.min((whiplash.valence || 0) * 100 / 0.4 * 100, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400 w-12 text-right">{((whiplash.valence || 0) * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VibeRadar;
|
||||||
88
frontend/src/index.css
Normal file
88
frontend/src/index.css
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* Custom Utilities from code.html */
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(24, 34, 52, 0.6);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.holographic-badge {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1) 0%, rgba(255, 255, 255, 0.05) 100%);
|
||||||
|
box-shadow: 0 0 20px rgba(37, 106, 244, 0.3), inset 0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holographic-badge::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
animation: holo-shine 3s infinite linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes holo-shine {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%) rotate(45deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(100%) rotate(45deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing-cursor::after {
|
||||||
|
content: "|";
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
50% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mood-gradient {
|
||||||
|
background: radial-gradient(circle at 50% 50%, rgba(37, 106, 244, 0.15), transparent 70%), radial-gradient(circle at 80% 20%, rgba(139, 92, 246, 0.1), transparent 50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide scrollbar for carousel */
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-grid circle {
|
||||||
|
fill: none;
|
||||||
|
stroke: #334155;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-grid line {
|
||||||
|
stroke: #334155;
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radar-area {
|
||||||
|
fill: rgba(37, 106, 244, 0.3);
|
||||||
|
stroke: #256af4;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paper texture */
|
||||||
|
.paper-texture {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
background-image: url(https://lh3.googleusercontent.com/aida-public/AB6AXuCxWgGFi3y5uU1Eo5AvX4bBjCZyqH_y2JcjejnbTD6deIOvWk3bplb-Bj1oFuS3P1LlYkmdnJOUkNL9g9L4yQd3Otfcz6qhp7psxQQqPTkZwV4myWl1ZoEp3ZQfBGYSI-nJnwMpWmwB1uO75co2eIFngOJE3Rn6JmLO_nOUKGhsut6iWdt_LKijBTH7SilsOX7HWTXfekHR2CwuUs4LJ6LkTMCVXS3R-aQTNfmsza_6PcRn40PTaBYS90sY9xtDPFcfgS2vzgPmPDZ6);
|
||||||
|
}
|
||||||
20
frontend/src/main.jsx
Normal file
20
frontend/src/main.jsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css';
|
||||||
|
import { ConfigProvider, theme } from 'antd';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ConfigProvider
|
||||||
|
theme={{
|
||||||
|
algorithm: theme.darkAlgorithm,
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#1DB954', // Spotify Green
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<App />
|
||||||
|
</ConfigProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
34
frontend/tailwind.config.js
Normal file
34
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
darkMode: "class",
|
||||||
|
safelist: [
|
||||||
|
'bg-primary/20', 'text-primary', 'border-primary/20',
|
||||||
|
'bg-accent-purple/20', 'text-accent-purple', 'border-accent-purple/20',
|
||||||
|
'bg-accent-neon/20', 'text-accent-neon', 'border-accent-neon/20',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
"primary": "#256af4",
|
||||||
|
"background-light": "#f5f6f8",
|
||||||
|
"background-dark": "#101622",
|
||||||
|
"card-dark": "#182234",
|
||||||
|
"card-darker": "#111927",
|
||||||
|
"accent-neon": "#0bda5e",
|
||||||
|
"accent-purple": "#8b5cf6",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
"display": ["Space Grotesk", "sans-serif"],
|
||||||
|
"mono": ["Space Grotesk", "monospace"],
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
16
frontend/vite.config.js
Normal file
16
frontend/vite.config.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
BIN
screen.png
Normal file
BIN
screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Reference in New Issue
Block a user