Add skip tracking, compressed heatmap, listening log, docs, tests, and OpenAI support

Major changes:
- Add skip tracking: poll currently-playing every 15s, detect skips (<30s listened)
- Add listening-log and sessions API endpoints
- Fix ReccoBeats client to extract spotify_id from href response
- Compress heatmap from 24 hours to 6 x 4-hour blocks
- Add OpenAI support in narrative service (use max_completion_tokens for new models)
- Add ListeningLog component with timeline and list views
- Update all frontend components to use real data (album art, play counts)
- Add docker-compose external network (dockernet) support
- Add comprehensive documentation (API, DATA_MODEL, ARCHITECTURE, FRONTEND)
- Add unit tests for ingest and API endpoints
This commit is contained in:
bnair123
2025-12-30 00:15:01 +04:00
parent faee830545
commit 887e78bf47
26 changed files with 1942 additions and 662 deletions

View File

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

View File

@@ -1,6 +1,6 @@
import asyncio import asyncio
import os import os
from datetime import datetime from datetime import datetime, timedelta
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import Track, PlayHistory, Artist from .models import Track, PlayHistory, Artist
from .database import SessionLocal from .database import SessionLocal
@@ -9,6 +9,17 @@ from .services.reccobeats_client import ReccoBeatsClient
from .services.genius_client import GeniusClient from .services.genius_client import GeniusClient
from dateutil import parser from dateutil import parser
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
# Initialize Clients # Initialize Clients
def get_spotify_client(): def get_spotify_client():
return SpotifyClient( return SpotifyClient(
@@ -17,12 +28,15 @@ def get_spotify_client():
refresh_token=os.getenv("SPOTIFY_REFRESH_TOKEN"), refresh_token=os.getenv("SPOTIFY_REFRESH_TOKEN"),
) )
def get_reccobeats_client(): def get_reccobeats_client():
return ReccoBeatsClient() return ReccoBeatsClient()
def get_genius_client(): def get_genius_client():
return GeniusClient() return GeniusClient()
async def ensure_artists_exist(db: Session, artists_data: list): async def ensure_artists_exist(db: Session, artists_data: list):
""" """
Ensures that all artists in the list exist in the Artist table. Ensures that all artists in the list exist in the Artist table.
@@ -37,17 +51,18 @@ async def ensure_artists_exist(db: Session, artists_data: list):
if "images" in a_data and a_data["images"]: if "images" in a_data and a_data["images"]:
img = a_data["images"][0]["url"] img = a_data["images"][0]["url"]
artist = Artist( artist = Artist(id=artist_id, name=a_data["name"], genres=[], image_url=img)
id=artist_id,
name=a_data["name"],
genres=[],
image_url=img
)
db.add(artist) db.add(artist)
artist_objects.append(artist) artist_objects.append(artist)
return artist_objects return artist_objects
async def enrich_tracks(db: Session, spotify_client: SpotifyClient, recco_client: ReccoBeatsClient, genius_client: GeniusClient):
async def enrich_tracks(
db: Session,
spotify_client: SpotifyClient,
recco_client: ReccoBeatsClient,
genius_client: GeniusClient,
):
""" """
Enrichment Pipeline: Enrichment Pipeline:
1. Audio Features (ReccoBeats) 1. Audio Features (ReccoBeats)
@@ -56,18 +71,19 @@ async def enrich_tracks(db: Session, spotify_client: SpotifyClient, recco_client
""" """
# 1. Enrich Audio Features # 1. Enrich Audio Features
tracks_missing_features = db.query(Track).filter(Track.danceability == None).limit(50).all() tracks_missing_features = (
db.query(Track).filter(Track.danceability == None).limit(50).all()
)
if tracks_missing_features: if tracks_missing_features:
print(f"Enriching {len(tracks_missing_features)} tracks with audio features...") print(f"Enriching {len(tracks_missing_features)} tracks with audio features...")
ids = [t.id for t in tracks_missing_features] ids = [t.id for t in tracks_missing_features]
features_list = await recco_client.get_audio_features(ids) features_list = await recco_client.get_audio_features(ids)
# Map features by ID
features_map = {} features_map = {}
for f in features_list: for f in features_list:
# Handle potential ID mismatch or URI format tid = f.get("spotify_id") or f.get("id")
tid = f.get("id") if tid:
if tid: features_map[tid] = f features_map[tid] = f
for track in tracks_missing_features: for track in tracks_missing_features:
data = features_map.get(track.id) data = features_map.get(track.id)
@@ -87,42 +103,55 @@ async def enrich_tracks(db: Session, spotify_client: SpotifyClient, recco_client
db.commit() db.commit()
# 2. Enrich Artist Genres & Images (Spotify) # 2. Enrich Artist Genres & Images (Spotify)
artists_missing_data = db.query(Artist).filter((Artist.genres == None) | (Artist.image_url == None)).limit(50).all() artists_missing_data = (
db.query(Artist)
.filter((Artist.genres == None) | (Artist.image_url == None))
.limit(50)
.all()
)
if artists_missing_data: if artists_missing_data:
print(f"Enriching {len(artists_missing_data)} artists with genres/images...") print(f"Enriching {len(artists_missing_data)} artists with genres/images...")
artist_ids_list = [a.id for a in artists_missing_data] artist_ids_list = [a.id for a in artists_missing_data]
artist_data_map = {} artist_data_map = {}
for i in range(0, len(artist_ids_list), 50): for i in range(0, len(artist_ids_list), 50):
chunk = artist_ids_list[i:i+50] chunk = artist_ids_list[i : i + 50]
artists_data = await spotify_client.get_artists(chunk) artists_data = await spotify_client.get_artists(chunk)
for a_data in artists_data: for a_data in artists_data:
if a_data: if a_data:
img = a_data["images"][0]["url"] if a_data.get("images") else None img = a_data["images"][0]["url"] if a_data.get("images") else None
artist_data_map[a_data["id"]] = { artist_data_map[a_data["id"]] = {
"genres": a_data.get("genres", []), "genres": a_data.get("genres", []),
"image_url": img "image_url": img,
} }
for artist in artists_missing_data: for artist in artists_missing_data:
data = artist_data_map.get(artist.id) data = artist_data_map.get(artist.id)
if data: if data:
if artist.genres is None: artist.genres = data["genres"] if artist.genres is None:
if artist.image_url is None: artist.image_url = data["image_url"] artist.genres = data["genres"]
if artist.image_url is None:
artist.image_url = data["image_url"]
elif artist.genres is None: elif artist.genres is None:
artist.genres = [] # Prevent retry loop artist.genres = [] # Prevent retry loop
db.commit() db.commit()
# 3. Enrich Lyrics (Genius) # 3. Enrich Lyrics (Genius)
# Only fetch for tracks that have been played recently to avoid spamming Genius API # Only fetch for tracks that have been played recently to avoid spamming Genius API
tracks_missing_lyrics = db.query(Track).filter(Track.lyrics == None).order_by(Track.updated_at.desc()).limit(10).all() 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: if tracks_missing_lyrics and genius_client.genius:
print(f"Enriching {len(tracks_missing_lyrics)} tracks with lyrics (Genius)...") print(f"Enriching {len(tracks_missing_lyrics)} tracks with lyrics (Genius)...")
for track in tracks_missing_lyrics: for track in tracks_missing_lyrics:
# We need the primary artist name # We need the primary artist name
artist_name = track.artist.split(",")[0] # Heuristic: take first artist artist_name = track.artist.split(",")[0] # Heuristic: take first artist
print(f"Searching Genius for: {track.name} by {artist_name}") print(f"Searching Genius for: {track.name} by {artist_name}")
data = genius_client.search_song(track.name, artist_name) data = genius_client.search_song(track.name, artist_name)
@@ -133,7 +162,7 @@ async def enrich_tracks(db: Session, spotify_client: SpotifyClient, recco_client
if not track.image_url and data.get("image_url"): if not track.image_url and data.get("image_url"):
track.image_url = data["image_url"] track.image_url = data["image_url"]
else: else:
track.lyrics = "" # Mark as empty to prevent retry loop track.lyrics = "" # Mark as empty to prevent retry loop
# Small sleep to be nice to API? GeniusClient is synchronous. # Small sleep to be nice to API? GeniusClient is synchronous.
# We are in async function but GeniusClient is blocking. It's fine for worker. # We are in async function but GeniusClient is blocking. It's fine for worker.
@@ -178,7 +207,7 @@ async def ingest_recently_played(db: Session):
image_url=image_url, 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,
) )
# Handle Artists Relation # Handle Artists Relation
@@ -191,21 +220,27 @@ async def ingest_recently_played(db: Session):
# Ensure relationships exist logic... # Ensure relationships exist logic...
if not track.artists and track.raw_data and "artists" in track.raw_data: 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"]) artist_objects = await ensure_artists_exist(db, track.raw_data["artists"])
track.artists = artist_objects track.artists = artist_objects
db.commit() db.commit()
exists = db.query(PlayHistory).filter( exists = (
PlayHistory.track_id == track_id, db.query(PlayHistory)
PlayHistory.played_at == played_at .filter(
).first() 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)
@@ -214,17 +249,145 @@ async def ingest_recently_played(db: Session):
# Enrich # Enrich
await enrich_tracks(db, spotify_client, recco_client, genius_client) 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()
poll_count = 0
try: try:
while True: while True:
print("Worker: Polling Spotify...") poll_count += 1
await ingest_recently_played(db)
print("Worker: Sleeping for 60 seconds...") await poll_currently_playing(db, spotify_client, tracker)
await asyncio.sleep(60)
if poll_count % 4 == 0:
print("Worker: Polling recently-played...")
await ingest_recently_played(db)
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
existing = (
db.query(PlayHistory)
.filter(
PlayHistory.track_id == tracker.current_track_id,
PlayHistory.played_at >= tracker.track_start_time - timedelta(seconds=5),
PlayHistory.played_at <= tracker.track_start_time + timedelta(seconds=5),
)
.first()
)
if existing:
if existing.listened_ms is None:
existing.listened_ms = listened_ms
existing.skipped = skipped
existing.source = "currently_playing"
db.commit()
else:
play = PlayHistory(
track_id=tracker.current_track_id,
played_at=tracker.track_start_time,
listened_ms=listened_ms,
skipped=skipped,
source="currently_playing",
)
db.add(play)
db.commit()
print(
f"Finalized: {tracker.current_track_id} listened={listened_ms}ms skipped={skipped}"
)
tracker.current_track_id = None
tracker.track_start_time = None
tracker.accumulated_listen_ms = 0
tracker.last_progress_ms = 0
tracker.last_poll_time = None
tracker.is_paused = False
async def ensure_track_exists(
db: Session, track_data: dict, spotify_client: SpotifyClient
):
track_id = track_data["id"]
track = db.query(Track).filter(Track.id == track_id).first()
if not track:
image_url = None
if track_data.get("album") and track_data["album"].get("images"):
image_url = track_data["album"]["images"][0]["url"]
track = Track(
id=track_id,
name=track_data["name"],
artist=", ".join([a["name"] for a in track_data.get("artists", [])]),
album=track_data.get("album", {}).get("name", "Unknown"),
image_url=image_url,
duration_ms=track_data.get("duration_ms"),
popularity=track_data.get("popularity"),
raw_data=track_data,
)
artists_data = track_data.get("artists", [])
artist_objects = await ensure_artists_exist(db, artists_data)
track.artists = artist_objects
db.add(track)
db.commit()

View File

@@ -1,11 +1,15 @@
from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks from fastapi import FastAPI, Depends, HTTPException, BackgroundTasks, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, joinedload
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List, Optional from typing import List, Optional
from dotenv import load_dotenv from dotenv import load_dotenv
from .database import engine, Base, get_db from .database import engine, Base, get_db
from .models import PlayHistory as PlayHistoryModel, Track as TrackModel, AnalysisSnapshot from .models import (
PlayHistory as PlayHistoryModel,
Track as TrackModel,
AnalysisSnapshot,
)
from . import schemas from . import schemas
from .ingest import ingest_recently_played from .ingest import ingest_recently_played
from .services.stats_service import StatsService from .services.stats_service import StatsService
@@ -13,7 +17,6 @@ from .services.narrative_service import NarrativeService
load_dotenv() load_dotenv()
# Create tables
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@@ -22,37 +25,49 @@ app = FastAPI(title="Music Analyser Backend")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:5173"], allow_origins=["http://localhost:5173", "http://localhost:8991"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], 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.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") @app.post("/trigger-ingest")
async def trigger_ingest(background_tasks: BackgroundTasks, db: Session = Depends(get_db)): async def trigger_ingest(
background_tasks: BackgroundTasks, db: Session = Depends(get_db)
):
"""Triggers Spotify ingestion in the background.""" """Triggers Spotify ingestion in the background."""
background_tasks.add_task(ingest_recently_played, db) background_tasks.add_task(ingest_recently_played, db)
return {"status": "Ingestion started in background"} return {"status": "Ingestion started in background"}
@app.post("/trigger-analysis") @app.post("/trigger-analysis")
def trigger_analysis( def trigger_analysis(
days: int = 30, days: int = 30,
model_name: str = "gemini-2.5-flash", model_name: str = "gpt-5-mini-2025-08-07",
db: Session = Depends(get_db) db: Session = Depends(get_db),
): ):
""" """
Runs the full analysis pipeline (Stats + LLM) for the last X days. Runs the full analysis pipeline (Stats + LLM) for the last X days.
@@ -67,7 +82,9 @@ def trigger_analysis(
stats_json = stats_service.generate_full_report(start_date, end_date) stats_json = stats_service.generate_full_report(start_date, end_date)
if stats_json["volume"]["total_plays"] == 0: if stats_json["volume"]["total_plays"] == 0:
raise HTTPException(status_code=404, detail="No plays found in the specified period.") raise HTTPException(
status_code=404, detail="No plays found in the specified period."
)
narrative_service = NarrativeService(model_name=model_name) narrative_service = NarrativeService(model_name=model_name)
narrative_json = narrative_service.generate_full_narrative(stats_json) narrative_json = narrative_service.generate_full_narrative(stats_json)
@@ -79,7 +96,7 @@ def trigger_analysis(
period_label=f"last_{days}_days", period_label=f"last_{days}_days",
metrics_payload=stats_json, metrics_payload=stats_json,
narrative_report=narrative_json, narrative_report=narrative_json,
model_used=model_name model_used=model_name,
) )
db.add(snapshot) db.add(snapshot)
db.commit() db.commit()
@@ -90,7 +107,7 @@ def trigger_analysis(
"snapshot_id": snapshot.id, "snapshot_id": snapshot.id,
"period": {"start": start_date, "end": end_date}, "period": {"start": start_date, "end": end_date},
"metrics": stats_json, "metrics": stats_json,
"narrative": narrative_json "narrative": narrative_json,
} }
except HTTPException: except HTTPException:
@@ -99,7 +116,91 @@ def trigger_analysis(
print(f"Analysis Failed: {e}") print(f"Analysis Failed: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/snapshots") @app.get("/snapshots")
def get_snapshots(limit: int = 10, db: Session = Depends(get_db)): def get_snapshots(limit: int = 10, db: Session = Depends(get_db)):
"""Retrieve past analysis snapshots.""" return (
return db.query(AnalysisSnapshot).order_by(AnalysisSnapshot.date.desc()).limit(limit).all() 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),
},
}

