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):
"""Fetch JSON from El Goose API with error handling"""
"""Fetch JSON from El Goose API (Single Page)"""
url = f"{BASE_URL}/{endpoint}.json"
try:
response = requests.get(url, params=params)
@ -50,11 +50,45 @@ def fetch_json(endpoint, params=None):
print(f"❌ Failed to fetch {endpoint}: {e}")
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):
"""Create demo user personas"""
print("\n📝 Creating user personas...")
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(
email=user_data["email"],
hashed_password=pwd_context.hash("demo123"),
@ -76,13 +110,13 @@ def create_users(session):
users.append(user)
session.commit()
print(f"✓ Created {len(users)} users")
print(f"✓ Created/Found {len(users)} users")
return users
def import_venues(session):
"""Import all venues"""
print("\n🏛️ Importing venues...")
venues_data = fetch_json("venues")
venues_data = fetch_all_json("venues")
if not venues_data:
return {}
@ -113,7 +147,7 @@ def import_venues(session):
def import_songs(session, vertical_id):
"""Import all songs"""
print("\n🎵 Importing songs...")
songs_data = fetch_json("songs")
songs_data = fetch_all_json("songs")
if not songs_data:
return {}
@ -148,12 +182,13 @@ def import_shows(session, vertical_id, venue_map):
"""Import all Goose shows"""
print("\n🎤 Importing shows...")
params = {"artist": ARTIST_ID}
shows_data = fetch_json("shows", params)
shows_data = fetch_all_json("shows", params)
if not shows_data:
# Fallback: fetch all shows and filter
# Fallback: fetch all shows and filter
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]
if not shows_data:
@ -186,17 +221,29 @@ def import_shows(session, vertical_id, venue_map):
# Create show
show_date = datetime.strptime(s['showdate'], '%Y-%m-%d')
show = Show(
date=show_date,
vertical_id=vertical_id,
venue_id=venue_map.get(s['venue_id']),
tour_id=tour_id,
notes=s.get('showtitle')
)
session.add(show)
session.commit()
session.refresh(show)
show_map[s['show_id']] = show.id
# 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(
date=show_date,
vertical_id=vertical_id,
venue_id=venue_map.get(s['venue_id']),
tour_id=tour_id,
notes=s.get('showtitle')
)
session.add(show)
session.commit()
session.refresh(show)
show_map[s['show_id']] = show.id
if len(show_map) % 50 == 0:
print(f" Progress: {len(show_map)} shows...")
@ -209,7 +256,7 @@ def import_setlists(session, show_map, song_map):
print("\n📋 Importing setlists...")
# 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:
print("❌ No setlist data found")
return
@ -229,16 +276,26 @@ def import_setlists(session, show_map, song_map):
if not our_show_id or not our_song_id:
continue
perf = Performance(
show_id=our_show_id,
song_id=our_song_id,
position=perf_data.get('position', 0),
set_name=perf_data.get('set'),
segue=bool(perf_data.get('segue', 0)),
notes=perf_data.get('notes')
)
session.add(perf)
performance_count += 1
# 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(
show_id=our_show_id,
song_id=our_song_id,
position=perf_data.get('position', 0),
set_name=perf_data.get('set'),
segue=bool(perf_data.get('segue', 0)),
notes=perf_data.get('notes')
)
session.add(perf)
performance_count += 1
if performance_count % 100 == 0:
session.commit()

View file

@ -144,6 +144,7 @@ class Rating(SQLModel, table=True):
show_id: Optional[int] = Field(default=None, foreign_key="show.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")

View file

@ -62,8 +62,10 @@ def create_rating(
query = query.where(Rating.show_id == rating.show_id)
elif 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:
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()
if existing_rating:
@ -85,6 +87,7 @@ def create_rating(
def get_average_rating(
show_id: Optional[int] = None,
song_id: Optional[int] = None,
performance_id: Optional[int] = None,
session: Session = Depends(get_session)
):
query = select(func.avg(Rating.score))
@ -92,8 +95,10 @@ def get_average_rating(
query = query.where(Rating.show_id == show_id)
elif song_id:
query = query.where(Rating.song_id == song_id)
elif performance_id:
query = query.where(Rating.performance_id == performance_id)
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()
return float(avg) if avg else 0.0

View file

@ -1,4 +1,5 @@
from typing import List
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select
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)
).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
song_with_stats = SongReadWithStats(
**song.model_dump(),
**stats
)
song_with_stats.tags = tags
song_with_stats.performances = perf_dtos
return song_with_stats
@router.patch("/{song_id}", response_model=SongRead)

View file

@ -57,10 +57,7 @@ class SongRead(SongBase):
id: int
tags: List["TagRead"] = []
class SongReadWithStats(SongRead):
times_played: int
gap: int
last_played: Optional[datetime] = None
class SongUpdate(SQLModel):
title: Optional[str] = None
@ -92,6 +89,20 @@ class PerformanceRead(PerformanceBase):
song: Optional["SongRead"] = None
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):
show: Optional["ShowRead"] = None
previous_performance_id: Optional[int] = None
@ -210,6 +221,7 @@ class RatingBase(SQLModel):
score: int
show_id: Optional[int] = None
song_id: Optional[int] = None
performance_id: Optional[int] = None
class RatingCreate(RatingBase):
pass

View file

@ -9,12 +9,12 @@ export default function Home() {
{/* 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">
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl">
Elmeg Demo
Elmeg Archive
</h1>
<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 />
36 Personas. Wiki-linked Reviews. Interconnected Data.
Discover shows, rate performances, and connect with fans.
</p>
<div className="flex gap-4">
<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">
<Users className="h-8 w-8 mb-2 text-purple-500" />
<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 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" />

View file

@ -94,11 +94,40 @@ export default async function SongDetailPage({ params }: { params: Promise<{ id:
<CardTitle>Performance History</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-sm">Performance history coming soon...</p>
{/*
We need to fetch performances list here.
For now, we leave a placeholder.
*/}
{song.performances && song.performances.length > 0 ? (
<div className="space-y-4">
{song.performances.map((perf: any) => (
<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>
</Card>