From b38da24055d15ba43470bc92fc88213c1ae712ef Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Sun, 28 Dec 2025 23:34:05 -0800 Subject: [PATCH] feat: Cross-band milestone - Festivals, Playlists, Musicians, Venue Timeline Sprint 2: Added 54 musicians with 78 band memberships - Phish, Widespread Panic, Umphreys McGee core members - Notable sit-in artists (Karl Denson, Branford Marsalis, Derek/Susan Trucks) - Toy Factory Project supergroup (Oteil, Marcus King, Charlie Starr) Sprint 4: Festival entity for multi-band events - Festival and ShowFestival models - /festivals API with list, detail, by-band endpoints Sprint 5: User Playlists for curated collections - UserPlaylist and PlaylistPerformance models - Full CRUD /playlists API Sprint 6: Venue Timeline endpoint - /venues/{slug}/timeline for chronological cross-band history Blockers (need production data): - Venue linking script (no venues in local DB) - Canon song linking (no songs in local DB) --- .gitignore | 1 + backend/main.py | 4 +- backend/models.py | 56 +++++++ backend/routers/festivals.py | 168 +++++++++++++++++++ backend/routers/playlists.py | 317 +++++++++++++++++++++++++++++++++++ backend/routers/venues.py | 64 +++++++ 6 files changed, 609 insertions(+), 1 deletion(-) create mode 100644 backend/routers/festivals.py create mode 100644 backend/routers/playlists.py diff --git a/.gitignore b/.gitignore index 447d941..7e50d9b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ node_modules/ .env *.log .DS_Store +backend/importers/.cache/ diff --git a/backend/main.py b/backend/main.py index 3b1a7ff..34b9760 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 +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 fastapi.middleware.cors import CORSMiddleware @@ -49,6 +49,8 @@ app.include_router(canon.router) app.include_router(on_this_day.router) app.include_router(discover.router) app.include_router(bands.router) +app.include_router(festivals.router) +app.include_router(playlists.router) # Optional features - can be disabled via env vars diff --git a/backend/models.py b/backend/models.py index 0f2ddb6..e363488 100644 --- a/backend/models.py +++ b/backend/models.py @@ -157,6 +157,33 @@ class Tour(SQLModel, table=True): shows: List["Show"] = Relationship(back_populates="tour") + +class Festival(SQLModel, table=True): + """Multi-band festivals that span multiple shows/dates (Bonnaroo, Lockn, etc.)""" + id: Optional[int] = Field(default=None, primary_key=True) + name: str = Field(index=True) + slug: str = Field(unique=True, index=True) + year: Optional[int] = Field(default=None, description="Festival year/edition") + start_date: Optional[datetime] = None + end_date: Optional[datetime] = None + venue_id: Optional[int] = Field(default=None, foreign_key="venue.id") + website_url: Optional[str] = Field(default=None) + description: Optional[str] = Field(default=None) + + # Relationships + shows: List["ShowFestival"] = Relationship(back_populates="festival") + + +class ShowFestival(SQLModel, table=True): + """Link table for shows at festivals (many-to-many)""" + id: Optional[int] = Field(default=None, primary_key=True) + show_id: int = Field(foreign_key="show.id") + festival_id: int = Field(foreign_key="festival.id") + stage: Optional[str] = Field(default=None, description="Which stage (Main, Second, etc.)") + set_time: Optional[str] = Field(default=None, description="Scheduled time slot") + + festival: Festival = Relationship(back_populates="shows") + class Artist(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str = Field(index=True) @@ -382,6 +409,35 @@ class User(SQLModel, table=True): preferences: Optional["UserPreferences"] = Relationship(back_populates="user", sa_relationship_kwargs={"uselist": False}) reports: List["Report"] = Relationship(back_populates="user") notifications: List["Notification"] = Relationship(back_populates="user") + playlists: List["UserPlaylist"] = Relationship(back_populates="user") + + +class UserPlaylist(SQLModel, table=True): + """User-created curated collections of performances""" + id: Optional[int] = Field(default=None, primary_key=True) + user_id: int = Field(foreign_key="user.id") + name: str = Field(index=True) + slug: str = Field(index=True) + description: Optional[str] = Field(default=None) + is_public: bool = Field(default=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) + + # Relationships + user: "User" = Relationship(back_populates="playlists") + performances: List["PlaylistPerformance"] = Relationship(back_populates="playlist") + + +class PlaylistPerformance(SQLModel, table=True): + """Link table for performances in playlists with ordering""" + id: Optional[int] = Field(default=None, primary_key=True) + playlist_id: int = Field(foreign_key="userplaylist.id") + performance_id: int = Field(foreign_key="performance.id") + position: int = Field(description="Order in playlist, 1-indexed") + added_at: datetime = Field(default_factory=datetime.utcnow) + notes: Optional[str] = Field(default=None, description="User notes about this performance") + + playlist: UserPlaylist = Relationship(back_populates="performances") class Report(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) diff --git a/backend/routers/festivals.py b/backend/routers/festivals.py new file mode 100644 index 0000000..fe65804 --- /dev/null +++ b/backend/routers/festivals.py @@ -0,0 +1,168 @@ +""" +Festival API endpoints for multi-band festival discovery. +""" +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session, select +from pydantic import BaseModel +from database import get_session +from models import Festival, ShowFestival, Show, Vertical, Venue + +router = APIRouter(prefix="/festivals", tags=["festivals"]) + + +class FestivalRead(BaseModel): + id: int + name: str + slug: str + year: Optional[int] + start_date: Optional[str] + end_date: Optional[str] + website_url: Optional[str] + description: Optional[str] + + +class FestivalShowRead(BaseModel): + show_id: int + show_slug: Optional[str] + show_date: str + vertical_name: str + vertical_slug: str + stage: Optional[str] + set_time: Optional[str] + + +class FestivalDetailRead(BaseModel): + festival: FestivalRead + shows: List[FestivalShowRead] + bands_count: int + + +@router.get("/", response_model=List[FestivalRead]) +def list_festivals( + year: Optional[int] = None, + limit: int = Query(default=50, le=100), + offset: int = 0, + session: Session = Depends(get_session) +): + """List all festivals, optionally filtered by year""" + query = select(Festival) + if year: + query = query.where(Festival.year == year) + query = query.order_by(Festival.year.desc(), Festival.name).offset(offset).limit(limit) + + festivals = session.exec(query).all() + return [ + FestivalRead( + id=f.id, + name=f.name, + slug=f.slug, + year=f.year, + start_date=f.start_date.strftime("%Y-%m-%d") if f.start_date else None, + end_date=f.end_date.strftime("%Y-%m-%d") if f.end_date else None, + website_url=f.website_url, + description=f.description + ) + for f in festivals + ] + + +@router.get("/{slug}", response_model=FestivalDetailRead) +def get_festival(slug: str, session: Session = Depends(get_session)): + """Get festival details with all shows across bands""" + festival = session.exec( + select(Festival).where(Festival.slug == slug) + ).first() + + if not festival: + raise HTTPException(status_code=404, detail="Festival not found") + + # Get all shows at this festival + show_festivals = session.exec( + select(ShowFestival).where(ShowFestival.festival_id == festival.id) + ).all() + + shows = [] + bands_seen = set() + for sf in show_festivals: + show = session.get(Show, sf.show_id) + if show: + vertical = session.get(Vertical, show.vertical_id) + if vertical: + bands_seen.add(vertical.id) + shows.append(FestivalShowRead( + show_id=show.id, + show_slug=show.slug, + show_date=show.date.strftime("%Y-%m-%d") if show.date else "Unknown", + vertical_name=vertical.name if vertical else "Unknown", + vertical_slug=vertical.slug if vertical else "unknown", + stage=sf.stage, + set_time=sf.set_time + )) + + # Sort by date + shows.sort(key=lambda x: x.show_date) + + return FestivalDetailRead( + festival=FestivalRead( + id=festival.id, + name=festival.name, + slug=festival.slug, + year=festival.year, + start_date=festival.start_date.strftime("%Y-%m-%d") if festival.start_date else None, + end_date=festival.end_date.strftime("%Y-%m-%d") if festival.end_date else None, + website_url=festival.website_url, + description=festival.description + ), + shows=shows, + bands_count=len(bands_seen) + ) + + +@router.get("/by-band/{vertical_slug}") +def get_festivals_by_band(vertical_slug: str, session: Session = Depends(get_session)): + """Get all festivals a band has 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 shows for this vertical that are linked to festivals + shows = session.exec( + select(Show).where(Show.vertical_id == vertical.id) + ).all() + + show_ids = [s.id for s in shows] + + if not show_ids: + return [] + + # Get festival links + show_festivals = session.exec( + select(ShowFestival).where(ShowFestival.show_id.in_(show_ids)) + ).all() + + festival_ids = list(set(sf.festival_id for sf in show_festivals)) + + if not festival_ids: + return [] + + festivals = session.exec( + select(Festival).where(Festival.id.in_(festival_ids)) + ).all() + + return [ + FestivalRead( + id=f.id, + name=f.name, + slug=f.slug, + year=f.year, + start_date=f.start_date.strftime("%Y-%m-%d") if f.start_date else None, + end_date=f.end_date.strftime("%Y-%m-%d") if f.end_date else None, + website_url=f.website_url, + description=f.description + ) + for f in sorted(festivals, key=lambda x: x.year or 0, reverse=True) + ] diff --git a/backend/routers/playlists.py b/backend/routers/playlists.py new file mode 100644 index 0000000..4932822 --- /dev/null +++ b/backend/routers/playlists.py @@ -0,0 +1,317 @@ +""" +User Playlist API - curated collections of performances. +""" +from typing import List, Optional +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlmodel import Session, select +from pydantic import BaseModel +from datetime import datetime +from database import get_session +from models import UserPlaylist, PlaylistPerformance, Performance, Show, Song, User +from auth import get_current_user +from slugify import generate_slug + +router = APIRouter(prefix="/playlists", tags=["playlists"]) + + +class PlaylistCreate(BaseModel): + name: str + description: Optional[str] = None + is_public: bool = True + + +class PlaylistUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + is_public: Optional[bool] = None + + +class PerformanceInPlaylist(BaseModel): + performance_id: int + position: int + notes: Optional[str] = None + song_title: str + show_date: str + show_slug: Optional[str] + + +class PlaylistRead(BaseModel): + id: int + name: str + slug: str + description: Optional[str] + is_public: bool + user_id: int + username: Optional[str] + created_at: str + performance_count: int + + +class PlaylistDetailRead(BaseModel): + id: int + name: str + slug: str + description: Optional[str] + is_public: bool + user_id: int + username: Optional[str] + created_at: str + performances: List[PerformanceInPlaylist] + + +@router.post("/", response_model=PlaylistRead) +def create_playlist( + playlist: PlaylistCreate, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Create a new playlist""" + slug = generate_slug(playlist.name) + + # Make slug unique per user + existing = session.exec( + select(UserPlaylist) + .where(UserPlaylist.user_id == current_user.id) + .where(UserPlaylist.slug == slug) + ).first() + if existing: + slug = f"{slug}-{int(datetime.utcnow().timestamp())}" + + db_playlist = UserPlaylist( + user_id=current_user.id, + name=playlist.name, + slug=slug, + description=playlist.description, + is_public=playlist.is_public + ) + session.add(db_playlist) + session.commit() + session.refresh(db_playlist) + + return PlaylistRead( + id=db_playlist.id, + name=db_playlist.name, + slug=db_playlist.slug, + description=db_playlist.description, + is_public=db_playlist.is_public, + user_id=db_playlist.user_id, + username=current_user.username, + created_at=db_playlist.created_at.isoformat(), + performance_count=0 + ) + + +@router.get("/", response_model=List[PlaylistRead]) +def list_playlists( + user_id: Optional[int] = None, + limit: int = Query(default=20, le=100), + offset: int = 0, + session: Session = Depends(get_session) +): + """List public playlists, optionally filtered by user""" + query = select(UserPlaylist).where(UserPlaylist.is_public == True) + + if user_id: + query = query.where(UserPlaylist.user_id == user_id) + + query = query.order_by(UserPlaylist.created_at.desc()).offset(offset).limit(limit) + playlists = session.exec(query).all() + + result = [] + for p in playlists: + user = session.get(User, p.user_id) + perf_count = len(session.exec( + select(PlaylistPerformance).where(PlaylistPerformance.playlist_id == p.id) + ).all()) + result.append(PlaylistRead( + id=p.id, + name=p.name, + slug=p.slug, + description=p.description, + is_public=p.is_public, + user_id=p.user_id, + username=user.username if user else None, + created_at=p.created_at.isoformat(), + performance_count=perf_count + )) + + return result + + +@router.get("/mine", response_model=List[PlaylistRead]) +def list_my_playlists( + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """List current user's playlists (public and private)""" + playlists = session.exec( + select(UserPlaylist) + .where(UserPlaylist.user_id == current_user.id) + .order_by(UserPlaylist.created_at.desc()) + ).all() + + result = [] + for p in playlists: + perf_count = len(session.exec( + select(PlaylistPerformance).where(PlaylistPerformance.playlist_id == p.id) + ).all()) + result.append(PlaylistRead( + id=p.id, + name=p.name, + slug=p.slug, + description=p.description, + is_public=p.is_public, + user_id=p.user_id, + username=current_user.username, + created_at=p.created_at.isoformat(), + performance_count=perf_count + )) + + return result + + +@router.get("/{playlist_id}", response_model=PlaylistDetailRead) +def get_playlist( + playlist_id: int, + session: Session = Depends(get_session) +): + """Get playlist details with performances""" + playlist = session.get(UserPlaylist, playlist_id) + if not playlist: + raise HTTPException(status_code=404, detail="Playlist not found") + + if not playlist.is_public: + raise HTTPException(status_code=403, detail="This playlist is private") + + user = session.get(User, playlist.user_id) + + # Get performances + playlist_perfs = session.exec( + select(PlaylistPerformance) + .where(PlaylistPerformance.playlist_id == playlist_id) + .order_by(PlaylistPerformance.position) + ).all() + + performances = [] + for pp in playlist_perfs: + perf = session.get(Performance, pp.performance_id) + if perf: + song = session.get(Song, perf.song_id) if perf.song_id else None + show = session.get(Show, perf.show_id) if perf.show_id else None + performances.append(PerformanceInPlaylist( + performance_id=pp.performance_id, + position=pp.position, + notes=pp.notes, + song_title=song.title if song else "Unknown", + show_date=show.date.strftime("%Y-%m-%d") if show and show.date else "Unknown", + show_slug=show.slug if show else None + )) + + return PlaylistDetailRead( + id=playlist.id, + name=playlist.name, + slug=playlist.slug, + description=playlist.description, + is_public=playlist.is_public, + user_id=playlist.user_id, + username=user.username if user else None, + created_at=playlist.created_at.isoformat(), + performances=performances + ) + + +@router.post("/{playlist_id}/performances/{performance_id}") +def add_to_playlist( + playlist_id: int, + performance_id: int, + notes: Optional[str] = None, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Add a performance to a playlist""" + playlist = session.get(UserPlaylist, playlist_id) + if not playlist: + raise HTTPException(status_code=404, detail="Playlist not found") + if playlist.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not your playlist") + + # Check if already in playlist + existing = session.exec( + select(PlaylistPerformance) + .where(PlaylistPerformance.playlist_id == playlist_id) + .where(PlaylistPerformance.performance_id == performance_id) + ).first() + if existing: + raise HTTPException(status_code=400, detail="Already in playlist") + + # Get next position + all_perfs = session.exec( + select(PlaylistPerformance).where(PlaylistPerformance.playlist_id == playlist_id) + ).all() + next_position = len(all_perfs) + 1 + + pp = PlaylistPerformance( + playlist_id=playlist_id, + performance_id=performance_id, + position=next_position, + notes=notes + ) + session.add(pp) + session.commit() + + return {"message": "Added to playlist", "position": next_position} + + +@router.delete("/{playlist_id}/performances/{performance_id}") +def remove_from_playlist( + playlist_id: int, + performance_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Remove a performance from a playlist""" + playlist = session.get(UserPlaylist, playlist_id) + if not playlist: + raise HTTPException(status_code=404, detail="Playlist not found") + if playlist.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not your playlist") + + pp = session.exec( + select(PlaylistPerformance) + .where(PlaylistPerformance.playlist_id == playlist_id) + .where(PlaylistPerformance.performance_id == performance_id) + ).first() + + if not pp: + raise HTTPException(status_code=404, detail="Not in playlist") + + session.delete(pp) + session.commit() + + return {"message": "Removed from playlist"} + + +@router.delete("/{playlist_id}") +def delete_playlist( + playlist_id: int, + session: Session = Depends(get_session), + current_user: User = Depends(get_current_user) +): + """Delete a playlist""" + playlist = session.get(UserPlaylist, playlist_id) + if not playlist: + raise HTTPException(status_code=404, detail="Playlist not found") + if playlist.user_id != current_user.id: + raise HTTPException(status_code=403, detail="Not your playlist") + + # Delete all playlist performances first + perfs = session.exec( + select(PlaylistPerformance).where(PlaylistPerformance.playlist_id == playlist_id) + ).all() + for p in perfs: + session.delete(p) + + session.delete(playlist) + session.commit() + + return {"message": "Playlist deleted"} diff --git a/backend/routers/venues.py b/backend/routers/venues.py index aca78f0..1a08aeb 100644 --- a/backend/routers/venues.py +++ b/backend/routers/venues.py @@ -115,3 +115,67 @@ def get_venue_across_bands(slug: str, session: Session = Depends(get_session)): "bands_count": len(bands_list), "bands": bands_list } + + +@router.get("/{slug}/timeline") +def get_venue_timeline( + slug: str, + limit: int = Query(default=50, le=200), + offset: int = 0, + session: Session = Depends(get_session) +): + """Get chronological timeline of all shows at this venue across all bands""" + from models import VenueCanon, Show, Vertical + + venue = session.exec(select(Venue).where(Venue.slug == slug)).first() + if not venue: + raise HTTPException(status_code=404, detail="Venue not found") + + # Get all linked venues via canon + venue_ids = [venue.id] + if venue.canon_id: + linked = session.exec( + select(Venue).where(Venue.canon_id == venue.canon_id) + ).all() + venue_ids = [v.id for v in linked] + + # Get all shows at these venues, ordered by date + shows = session.exec( + select(Show) + .where(Show.venue_id.in_(venue_ids)) + .order_by(Show.date.desc()) + .offset(offset) + .limit(limit) + ).all() + + timeline = [] + for show in shows: + vertical = session.get(Vertical, show.vertical_id) + timeline.append({ + "show_id": show.id, + "show_slug": show.slug, + "date": show.date.strftime("%Y-%m-%d") if show.date else None, + "vertical_name": vertical.name if vertical else "Unknown", + "vertical_slug": vertical.slug if vertical else "unknown", + "vertical_color": vertical.primary_color if vertical else None, + "notes": show.notes + }) + + # Get total count + total = len(session.exec( + select(Show).where(Show.venue_id.in_(venue_ids)) + ).all()) + + return { + "venue": { + "id": venue.id, + "name": venue.name, + "slug": venue.slug, + "city": venue.city, + "state": venue.state + }, + "total_shows": total, + "offset": offset, + "limit": limit, + "timeline": timeline + }