View File

@@ -1,35 +1,50 @@
from sqlalchemy import Column, Integer, String, DateTime, JSON, ForeignKey, Float, Table, Text 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 # Association Table for Many-to-Many Relationship between Track and Artist
track_artists = Table( track_artists = Table(
'track_artists', "track_artists",
Base.metadata, Base.metadata,
Column('track_id', String, ForeignKey('tracks.id'), primary_key=True), Column("track_id", String, ForeignKey("tracks.id"), primary_key=True),
Column('artist_id', String, ForeignKey('artists.id'), primary_key=True) Column("artist_id", String, ForeignKey("artists.id"), primary_key=True),
) )
class Artist(Base): class Artist(Base):
__tablename__ = "artists" __tablename__ = "artists"
id = Column(String, primary_key=True, index=True) # Spotify ID id = Column(String, primary_key=True, index=True) # Spotify ID
name = Column(String) name = Column(String)
genres = Column(JSON, nullable=True) # List of genre strings genres = Column(JSON, nullable=True) # List of genre strings
image_url = Column(String, nullable=True) # Artist profile image image_url = Column(String, nullable=True) # Artist profile image
# Relationships # Relationships
tracks = relationship("Track", secondary=track_artists, back_populates="artists") 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
name = Column(String) name = Column(String)
artist = Column(String) # Display string (e.g. "Drake, Future") - kept for convenience 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 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)
@@ -55,7 +70,7 @@ class Track(Base):
genres = Column(JSON, nullable=True) genres = Column(JSON, nullable=True)
# AI Analysis fields # AI Analysis fields
lyrics = Column(Text, nullable=True) # Full lyrics from Genius 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) genre_tags = Column(String, nullable=True)
@@ -71,11 +86,13 @@ 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")
@@ -84,16 +101,19 @@ class AnalysisSnapshot(Base):
Stores the computed statistics and LLM analysis for a given period. Stores the computed statistics and LLM analysis for a given period.
Allows for trend analysis over time. Allows for trend analysis over time.
""" """
__tablename__ = "analysis_snapshots" __tablename__ = "analysis_snapshots"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
date = Column(DateTime, default=datetime.utcnow, index=True) # When the analysis was run date = Column(
DateTime, default=datetime.utcnow, index=True
) # When the analysis was run
period_start = Column(DateTime) period_start = Column(DateTime)
period_end = Column(DateTime) period_end = Column(DateTime)
period_label = Column(String) # e.g., "last_30_days", "monthly_nov_2023" period_label = Column(String) # e.g., "last_30_days", "monthly_nov_2023"
# The heavy lifting: stored as JSON blobs # The heavy lifting: stored as JSON blobs
metrics_payload = Column(JSON) # The input to the LLM (StatsService output) metrics_payload = Column(JSON) # The input to the LLM (StatsService output)
narrative_report = Column(JSON) # The output from the LLM (NarrativeService output) narrative_report = Column(JSON) # The output from the LLM (NarrativeService output)
model_used = Column(String, nullable=True) # e.g. "gemini-1.5-flash" model_used = Column(String, nullable=True) # e.g. "gemini-1.5-flash"

View File

