feat: Restore performances, enable HeadyVersion ratings, update branding

- Updated import script to fix pagination and add idempotency
- Added performances list to Song Detail API and schemas
- Activated backend rating logic for performances
- Updated Landing Page branding
- Implemented frontend performance list display
This commit is contained in:
fullsizemalt 2025-12-20 02:05:45 -08:00
parent b837d7654f
commit 65bb05d9b0
7 changed files with 202 additions and 43 deletions

View file

@ -34,7 +34,7 @@ DEMO_USERS = [
] ]
def fetch_json(endpoint, params=None): def fetch_json(endpoint, params=None):
"""Fetch JSON from El Goose API with error handling""" """Fetch JSON from El Goose API (Single Page)"""
url = f"{BASE_URL}/{endpoint}.json" url = f"{BASE_URL}/{endpoint}.json"
try: try:
response = requests.get(url, params=params) response = requests.get(url, params=params)
@ -50,11 +50,45 @@ def fetch_json(endpoint, params=None):
print(f"❌ Failed to fetch {endpoint}: {e}") print(f"❌ Failed to fetch {endpoint}: {e}")
return None return None
def fetch_all_json(endpoint, params=None):
"""Fetch ALL data from El Goose API using pagination"""
all_data = []
page = 1
params = params.copy() if params else {}
print(f" Fetching {endpoint} pages...", end="", flush=True)
while True:
params['page'] = page
data = fetch_json(endpoint, params)
if not data:
break
all_data.extend(data)
print(f" {page}", end="", flush=True)
# If less than 50 results (typical page size might be larger, but if small, likely last page)
# Actually API returns empty list [] if page out of range usually.
# So 'if not data' handles it.
# Safety break if too many pages
if page > 100:
print(" (Safety limit reached)", end="")
break
page += 1
print(" Done.")
return all_data
def create_users(session): def create_users(session):
"""Create demo user personas""" """Create demo user personas"""
print("\n📝 Creating user personas...") print("\n📝 Creating user personas...")
users = [] users = []
for user_data in DEMO_USERS: for user_data in DEMO_USERS:
# Check existing
existing = session.exec(
select(User).where(User.email == user_data["email"])
).first()
if existing:
users.append(existing)
continue
user = User( user = User(
email=user_data["email"], email=user_data["email"],
hashed_password=pwd_context.hash("demo123"), hashed_password=pwd_context.hash("demo123"),
@ -76,13 +110,13 @@ def create_users(session):
users.append(user) users.append(user)
session.commit() session.commit()
print(f"✓ Created {len(users)} users") print(f"✓ Created/Found {len(users)} users")
return users return users
def import_venues(session): def import_venues(session):
"""Import all venues""" """Import all venues"""
print("\n🏛️ Importing venues...") print("\n🏛️ Importing venues...")
venues_data = fetch_json("venues") venues_data = fetch_all_json("venues")
if not venues_data: if not venues_data:
return {} return {}
@ -113,7 +147,7 @@ def import_venues(session):
def import_songs(session, vertical_id): def import_songs(session, vertical_id):
"""Import all songs""" """Import all songs"""
print("\n🎵 Importing songs...") print("\n🎵 Importing songs...")
songs_data = fetch_json("songs") songs_data = fetch_all_json("songs")
if not songs_data: if not songs_data:
return {} return {}
@ -148,12 +182,13 @@ def import_shows(session, vertical_id, venue_map):
"""Import all Goose shows""" """Import all Goose shows"""
print("\n🎤 Importing shows...") print("\n🎤 Importing shows...")
params = {"artist": ARTIST_ID} params = {"artist": ARTIST_ID}
shows_data = fetch_json("shows", params) shows_data = fetch_all_json("shows", params)
if not shows_data: if not shows_data:
# Fallback: fetch all shows and filter
# Fallback: fetch all shows and filter # Fallback: fetch all shows and filter
print(" Fetching all shows and filtering for Goose...") print(" Fetching all shows and filtering for Goose...")
shows_data = fetch_json("shows") shows_data = fetch_all_json("shows")
shows_data = [s for s in (shows_data or []) if s.get('artist_id') == ARTIST_ID] shows_data = [s for s in (shows_data or []) if s.get('artist_id') == ARTIST_ID]
if not shows_data: if not shows_data:
@ -186,6 +221,18 @@ def import_shows(session, vertical_id, venue_map):
# Create show # Create show
show_date = datetime.strptime(s['showdate'], '%Y-%m-%d') show_date = datetime.strptime(s['showdate'], '%Y-%m-%d')
# Check existing show
existing_show = session.exec(
select(Show).where(
Show.date == show_date,
Show.venue_id == venue_map.get(s['venue_id'])
)
).first()
if existing_show:
show_map[s['show_id']] = existing_show.id
else:
show = Show( show = Show(
date=show_date, date=show_date,
vertical_id=vertical_id, vertical_id=vertical_id,
@ -209,7 +256,7 @@ def import_setlists(session, show_map, song_map):
print("\n📋 Importing setlists...") print("\n📋 Importing setlists...")
# Fetch all setlists (this gets all performances across all shows) # Fetch all setlists (this gets all performances across all shows)
setlists_data = fetch_json("setlists") setlists_data = fetch_all_json("setlists")
if not setlists_data: if not setlists_data:
print("❌ No setlist data found") print("❌ No setlist data found")
return return
@ -229,6 +276,16 @@ def import_setlists(session, show_map, song_map):
if not our_show_id or not our_song_id: if not our_show_id or not our_song_id:
continue continue
# Check existing performance
existing_perf = session.exec(
select(Performance).where(
Performance.show_id == our_show_id,
Performance.song_id == our_song_id,
Performance.position == perf_data.get('position', 0)
)
).first()
if not existing_perf:
perf = Performance( perf = Performance(
show_id=our_show_id, show_id=our_show_id,
song_id=our_song_id, song_id=our_song_id,

View file

@ -144,6 +144,7 @@ class Rating(SQLModel, table=True):
show_id: Optional[int] = Field(default=None, foreign_key="show.id") show_id: Optional[int] = Field(default=None, foreign_key="show.id")
song_id: Optional[int] = Field(default=None, foreign_key="song.id") song_id: Optional[int] = Field(default=None, foreign_key="song.id")
performance_id: Optional[int] = Field(default=None, foreign_key="performance.id")
user: "User" = Relationship(back_populates="ratings") user: "User" = Relationship(back_populates="ratings")

View file

@ -62,8 +62,10 @@ def create_rating(
query = query.where(Rating.show_id == rating.show_id) query = query.where(Rating.show_id == rating.show_id)
elif rating.song_id: elif rating.song_id:
query = query.where(Rating.song_id == rating.song_id) query = query.where(Rating.song_id == rating.song_id)
elif rating.performance_id:
query = query.where(Rating.performance_id == rating.performance_id)
else: else:
raise HTTPException(status_code=400, detail="Must rate a show or song") raise HTTPException(status_code=400, detail="Must rate a show, song, or performance")
existing_rating = session.exec(query).first() existing_rating = session.exec(query).first()
if existing_rating: if existing_rating:
@ -85,6 +87,7 @@ def create_rating(
def get_average_rating( def get_average_rating(
show_id: Optional[int] = None, show_id: Optional[int] = None,
song_id: Optional[int] = None, song_id: Optional[int] = None,
performance_id: Optional[int] = None,
session: Session = Depends(get_session) session: Session = Depends(get_session)
): ):
query = select(func.avg(Rating.score)) query = select(func.avg(Rating.score))
@ -92,8 +95,10 @@ def get_average_rating(
query = query.where(Rating.show_id == show_id) query = query.where(Rating.show_id == show_id)
elif song_id: elif song_id:
query = query.where(Rating.song_id == song_id) query = query.where(Rating.song_id == song_id)
elif performance_id:
query = query.where(Rating.performance_id == performance_id)
else: else:
raise HTTPException(status_code=400, detail="Must specify show_id or song_id") raise HTTPException(status_code=400, detail="Must specify show_id, song_id, or performance_id")
avg = session.exec(query).first() avg = session.exec(query).first()
return float(avg) if avg else 0.0 return float(avg) if avg else 0.0

View file

@ -1,4 +1,5 @@
from typing import List from typing import List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select from sqlmodel import Session, select
from database import get_session from database import get_session
@ -38,12 +39,66 @@ def read_song(song_id: int, session: Session = Depends(get_session)):
.where(EntityTag.entity_id == song_id) .where(EntityTag.entity_id == song_id)
).all() ).all()
# Fetch performances
# We join Show to ensure we can order by date
from models import Show, Performance, Rating
from sqlmodel import func
# We need PerformanceReadWithShow from schemas
from schemas import PerformanceReadWithShow
perfs = session.exec(
select(Performance)
.join(Show)
.where(Performance.song_id == song_id)
.order_by(Show.date.desc())
).all()
# Calculate ratings
perf_ids = [p.id for p in perfs]
rating_stats = {}
if perf_ids:
results = session.exec(
select(Rating.performance_id, func.avg(Rating.score), func.count(Rating.id))
.where(Rating.performance_id.in_(perf_ids))
.group_by(Rating.performance_id)
).all()
for r in results:
rating_stats[r[0]] = {"avg": float(r[1]) if r[1] else 0.0, "count": r[2]}
perf_dtos = []
for p in perfs:
# Lazy load show/venue (could be optimized)
venue_name = "Unknown"
venue_city = ""
venue_state = ""
show_date = datetime.now()
if p.show:
show_date = p.show.date
if p.show.venue:
venue_name = p.show.venue.name
venue_city = p.show.venue.city
venue_state = p.show.venue.state
stats = rating_stats.get(p.id, {"avg": 0.0, "count": 0})
perf_dtos.append(PerformanceReadWithShow(
**p.model_dump(),
show_date=show_date,
venue_name=venue_name,
venue_city=venue_city,
venue_state=venue_state,
avg_rating=stats["avg"],
total_reviews=stats["count"]
))
# Merge song data with stats # Merge song data with stats
song_with_stats = SongReadWithStats( song_with_stats = SongReadWithStats(
**song.model_dump(), **song.model_dump(),
**stats **stats
) )
song_with_stats.tags = tags song_with_stats.tags = tags
song_with_stats.performances = perf_dtos
return song_with_stats return song_with_stats
@router.patch("/{song_id}", response_model=SongRead) @router.patch("/{song_id}", response_model=SongRead)

View file

@ -57,10 +57,7 @@ class SongRead(SongBase):
id: int id: int
tags: List["TagRead"] = [] tags: List["TagRead"] = []
class SongReadWithStats(SongRead):
times_played: int
gap: int
last_played: Optional[datetime] = None
class SongUpdate(SQLModel): class SongUpdate(SQLModel):
title: Optional[str] = None title: Optional[str] = None
@ -92,6 +89,20 @@ class PerformanceRead(PerformanceBase):
song: Optional["SongRead"] = None song: Optional["SongRead"] = None
nicknames: List["PerformanceNicknameRead"] = [] nicknames: List["PerformanceNicknameRead"] = []
class PerformanceReadWithShow(PerformanceRead):
show_date: datetime
venue_name: str
venue_city: str
venue_state: Optional[str] = None
avg_rating: Optional[float] = 0.0
total_reviews: Optional[int] = 0
class SongReadWithStats(SongRead):
times_played: int
gap: int
last_played: Optional[datetime] = None
performances: List[PerformanceReadWithShow] = []
class PerformanceDetailRead(PerformanceRead): class PerformanceDetailRead(PerformanceRead):
show: Optional["ShowRead"] = None show: Optional["ShowRead"] = None
previous_performance_id: Optional[int] = None previous_performance_id: Optional[int] = None
@ -210,6 +221,7 @@ class RatingBase(SQLModel):
score: int score: int
show_id: Optional[int] = None show_id: Optional[int] = None
song_id: Optional[int] = None song_id: Optional[int] = None
performance_id: Optional[int] = None
class RatingCreate(RatingBase): class RatingCreate(RatingBase):
pass pass

View file

@ -9,12 +9,12 @@ export default function Home() {
{/* Hero Section */} {/* Hero Section */}
<section className="flex flex-col items-center gap-4 py-12 text-center md:py-20 bg-gradient-to-b from-background to-accent/20 rounded-3xl border"> <section className="flex flex-col items-center gap-4 py-12 text-center md:py-20 bg-gradient-to-b from-background to-accent/20 rounded-3xl border">
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl"> <h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl">
Elmeg Demo Elmeg Archive
</h1> </h1>
<p className="max-w-[600px] text-lg text-muted-foreground"> <p className="max-w-[600px] text-lg text-muted-foreground">
Explore the "vibey" data we just generated. The ultimate community archive for Goose history.
<br /> <br />
36 Personas. Wiki-linked Reviews. Interconnected Data. Discover shows, rate performances, and connect with fans.
</p> </p>
<div className="flex gap-4"> <div className="flex gap-4">
<Link href="/leaderboards"> <Link href="/leaderboards">
@ -60,7 +60,7 @@ export default function Home() {
<Link href="/leaderboards" className="block p-6 border rounded-xl hover:bg-accent transition-colors"> <Link href="/leaderboards" className="block p-6 border rounded-xl hover:bg-accent transition-colors">
<Users className="h-8 w-8 mb-2 text-purple-500" /> <Users className="h-8 w-8 mb-2 text-purple-500" />
<h3 className="font-bold">Community</h3> <h3 className="font-bold">Community</h3>
<p className="text-sm text-muted-foreground">Meet the 36 personas</p> <p className="text-sm text-muted-foreground">Join the conversation</p>
</Link> </Link>
<Link href="/leaderboards" className="block p-6 border rounded-xl hover:bg-accent transition-colors"> <Link href="/leaderboards" className="block p-6 border rounded-xl hover:bg-accent transition-colors">
<Trophy className="h-8 w-8 mb-2 text-yellow-500" /> <Trophy className="h-8 w-8 mb-2 text-yellow-500" />

View file

@ -94,11 +94,40 @@ export default async function SongDetailPage({ params }: { params: Promise<{ id:
<CardTitle>Performance History</CardTitle> <CardTitle>Performance History</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-muted-foreground text-sm">Performance history coming soon...</p> {song.performances && song.performances.length > 0 ? (
{/* <div className="space-y-4">
We need to fetch performances list here. {song.performances.map((perf: any) => (
For now, we leave a placeholder. <div key={perf.id} className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0">
*/} <div className="space-y-1">
<Link href={`/shows/${perf.show_id}`} className="font-medium hover:underline text-primary">
{new Date(perf.show_date).toLocaleDateString(undefined, {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</Link>
<div className="text-sm text-muted-foreground flex items-center gap-2">
<span>
{perf.venue_name}, {perf.venue_city} {perf.venue_state}
</span>
</div>
{perf.notes && (
<p className="text-sm italic text-muted-foreground/80">"{perf.notes}"</p>
)}
</div>
<div className="flex flex-col items-end gap-1">
{/* Placeholder for Rating UI */}
<span className="text-xs font-semibold bg-secondary px-2 py-1 rounded-full text-secondary-foreground">
{perf.avg_rating > 0 ? perf.avg_rating.toFixed(1) : "Unrated"}
</span>
</div>
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-sm">No performances recorded.</p>
)}
</CardContent> </CardContent>
</Card> </Card>