diff --git a/backend/main.py b/backend/main.py index 34b9760..489746e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,6 @@ from fastapi import FastAPI import os -from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos, musicians, sequences, verticals, canon, on_this_day, discover, bands, festivals, playlists +from routers import auth, shows, venues, songs, social, tours, artists, preferences, reviews, badges, nicknames, moderation, attendance, groups, users, search, performances, notifications, feed, leaderboards, stats, admin, chase, gamification, videos, musicians, sequences, verticals, canon, on_this_day, discover, bands, festivals, playlists, analytics from fastapi.middleware.cors import CORSMiddleware @@ -51,6 +51,7 @@ app.include_router(discover.router) app.include_router(bands.router) app.include_router(festivals.router) app.include_router(playlists.router) +app.include_router(analytics.router) # Optional features - can be disabled via env vars diff --git a/backend/routers/analytics.py b/backend/routers/analytics.py new file mode 100644 index 0000000..64a4648 --- /dev/null +++ b/backend/routers/analytics.py @@ -0,0 +1,423 @@ +""" +Analytics API - Charts, Trends, Velocity, Gap Analysis. +Deep insights into song performance patterns and band statistics. +""" +from typing import List, Optional +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session, select, func, desc +from pydantic import BaseModel +from database import get_session +from models import Song, Show, Performance, Vertical + +router = APIRouter(prefix="/analytics", tags=["analytics"]) + + +class SongGapAnalysis(BaseModel): + """Gap analysis for a song - days since last played""" + song_id: int + song_title: str + song_slug: str + last_played: Optional[str] + days_since_played: Optional[int] + total_plays: int + average_gap_days: Optional[float] + + +class SongTrend(BaseModel): + """Play count trend over time periods""" + period: str # "2024-Q1", "2024-06", etc. + play_count: int + + +class SongVelocity(BaseModel): + """Song velocity - frequency and recency metrics""" + song_id: int + song_title: str + song_slug: str + plays_last_30_days: int + plays_last_90_days: int + plays_last_year: int + total_plays: int + velocity_score: float # Higher = more frequently played recently + + +class BandStats(BaseModel): + """Aggregate statistics for a band""" + vertical_id: int + vertical_name: str + vertical_slug: str + total_shows: int + total_songs: int + total_performances: int + unique_songs_played: int + avg_songs_per_show: float + first_show: Optional[str] + last_show: Optional[str] + + +class MonthlyActivity(BaseModel): + """Monthly show/performance counts""" + month: str + show_count: int + performance_count: int + + +@router.get("/gaps/{vertical_slug}", response_model=List[SongGapAnalysis]) +def get_song_gaps( + vertical_slug: str, + min_plays: int = Query(default=5, description="Minimum plays to include"), + limit: int = Query(default=50, le=200), + session: Session = Depends(get_session) +): + """ + Get gap analysis for songs - how long since each song was last played. + Useful for identifying songs that are "due" to be played. + """ + vertical = session.exec( + select(Vertical).where(Vertical.slug == vertical_slug) + ).first() + if not vertical: + raise HTTPException(status_code=404, detail="Band not found") + + # Get all songs for this vertical with play counts + songs = session.exec( + select(Song).where(Song.vertical_id == vertical.id) + ).all() + + today = datetime.now().date() + results = [] + + for song in songs: + # Get performances for this song + performances = session.exec( + select(Performance) + .join(Show) + .where(Performance.song_id == song.id) + .where(Show.date.isnot(None)) + .order_by(Show.date.desc()) + ).all() + + if len(performances) < min_plays: + continue + + # Get show dates for gap calculation + show_dates = [] + for perf in performances: + show = session.get(Show, perf.show_id) + if show and show.date: + show_dates.append(show.date.date() if hasattr(show.date, 'date') else show.date) + + if not show_dates: + continue + + show_dates.sort(reverse=True) + last_played = show_dates[0] + days_since = (today - last_played).days + + # Calculate average gap between plays + avg_gap = None + if len(show_dates) > 1: + gaps = [(show_dates[i] - show_dates[i+1]).days for i in range(len(show_dates)-1)] + avg_gap = sum(gaps) / len(gaps) + + results.append(SongGapAnalysis( + song_id=song.id, + song_title=song.title, + song_slug=song.slug or "", + last_played=last_played.strftime("%Y-%m-%d"), + days_since_played=days_since, + total_plays=len(performances), + average_gap_days=round(avg_gap, 1) if avg_gap else None + )) + + # Sort by days since played (longest gaps first) + results.sort(key=lambda x: x.days_since_played or 0, reverse=True) + return results[:limit] + + +@router.get("/velocity/{vertical_slug}", response_model=List[SongVelocity]) +def get_song_velocity( + vertical_slug: str, + limit: int = Query(default=50, le=200), + session: Session = Depends(get_session) +): + """ + Get song velocity - which songs are hot right now vs cooling down. + Higher velocity score = more frequently played recently. + """ + vertical = session.exec( + select(Vertical).where(Vertical.slug == vertical_slug) + ).first() + if not vertical: + raise HTTPException(status_code=404, detail="Band not found") + + today = datetime.now() + thirty_days_ago = today - timedelta(days=30) + ninety_days_ago = today - timedelta(days=90) + one_year_ago = today - timedelta(days=365) + + songs = session.exec( + select(Song).where(Song.vertical_id == vertical.id) + ).all() + + results = [] + for song in songs: + # Get all performances with show dates + performances = session.exec( + select(Performance, Show) + .join(Show) + .where(Performance.song_id == song.id) + .where(Show.date.isnot(None)) + ).all() + + if not performances: + continue + + plays_30 = 0 + plays_90 = 0 + plays_year = 0 + total = len(performances) + + for perf, show in performances: + if show.date >= thirty_days_ago: + plays_30 += 1 + if show.date >= ninety_days_ago: + plays_90 += 1 + if show.date >= one_year_ago: + plays_year += 1 + + # Velocity score: weighted recent plays (30d = 3x, 90d = 2x, year = 1x) + velocity = (plays_30 * 3) + (plays_90 * 2) + plays_year + + results.append(SongVelocity( + song_id=song.id, + song_title=song.title, + song_slug=song.slug or "", + plays_last_30_days=plays_30, + plays_last_90_days=plays_90, + plays_last_year=plays_year, + total_plays=total, + velocity_score=velocity + )) + + # Sort by velocity (hottest songs first) + results.sort(key=lambda x: x.velocity_score, reverse=True) + return results[:limit] + + +@router.get("/trends/{vertical_slug}") +def get_show_trends( + vertical_slug: str, + period: str = Query(default="month", description="month or quarter"), + session: Session = Depends(get_session) +): + """ + Get show activity trends over time - monthly or quarterly aggregates. + """ + vertical = session.exec( + select(Vertical).where(Vertical.slug == vertical_slug) + ).first() + if not vertical: + raise HTTPException(status_code=404, detail="Band not found") + + shows = session.exec( + select(Show) + .where(Show.vertical_id == vertical.id) + .where(Show.date.isnot(None)) + .order_by(Show.date) + ).all() + + # Group by period + trends = {} + for show in shows: + if period == "quarter": + q = (show.date.month - 1) // 3 + 1 + key = f"{show.date.year}-Q{q}" + else: + key = show.date.strftime("%Y-%m") + + if key not in trends: + trends[key] = {"shows": 0, "performances": 0} + trends[key]["shows"] += 1 + + # Count performances in this show + perf_count = len(session.exec( + select(Performance).where(Performance.show_id == show.id) + ).all()) + trends[key]["performances"] += perf_count + + return { + "vertical": vertical.name, + "period_type": period, + "trends": [ + {"period": k, "shows": v["shows"], "performances": v["performances"]} + for k, v in sorted(trends.items()) + ] + } + + +@router.get("/stats/{vertical_slug}", response_model=BandStats) +def get_band_stats( + vertical_slug: str, + session: Session = Depends(get_session) +): + """Get aggregate statistics for a band.""" + vertical = session.exec( + select(Vertical).where(Vertical.slug == vertical_slug) + ).first() + if not vertical: + raise HTTPException(status_code=404, detail="Band not found") + + # Total shows + shows = session.exec( + select(Show) + .where(Show.vertical_id == vertical.id) + .order_by(Show.date) + ).all() + + # Total unique songs + songs = session.exec( + select(Song).where(Song.vertical_id == vertical.id) + ).all() + + # Total performances + show_ids = [s.id for s in shows] + total_perfs = 0 + unique_songs_played = set() + + if show_ids: + all_perfs = session.exec( + select(Performance).where(Performance.show_id.in_(show_ids)) + ).all() + total_perfs = len(all_perfs) + unique_songs_played = set(p.song_id for p in all_perfs if p.song_id) + + # Date range + dated_shows = [s for s in shows if s.date] + first_show = min(s.date for s in dated_shows).strftime("%Y-%m-%d") if dated_shows else None + last_show = max(s.date for s in dated_shows).strftime("%Y-%m-%d") if dated_shows else None + + avg_songs = total_perfs / len(shows) if shows else 0 + + return BandStats( + vertical_id=vertical.id, + vertical_name=vertical.name, + vertical_slug=vertical.slug, + total_shows=len(shows), + total_songs=len(songs), + total_performances=total_perfs, + unique_songs_played=len(unique_songs_played), + avg_songs_per_show=round(avg_songs, 1), + first_show=first_show, + last_show=last_show + ) + + +@router.get("/bustouts/{vertical_slug}") +def get_bustouts( + vertical_slug: str, + days: int = Query(default=365, description="Look back period in days"), + gap_threshold: int = Query(default=180, description="Minimum gap days to count as bustout"), + session: Session = Depends(get_session) +): + """ + Find bustouts - songs that returned after a long gap. + """ + vertical = session.exec( + select(Vertical).where(Vertical.slug == vertical_slug) + ).first() + if not vertical: + raise HTTPException(status_code=404, detail="Band not found") + + cutoff_date = datetime.now() - timedelta(days=days) + + songs = session.exec( + select(Song).where(Song.vertical_id == vertical.id) + ).all() + + bustouts = [] + for song in songs: + # Get performances ordered by date + perfs_with_shows = session.exec( + select(Performance, Show) + .join(Show) + .where(Performance.song_id == song.id) + .where(Show.date.isnot(None)) + .order_by(Show.date) + ).all() + + if len(perfs_with_shows) < 2: + continue + + # Look for gaps > threshold followed by a play in the period + for i in range(1, len(perfs_with_shows)): + prev_show = perfs_with_shows[i-1][1] + curr_show = perfs_with_shows[i][1] + + gap = (curr_show.date - prev_show.date).days + + if gap >= gap_threshold and curr_show.date >= cutoff_date: + bustouts.append({ + "song_title": song.title, + "song_slug": song.slug, + "bustout_date": curr_show.date.strftime("%Y-%m-%d"), + "show_slug": curr_show.slug, + "gap_days": gap, + "previous_play": prev_show.date.strftime("%Y-%m-%d") + }) + + # Sort by gap (biggest bustouts first) + bustouts.sort(key=lambda x: x["gap_days"], reverse=True) + return {"vertical": vertical.name, "threshold_days": gap_threshold, "bustouts": bustouts} + + +@router.get("/debut-songs/{vertical_slug}") +def get_debut_songs( + vertical_slug: str, + days: int = Query(default=365, description="Look back period"), + session: Session = Depends(get_session) +): + """Find songs that debuted (first ever play) within the period.""" + vertical = session.exec( + select(Vertical).where(Vertical.slug == vertical_slug) + ).first() + if not vertical: + raise HTTPException(status_code=404, detail="Band not found") + + cutoff_date = datetime.now() - timedelta(days=days) + + songs = session.exec( + select(Song).where(Song.vertical_id == vertical.id) + ).all() + + debuts = [] + for song in songs: + # Find first performance + first_perf = session.exec( + select(Performance, Show) + .join(Show) + .where(Performance.song_id == song.id) + .where(Show.date.isnot(None)) + .order_by(Show.date) + ).first() + + if first_perf: + perf, show = first_perf + if show.date >= cutoff_date: + # Count total plays + total = len(session.exec( + select(Performance).where(Performance.song_id == song.id) + ).all()) + + debuts.append({ + "song_title": song.title, + "song_slug": song.slug, + "debut_date": show.date.strftime("%Y-%m-%d"), + "show_slug": show.slug, + "times_played_since": total + }) + + # Sort by debut date (newest first) + debuts.sort(key=lambda x: x["debut_date"], reverse=True) + return {"vertical": vertical.name, "period_days": days, "debuts": debuts}