feat: Add band profile and musician profile pages with API endpoints and database support
Some checks failed
Deploy Fediversion / deploy (push) Failing after 1s

This commit is contained in:
fullsizemalt 2025-12-28 23:00:30 -08:00
parent 762d2b81ff
commit cf7748a980
6 changed files with 1025 additions and 1 deletions

View file

@ -0,0 +1,340 @@
"""add_band_profile_and_musician_profile_fields
Revision ID: 81e183e75ff5
Revises: 65c515b4722a
Create Date: 2025-12-28 22:53:06.940185
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '81e183e75ff5'
down_revision: Union[str, Sequence[str], None] = '65c515b4722a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('musician',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('bio', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('primary_instrument', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('birth_year', sa.Integer(), nullable=True),
sa.Column('origin_city', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('origin_state', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('origin_country', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('website_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('wikipedia_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('instagram_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('musician', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_musician_name'), ['name'], unique=False)
batch_op.create_index(batch_op.f('ix_musician_slug'), ['slug'], unique=True)
op.create_table('scene',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('scene', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_scene_name'), ['name'], unique=True)
batch_op.create_index(batch_op.f('ix_scene_slug'), ['slug'], unique=True)
op.create_table('sequence',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('sequence', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_sequence_name'), ['name'], unique=False)
batch_op.create_index(batch_op.f('ix_sequence_slug'), ['slug'], unique=True)
op.create_table('bandmembership',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('musician_id', sa.Integer(), nullable=False),
sa.Column('artist_id', sa.Integer(), nullable=False),
sa.Column('role', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('start_date', sa.DateTime(), nullable=True),
sa.Column('end_date', sa.DateTime(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['artist_id'], ['artist.id'], ),
sa.ForeignKeyConstraint(['musician_id'], ['musician.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('reaction',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('entity_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('entity_id', sa.Integer(), nullable=False),
sa.Column('emoji', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('reaction', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_reaction_entity_id'), ['entity_id'], unique=False)
batch_op.create_index(batch_op.f('ix_reaction_entity_type'), ['entity_type'], unique=False)
op.create_table('songcanon',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('original_artist', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('original_artist_id', sa.Integer(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['original_artist_id'], ['artist.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('songcanon', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_songcanon_slug'), ['slug'], unique=True)
batch_op.create_index(batch_op.f('ix_songcanon_title'), ['title'], unique=False)
op.create_table('userverticalpreference',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('vertical_id', sa.Integer(), nullable=False),
sa.Column('display_mode', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('priority', sa.Integer(), nullable=False),
sa.Column('notify_on_show', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.ForeignKeyConstraint(['vertical_id'], ['vertical.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('userverticalpreference', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_userverticalpreference_user_id'), ['user_id'], unique=False)
batch_op.create_index(batch_op.f('ix_userverticalpreference_vertical_id'), ['vertical_id'], unique=False)
op.create_table('verticalscene',
sa.Column('vertical_id', sa.Integer(), nullable=False),
sa.Column('scene_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['scene_id'], ['scene.id'], ),
sa.ForeignKeyConstraint(['vertical_id'], ['vertical.id'], ),
sa.PrimaryKeyConstraint('vertical_id', 'scene_id')
)
op.create_table('chasesong',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('song_id', sa.Integer(), nullable=False),
sa.Column('priority', sa.Integer(), nullable=False),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('caught_at', sa.DateTime(), nullable=True),
sa.Column('caught_show_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['caught_show_id'], ['show.id'], ),
sa.ForeignKeyConstraint(['song_id'], ['song.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('chasesong', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_chasesong_song_id'), ['song_id'], unique=False)
batch_op.create_index(batch_op.f('ix_chasesong_user_id'), ['user_id'], unique=False)
op.create_table('sequencesong',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('sequence_id', sa.Integer(), nullable=False),
sa.Column('song_id', sa.Integer(), nullable=False),
sa.Column('position', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['sequence_id'], ['sequence.id'], ),
sa.ForeignKeyConstraint(['song_id'], ['song.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('performanceguest',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('performance_id', sa.Integer(), nullable=False),
sa.Column('musician_id', sa.Integer(), nullable=False),
sa.Column('instrument', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('notes', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['musician_id'], ['musician.id'], ),
sa.ForeignKeyConstraint(['performance_id'], ['performance.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('artist', schema=None) as batch_op:
batch_op.add_column(sa.Column('slug', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
batch_op.add_column(sa.Column('bio', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.create_index(batch_op.f('ix_artist_slug'), ['slug'], unique=True)
with op.batch_alter_table('badge', schema=None) as batch_op:
batch_op.add_column(sa.Column('tier', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
batch_op.add_column(sa.Column('category', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
batch_op.add_column(sa.Column('xp_reward', sa.Integer(), nullable=False))
with op.batch_alter_table('group', schema=None) as batch_op:
batch_op.add_column(sa.Column('vertical_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.create_index(batch_op.f('ix_group_vertical_id'), ['vertical_id'], unique=False)
batch_op.create_foreign_key(None, 'vertical', ['vertical_id'], ['id'])
with op.batch_alter_table('performance', schema=None) as batch_op:
batch_op.add_column(sa.Column('bandcamp_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('nugs_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
with op.batch_alter_table('show', schema=None) as batch_op:
batch_op.add_column(sa.Column('relisten_link', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
with op.batch_alter_table('song', schema=None) as batch_op:
batch_op.add_column(sa.Column('canon_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('artist_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key(None, 'songcanon', ['canon_id'], ['id'])
batch_op.create_foreign_key(None, 'artist', ['artist_id'], ['id'])
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('avatar_bg_color', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('avatar_text', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('profile_public', sa.Boolean(), nullable=False))
batch_op.add_column(sa.Column('show_attendance_public', sa.Boolean(), nullable=False))
batch_op.add_column(sa.Column('appear_in_leaderboards', sa.Boolean(), nullable=False))
with op.batch_alter_table('userpreferences', schema=None) as batch_op:
batch_op.add_column(sa.Column('theme', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
batch_op.add_column(sa.Column('email_on_reply', sa.Boolean(), nullable=False))
batch_op.add_column(sa.Column('email_on_chase', sa.Boolean(), nullable=False))
batch_op.add_column(sa.Column('email_digest', sa.Boolean(), nullable=False))
with op.batch_alter_table('vertical', schema=None) as batch_op:
batch_op.add_column(sa.Column('primary_artist_id', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('setlistfm_mbid', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=False))
batch_op.add_column(sa.Column('is_featured', sa.Boolean(), nullable=False))
batch_op.add_column(sa.Column('logo_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('accent_color', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('formed_year', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('origin_city', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('origin_state', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('origin_country', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('long_description', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('website_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('wikipedia_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('bandcamp_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('nugs_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('relisten_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.add_column(sa.Column('spotify_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
batch_op.create_foreign_key(None, 'artist', ['primary_artist_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('vertical', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_column('spotify_url')
batch_op.drop_column('relisten_url')
batch_op.drop_column('nugs_url')
batch_op.drop_column('bandcamp_url')
batch_op.drop_column('wikipedia_url')
batch_op.drop_column('website_url')
batch_op.drop_column('long_description')
batch_op.drop_column('origin_country')
batch_op.drop_column('origin_state')
batch_op.drop_column('origin_city')
batch_op.drop_column('formed_year')
batch_op.drop_column('accent_color')
batch_op.drop_column('logo_url')
batch_op.drop_column('is_featured')
batch_op.drop_column('is_active')
batch_op.drop_column('setlistfm_mbid')
batch_op.drop_column('primary_artist_id')
with op.batch_alter_table('userpreferences', schema=None) as batch_op:
batch_op.drop_column('email_digest')
batch_op.drop_column('email_on_chase')
batch_op.drop_column('email_on_reply')
batch_op.drop_column('theme')
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('appear_in_leaderboards')
batch_op.drop_column('show_attendance_public')
batch_op.drop_column('profile_public')
batch_op.drop_column('avatar_text')
batch_op.drop_column('avatar_bg_color')
with op.batch_alter_table('song', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_column('artist_id')
batch_op.drop_column('canon_id')
with op.batch_alter_table('show', schema=None) as batch_op:
batch_op.drop_column('relisten_link')
with op.batch_alter_table('performance', schema=None) as batch_op:
batch_op.drop_column('nugs_link')
batch_op.drop_column('bandcamp_link')
with op.batch_alter_table('group', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_index(batch_op.f('ix_group_vertical_id'))
batch_op.drop_column('image_url')
batch_op.drop_column('vertical_id')
with op.batch_alter_table('badge', schema=None) as batch_op:
batch_op.drop_column('xp_reward')
batch_op.drop_column('category')
batch_op.drop_column('tier')
with op.batch_alter_table('artist', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_artist_slug'))
batch_op.drop_column('image_url')
batch_op.drop_column('bio')
batch_op.drop_column('slug')
op.drop_table('performanceguest')
op.drop_table('sequencesong')
with op.batch_alter_table('chasesong', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_chasesong_user_id'))
batch_op.drop_index(batch_op.f('ix_chasesong_song_id'))
op.drop_table('chasesong')
op.drop_table('verticalscene')
with op.batch_alter_table('userverticalpreference', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_userverticalpreference_vertical_id'))
batch_op.drop_index(batch_op.f('ix_userverticalpreference_user_id'))
op.drop_table('userverticalpreference')
with op.batch_alter_table('songcanon', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_songcanon_title'))
batch_op.drop_index(batch_op.f('ix_songcanon_slug'))
op.drop_table('songcanon')
with op.batch_alter_table('reaction', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_reaction_entity_type'))
batch_op.drop_index(batch_op.f('ix_reaction_entity_id'))
op.drop_table('reaction')
op.drop_table('bandmembership')
with op.batch_alter_table('sequence', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_sequence_slug'))
batch_op.drop_index(batch_op.f('ix_sequence_name'))
op.drop_table('sequence')
with op.batch_alter_table('scene', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_scene_slug'))
batch_op.drop_index(batch_op.f('ix_scene_name'))
op.drop_table('scene')
with op.batch_alter_table('musician', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_musician_slug'))
batch_op.drop_index(batch_op.f('ix_musician_name'))
op.drop_table('musician')
# ### end Alembic commands ###

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
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 fastapi.middleware.cors import CORSMiddleware
@ -48,6 +48,7 @@ app.include_router(verticals.router)
app.include_router(canon.router)
app.include_router(on_this_day.router)
app.include_router(discover.router)
app.include_router(bands.router)
# Optional features - can be disabled via env vars

View file

@ -92,6 +92,21 @@ class Vertical(SQLModel, table=True):
logo_url: Optional[str] = Field(default=None, description="Band logo URL for UI")
accent_color: Optional[str] = Field(default=None, description="Hex color for accents")
# Rich profile fields
formed_year: Optional[int] = Field(default=None, description="Year band was formed")
origin_city: Optional[str] = Field(default=None, description="City of origin")
origin_state: Optional[str] = Field(default=None, description="State/province")
origin_country: Optional[str] = Field(default=None, description="Country")
long_description: Optional[str] = Field(default=None, description="Full band biography")
# Social/external links
website_url: Optional[str] = Field(default=None)
wikipedia_url: Optional[str] = Field(default=None)
bandcamp_url: Optional[str] = Field(default=None)
nugs_url: Optional[str] = Field(default=None)
relisten_url: Optional[str] = Field(default=None)
spotify_url: Optional[str] = Field(default=None)
# Relationships
shows: List["Show"] = Relationship(back_populates="vertical")
songs: List["Song"] = Relationship(back_populates="vertical")
@ -140,6 +155,17 @@ class Musician(SQLModel, table=True):
primary_instrument: Optional[str] = Field(default=None)
notes: Optional[str] = Field(default=None)
# Rich profile fields
birth_year: Optional[int] = Field(default=None)
origin_city: Optional[str] = Field(default=None)
origin_state: Optional[str] = Field(default=None)
origin_country: Optional[str] = Field(default=None)
# Social links
website_url: Optional[str] = Field(default=None)
wikipedia_url: Optional[str] = Field(default=None)
instagram_url: Optional[str] = Field(default=None)
# Relationships
memberships: List["BandMembership"] = Relationship(back_populates="musician")
guest_appearances: List["PerformanceGuest"] = Relationship(back_populates="musician")

112
backend/routers/bands.py Normal file
View file

@ -0,0 +1,112 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select, func
from typing import List, Optional
from database import get_session
from models import (
Vertical, Artist, Musician, BandMembership,
PerformanceGuest, Performance, Show, Song, Venue
)
router = APIRouter(prefix="/bands", tags=["bands"])
@router.get("")
async def list_bands(
scene: Optional[str] = None,
is_featured: Optional[bool] = None,
session: Session = Depends(get_session)
):
"""List all active bands, optionally filtered"""
query = select(Vertical).where(Vertical.is_active == True)
if is_featured is not None:
query = query.where(Vertical.is_featured == is_featured)
bands = session.exec(query.order_by(Vertical.name)).all()
return bands
@router.get("/{slug}")
async def get_band_profile(slug: str, session: Session = Depends(get_session)):
"""Get comprehensive band profile including members and stats"""
vertical = session.exec(
select(Vertical).where(Vertical.slug == slug)
).first()
if not vertical:
raise HTTPException(status_code=404, detail="Band not found")
# Get members via BandMembership if primary_artist_id exists
current_members = []
past_members = []
if vertical.primary_artist_id:
# Get all memberships for this band's artist
memberships = session.exec(
select(BandMembership, Musician)
.join(Musician, BandMembership.musician_id == Musician.id)
.where(BandMembership.artist_id == vertical.primary_artist_id)
.order_by(BandMembership.start_date)
).all()
for membership, musician in memberships:
member_data = {
"id": musician.id,
"name": musician.name,
"slug": musician.slug,
"image_url": musician.image_url,
"role": membership.role,
"primary_instrument": musician.primary_instrument,
"start_date": membership.start_date,
"end_date": membership.end_date,
"notes": membership.notes,
}
if membership.end_date is None:
current_members.append(member_data)
else:
past_members.append(member_data)
# Get stats
show_count = session.exec(
select(func.count(Show.id)).where(Show.vertical_id == vertical.id)
).one()
song_count = session.exec(
select(func.count(Song.id)).where(Song.vertical_id == vertical.id)
).one()
# Get venue count (distinct venues from shows)
venue_count = session.exec(
select(func.count(func.distinct(Show.venue_id)))
.where(Show.vertical_id == vertical.id)
).one()
# Get first and last show dates
first_show = session.exec(
select(Show.date)
.where(Show.vertical_id == vertical.id)
.order_by(Show.date.asc())
.limit(1)
).first()
last_show = session.exec(
select(Show.date)
.where(Show.vertical_id == vertical.id)
.order_by(Show.date.desc())
.limit(1)
).first()
stats = {
"total_shows": show_count,
"total_songs": song_count,
"total_venues": venue_count,
"first_show": first_show,
"last_show": last_show,
}
return {
"band": vertical,
"current_members": current_members,
"past_members": past_members,
"stats": stats,
}

View file

@ -0,0 +1,306 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import Link from "next/link"
import { Music, Calendar, MapPin, Users, ExternalLink, Globe } from "lucide-react"
interface BandPageProps {
params: {
slug: string
}
}
async function getBand(slug: string) {
const res = await fetch(`${process.env.INTERNAL_API_URL}/bands/${slug}`, {
next: { revalidate: 60 },
})
if (!res.ok) {
if (res.status === 404) return null
throw new Error("Failed to fetch band")
}
return res.json()
}
export async function generateMetadata({ params }: BandPageProps): Promise<Metadata> {
const data = await getBand(params.slug)
if (!data) return { title: "Band Not Found" }
return {
title: `${data.band.name} | Fediversion`,
description: data.band.description || `Band profile for ${data.band.name} on Fediversion.`,
}
}
export default async function BandPage({ params }: BandPageProps) {
const data = await getBand(params.slug)
if (!data) return notFound()
const { band, current_members, past_members, stats } = data
// Format origin location
const originParts = [band.origin_city, band.origin_state, band.origin_country].filter(Boolean)
const originLocation = originParts.join(", ")
return (
<div className="container py-8 space-y-8">
{/* Header */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
{band.logo_url ? (
<img
src={band.logo_url}
alt={band.name}
className="w-24 h-24 rounded-lg object-cover border-2 border-primary/20"
/>
) : (
<div
className="w-24 h-24 rounded-lg flex items-center justify-center text-3xl font-bold text-white"
style={{ backgroundColor: band.accent_color || '#6366f1' }}
>
{band.name[0]}
</div>
)}
<div>
<h1 className="text-4xl font-bold tracking-tight">{band.name}</h1>
<div className="flex flex-wrap gap-3 mt-2 text-sm text-muted-foreground">
{originLocation && (
<span className="flex items-center gap-1">
<MapPin className="h-4 w-4" />
{originLocation}
</span>
)}
{band.formed_year && (
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
Formed {band.formed_year}
</span>
)}
</div>
</div>
</div>
{band.description && (
<p className="max-w-3xl text-lg text-muted-foreground leading-relaxed">
{band.long_description || band.description}
</p>
)}
{/* External Links */}
<div className="flex flex-wrap gap-2">
{band.website_url && (
<Link href={band.website_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-accent hover:bg-accent/80 text-sm">
<Globe className="h-3 w-3" /> Website
</Link>
)}
{band.nugs_url && (
<Link href={band.nugs_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-orange-500/20 hover:bg-orange-500/30 text-sm text-orange-600 dark:text-orange-400">
<Music className="h-3 w-3" /> Nugs.net
</Link>
)}
{band.relisten_url && (
<Link href={band.relisten_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-blue-500/20 hover:bg-blue-500/30 text-sm text-blue-600 dark:text-blue-400">
<Music className="h-3 w-3" /> Relisten
</Link>
)}
{band.spotify_url && (
<Link href={band.spotify_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-green-500/20 hover:bg-green-500/30 text-sm text-green-600 dark:text-green-400">
<Music className="h-3 w-3" /> Spotify
</Link>
)}
{band.wikipedia_url && (
<Link href={band.wikipedia_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-gray-500/20 hover:bg-gray-500/30 text-sm">
<ExternalLink className="h-3 w-3" /> Wikipedia
</Link>
)}
</div>
</div>
<Separator />
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold">{stats.total_shows.toLocaleString()}</div>
<div className="text-sm text-muted-foreground">Shows</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold">{stats.total_songs.toLocaleString()}</div>
<div className="text-sm text-muted-foreground">Songs</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold">{stats.total_venues.toLocaleString()}</div>
<div className="text-sm text-muted-foreground">Venues</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold">
{stats.first_show && stats.last_show
? new Date(stats.last_show).getFullYear() - new Date(stats.first_show).getFullYear() + 1
: '—'
}
</div>
<div className="text-sm text-muted-foreground">Active Years</div>
</CardContent>
</Card>
</div>
{/* Members Section */}
{(current_members.length > 0 || past_members.length > 0) && (
<>
<h2 className="text-2xl font-bold flex items-center gap-2">
<Users className="h-6 w-6" /> Members
</h2>
<Tabs defaultValue="current" className="space-y-6">
<TabsList>
<TabsTrigger value="current">
Current
<Badge variant="secondary" className="ml-2">{current_members.length}</Badge>
</TabsTrigger>
<TabsTrigger value="past">
Past
<Badge variant="secondary" className="ml-2">{past_members.length}</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="current" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{current_members.map((member: any) => (
<Link
key={member.id}
href={`/musicians/${member.slug}`}
className="block group"
>
<Card className="h-full transition-colors group-hover:bg-accent/50">
<CardContent className="pt-6 flex items-center gap-4">
{member.image_url ? (
<img
src={member.image_url}
alt={member.name}
className="w-16 h-16 rounded-full object-cover"
/>
) : (
<div className="w-16 h-16 rounded-full bg-accent flex items-center justify-center text-xl font-bold">
{member.name[0]}
</div>
)}
<div>
<div className="font-semibold group-hover:text-primary transition-colors">
{member.name}
</div>
<div className="text-sm text-muted-foreground">
{member.role || member.primary_instrument}
</div>
{member.start_date && (
<div className="text-xs text-muted-foreground">
Since {new Date(member.start_date).getFullYear()}
</div>
)}
</div>
</CardContent>
</Card>
</Link>
))}
{current_members.length === 0 && (
<div className="col-span-full py-12 text-center text-muted-foreground">
No current members listed.
</div>
)}
</div>
</TabsContent>
<TabsContent value="past" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{past_members.map((member: any) => (
<Link
key={member.id}
href={`/musicians/${member.slug}`}
className="block group"
>
<Card className="h-full transition-colors group-hover:bg-accent/50">
<CardContent className="pt-6 flex items-center gap-4">
{member.image_url ? (
<img
src={member.image_url}
alt={member.name}
className="w-16 h-16 rounded-full object-cover opacity-75"
/>
) : (
<div className="w-16 h-16 rounded-full bg-accent flex items-center justify-center text-xl font-bold opacity-75">
{member.name[0]}
</div>
)}
<div>
<div className="font-semibold group-hover:text-primary transition-colors">
{member.name}
</div>
<div className="text-sm text-muted-foreground">
{member.role || member.primary_instrument}
</div>
{(member.start_date || member.end_date) && (
<div className="text-xs text-muted-foreground">
{member.start_date ? new Date(member.start_date).getFullYear() : '?'}
{' - '}
{member.end_date ? new Date(member.end_date).getFullYear() : '?'}
</div>
)}
</div>
</CardContent>
</Card>
</Link>
))}
{past_members.length === 0 && (
<div className="col-span-full py-12 text-center text-muted-foreground">
No past members listed.
</div>
)}
</div>
</TabsContent>
</Tabs>
</>
)}
{/* Quick Links */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Link href={`/${band.slug}/shows`} className="block">
<Card className="hover:bg-accent/50 transition-colors">
<CardContent className="pt-6 text-center">
<span className="font-semibold">All Shows</span>
</CardContent>
</Card>
</Link>
<Link href={`/${band.slug}/songs`} className="block">
<Card className="hover:bg-accent/50 transition-colors">
<CardContent className="pt-6 text-center">
<span className="font-semibold">All Songs</span>
</CardContent>
</Card>
</Link>
<Link href={`/${band.slug}/venues`} className="block">
<Card className="hover:bg-accent/50 transition-colors">
<CardContent className="pt-6 text-center">
<span className="font-semibold">All Venues</span>
</CardContent>
</Card>
</Link>
<Link href={`/${band.slug}/performances`} className="block">
<Card className="hover:bg-accent/50 transition-colors">
<CardContent className="pt-6 text-center">
<span className="font-semibold">Top Performances</span>
</CardContent>
</Card>
</Link>
</div>
</div>
)
}

View file

@ -0,0 +1,239 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
import { Card, CardContent } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import Link from "next/link"
import { Music, Calendar, MapPin, Users, ExternalLink, Globe, Instagram } from "lucide-react"
interface MusicianPageProps {
params: {
slug: string
}
}
async function getMusician(slug: string) {
const res = await fetch(`${process.env.INTERNAL_API_URL}/musicians/${slug}`, {
next: { revalidate: 60 },
})
if (!res.ok) {
if (res.status === 404) return null
throw new Error("Failed to fetch musician")
}
return res.json()
}
export async function generateMetadata({ params }: MusicianPageProps): Promise<Metadata> {
const data = await getMusician(params.slug)
if (!data) return { title: "Musician Not Found" }
return {
title: `${data.musician.name} | Fediversion`,
description: data.musician.bio || `Musician profile for ${data.musician.name} on Fediversion.`,
}
}
export default async function MusicianPage({ params }: MusicianPageProps) {
const data = await getMusician(params.slug)
if (!data) return notFound()
const { musician, bands, guest_appearances, sit_in_summary, stats } = data
return (
<div className="container py-8 space-y-8">
{/* Header */}
<div className="flex flex-col gap-4">
<div className="flex items-center gap-4">
{musician.image_url ? (
<img
src={musician.image_url}
alt={musician.name}
className="w-24 h-24 rounded-full object-cover border-2 border-primary/20"
/>
) : (
<div className="w-24 h-24 rounded-full bg-accent flex items-center justify-center text-3xl font-bold text-muted-foreground">
{musician.name[0]}
</div>
)}
<div>
<h1 className="text-4xl font-bold tracking-tight">{musician.name}</h1>
{musician.primary_instrument && (
<p className="text-muted-foreground flex items-center gap-1">
<Music className="h-4 w-4" />
{musician.primary_instrument}
</p>
)}
</div>
</div>
{musician.bio && (
<p className="max-w-3xl text-lg text-muted-foreground leading-relaxed">
{musician.bio}
</p>
)}
{/* External Links */}
<div className="flex flex-wrap gap-2">
{musician.website_url && (
<Link href={musician.website_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-accent hover:bg-accent/80 text-sm">
<Globe className="h-3 w-3" /> Website
</Link>
)}
{musician.instagram_url && (
<Link href={musician.instagram_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-pink-500/20 hover:bg-pink-500/30 text-sm text-pink-600 dark:text-pink-400">
<Instagram className="h-3 w-3" /> Instagram
</Link>
)}
{musician.wikipedia_url && (
<Link href={musician.wikipedia_url} target="_blank" className="inline-flex items-center gap-1 px-3 py-1 rounded-full bg-gray-500/20 hover:bg-gray-500/30 text-sm">
<ExternalLink className="h-3 w-3" /> Wikipedia
</Link>
)}
</div>
</div>
<Separator />
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card>
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold">{stats?.total_bands || 0}</div>
<div className="text-sm text-muted-foreground">Bands</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold">{stats?.current_bands || 0}</div>
<div className="text-sm text-muted-foreground">Current</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold">{stats?.total_sit_ins || 0}</div>
<div className="text-sm text-muted-foreground">Sit-Ins</div>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold">{stats?.bands_sat_in_with || 0}</div>
<div className="text-sm text-muted-foreground">Bands Sat In With</div>
</CardContent>
</Card>
</div>
{/* Band History */}
{bands && bands.length > 0 && (
<>
<h2 className="text-2xl font-bold flex items-center gap-2">
<Users className="h-6 w-6" /> Band History
</h2>
<div className="grid gap-4 md:grid-cols-2">
{bands.map((band: any, i: number) => (
<Link
key={i}
href={`/bands/${band.artist_slug || band.band_slug}`}
className="block group"
>
<Card className={`h-full transition-colors group-hover:bg-accent/50 ${band.is_current ? 'border-primary/50' : ''}`}>
<CardContent className="pt-6 flex items-center justify-between">
<div>
<div className="font-semibold group-hover:text-primary transition-colors flex items-center gap-2">
{band.artist_name}
{band.is_current && (
<Badge variant="default" className="text-xs">Current</Badge>
)}
</div>
<div className="text-sm text-muted-foreground">
{band.role}
</div>
</div>
<div className="text-sm text-muted-foreground text-right">
{band.start_date?.split('-')[0] || '?'}
{' - '}
{band.is_current ? 'Present' : (band.end_date?.split('-')[0] || '?')}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</>
)}
{/* Sit-In Summary */}
{sit_in_summary && sit_in_summary.length > 0 && (
<>
<h2 className="text-2xl font-bold flex items-center gap-2">
<Music className="h-6 w-6" /> Sit-In Summary
</h2>
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4">
{sit_in_summary.map((band: any, i: number) => (
<Link
key={i}
href={`/${band.vertical_slug}`}
className="block group"
>
<Card className="h-full transition-colors group-hover:bg-accent/50">
<CardContent className="pt-6 text-center">
<div className="font-semibold group-hover:text-primary transition-colors">
{band.vertical_name}
</div>
<div className="text-2xl font-bold text-primary">
{band.count}
</div>
<div className="text-xs text-muted-foreground">
sit-in{band.count !== 1 ? 's' : ''}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
</>
)}
{/* Recent Guest Appearances */}
{guest_appearances && guest_appearances.length > 0 && (
<>
<h2 className="text-2xl font-bold">Recent Guest Appearances</h2>
<div className="border rounded-lg">
<div className="divide-y">
{guest_appearances.slice(0, 20).map((appearance: any, i: number) => (
<div key={i} className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-accent/50 transition-colors">
<div>
<div className="flex items-center gap-2">
<Badge variant="outline">{appearance.vertical_name}</Badge>
<Link
href={`/shows/${appearance.performance_slug?.split('-').slice(0, 4).join('-') || '#'}`}
className="font-semibold hover:underline"
>
{appearance.show_date}
</Link>
</div>
{appearance.instrument && (
<p className="text-xs text-muted-foreground mt-1">
Playing: {appearance.instrument}
</p>
)}
</div>
<div className="text-sm">
<span className="text-muted-foreground">Sat in on: </span>
<span className="font-medium">{appearance.song_title}</span>
</div>
</div>
))}
</div>
</div>
{guest_appearances.length > 20 && (
<p className="text-sm text-muted-foreground text-center">
Showing 20 of {guest_appearances.length} appearances
</p>
)}
</>
)}
</div>
)
}