@@ -1,101 +1,154 @@
import os import os
import json import json
import re import re
from google import genai from typing import Dict, Any
from typing import Dict, Any, List, Optional
try:
from openai import OpenAI
except ImportError:
OpenAI = None
try:
from google import genai
except ImportError:
genai = None
class NarrativeService: class NarrativeService:
def __init__(self, model_name: str = "gemini-2.0-flash-exp"): def __init__(self, model_name: str = "gpt-5-mini-2025-08-07"):
self.api_key = os.getenv("GEMINI_API_KEY")
self.client = genai.Client(api_key=self.api_key) if self.api_key else None
if not self.api_key:
print("WARNING: GEMINI_API_KEY not found. LLM features will fail.")
self.model_name = model_name 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]: def generate_full_narrative(self, stats_json: Dict[str, Any]) -> Dict[str, Any]:
""" if not self.client:
Orchestrates the generation of the full narrative report. print("WARNING: No LLM client available")
Currently uses a single call for consistency and speed.
"""
if not self.api_key:
return self._get_fallback_narrative() return self._get_fallback_narrative()
clean_stats = self._shape_payload(stats_json) clean_stats = self._shape_payload(stats_json)
prompt = self._build_prompt(clean_stats)
prompt = f"""
You are a witty, insightful, and slightly snarky music critic analyzing a user's Spotify listening data.
Your goal is to generate a JSON report that acts as a deeper, more honest "Spotify Wrapped".
**CORE RULES:**
1. **NO Mental Health Diagnoses:** Do not mention depression, anxiety, or therapy. Stick to behavioral descriptors (e.g., "introspective", "high-energy").
2. **Be Specific:** Use the provided metrics. Don't say "You like pop," say "Your Mainstream Score of 85% suggests..."
3. **Roast Gently:** Be playful but not cruel.
4. **JSON Output Only:** Return strictly valid JSON.
**DATA TO ANALYZE:**
{json.dumps(clean_stats, indent=2)}
**REQUIRED JSON STRUCTURE:**
{{
"vibe_check": "2-3 paragraphs describing their overall listening personality this period.",
"patterns": ["Observation 1", "Observation 2", "Observation 3 (Look for specific habits like skipping or late-night sessions)"],
"persona": "A creative label (e.g., 'The Genre Chameleon', 'Nostalgic Dad-Rocker').",
"era_insight": "A specific comment on their Musical Age ({clean_stats.get('era', {}).get('musical_age', 'N/A')}) and Nostalgia Gap.",
"roast": "A 1-2 sentence playful roast about their taste.",
"comparison": "A short comment comparing this period to the previous one (if data exists)."
}}
"""
try: try:
response = self.client.models.generate_content( if self.provider == "openai":
model=self.model_name, return self._call_openai(prompt)
contents=prompt, elif self.provider == "gemini":
config=genai.types.GenerateContentConfig(response_mime_type="application/json") return self._call_gemini(prompt)
)
return self._clean_and_parse_json(response.text)
except Exception as e: except Exception as e:
print(f"LLM Generation Error: {e}") print(f"LLM Generation Error: {e}")
return self._get_fallback_narrative() return self._get_fallback_narrative()
return self._get_fallback_narrative()
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=1500,
temperature=0.8,
)
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:
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.
**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 ({clean_stats.get("era", {}).get("musical_age", "N/A")}).",
"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]: def _shape_payload(self, stats: Dict[str, Any]) -> Dict[str, Any]:
"""
Compresses the stats JSON to save tokens and focus the LLM.
Removes raw lists beyond top 5/10.
"""
s = stats.copy() s = stats.copy()
# Simplify Volume
if "volume" in s: if "volume" in s:
s["volume"] = { volume_copy = {
k: v for k, v in s["volume"].items() k: v
for k, v in s["volume"].items()
if k not in ["top_tracks", "top_artists", "top_albums", "top_genres"] if k not in ["top_tracks", "top_artists", "top_albums", "top_genres"]
} }
# Add back condensed top lists (just names) volume_copy["top_tracks"] = [
s["volume"]["top_tracks"] = [t["name"] for t in stats["volume"].get("top_tracks", [])[:5]] t["name"] for t in stats["volume"].get("top_tracks", [])[:5]
s["volume"]["top_artists"] = [a["name"] for a in stats["volume"].get("top_artists", [])[:5]] ]
s["volume"]["top_genres"] = [g["name"] for g in stats["volume"].get("top_genres", [])[: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
# Simplify Time (Keep distributions but maybe round them?) if "time_habits" in s:
# Keeping hourly/daily is fine, they are small arrays. s["time_habits"] = {
k: v for k, v in s["time_habits"].items() if k != "heatmap"
}
# Simplify Vibe (Remove huge transition arrays if they accidentally leaked, though stats service handles this) if "sessions" in s:
s["sessions"] = {
k: v for k, v in s["sessions"].items() if k != "session_list"
}
# Remove period details if verbose
return s return s
def _clean_and_parse_json(self, raw_text: str) -> Dict[str, Any]: def _clean_and_parse_json(self, raw_text: str) -> Dict[str, Any]:
"""
Robust JSON extractor.
"""
try: try:
# 1. Try direct parse
return json.loads(raw_text) return json.loads(raw_text)
except json.JSONDecodeError: except json.JSONDecodeError:
pass pass
# 2. Extract between first { and last }
try: try:
match = re.search(r"\{.*\}", raw_text, re.DOTALL) match = re.search(r"\{.*\}", raw_text, re.DOTALL)
if match: if match:
@@ -107,16 +160,11 @@ Your goal is to generate a JSON report that acts as a deeper, more honest "Spoti
def _get_fallback_narrative(self) -> Dict[str, Any]: def _get_fallback_narrative(self) -> Dict[str, Any]:
return { return {
"vibe_check": "Data processing error. You're too mysterious for us to analyze right now.", "vibe_check_short": "Your taste is... interesting.",
"vibe_check": "Data processing error. You're too mysterious to analyze right now.",
"patterns": [], "patterns": [],
"persona": "The Enigma", "persona": "The Enigma",
"era_insight": "Time is a flat circle.", "era_insight": "Time is a flat circle.",
"roast": "You broke the machine. Congratulations.", "roast": "You broke the machine. Congratulations.",
"comparison": "N/A" "comparison": "N/A",
} }
# Individual accessors if needed by frontend, though full_narrative is preferred
def generate_vibe_check(self, stats): return self.generate_full_narrative(stats).get("vibe_check")
def identify_patterns(self, stats): return self.generate_full_narrative(stats).get("patterns")
def generate_persona(self, stats): return self.generate_full_narrative(stats).get("persona")
def generate_roast(self, stats): return self.generate_full_narrative(stats).get("roast")

View File

@@ -3,16 +3,30 @@ from typing import List, Dict, Any
RECCOBEATS_API_URL = "https://api.reccobeats.com/v1/audio-features" RECCOBEATS_API_URL = "https://api.reccobeats.com/v1/audio-features"
class ReccoBeatsClient: class ReccoBeatsClient:
async def get_audio_features(self, spotify_ids: List[str]) -> List[Dict[str, Any]]: async def get_audio_features(self, spotify_ids: List[str]) -> List[Dict[str, Any]]:
if not spotify_ids: if not spotify_ids:
return [] return []
ids_param = ",".join(spotify_ids) ids_param = ",".join(spotify_ids)
async with httpx.AsyncClient() as client: async with httpx.AsyncClient(timeout=30.0) as client:
try: try:
response = await client.get(RECCOBEATS_API_URL, params={"ids": ids_param}) response = await client.get(
RECCOBEATS_API_URL, params={"ids": ids_param}
)
if response.status_code != 200: if response.status_code != 200:
print(f"ReccoBeats API returned status {response.status_code}")
return [] return []
return response.json().get("content", [])
except Exception: content = response.json().get("content", [])
for item in content:
href = item.get("href", "")
if "spotify.com/track/" in href:
spotify_id = href.split("/track/")[-1].split("?")[0]
item["spotify_id"] = spotify_id
return content
except Exception as e:
print(f"ReccoBeats API error: {e}")
return [] return []

View File

@@ -8,6 +8,7 @@ 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
@@ -92,3 +93,17 @@ class SpotifyClient:
return [] return []
return response.json().get("artists", []) 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()

View File

@@ -8,11 +8,17 @@ from sklearn.cluster import KMeans
from ..models import PlayHistory, Track, Artist from ..models import PlayHistory, Track, Artist
class StatsService: class StatsService:
def __init__(self, db: Session): def __init__(self, db: Session):
self.db = db self.db = db
def compute_comparison(self, current_stats: Dict[str, Any], period_start: datetime, period_end: datetime) -> Dict[str, Any]: def compute_comparison(
self,
current_stats: Dict[str, Any],
period_start: datetime,
period_end: datetime,
) -> Dict[str, Any]:
""" """
Calculates deltas vs the previous period of the same length. Calculates deltas vs the previous period of the same length.
""" """
@@ -44,28 +50,38 @@ class StatsService:
deltas["valence_delta"] = round(curr_v - prev_v, 2) deltas["valence_delta"] = round(curr_v - prev_v, 2)
# Popularity # Popularity
if "avg_popularity" in current_stats["taste"] and "avg_popularity" in prev_taste: if (
deltas["popularity_delta"] = round(current_stats["taste"]["avg_popularity"] - prev_taste["avg_popularity"], 1) "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 { return {
"previous_period": { "previous_period": {
"start": prev_start.isoformat(), "start": prev_start.isoformat(),
"end": prev_end.isoformat() "end": prev_end.isoformat(),
}, },
"deltas": deltas "deltas": deltas,
} }
def compute_volume_stats(self, period_start: datetime, period_end: datetime) -> Dict[str, Any]: def compute_volume_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
""" """
Calculates volume metrics including Concentration (HHI, Gini, Entropy) and Top Lists. Calculates volume metrics including Concentration (HHI, Gini, Entropy) and Top Lists.
""" """
# Eager load tracks AND artists to fix the "Artist String Problem" and performance # Eager load tracks AND artists to fix the "Artist String Problem" and performance
# Use < period_end for half-open interval to avoid double counting boundaries # Use < period_end for half-open interval to avoid double counting boundaries
query = self.db.query(PlayHistory).options( query = (
joinedload(PlayHistory.track).joinedload(Track.artists) self.db.query(PlayHistory)
).filter( .options(joinedload(PlayHistory.track).joinedload(Track.artists))
PlayHistory.played_at >= period_start, .filter(
PlayHistory.played_at < period_end PlayHistory.played_at >= period_start,
PlayHistory.played_at < period_end,
)
) )
plays = query.all() plays = query.all()
total_plays = len(plays) total_plays = len(plays)
@@ -86,15 +102,18 @@ class StatsService:
# Helper to safely get image # Helper to safely get image
def get_track_image(t): def get_track_image(t):
if t.image_url: return t.image_url 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"]: if t.raw_data and "album" in t.raw_data and "images" in t.raw_data["album"]:
imgs = t.raw_data["album"]["images"] imgs = t.raw_data["album"]["images"]
if imgs: return imgs[0].get("url") if imgs:
return imgs[0].get("url")
return None return None
for p in plays: for p in plays:
t = p.track t = p.track
if not t: continue if not t:
continue
total_ms += t.duration_ms if t.duration_ms else 0 total_ms += t.duration_ms if t.duration_ms else 0
@@ -119,7 +138,10 @@ class StatsService:
for artist in t.artists: for artist in t.artists:
artist_counts[artist.id] = artist_counts.get(artist.id, 0) + 1 artist_counts[artist.id] = artist_counts.get(artist.id, 0) + 1
if artist.id not in artist_map: if artist.id not in artist_map:
artist_map[artist.id] = {"name": artist.name, "image": artist.image_url} artist_map[artist.id] = {
"name": artist.name,
"image": artist.image_url,
}
# Genre Aggregation # Genre Aggregation
if artist.genres: if artist.genres:
@@ -138,39 +160,63 @@ class StatsService:
"name": track_map[tid].name, "name": track_map[tid].name,
"artist": ", ".join([a.name for a in track_map[tid].artists]), "artist": ", ".join([a.name for a in track_map[tid].artists]),
"image": get_track_image(track_map[tid]), "image": get_track_image(track_map[tid]),
"count": c "count": c,
} }
for tid, c in sorted(track_counts.items(), key=lambda x: x[1], reverse=True)[:5] for tid, c in sorted(
track_counts.items(), key=lambda x: x[1], reverse=True
)[:5]
] ]
top_artists = [ 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] "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 = [ 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] "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]] top_genres = [
{"name": k, "count": v}
for k, v in sorted(genre_counts.items(), key=lambda x: x[1], reverse=True)[
:5
]
]
# Concentration Metrics # Concentration Metrics
# HHI: Sum of (share)^2 # HHI: Sum of (share)^2
hhi = sum([s ** 2 for s in shares]) hhi = sum([s**2 for s in shares])
# Gini Coefficient # Gini Coefficient
sorted_shares = sorted(shares) sorted_shares = sorted(shares)
n = len(shares) n = len(shares)
gini = 0 gini = 0
if n > 0: if n > 0:
gini = (2 * sum((i + 1) * x for i, x in enumerate(sorted_shares))) / (n * sum(sorted_shares)) - (n + 1) / n gini = (2 * sum((i + 1) * x for i, x in enumerate(sorted_shares))) / (
n * sum(sorted_shares)
) - (n + 1) / n
# Genre Entropy: -SUM(p * log(p)) # Genre Entropy: -SUM(p * log(p))
total_genre_occurrences = sum(genre_counts.values()) total_genre_occurrences = sum(genre_counts.values())
genre_entropy = 0 genre_entropy = 0
if total_genre_occurrences > 0: if total_genre_occurrences > 0:
genre_probs = [count / total_genre_occurrences for count in genre_counts.values()] 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]) genre_entropy = -sum([p * math.log(p) for p in genre_probs if p > 0])
# Top 5 Share # Top 5 Share
@@ -188,32 +234,53 @@ class StatsService:
"top_artists": top_artists, "top_artists": top_artists,
"top_albums": top_albums, "top_albums": top_albums,
"top_genres": top_genres, "top_genres": top_genres,
"repeat_rate": round((total_plays - unique_tracks) / total_plays, 3) if total_plays else 0, "repeat_rate": round((total_plays - unique_tracks) / total_plays, 3)
"one_and_done_rate": round(one_and_done / unique_tracks, 3) if unique_tracks else 0, if total_plays
else 0,
"one_and_done_rate": round(one_and_done / unique_tracks, 3)
if unique_tracks
else 0,
"concentration": { "concentration": {
"hhi": round(hhi, 4), "hhi": round(hhi, 4),
"gini": round(gini, 4), "gini": round(gini, 4),
"top_1_share": round(max(shares), 3) if shares else 0, "top_1_share": round(max(shares), 3) if shares else 0,
"top_5_share": round(top_5_share, 3), "top_5_share": round(top_5_share, 3),
"genre_entropy": round(genre_entropy, 2) "genre_entropy": round(genre_entropy, 2),
} },
} }
def compute_time_stats(self, period_start: datetime, period_end: datetime) -> Dict[str, Any]: def compute_time_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
""" """
Includes Part-of-Day buckets, Listening Streaks, Active Days, and 2D Heatmap. Includes Part-of-Day buckets, Listening Streaks, Active Days, and 2D Heatmap.
""" """
query = self.db.query(PlayHistory).filter( query = (
PlayHistory.played_at >= period_start, self.db.query(PlayHistory)
PlayHistory.played_at < period_end .filter(
).order_by(PlayHistory.played_at.asc()) PlayHistory.played_at >= period_start,
PlayHistory.played_at < period_end,
)
.order_by(PlayHistory.played_at.asc())
)
plays = query.all() plays = query.all()
if not plays: if not plays:
return {} return {}
# Heatmap: 7 days x 24 hours # Heatmap: 7 days x 24 hours (granular) and 7 days x 6 blocks (compressed)
heatmap = [[0 for _ in range(24)] for _ in range(7)] heatmap = [[0 for _ in range(24)] for _ in range(7)]
# Compressed heatmap: 6 x 4-hour blocks per day
# Blocks: 0-4 (Night), 4-8 (Early Morning), 8-12 (Morning), 12-16 (Afternoon), 16-20 (Evening), 20-24 (Night)
heatmap_compressed = [[0 for _ in range(6)] for _ in range(7)]
block_labels = [
"12am-4am",
"4am-8am",
"8am-12pm",
"12pm-4pm",
"4pm-8pm",
"8pm-12am",
]
hourly_counts = [0] * 24 hourly_counts = [0] * 24
weekday_counts = [0] * 7 weekday_counts = [0] * 7
@@ -225,9 +292,15 @@ class StatsService:
h = p.played_at.hour h = p.played_at.hour
d = p.played_at.weekday() d = p.played_at.weekday()
# Populate Heatmap # Populate Heatmap (granular)
heatmap[d][h] += 1 heatmap[d][h] += 1
# Populate compressed heatmap (4-hour blocks)
block_idx = (
h // 4
) # 0-3 -> 0, 4-7 -> 1, 8-11 -> 2, 12-15 -> 3, 16-19 -> 4, 20-23 -> 5
heatmap_compressed[d][block_idx] += 1
hourly_counts[h] += 1 hourly_counts[h] += 1
weekday_counts[d] += 1 weekday_counts[d] += 1
active_dates.add(p.played_at.date()) active_dates.add(p.played_at.date())
@@ -261,26 +334,38 @@ class StatsService:
active_days_count = len(active_dates) active_days_count = len(active_dates)
return { return {
"heatmap": heatmap, # 7x24 Matrix "heatmap": heatmap,
"heatmap_compressed": heatmap_compressed,
"block_labels": block_labels,
"hourly_distribution": hourly_counts, "hourly_distribution": hourly_counts,
"peak_hour": hourly_counts.index(max(hourly_counts)), "peak_hour": hourly_counts.index(max(hourly_counts)),
"weekday_distribution": weekday_counts, "weekday_distribution": weekday_counts,
"daily_distribution": weekday_counts,
"weekend_share": round(weekend_plays / len(plays), 2), "weekend_share": round(weekend_plays / len(plays), 2),
"part_of_day": part_of_day, "part_of_day": part_of_day,
"listening_streak": current_streak, "listening_streak": current_streak,
"longest_streak": longest_streak, "longest_streak": longest_streak,
"active_days": active_days_count, "active_days": active_days_count,
"avg_plays_per_active_day": round(len(plays) / active_days_count, 1) if active_days_count else 0 "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]: def compute_session_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
""" """
Includes Micro-sessions, Marathon sessions, Energy Arcs, Median metrics, and Session List. Includes Micro-sessions, Marathon sessions, Energy Arcs, Median metrics, and Session List.
""" """
query = self.db.query(PlayHistory).options(joinedload(PlayHistory.track)).filter( query = (
PlayHistory.played_at >= period_start, self.db.query(PlayHistory)
PlayHistory.played_at < period_end .options(joinedload(PlayHistory.track))
).order_by(PlayHistory.played_at.asc()) .filter(
PlayHistory.played_at >= period_start,
PlayHistory.played_at < period_end,
)
.order_by(PlayHistory.played_at.asc())
)
plays = query.all() plays = query.all()
if not plays: if not plays:
@@ -291,7 +376,7 @@ class StatsService:
# 1. Sessionization (Gap > 20 mins) # 1. Sessionization (Gap > 20 mins)
for i in range(1, len(plays)): for i in range(1, len(plays)):
diff = (plays[i].played_at - plays[i-1].played_at).total_seconds() / 60 diff = (plays[i].played_at - plays[i - 1].played_at).total_seconds() / 60
if diff > 20: if diff > 20:
sessions.append(current_session) sessions.append(current_session)
current_session = [] current_session = []
@@ -305,7 +390,7 @@ class StatsService:
energy_arcs = {"rising": 0, "falling": 0, "flat": 0, "unknown": 0} energy_arcs = {"rising": 0, "falling": 0, "flat": 0, "unknown": 0}
start_hour_dist = [0] * 24 start_hour_dist = [0] * 24
session_list = [] # Metadata for timeline session_list = [] # Metadata for timeline
for sess in sessions: for sess in sessions:
start_t = sess[0].played_at start_t = sess[0].played_at
@@ -319,7 +404,7 @@ class StatsService:
duration = (end_t - start_t).total_seconds() / 60 duration = (end_t - start_t).total_seconds() / 60
lengths_min.append(duration) lengths_min.append(duration)
else: else:
duration = 3.0 # Approx single song duration = 3.0 # Approx single song
lengths_min.append(duration) lengths_min.append(duration)
# Types # Types
@@ -332,22 +417,32 @@ class StatsService:
sess_type = "Marathon" sess_type = "Marathon"
# Store Session Metadata # Store Session Metadata
session_list.append({ session_list.append(
"start_time": start_t.isoformat(), {
"end_time": end_t.isoformat(), "start_time": start_t.isoformat(),
"duration_minutes": round(duration, 1), "end_time": end_t.isoformat(),
"track_count": len(sess), "duration_minutes": round(duration, 1),
"type": sess_type "track_count": len(sess),
}) "type": sess_type,
}
)
# Energy Arc # Energy Arc
first_t = sess[0].track first_t = sess[0].track
last_t = sess[-1].track last_t = sess[-1].track
if first_t and last_t and first_t.energy is not None and last_t.energy is not None: if (
first_t
and last_t
and first_t.energy is not None
and last_t.energy is not None
):
diff = last_t.energy - first_t.energy diff = last_t.energy - first_t.energy
if diff > 0.1: energy_arcs["rising"] += 1 if diff > 0.1:
elif diff < -0.1: energy_arcs["falling"] += 1 energy_arcs["rising"] += 1
else: energy_arcs["flat"] += 1 elif diff < -0.1:
energy_arcs["falling"] += 1
else:
energy_arcs["flat"] += 1
else: else:
energy_arcs["unknown"] += 1 energy_arcs["unknown"] += 1
@@ -369,17 +464,24 @@ class StatsService:
"micro_session_rate": round(micro_sessions / len(sessions), 2), "micro_session_rate": round(micro_sessions / len(sessions), 2),
"marathon_session_rate": round(marathon_sessions / len(sessions), 2), "marathon_session_rate": round(marathon_sessions / len(sessions), 2),
"energy_arcs": energy_arcs, "energy_arcs": energy_arcs,
"session_list": session_list "session_list": session_list,
} }
def compute_vibe_stats(self, period_start: datetime, period_end: datetime) -> Dict[str, Any]: def compute_vibe_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
""" """
Aggregates Audio Features + Calculates Whiplash + Clustering + Harmonic Profile. Aggregates Audio Features + Calculates Whiplash + Clustering + Harmonic Profile.
""" """
plays = self.db.query(PlayHistory).filter( plays = (
PlayHistory.played_at >= period_start, self.db.query(PlayHistory)
PlayHistory.played_at < period_end .filter(
).order_by(PlayHistory.played_at.asc()).all() PlayHistory.played_at >= period_start,
PlayHistory.played_at < period_end,
)
.order_by(PlayHistory.played_at.asc())
.all()
)
if not plays: if not plays:
return {} return {}
@@ -389,8 +491,17 @@ class StatsService:
track_map = {t.id: t for t in tracks} track_map = {t.id: t for t in tracks}
# 1. Aggregates # 1. Aggregates
feature_keys = ["energy", "valence", "danceability", "tempo", "acousticness", feature_keys = [
"instrumentalness", "liveness", "speechiness", "loudness"] "energy",
"valence",
"danceability",
"tempo",
"acousticness",
"instrumentalness",
"liveness",
"speechiness",
"loudness",
]
features = {k: [] for k in feature_keys} features = {k: [] for k in feature_keys}
# For Clustering: List of [energy, valence, danceability, acousticness] # For Clustering: List of [energy, valence, danceability, acousticness]
@@ -408,7 +519,8 @@ class StatsService:
for i, p in enumerate(plays): for i, p in enumerate(plays):
t = track_map.get(p.track_id) t = track_map.get(p.track_id)
if not t: continue if not t:
continue
# Robust Null Check: Append separately # Robust Null Check: Append separately
for key in feature_keys: for key in feature_keys:
@@ -417,29 +529,43 @@ class StatsService:
features[key].append(val) features[key].append(val)
# Cluster Data (only if all 4 exist) # Cluster Data (only if all 4 exist)
if all(getattr(t, k) is not None for k in ["energy", "valence", "danceability", "acousticness"]): if all(
cluster_data.append([t.energy, t.valence, t.danceability, t.acousticness]) getattr(t, k) is not None
for k in ["energy", "valence", "danceability", "acousticness"]
):
cluster_data.append(
[t.energy, t.valence, t.danceability, t.acousticness]
)
# Harmonic # Harmonic
if t.key is not None: keys.append(t.key) if t.key is not None:
if t.mode is not None: modes.append(t.mode) keys.append(t.key)
if t.mode is not None:
modes.append(t.mode)
# Tempo Zones # Tempo Zones
if t.tempo is not None: if t.tempo is not None:
if t.tempo < 100: tempo_zones["chill"] += 1 if t.tempo < 100:
elif t.tempo < 130: tempo_zones["groove"] += 1 tempo_zones["chill"] += 1
else: tempo_zones["hype"] += 1 elif t.tempo < 130:
tempo_zones["groove"] += 1
else:
tempo_zones["hype"] += 1
# Calculate Transitions (Whiplash) # Calculate Transitions (Whiplash)
if i > 0 and previous_track: if i > 0 and previous_track:
time_diff = (p.played_at - plays[i - 1].played_at).total_seconds() time_diff = (p.played_at - plays[i - 1].played_at).total_seconds()
if time_diff < 300: # 5 min gap max if time_diff < 300: # 5 min gap max
if t.tempo is not None and previous_track.tempo is not None: if t.tempo is not None and previous_track.tempo is not None:
transitions["tempo"].append(abs(t.tempo - previous_track.tempo)) transitions["tempo"].append(abs(t.tempo - previous_track.tempo))
if t.energy is not None and previous_track.energy is not None: if t.energy is not None and previous_track.energy is not None:
transitions["energy"].append(abs(t.energy - previous_track.energy)) transitions["energy"].append(
abs(t.energy - previous_track.energy)
)
if t.valence is not None and previous_track.valence is not None: if t.valence is not None and previous_track.valence is not None:
transitions["valence"].append(abs(t.valence - previous_track.valence)) transitions["valence"].append(
abs(t.valence - previous_track.valence)
)
previous_track = t previous_track = t
@@ -448,33 +574,42 @@ class StatsService:
for key, values in features.items(): for key, values in features.items():
valid = [v for v in values if v is not None] valid = [v for v in values if v is not None]
if valid: if valid:
stats[f"avg_{key}"] = float(np.mean(valid)) avg_val = float(np.mean(valid))
stats[key] = round(avg_val, 3)
stats[f"avg_{key}"] = avg_val
stats[f"std_{key}"] = float(np.std(valid)) stats[f"std_{key}"] = float(np.std(valid))
stats[f"p10_{key}"] = float(np.percentile(valid, 10)) stats[f"p10_{key}"] = float(np.percentile(valid, 10))
stats[f"p50_{key}"] = float(np.percentile(valid, 50)) # Median stats[f"p50_{key}"] = float(np.percentile(valid, 50))
stats[f"p90_{key}"] = float(np.percentile(valid, 90)) stats[f"p90_{key}"] = float(np.percentile(valid, 90))
else: else:
stats[key] = 0.0
stats[f"avg_{key}"] = None stats[f"avg_{key}"] = None
# Derived Metrics # Derived Metrics
if stats.get("avg_energy") is not None and stats.get("avg_valence") is not None: if stats.get("avg_energy") is not None and stats.get("avg_valence") is not None:
stats["mood_quadrant"] = { stats["mood_quadrant"] = {
"x": round(stats["avg_valence"], 2), "x": round(stats["avg_valence"], 2),
"y": round(stats["avg_energy"], 2) "y": round(stats["avg_energy"], 2),
} }
avg_std = (stats.get("std_energy", 0) + stats.get("std_valence", 0)) / 2 avg_std = (stats.get("std_energy", 0) + stats.get("std_valence", 0)) / 2
stats["consistency_score"] = round(1.0 - avg_std, 2) stats["consistency_score"] = round(1.0 - avg_std, 2)
if stats.get("avg_tempo") is not None and stats.get("avg_danceability") is not None: if (
stats.get("avg_tempo") is not None
and stats.get("avg_danceability") is not None
):
stats["rhythm_profile"] = { stats["rhythm_profile"] = {
"avg_tempo": round(stats["avg_tempo"], 1), "avg_tempo": round(stats["avg_tempo"], 1),
"avg_danceability": round(stats["avg_danceability"], 2) "avg_danceability": round(stats["avg_danceability"], 2),
} }
if stats.get("avg_acousticness") is not None and stats.get("avg_instrumentalness") is not None: if (
stats.get("avg_acousticness") is not None
and stats.get("avg_instrumentalness") is not None
):
stats["texture_profile"] = { stats["texture_profile"] = {
"acousticness": round(stats["avg_acousticness"], 2), "acousticness": round(stats["avg_acousticness"], 2),
"instrumentalness": round(stats["avg_instrumentalness"], 2) "instrumentalness": round(stats["avg_instrumentalness"], 2),
} }
# Whiplash # Whiplash
@@ -488,7 +623,9 @@ class StatsService:
# Tempo Zones # Tempo Zones
total_tempo = sum(tempo_zones.values()) total_tempo = sum(tempo_zones.values())
if total_tempo > 0: if total_tempo > 0:
stats["tempo_zones"] = {k: round(v / total_tempo, 2) for k, v in tempo_zones.items()} stats["tempo_zones"] = {
k: round(v / total_tempo, 2) for k, v in tempo_zones.items()
}
else: else:
stats["tempo_zones"] = {} stats["tempo_zones"] = {}
@@ -497,21 +634,39 @@ class StatsService:
major_count = len([m for m in modes if m == 1]) major_count = len([m for m in modes if m == 1])
stats["harmonic_profile"] = { stats["harmonic_profile"] = {
"major_pct": round(major_count / len(modes), 2), "major_pct": round(major_count / len(modes), 2),
"minor_pct": round((len(modes) - major_count) / len(modes), 2) "minor_pct": round((len(modes) - major_count) / len(modes), 2),
} }
if keys: if keys:
# Map integers to pitch class notation # Map integers to pitch class notation
pitch_class = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] pitch_class = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
]
key_counts = {} key_counts = {}
for k in keys: for k in keys:
if 0 <= k < 12: if 0 <= k < 12:
label = pitch_class[k] label = pitch_class[k]
key_counts[label] = key_counts.get(label, 0) + 1 key_counts[label] = key_counts.get(label, 0) + 1
stats["top_keys"] = [{"key": k, "count": v} for k, v in sorted(key_counts.items(), key=lambda x: x[1], reverse=True)[:3]] stats["top_keys"] = [
{"key": k, "count": v}
for k, v in sorted(
key_counts.items(), key=lambda x: x[1], reverse=True
)[:3]
]
# CLUSTERING (K-Means) # CLUSTERING (K-Means)
if len(cluster_data) >= 5: # Need enough data points if len(cluster_data) >= 5: # Need enough data points
try: try:
# Features: energy, valence, danceability, acousticness # Features: energy, valence, danceability, acousticness
kmeans = KMeans(n_clusters=3, random_state=42, n_init=10) kmeans = KMeans(n_clusters=3, random_state=42, n_init=10)
@@ -520,9 +675,10 @@ class StatsService:
# Analyze clusters # Analyze clusters
clusters = [] clusters = []
for i in range(3): for i in range(3):
mask = (labels == i) mask = labels == i
count = np.sum(mask) count = np.sum(mask)
if count == 0: continue if count == 0:
continue
centroid = kmeans.cluster_centers_[i] centroid = kmeans.cluster_centers_[i]
share = count / len(cluster_data) share = count / len(cluster_data)
@@ -530,24 +686,32 @@ class StatsService:
# Heuristic Naming # Heuristic Naming
c_energy, c_valence, c_dance, c_acoustic = centroid c_energy, c_valence, c_dance, c_acoustic = centroid
name = "Mixed Vibe" name = "Mixed Vibe"
if c_energy > 0.7: name = "High Energy" if c_energy > 0.7:
elif c_acoustic > 0.7: name = "Acoustic / Chill" name = "High Energy"
elif c_valence < 0.3: name = "Melancholy" elif c_acoustic > 0.7:
elif c_dance > 0.7: name = "Dance / Groove" name = "Acoustic / Chill"
elif c_valence < 0.3:
name = "Melancholy"
elif c_dance > 0.7:
name = "Dance / Groove"
clusters.append({ clusters.append(
"name": name, {
"share": round(share, 2), "name": name,
"features": { "share": round(share, 2),
"energy": round(c_energy, 2), "features": {
"valence": round(c_valence, 2), "energy": round(c_energy, 2),
"danceability": round(c_dance, 2), "valence": round(c_valence, 2),
"acousticness": round(c_acoustic, 2) "danceability": round(c_dance, 2),
"acousticness": round(c_acoustic, 2),
},
} }
}) )
# Sort by share # Sort by share
stats["clusters"] = sorted(clusters, key=lambda x: x["share"], reverse=True) stats["clusters"] = sorted(
clusters, key=lambda x: x["share"], reverse=True
)
except Exception as e: except Exception as e:
print(f"Clustering failed: {e}") print(f"Clustering failed: {e}")
stats["clusters"] = [] stats["clusters"] = []
@@ -556,13 +720,19 @@ class StatsService:
return stats return stats
def compute_era_stats(self, period_start: datetime, period_end: datetime) -> Dict[str, Any]: def compute_era_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
""" """
Includes Nostalgia Gap and granular decade breakdown. Includes Nostalgia Gap and granular decade breakdown.
""" """
query = self.db.query(PlayHistory).options(joinedload(PlayHistory.track)).filter( query = (
PlayHistory.played_at >= period_start, self.db.query(PlayHistory)
PlayHistory.played_at < period_end .options(joinedload(PlayHistory.track))
.filter(
PlayHistory.played_at >= period_start,
PlayHistory.played_at < period_end,
)
) )
plays = query.all() plays = query.all()
@@ -597,19 +767,27 @@ class StatsService:
return { return {
"musical_age": int(avg_year), "musical_age": int(avg_year),
"nostalgia_gap": int(current_year - avg_year), "nostalgia_gap": int(current_year - avg_year),
"freshness_score": dist.get(f"{int(current_year / 10) * 10}s", 0), # Share of current decade "freshness_score": dist.get(
"decade_distribution": dist f"{int(current_year / 10) * 10}s", 0
), # Share of current decade
"decade_distribution": dist,
} }
def compute_skip_stats(self, period_start: datetime, period_end: datetime) -> Dict[str, Any]: def compute_skip_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
""" """
Implements boredom skip detection: Implements boredom skip detection:
(next_track.played_at - current_track.played_at) < (current_track.duration_ms / 1000 - 10s) (next_track.played_at - current_track.played_at) < (current_track.duration_ms / 1000 - 10s)
""" """
query = self.db.query(PlayHistory).filter( query = (
PlayHistory.played_at >= period_start, self.db.query(PlayHistory)
PlayHistory.played_at <= period_end .filter(
).order_by(PlayHistory.played_at.asc()) PlayHistory.played_at >= period_start,
PlayHistory.played_at <= period_end,
)
.order_by(PlayHistory.played_at.asc())
)
plays = query.all() plays = query.all()
if len(plays) < 2: if len(plays) < 2:
@@ -622,13 +800,15 @@ class StatsService:
for i in range(len(plays) - 1): for i in range(len(plays) - 1):
current_play = plays[i] current_play = plays[i]
next_play = plays[i+1] next_play = plays[i + 1]
track = track_map.get(current_play.track_id) track = track_map.get(current_play.track_id)
if not track or not track.duration_ms: if not track or not track.duration_ms:
continue continue
diff_seconds = (next_play.played_at - current_play.played_at).total_seconds() diff_seconds = (
next_play.played_at - current_play.played_at
).total_seconds()
# Logic: If diff < (duration - 10s), it's a skip. # Logic: If diff < (duration - 10s), it's a skip.
# Convert duration to seconds # Convert duration to seconds
@@ -641,25 +821,29 @@ class StatsService:
if diff_seconds < (duration_sec - 10): if diff_seconds < (duration_sec - 10):
skips += 1 skips += 1
return { return {"total_skips": skips, "skip_rate": round(skips / len(plays), 3)}
"total_skips": skips,
"skip_rate": round(skips / len(plays), 3)
}
def compute_context_stats(self, period_start: datetime, period_end: datetime) -> Dict[str, Any]: def compute_context_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
""" """
Analyzes context_uri to determine if user listens to Playlists, Albums, or Artists. Analyzes context_uri to determine if user listens to Playlists, Albums, or Artists.
""" """
query = self.db.query(PlayHistory).filter( query = self.db.query(PlayHistory).filter(
PlayHistory.played_at >= period_start, PlayHistory.played_at >= period_start, PlayHistory.played_at <= period_end
PlayHistory.played_at <= period_end
) )
plays = query.all() plays = query.all()
if not plays: if not plays:
return {} return {}
context_counts = {"playlist": 0, "album": 0, "artist": 0, "collection": 0, "unknown": 0} context_counts = {
"playlist": 0,
"album": 0,
"artist": 0,
"collection": 0,
"unknown": 0,
}
unique_contexts = {} unique_contexts = {}
for p in plays: for p in plays:
@@ -686,26 +870,32 @@ class StatsService:
breakdown = {k: round(v / total, 2) for k, v in context_counts.items()} breakdown = {k: round(v / total, 2) for k, v in context_counts.items()}
# Top 5 Contexts (Requires resolving URI to name, possibly missing metadata here) # Top 5 Contexts (Requires resolving URI to name, possibly missing metadata here)
sorted_contexts = sorted(unique_contexts.items(), key=lambda x: x[1], reverse=True)[:5] sorted_contexts = sorted(
unique_contexts.items(), key=lambda x: x[1], reverse=True
)[:5]
return { return {
"type_breakdown": breakdown, "type_breakdown": breakdown,
"album_purist_score": breakdown.get("album", 0), "album_purist_score": breakdown.get("album", 0),
"playlist_dependency": breakdown.get("playlist", 0), "playlist_dependency": breakdown.get("playlist", 0),
"context_loyalty": round(len(plays) / len(unique_contexts), 2) if unique_contexts else 0, "context_loyalty": round(len(plays) / len(unique_contexts), 2)
"top_context_uris": [{"uri": k, "count": v} for k, v in sorted_contexts] 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]: def compute_taste_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
""" """
Mainstream vs. Hipster analysis based on Track.popularity (0-100). Mainstream vs. Hipster analysis based on Track.popularity (0-100).
""" """
query = self.db.query(PlayHistory).filter( query = self.db.query(PlayHistory).filter(
PlayHistory.played_at >= period_start, PlayHistory.played_at >= period_start, PlayHistory.played_at <= period_end
PlayHistory.played_at <= period_end
) )
plays = query.all() plays = query.all()
if not plays: return {} if not plays:
return {}
track_ids = list(set([p.track_id for p in plays])) track_ids = list(set([p.track_id for p in plays]))
tracks = self.db.query(Track).filter(Track.id.in_(track_ids)).all() tracks = self.db.query(Track).filter(Track.id.in_(track_ids)).all()
@@ -730,20 +920,27 @@ class StatsService:
"avg_popularity": round(avg_pop, 1), "avg_popularity": round(avg_pop, 1),
"hipster_score": round((underground_plays / len(pop_values)) * 100, 1), "hipster_score": round((underground_plays / len(pop_values)) * 100, 1),
"mainstream_score": round((mainstream_plays / len(pop_values)) * 100, 1), "mainstream_score": round((mainstream_plays / len(pop_values)) * 100, 1),
"obscurity_rating": round(100 - avg_pop, 1) "obscurity_rating": round(100 - avg_pop, 1),
} }
def compute_lifecycle_stats(self, period_start: datetime, period_end: datetime) -> Dict[str, Any]: def compute_lifecycle_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
""" """
Determines if tracks are 'New Discoveries' or 'Old Favorites'. Determines if tracks are 'New Discoveries' or 'Old Favorites'.
""" """
# 1. Get tracks played in this period # 1. Get tracks played in this period
current_plays = self.db.query(PlayHistory).filter( current_plays = (
PlayHistory.played_at >= period_start, self.db.query(PlayHistory)
PlayHistory.played_at <= period_end .filter(
).all() PlayHistory.played_at >= period_start,
PlayHistory.played_at <= period_end,
)
.all()
)
if not current_plays: return {} if not current_plays:
return {}
current_track_ids = set([p.track_id for p in current_plays]) current_track_ids = set([p.track_id for p in current_plays])
@@ -751,7 +948,7 @@ class StatsService:
# We find which of the current_track_ids exist in history < period_start # We find which of the current_track_ids exist in history < period_start
old_tracks_query = self.db.query(distinct(PlayHistory.track_id)).filter( old_tracks_query = self.db.query(distinct(PlayHistory.track_id)).filter(
PlayHistory.track_id.in_(current_track_ids), PlayHistory.track_id.in_(current_track_ids),
PlayHistory.played_at < period_start PlayHistory.played_at < period_start,
) )
old_track_ids = set([r[0] for r in old_tracks_query.all()]) old_track_ids = set([r[0] for r in old_tracks_query.all()])
@@ -765,21 +962,32 @@ class StatsService:
return { return {
"discovery_count": discovery_count, "discovery_count": discovery_count,
"discovery_rate": round(plays_on_new / total_plays, 3) if total_plays > 0 else 0, "discovery_rate": round(plays_on_new / total_plays, 3)
"recurrence_rate": round((total_plays - plays_on_new) / total_plays, 3) if total_plays > 0 else 0 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]: def compute_explicit_stats(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
""" """
Analyzes explicit content consumption. Analyzes explicit content consumption.
""" """
query = self.db.query(PlayHistory).options(joinedload(PlayHistory.track)).filter( query = (
PlayHistory.played_at >= period_start, self.db.query(PlayHistory)
PlayHistory.played_at <= period_end .options(joinedload(PlayHistory.track))
.filter(
PlayHistory.played_at >= period_start,
PlayHistory.played_at <= period_end,
)
) )
plays = query.all() plays = query.all()
if not plays: return {"explicit_rate": 0, "hourly_explicit_rate": []} if not plays:
return {"explicit_rate": 0, "hourly_explicit_rate": []}
total_plays = len(plays) total_plays = len(plays)
explicit_count = 0 explicit_count = 0
@@ -811,13 +1019,18 @@ class StatsService:
return { return {
"explicit_rate": round(explicit_count / total_plays, 3), "explicit_rate": round(explicit_count / total_plays, 3),
"total_explicit_plays": explicit_count, "total_explicit_plays": explicit_count,
"hourly_explicit_distribution": hourly_rates "hourly_explicit_distribution": hourly_rates,
} }
def generate_full_report(self, period_start: datetime, period_end: datetime) -> Dict[str, Any]: def generate_full_report(
self, period_start: datetime, period_end: datetime
) -> Dict[str, Any]:
# 1. Calculate all current stats # 1. Calculate all current stats
current_stats = { current_stats = {
"period": {"start": period_start.isoformat(), "end": period_end.isoformat()}, "period": {
"start": period_start.isoformat(),
"end": period_end.isoformat(),
},
"volume": self.compute_volume_stats(period_start, period_end), "volume": self.compute_volume_stats(period_start, period_end),
"time_habits": self.compute_time_stats(period_start, period_end), "time_habits": self.compute_time_stats(period_start, period_end),
"sessions": self.compute_session_stats(period_start, period_end), "sessions": self.compute_session_stats(period_start, period_end),
@@ -827,21 +1040,31 @@ class StatsService:
"taste": self.compute_taste_stats(period_start, period_end), "taste": self.compute_taste_stats(period_start, period_end),
"lifecycle": self.compute_lifecycle_stats(period_start, period_end), "lifecycle": self.compute_lifecycle_stats(period_start, period_end),
"flags": self.compute_explicit_stats(period_start, period_end), "flags": self.compute_explicit_stats(period_start, period_end),
"skips": self.compute_skip_stats(period_start, period_end) "skips": self.compute_skip_stats(period_start, period_end),
} }
# 2. Calculate Comparison # 2. Calculate Comparison
current_stats["comparison"] = self.compute_comparison(current_stats, period_start, period_end) current_stats["comparison"] = self.compute_comparison(
current_stats, period_start, period_end
)
return current_stats return current_stats
def _empty_volume_stats(self): def _empty_volume_stats(self):
return { return {
"total_plays": 0, "estimated_minutes": 0, "unique_tracks": 0, "total_plays": 0,
"unique_artists": 0, "unique_albums": 0, "unique_genres": 0, "estimated_minutes": 0,
"top_tracks": [], "top_artists": [], "top_albums": [], "top_genres": [], "unique_tracks": 0,
"repeat_rate": 0, "one_and_done_rate": 0, "unique_artists": 0,
"concentration": {} "unique_albums": 0,
"unique_genres": 0,
"top_tracks": [],
"top_artists": [],
"top_albums": [],
"top_genres": [],
"repeat_rate": 0,
"one_and_done_rate": 0,
"concentration": {},
} }
def _pct_change(self, curr, prev): def _pct_change(self, curr, prev):

View File

@@ -13,3 +13,4 @@ alembic==1.13.1
scikit-learn==1.4.0 scikit-learn==1.4.0
lyricsgenius==3.0.1 lyricsgenius==3.0.1
google-genai==1.56.0 google-genai==1.56.0
openai>=1.0.0

View File

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

View File

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

View File

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

View File

@@ -1,155 +0,0 @@
import os
import json
# import pytest <-- Removed
from datetime import datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from backend.app.models import Base, PlayHistory, Track, Artist
from backend.app.services.stats_service import StatsService
# Setup Test Database
# @pytest.fixture <-- Removed
def db_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
def seed_data(db):
"""
Seeds the database with specific patterns to verify metrics.
Pattern:
- High Energy/Happy Session (Morning)
- Low Energy/Sad Session (Night)
- Skips
- Repeats
"""
# 1. Create Artists
a1 = Artist(id="a1", name="The Hype Men", genres=["pop", "dance"])
a2 = Artist(id="a2", name="Sad Bois", genres=["indie", "folk"])
a3 = Artist(id="a3", name="Mozart", genres=["classical"])
db.add_all([a1, a2, a3])
# 2. Create Tracks
# High Energy, High Valence, Fast
t1 = Track(
id="t1", name="Party Anthem", album="Hype Vol 1", duration_ms=180000,
popularity=80, energy=0.9, valence=0.9, danceability=0.8, tempo=140.0, acousticness=0.1, instrumentalness=0.0,
key=0, mode=1 # C Major
)
t1.artists.append(a1)
# Low Energy, Low Valence, Slow
t2 = Track(
id="t2", name="Rainy Day", album="Sad Vol 1", duration_ms=240000,
popularity=20, energy=0.2, valence=0.1, danceability=0.3, tempo=80.0, acousticness=0.9, instrumentalness=0.0,
key=9, mode=0 # A Minor
)
t2.artists.append(a2)
# Classical (Instrumental)
t3 = Track(
id="t3", name="Symphony 40", album="Classics", duration_ms=300000,
popularity=50, energy=0.4, valence=0.5, danceability=0.1, tempo=110.0, acousticness=0.8, instrumentalness=0.9,
key=5, mode=0
)
t3.artists.append(a3)
db.add_all([t1, t2, t3])
db.commit()
# 3. Create History
base_time = datetime(2023, 11, 1, 8, 0, 0) # Morning
plays = []
# SESSION 1: Morning Hype (3 plays of t1)
# 08:00
plays.append(PlayHistory(track_id="t1", played_at=base_time, context_uri="spotify:playlist:morning"))
# 08:04 (4 min gap)
plays.append(PlayHistory(track_id="t1", played_at=base_time + timedelta(minutes=4), context_uri="spotify:playlist:morning"))
# 08:08
plays.append(PlayHistory(track_id="t1", played_at=base_time + timedelta(minutes=8), context_uri="spotify:playlist:morning"))
# GAP > 20 mins -> New Session
# SESSION 2: Night Sadness (t2, t2, t3)
# 22:00
night_time = datetime(2023, 11, 1, 22, 0, 0)
plays.append(PlayHistory(track_id="t2", played_at=night_time, context_uri="spotify:album:sad"))
# SKIP SIMULATION: t2 played at 22:00, next play at 22:00:20 (20s later).
# Duration is 240s. 20s < 230s. This is a skip.
# But wait, logic says "boredom skip".
# If I play t2 at 22:00.
# And play t3 at 22:00:40.
# Diff = 40s. 40 < (240 - 10). Yes, Skip.
plays.append(PlayHistory(track_id="t3", played_at=night_time + timedelta(seconds=40), context_uri="spotify:album:sad"))
# Finish t3 (5 mins)
plays.append(PlayHistory(track_id="t3", played_at=night_time + timedelta(seconds=40) + timedelta(minutes=5, seconds=10), context_uri="spotify:album:sad"))
db.add_all(plays)
db.commit()
def test_stats_generation(db_session):
seed_data(db_session)
stats_service = StatsService(db_session)
start = datetime(2023, 11, 1, 0, 0, 0)
end = datetime(2023, 11, 2, 0, 0, 0)
report = stats_service.generate_full_report(start, end)
print("\n--- GENERATED REPORT ---")
print(json.dumps(report, indent=2, default=str))
print("------------------------\n")
# Assertions
# 1. Volume
assert report["volume"]["total_plays"] == 6
assert report["volume"]["unique_tracks"] == 3
# Top track should be t1 (3 plays)
assert report["volume"]["top_tracks"][0]["name"] == "Party Anthem"
# 2. Time
# 3 plays in morning (8am), 3 plays at night (22pm)
assert report["time_habits"]["part_of_day"]["morning"] == 3
assert report["time_habits"]["part_of_day"]["night"] == 0 # 22:00 is "evening" in buckets (18-23)
assert report["time_habits"]["part_of_day"]["evening"] == 3
# 3. Sessions
# Should be 2 sessions (gap between 08:08 and 22:00)
assert report["sessions"]["count"] == 2
# 4. Skips
# 1 skip detected (t2 -> t3 gap was 40s vs 240s duration)
assert report["skips"]["total_skips"] == 1
# 5. Vibe & Clustering
# Should have cluster info
assert "clusters" in report["vibe"]
# Check harmonic
assert report["vibe"]["harmonic_profile"]["major_pct"] > 0
# Check tempo zones (t1=140=Hype, t2=80=Chill, t3=110=Groove)
# 3x t1 (Hype), 1x t2 (Chill), 2x t3 (Groove)
# Total 6. Hype=0.5, Chill=0.17, Groove=0.33
zones = report["vibe"]["tempo_zones"]
assert zones["hype"] == 0.5
# 6. Context
# Morning = Playlist (3), Night = Album (3) -> 50/50
assert report["context"]["type_breakdown"]["playlist"] == 0.5
assert report["context"]["type_breakdown"]["album"] == 0.5
if __name__ == "__main__":
# Manually run if executed as script
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
test_stats_generation(session)

View File

@@ -0,0 +1,64 @@
# 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
ports:
- '8000:8000'
networks:
- dockernet
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/snapshots?limit=1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
frontend:
build:
context: ./frontend
image: ghcr.io/bnair123/musicanalyser-frontend:latest
container_name: music-analyser-frontend
restart: unless-stopped
ports:
- '8991:80'
networks:
- dockernet
depends_on:
backend:
condition: service_healthy
volumes:
music_data:
driver: local
networks:
dockernet:
external: true
# If you don't have an external dockernet, create it with:
# docker network create dockernet
# Or change to:
# dockernet:
# driver: bridge

View File

@@ -16,14 +16,18 @@ services:
- SPOTIFY_REFRESH_TOKEN=${SPOTIFY_REFRESH_TOKEN} - SPOTIFY_REFRESH_TOKEN=${SPOTIFY_REFRESH_TOKEN}
- GEMINI_API_KEY=${GEMINI_API_KEY} - GEMINI_API_KEY=${GEMINI_API_KEY}
- GENIUS_ACCESS_TOKEN=${GENIUS_ACCESS_TOKEN} - GENIUS_ACCESS_TOKEN=${GENIUS_ACCESS_TOKEN}
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_APIKEY=${OPENAI_APIKEY}
ports: ports:
- '8000:8000' - '8000:8000'
networks:
- dockernet
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/"] test: ["CMD", "curl", "-f", "http://localhost:8000/snapshots?limit=1"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
start_period: 40s start_period: 60s
frontend: frontend:
build: build:
@@ -33,6 +37,8 @@ services:
restart: unless-stopped restart: unless-stopped
ports: ports:
- '8991:80' - '8991:80'
networks:
- dockernet
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy
@@ -40,3 +46,7 @@ services:
volumes: volumes:
music_data: music_data:
driver: local driver: local
networks:
dockernet:
external: true

125
docs/API.md Normal file
View File

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

43
docs/ARCHITECTURE.md Normal file
View File

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

89
docs/DATA_MODEL.md Normal file
View File

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

61
docs/FRONTEND.md Normal file
View File

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

View File

@@ -5,7 +5,8 @@ import StatsGrid from './StatsGrid';
import VibeRadar from './VibeRadar'; import VibeRadar from './VibeRadar';
import HeatMap from './HeatMap'; import HeatMap from './HeatMap';
import TopRotation from './TopRotation'; import TopRotation from './TopRotation';
import { Spin } from 'antd'; // Keeping Spin for loading state import ListeningLog from './ListeningLog';
import { Spin } from 'antd';
const API_BASE_URL = '/api'; const API_BASE_URL = '/api';
@@ -13,7 +14,7 @@ const Dashboard = () => {
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const getTodayKey = () => `sonicstats_v1_${new Date().toISOString().split('T')[0]}`; const getTodayKey = () => `sonicstats_v2_${new Date().toISOString().split('T')[0]}`;
const fetchData = async (forceRefresh = false) => { const fetchData = async (forceRefresh = false) => {
setLoading(true); setLoading(true);
@@ -73,9 +74,11 @@ const Dashboard = () => {
); );
} }
const vibeCheckFull = data?.narrative?.vibe_check || "";
const patterns = data?.narrative?.patterns || [];
return ( return (
<> <>
{/* Navbar */}
<header className="sticky top-0 z-50 glass-panel border-b border-[#222f49]"> <header className="sticky top-0 z-50 glass-panel border-b border-[#222f49]">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between"> <div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -98,27 +101,52 @@ const Dashboard = () => {
</header> </header>
<main className="flex-grow w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8"> <main className="flex-grow w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
{/* Hero */}
<NarrativeSection narrative={data?.narrative} vibe={data?.metrics?.vibe} /> <NarrativeSection narrative={data?.narrative} vibe={data?.metrics?.vibe} />
{/* Stats Bento Grid */}
<StatsGrid metrics={data?.metrics} /> <StatsGrid metrics={data?.metrics} />
{/* Sonic DNA & Chronobiology Split */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Col: Sonic DNA (2/3 width) */}
<div className="lg:col-span-2 space-y-8"> <div className="lg:col-span-2 space-y-8">
<VibeRadar vibe={data?.metrics?.vibe} /> <VibeRadar vibe={data?.metrics?.vibe} />
<TopRotation volume={data?.metrics?.volume} /> <TopRotation volume={data?.metrics?.volume} />
</div> </div>
{/* Right Col: Chronobiology (1/3 width) */}
<div className="lg:col-span-1 space-y-8"> <div className="lg:col-span-1 space-y-8">
<HeatMap timeHabits={data?.metrics?.time_habits} /> <HeatMap timeHabits={data?.metrics?.time_habits} sessions={data?.metrics?.sessions} />
</div> </div>
</div> </div>
{/* Footer: The Roast */} <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 && ( {data?.narrative?.roast && (
<footer className="pb-8"> <footer className="pb-8">
<div className="paper-texture rounded-xl p-8 border border-white/10 relative overflow-hidden group"> <div className="paper-texture rounded-xl p-8 border border-white/10 relative overflow-hidden group">

View File

@@ -1,31 +1,43 @@
import React from 'react'; import React from 'react';
import { format, parseISO } from 'date-fns';
const HeatMap = ({ timeHabits }) => { const HeatMap = ({ timeHabits, sessions }) => {
if (!timeHabits) return null; if (!timeHabits) return null;
// Helper to get intensity for a day/time slot const heatmapCompressed = timeHabits.heatmap_compressed || timeHabits.heatmap || [];
// Since we only have aggregate hourly and daily stats, we'll approximate: const blockLabels = timeHabits.block_labels || ["12am-4am", "4am-8am", "8am-12pm", "12pm-4pm", "4pm-8pm", "8pm-12am"];
// Cell(d, h) ~ Daily(d) * Hourly(h) const sessionList = sessions?.session_list || [];
// Normalize daily distribution (0-6, Mon-Sun) const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// API usually returns 0=Monday or 0=Sunday depending on backend. Let's assume 0=Monday for now. const blocks = blockLabels.length > 0 ? blockLabels : Array.from({ length: 6 }, (_, i) => `${i*4}h-${(i+1)*4}h`);
const dailyDist = timeHabits.daily_distribution || {};
const hourlyDist = timeHabits.hourly_distribution || {};
const days = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; const maxVal = Math.max(...heatmapCompressed.flat(), 1);
const timeBlocks = [
{ label: 'Night', hours: [0, 1, 2, 3, 4, 5] },
{ label: 'Morning', hours: [6, 7, 8, 9, 10, 11] },
{ label: 'Noon', hours: [12, 13, 14, 15, 16, 17] },
{ label: 'Evening', hours: [18, 19, 20, 21, 22, 23] }
];
const maxDaily = Math.max(...Object.values(dailyDist)) || 1; const getIntensityClass = (val) => {
const maxHourly = Math.max(...Object.values(hourlyDist)) || 1; 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";
};
// Flatten grid for rendering: 4 rows (time blocks) x 7 cols (days) const recentSessions = sessionList.slice(-5).reverse();
// Actually code.html has many small squares. It looks like each column is a day, and rows are finer time slots.
// Let's do 4 rows representing 6-hour blocks. 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 ( return (
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6 h-full"> <div className="bg-card-dark border border-[#222f49] rounded-xl p-6 h-full">
@@ -37,73 +49,66 @@ const HeatMap = ({ timeHabits }) => {
<div className="mb-8"> <div className="mb-8">
<h4 className="text-sm text-slate-400 mb-3 font-medium">Listening Heatmap</h4> <h4 className="text-sm text-slate-400 mb-3 font-medium">Listening Heatmap</h4>
{/* Grid */} <div className="flex gap-2">
<div className="grid grid-cols-7 gap-1"> <div className="flex flex-col justify-between text-[10px] text-slate-500 pr-1 py-1">
{/* Header Days */} {blocks.map((label, i) => (
{days.map((d, i) => ( <span key={i} className="leading-tight">{label.split('-')[0]}</span>
<div key={i} className="text-[10px] text-center text-slate-500">{d}</div> ))}
))} </div>
{/* Generate cells: 4 rows x 7 cols */} <div className="flex-1">
{timeBlocks.map((block, rowIdx) => ( <div className="grid grid-cols-7 gap-1 mb-2">
<React.Fragment key={rowIdx}> {days.map((d, i) => (
{days.map((_, colIdx) => { <div key={i} className="text-[10px] text-center text-slate-500 font-medium">{d}</div>
// Calculate approximated intensity ))}
const dayVal = dailyDist[colIdx] || 0; </div>
const blockVal = block.hours.reduce((acc, h) => acc + (hourlyDist[h] || 0), 0);
// Normalize <div className="grid grid-cols-7 gap-1">
const intensity = (dayVal / maxDaily) * (blockVal / (maxHourly * 6)); {blocks.map((block, blockIdx) => (
days.map((day, dayIdx) => {
let bgClass = "bg-[#1e293b]"; // Default empty const val = heatmapCompressed[dayIdx]?.[blockIdx] || 0;
if (intensity > 0.8) bgClass = "bg-primary"; return (
else if (intensity > 0.6) bgClass = "bg-primary/80"; <div
else if (intensity > 0.4) bgClass = "bg-primary/60"; key={`${dayIdx}-${blockIdx}`}
else if (intensity > 0.2) bgClass = "bg-primary/40"; className={`h-6 rounded ${getIntensityClass(val)} transition-colors hover:ring-2 hover:ring-primary/50`}
else if (intensity > 0) bgClass = "bg-primary/20"; title={`${day} ${block} - ${val} plays`}
/>
return ( );
<div })
key={`${rowIdx}-${colIdx}`} )).flat()}
className={`aspect-square rounded-sm ${bgClass}`} </div>
title={`${block.label} on ${days[colIdx]}`} </div>
></div>
);
})}
</React.Fragment>
))}
</div> </div>
<div className="flex justify-between mt-2 text-[10px] text-slate-500"> <div className="flex justify-end gap-1 mt-3 items-center">
<span>00:00</span> <span className="text-[9px] text-slate-500">Less</span>
<span>12:00</span> <div className="w-3 h-3 rounded-sm bg-primary/20"></div>
<span>23:59</span> <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> </div>
{/* Session Flow (Static for now as API doesn't provide session logs yet) */}
<div> <div>
<h4 className="text-sm text-slate-400 mb-4 font-medium">Session Flow</h4> <h4 className="text-sm text-slate-400 mb-4 font-medium">Recent Sessions</h4>
<div className="relative pl-4 border-l border-[#334155] space-y-6"> {recentSessions.length > 0 ? (
<div className="relative"> <div className="relative pl-4 border-l border-[#334155] space-y-4">
<span className="absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full bg-primary ring-4 ring-card-dark"></span> {recentSessions.map((session, idx) => (
<p className="text-xs text-slate-400">Today, 2:30 PM</p> <div key={idx} className="relative">
<p className="text-white font-bold text-sm">Marathoning</p> <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-primary mt-0.5">3h 42m session</p> <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> </div>
<div className="relative"> ) : (
<span className="absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full bg-slate-600 ring-4 ring-card-dark"></span> <p className="text-sm text-slate-500">No session data yet</p>
<p className="text-xs text-slate-400">Yesterday, 9:15 AM</p> )}
<p className="text-white font-bold text-sm">Micro-Dosing</p>
<p className="text-xs text-slate-500 mt-0.5">12m commute</p>
</div>
<div className="relative">
<span className="absolute -left-[21px] top-1 h-2.5 w-2.5 rounded-full bg-primary/50 ring-4 ring-card-dark"></span>
<p className="text-xs text-slate-400">Yesterday, 8:00 PM</p>
<p className="text-white font-bold text-sm">Deep Focus</p>
<p className="text-xs text-primary/70 mt-0.5">1h 15m session</p>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

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

View File

@@ -5,37 +5,38 @@ const NarrativeSection = ({ narrative, vibe }) => {
if (!narrative) return null; if (!narrative) return null;
const persona = narrative.persona || "THE UNKNOWN LISTENER"; const persona = narrative.persona || "THE UNKNOWN LISTENER";
const vibeCheck = narrative.vibe_check || "Analyzing auditory aura..."; const vibeCheckShort = narrative.vibe_check_short || narrative.vibe_check?.substring(0, 120) + "..." || "Analyzing auditory aura...";
// Generate tags based on vibe metrics if available
const getTags = () => { const getTags = () => {
if (!vibe) return []; if (!vibe) return [];
const tags = []; const tags = [];
if (vibe.valence > 0.6) tags.push({ text: "HIGH VALENCE", color: "primary" }); const valence = vibe.valence || 0;
else if (vibe.valence < 0.4) tags.push({ text: "MELANCHOLIC", color: "accent-purple" }); const energy = vibe.energy || 0;
const danceability = vibe.danceability || 0;
if (vibe.energy > 0.6) tags.push({ text: "HIGH ENERGY", color: "accent-neon" }); if (valence > 0.6) tags.push({ text: "HIGH VALENCE", color: "primary" });
else if (vibe.energy < 0.4) tags.push({ text: "CHILL VIBES", color: "accent-purple" }); else if (valence < 0.4) tags.push({ text: "MELANCHOLIC", color: "accent-purple" });
if (vibe.danceability > 0.7) tags.push({ text: "DANCEABLE", color: "primary" }); 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" });
return tags.slice(0, 3); // Max 3 tags if (danceability > 0.7) tags.push({ text: "DANCEABLE", color: "primary" });
return tags.slice(0, 3);
}; };
const tags = getTags(); const tags = getTags();
// Default tags if none generated
if (tags.length === 0) { if (tags.length === 0) {
tags.push({ text: "ECLECTIC", color: "primary" }); tags.push({ text: "ECLECTIC", color: "primary" });
tags.push({ text: "MYSTERIOUS", color: "accent-purple" }); tags.push({ text: "MYSTERIOUS", color: "accent-purple" });
} }
return ( return (
<section className="relative rounded-2xl overflow-hidden min-h-[400px] flex items-center justify-center p-8 bg-card-dark border border-[#222f49]"> <section className="relative rounded-2xl overflow-hidden min-h-[300px] flex items-center justify-center p-8 bg-card-dark border border-[#222f49]">
{/* Dynamic Background */}
<div className="absolute inset-0 mood-gradient"></div> <div className="absolute inset-0 mood-gradient"></div>
<div className="relative z-10 flex flex-col items-center text-center max-w-2xl gap-6"> <div className="relative z-10 flex flex-col items-center text-center max-w-2xl gap-4">
<motion.div <motion.div
initial={{ scale: 0.9, opacity: 0 }} initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }} animate={{ scale: 1, opacity: 1 }}
@@ -47,11 +48,11 @@ const NarrativeSection = ({ narrative, vibe }) => {
</h1> </h1>
</motion.div> </motion.div>
<div className="font-mono text-primary/80 text-lg md:text-xl font-medium tracking-wide"> <div className="font-mono text-primary/80 text-base md:text-lg font-medium tracking-wide max-w-lg">
<span className="typing-cursor">{vibeCheck}</span> <span className="typing-cursor">{vibeCheckShort}</span>
</div> </div>
<div className="mt-4 flex gap-3 flex-wrap justify-center"> <div className="mt-2 flex gap-3 flex-wrap justify-center">
{tags.map((tag, i) => ( {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`}> <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} {tag.text}

View File

@@ -4,27 +4,21 @@ const StatsGrid = ({ metrics }) => {
if (!metrics) return null; if (!metrics) return null;
const totalMinutes = Math.round((metrics.volume?.estimated_minutes || 0)); const totalMinutes = Math.round((metrics.volume?.estimated_minutes || 0));
// Calculate days for the "That's X days straight" text
const daysListened = (totalMinutes / (24 * 60)).toFixed(1); const daysListened = (totalMinutes / (24 * 60)).toFixed(1);
const obsessionTrack = metrics.volume?.top_tracks?.[0]; const obsessionTrack = metrics.volume?.top_tracks?.[0];
const obsessionName = obsessionTrack ? obsessionTrack.name : "N/A"; const obsessionName = obsessionTrack ? obsessionTrack.name : "N/A";
const obsessionArtist = obsessionTrack ? obsessionTrack.artist : "N/A"; const obsessionArtist = obsessionTrack ? obsessionTrack.artist : "N/A";
const obsessionCount = obsessionTrack ? obsessionTrack.count : 0; 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";
// Fallback image if we don't have one (API currently doesn't seem to return it in top_tracks simple list) const uniqueArtists = metrics.volume?.unique_artists || 0;
// We'll use a nice gradient or abstract pattern
const obsessionImage = "https://images.unsplash.com/photo-1614613535308-eb5fbd3d2c17?q=80&w=2070&auto=format&fit=crop";
const newDiscoveries = metrics.volume?.unique_artists || 0; const hipsterScore = metrics.taste?.hipster_score || 0;
const obscurityRating = metrics.taste?.obscurity_rating || 0;
// Mocking the "Underground" percentage for now as it's not in the standard payload
// Could derive from popularity if available, but let's randomize slightly based on unique artists to make it feel dynamic
const undergroundScore = Math.min(95, Math.max(10, Math.round((newDiscoveries % 100))));
return ( return (
<section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <section className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Card 1: Minutes Listened */}
<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="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"> <div className="flex items-start justify-between">
<span className="text-slate-400 text-sm font-medium uppercase tracking-wider">Minutes Listened</span> <span className="text-slate-400 text-sm font-medium uppercase tracking-wider">Minutes Listened</span>
@@ -39,7 +33,6 @@ const StatsGrid = ({ metrics }) => {
</div> </div>
</div> </div>
{/* Card 2: Obsession Track */}
<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="bg-card-dark border border-[#222f49] rounded-xl relative overflow-hidden h-full min-h-[200px] group lg:col-span-2">
<div <div
className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-105" className="absolute inset-0 bg-cover bg-center transition-transform duration-700 group-hover:scale-105"
@@ -62,16 +55,13 @@ const StatsGrid = ({ metrics }) => {
</div> </div>
</div> </div>
{/* Card 3: New Discoveries & Mainstream Gauge */}
<div className="flex flex-col gap-4 h-full"> <div className="flex flex-col gap-4 h-full">
{/* Discoveries */}
<div className="bg-card-dark border border-[#222f49] rounded-xl p-5 flex-1 flex flex-col justify-center items-center text-center"> <div className="bg-card-dark border border-[#222f49] rounded-xl p-5 flex-1 flex flex-col justify-center items-center text-center">
<span className="material-symbols-outlined text-4xl text-primary mb-2">visibility</span> <span className="material-symbols-outlined text-4xl text-primary mb-2">visibility</span>
<div className="text-3xl font-bold text-white">{newDiscoveries}</div> <div className="text-3xl font-bold text-white">{uniqueArtists}</div>
<div className="text-slate-400 text-xs uppercase tracking-wider">Unique Artists</div> <div className="text-slate-400 text-xs uppercase tracking-wider">Unique Artists</div>
</div> </div>
{/* Gauge */}
<div className="bg-card-dark border border-[#222f49] rounded-xl p-5 flex-1 flex flex-col justify-center items-center"> <div className="bg-card-dark border border-[#222f49] rounded-xl p-5 flex-1 flex flex-col justify-center items-center">
<div className="relative size-20"> <div className="relative size-20">
<svg className="size-full -rotate-90" viewBox="0 0 36 36"> <svg className="size-full -rotate-90" viewBox="0 0 36 36">
@@ -81,15 +71,16 @@ const StatsGrid = ({ metrics }) => {
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
strokeDasharray={`${undergroundScore}, 100`} strokeDasharray={`${Math.min(hipsterScore, 100)}, 100`}
strokeWidth="3" strokeWidth="3"
></path> ></path>
</svg> </svg>
<div className="absolute inset-0 flex items-center justify-center flex-col"> <div className="absolute inset-0 flex items-center justify-center flex-col">
<span className="text-sm font-bold text-white">{undergroundScore}%</span> <span className="text-sm font-bold text-white">{hipsterScore.toFixed(0)}%</span>
</div> </div>
</div> </div>
<div className="text-slate-400 text-[10px] uppercase tracking-wider mt-2">Underground Certified</div> <div className="text-slate-400 text-[10px] uppercase tracking-wider mt-2">Hipster Score</div>
<div className="text-slate-500 text-[9px] mt-1">Obscurity: {obscurityRating.toFixed(0)}%</div>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -3,14 +3,7 @@ import React from 'react';
const TopRotation = ({ volume }) => { const TopRotation = ({ volume }) => {
if (!volume || !volume.top_tracks) return null; if (!volume || !volume.top_tracks) return null;
// Use placeholder images since API doesn't return album art in the simple list yet const fallbackImage = "https://images.unsplash.com/photo-1619983081563-430f63602796?q=80&w=200&auto=format&fit=crop";
const placeHolderImages = [
"https://images.unsplash.com/photo-1619983081563-430f63602796?q=80&w=1000&auto=format&fit=crop",
"https://images.unsplash.com/photo-1493225255756-d9584f8606e9?q=80&w=1000&auto=format&fit=crop",
"https://images.unsplash.com/photo-1470225620780-dba8ba36b745?q=80&w=1000&auto=format&fit=crop",
"https://images.unsplash.com/photo-1511671782779-c97d3d27a1d4?q=80&w=1000&auto=format&fit=crop",
"https://images.unsplash.com/photo-1514525253440-b393452e8d26?q=80&w=1000&auto=format&fit=crop"
];
return ( return (
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6 overflow-hidden"> <div className="bg-card-dark border border-[#222f49] rounded-xl p-6 overflow-hidden">
@@ -24,17 +17,19 @@ const TopRotation = ({ volume }) => {
<div className="flex gap-4 overflow-x-auto no-scrollbar pb-2"> <div className="flex gap-4 overflow-x-auto no-scrollbar pb-2">
{volume.top_tracks.slice(0, 5).map((track, i) => { {volume.top_tracks.slice(0, 5).map((track, i) => {
const name = track.name || track[0]; const name = track.name || "Unknown";
const artist = track.artist || track[1]; const artist = track.artist || "Unknown";
const image = track.image || fallbackImage;
return ( 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 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 <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' : ''}`} 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('${placeHolderImages[i % placeHolderImages.length]}')` }} style={{ backgroundImage: `url('${image}')` }}
></div> ></div>
<p className={`text-white font-medium truncate ${i === 0 ? 'font-bold' : 'text-sm'}`}>{name}</p> <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-slate-400 truncate">{artist}</p>
<p className="text-xs text-primary">{track.count} plays</p>
</div> </div>
); );
})} })}

View File

@@ -13,17 +13,30 @@ const VibeRadar = ({ vibe }) => {
{ subject: 'Live', A: vibe.liveness || 0, fullMark: 1 }, { subject: 'Live', A: vibe.liveness || 0, fullMark: 1 },
]; ];
// Calculate mood percentages based on vibe metrics const energy = vibe.energy || 0;
const partyScore = Math.round(((vibe.energy + vibe.danceability) / 2) * 100); const danceability = vibe.danceability || 0;
const focusScore = Math.round(((vibe.instrumentalness + (1 - vibe.valence)) / 2) * 100); const instrumentalness = vibe.instrumentalness || 0;
const chillScore = Math.round(((vibe.acousticness + (1 - vibe.energy)) / 2) * 100); const valence = vibe.valence || 0;
const acousticness = vibe.acousticness || 0;
// Normalize to sum to 100 roughly (just for display) const partyScore = Math.round(((energy + danceability) / 2) * 100);
const total = partyScore + focusScore + chillScore; 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 partyPct = Math.round((partyScore / total) * 100);
const focusPct = Math.round((focusScore / total) * 100); const focusPct = Math.round((focusScore / total) * 100);
const chillPct = 100 - partyPct - focusPct; 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 ( return (
<div className="bg-card-dark border border-[#222f49] rounded-xl p-6"> <div className="bg-card-dark border border-[#222f49] rounded-xl p-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
@@ -34,7 +47,6 @@ const VibeRadar = ({ vibe }) => {
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Feature Radar */}
<div className="aspect-square relative flex items-center justify-center bg-card-darker rounded-lg border border-[#222f49]/50 p-4"> <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}> <ResponsiveContainer width="100%" height={200} minHeight={200}>
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={data}> <RadarChart cx="50%" cy="50%" outerRadius="70%" data={data}>
@@ -53,9 +65,7 @@ const VibeRadar = ({ vibe }) => {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
{/* Mood Modes & Whiplash */}
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Mood Bubbles */}
<div className="flex-1 flex flex-col justify-center"> <div className="flex-1 flex flex-col justify-center">
<h4 className="text-sm text-slate-400 mb-4 font-medium uppercase">Mood Clusters</h4> <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="relative h-40 w-full rounded-lg border border-dashed border-[#334155] bg-card-darker/50">
@@ -71,17 +81,42 @@ const VibeRadar = ({ vibe }) => {
</div> </div>
</div> </div>
{/* Whiplash Meter */}
<div> <div>
<div className="flex justify-between items-end mb-2"> <div className="flex justify-between items-end mb-2">
<h4 className="text-sm text-slate-400 font-medium uppercase">Whiplash Meter</h4> <h4 className="text-sm text-slate-400 font-medium uppercase">Whiplash Meter</h4>
<span className="text-xs text-red-400 font-bold">HIGH VOLATILITY</span> <span className={`text-xs font-bold ${volatilityColor}`}>{volatilityLevel} VOLATILITY</span>
</div> </div>
<div className="h-12 w-full bg-card-darker rounded flex items-center px-2 overflow-hidden relative"> <div className="space-y-2">
{/* Fake waveform */} <div className="flex items-center gap-2">
<svg className="w-full h-full text-red-500" viewBox="0 0 300 50" preserveAspectRatio="none"> <span className="text-xs text-slate-500 w-16">Tempo</span>
<path d="M0,25 Q10,5 20,25 T40,25 T60,45 T80,5 T100,25 T120,40 T140,10 T160,25 T180,25 T200,45 T220,5 T240,25 T260,40 T280,10 T300,25" fill="none" stroke="currentColor" strokeWidth="2"></path> <div className="flex-1 h-2 bg-card-darker rounded overflow-hidden">
</svg> <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> </div>