feat: Cross-band milestone - Festivals, Playlists, Musicians, Venue Timeline
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s

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)
This commit is contained in:
fullsizemalt 2025-12-28 23:34:05 -08:00
parent 2c7ff6207a
commit b38da24055
6 changed files with 609 additions and 1 deletions

1
.gitignore vendored
View file

@ -7,3 +7,4 @@ node_modules/
.env
*.log
.DS_Store
backend/importers/.cache/

View file

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

View file

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

View file

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

View file

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

View file

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