Compare commits
2 commits
cf7748a980
...
af6a4ae5d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af6a4ae5d3 | ||
|
|
60e2abfb65 |
4 changed files with 211 additions and 0 deletions
|
|
@ -112,6 +112,24 @@ class Vertical(SQLModel, table=True):
|
||||||
songs: List["Song"] = Relationship(back_populates="vertical")
|
songs: List["Song"] = Relationship(back_populates="vertical")
|
||||||
scenes: List["Scene"] = Relationship(back_populates="verticals", link_model=VerticalScene)
|
scenes: List["Scene"] = Relationship(back_populates="verticals", link_model=VerticalScene)
|
||||||
|
|
||||||
|
class VenueCanon(SQLModel, table=True):
|
||||||
|
"""Canonical venue independent of band - enables cross-band venue linking (like SongCanon for songs)"""
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
name: str = Field(index=True)
|
||||||
|
slug: str = Field(unique=True, index=True)
|
||||||
|
city: str
|
||||||
|
state: Optional[str] = Field(default=None)
|
||||||
|
country: str = Field(default="USA")
|
||||||
|
latitude: Optional[float] = Field(default=None)
|
||||||
|
longitude: Optional[float] = Field(default=None)
|
||||||
|
capacity: Optional[int] = Field(default=None)
|
||||||
|
website_url: Optional[str] = Field(default=None)
|
||||||
|
notes: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
# All venue records that point to this canonical venue
|
||||||
|
venues: List["Venue"] = Relationship(back_populates="canon")
|
||||||
|
|
||||||
|
|
||||||
class Venue(SQLModel, table=True):
|
class Venue(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
name: str = Field(index=True)
|
name: str = Field(index=True)
|
||||||
|
|
@ -122,8 +140,13 @@ class Venue(SQLModel, table=True):
|
||||||
capacity: Optional[int] = Field(default=None)
|
capacity: Optional[int] = Field(default=None)
|
||||||
notes: Optional[str] = Field(default=None)
|
notes: Optional[str] = Field(default=None)
|
||||||
|
|
||||||
|
# Link to canonical venue for cross-band deduplication
|
||||||
|
canon_id: Optional[int] = Field(default=None, foreign_key="venuecanon.id")
|
||||||
|
canon: Optional[VenueCanon] = Relationship(back_populates="venues")
|
||||||
|
|
||||||
shows: List["Show"] = Relationship(back_populates="venue")
|
shows: List["Show"] = Relationship(back_populates="venue")
|
||||||
|
|
||||||
|
|
||||||
class Tour(SQLModel, table=True):
|
class Tour(SQLModel, table=True):
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
name: str = Field(index=True)
|
name: str = Field(index=True)
|
||||||
|
|
|
||||||
|
|
@ -110,3 +110,72 @@ def update_song(song_id: int, song: SongUpdate, session: Session = Depends(get_s
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(db_song)
|
session.refresh(db_song)
|
||||||
return db_song
|
return db_song
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}/versions")
|
||||||
|
def get_song_versions(slug: str, session: Session = Depends(get_session)):
|
||||||
|
"""Get all versions of a song across different bands (via SongCanon)"""
|
||||||
|
from models import SongCanon, Vertical
|
||||||
|
|
||||||
|
# Find the song by slug
|
||||||
|
song = session.exec(select(Song).where(Song.slug == slug)).first()
|
||||||
|
if not song:
|
||||||
|
raise HTTPException(status_code=404, detail="Song not found")
|
||||||
|
|
||||||
|
# If no canon link, return empty
|
||||||
|
if not song.canon_id:
|
||||||
|
return {
|
||||||
|
"song": {
|
||||||
|
"id": song.id,
|
||||||
|
"title": song.title,
|
||||||
|
"slug": song.slug,
|
||||||
|
"vertical_id": song.vertical_id,
|
||||||
|
},
|
||||||
|
"canon": None,
|
||||||
|
"other_versions": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get the canon entry
|
||||||
|
canon = session.get(SongCanon, song.canon_id)
|
||||||
|
|
||||||
|
# Get all other versions (same canon, different song)
|
||||||
|
other_songs = session.exec(
|
||||||
|
select(Song)
|
||||||
|
.where(Song.canon_id == song.canon_id)
|
||||||
|
.where(Song.id != song.id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
other_versions = []
|
||||||
|
for s in other_songs:
|
||||||
|
vertical = session.get(Vertical, s.vertical_id)
|
||||||
|
# Get play count for this version
|
||||||
|
play_count = session.exec(
|
||||||
|
select(func.count(Performance.id))
|
||||||
|
.where(Performance.song_id == s.id)
|
||||||
|
).one()
|
||||||
|
|
||||||
|
other_versions.append({
|
||||||
|
"id": s.id,
|
||||||
|
"title": s.title,
|
||||||
|
"slug": s.slug,
|
||||||
|
"vertical_id": s.vertical_id,
|
||||||
|
"vertical_name": vertical.name if vertical else "Unknown",
|
||||||
|
"vertical_slug": vertical.slug if vertical else "unknown",
|
||||||
|
"play_count": play_count,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"song": {
|
||||||
|
"id": song.id,
|
||||||
|
"title": song.title,
|
||||||
|
"slug": song.slug,
|
||||||
|
"vertical_id": song.vertical_id,
|
||||||
|
},
|
||||||
|
"canon": {
|
||||||
|
"id": canon.id,
|
||||||
|
"title": canon.title,
|
||||||
|
"slug": canon.slug,
|
||||||
|
"original_artist": canon.original_artist,
|
||||||
|
} if canon else None,
|
||||||
|
"other_versions": other_versions
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,3 +40,78 @@ def update_venue(venue_id: int, venue: VenueUpdate, session: Session = Depends(g
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(db_venue)
|
session.refresh(db_venue)
|
||||||
return db_venue
|
return db_venue
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{slug}/across-bands")
|
||||||
|
def get_venue_across_bands(slug: str, session: Session = Depends(get_session)):
|
||||||
|
"""Get aggregated stats for a venue across all bands that have played there (via VenueCanon)"""
|
||||||
|
from models import VenueCanon, Show, Vertical
|
||||||
|
from sqlmodel import func
|
||||||
|
|
||||||
|
# Find the venue by slug
|
||||||
|
venue = session.exec(select(Venue).where(Venue.slug == slug)).first()
|
||||||
|
if not venue:
|
||||||
|
raise HTTPException(status_code=404, detail="Venue not found")
|
||||||
|
|
||||||
|
# If this venue has a canon_id, get all linked venues
|
||||||
|
linked_venues = [venue]
|
||||||
|
if venue.canon_id:
|
||||||
|
linked_venues = session.exec(
|
||||||
|
select(Venue).where(Venue.canon_id == venue.canon_id)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
venue_ids = [v.id for v in linked_venues]
|
||||||
|
|
||||||
|
# Get all shows at these venues
|
||||||
|
shows = session.exec(
|
||||||
|
select(Show)
|
||||||
|
.where(Show.venue_id.in_(venue_ids))
|
||||||
|
.order_by(Show.date.desc())
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Group by vertical/band
|
||||||
|
bands_stats = {}
|
||||||
|
for show in shows:
|
||||||
|
vertical = session.get(Vertical, show.vertical_id)
|
||||||
|
if vertical:
|
||||||
|
if vertical.id not in bands_stats:
|
||||||
|
bands_stats[vertical.id] = {
|
||||||
|
"vertical_id": vertical.id,
|
||||||
|
"vertical_name": vertical.name,
|
||||||
|
"vertical_slug": vertical.slug,
|
||||||
|
"show_count": 0,
|
||||||
|
"first_show": show.date,
|
||||||
|
"last_show": show.date,
|
||||||
|
"recent_shows": []
|
||||||
|
}
|
||||||
|
bands_stats[vertical.id]["show_count"] += 1
|
||||||
|
if show.date < bands_stats[vertical.id]["first_show"]:
|
||||||
|
bands_stats[vertical.id]["first_show"] = show.date
|
||||||
|
if show.date > bands_stats[vertical.id]["last_show"]:
|
||||||
|
bands_stats[vertical.id]["last_show"] = show.date
|
||||||
|
if len(bands_stats[vertical.id]["recent_shows"]) < 3:
|
||||||
|
bands_stats[vertical.id]["recent_shows"].append({
|
||||||
|
"date": show.date.strftime("%Y-%m-%d") if show.date else None,
|
||||||
|
"slug": show.slug
|
||||||
|
})
|
||||||
|
|
||||||
|
# Format response
|
||||||
|
bands_list = sorted(bands_stats.values(), key=lambda x: x["show_count"], reverse=True)
|
||||||
|
for band in bands_list:
|
||||||
|
band["first_show"] = band["first_show"].strftime("%Y-%m-%d") if band["first_show"] else None
|
||||||
|
band["last_show"] = band["last_show"].strftime("%Y-%m-%d") if band["last_show"] else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"venue": {
|
||||||
|
"id": venue.id,
|
||||||
|
"name": venue.name,
|
||||||
|
"slug": venue.slug,
|
||||||
|
"city": venue.city,
|
||||||
|
"state": venue.state,
|
||||||
|
"country": venue.country,
|
||||||
|
"capacity": venue.capacity,
|
||||||
|
},
|
||||||
|
"total_shows": len(shows),
|
||||||
|
"bands_count": len(bands_list),
|
||||||
|
"bands": bands_list
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,50 @@ export default async function SongDetailPage({ params }: { params: Promise<{ slu
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Cross-Band Versions */}
|
||||||
|
{relatedVersions && relatedVersions.length > 0 && (
|
||||||
|
<Card className="border-2 border-indigo-500/20 bg-gradient-to-br from-indigo-50/50 to-purple-50/50 dark:from-indigo-900/10 dark:to-purple-900/10">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-indigo-700 dark:text-indigo-400">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
|
||||||
|
<path d="M2 12h20" />
|
||||||
|
</svg>
|
||||||
|
Also Played By
|
||||||
|
</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This song is performed by {relatedVersions.length + 1} different bands
|
||||||
|
</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{relatedVersions.map((version: any) => (
|
||||||
|
<Link
|
||||||
|
key={version.id}
|
||||||
|
href={`/${version.vertical_slug}/songs/${version.slug}`}
|
||||||
|
className="block group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-3 rounded-lg bg-background/50 hover:bg-background/80 transition-colors border border-transparent hover:border-indigo-200 dark:hover:border-indigo-800">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium group-hover:text-primary transition-colors">
|
||||||
|
{version.vertical_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{version.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
View →
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<SongEvolutionChart performances={song.performances || []} />
|
<SongEvolutionChart performances={song.performances || []} />
|
||||||
|
|
||||||
{/* Performance List Component (Handles Client Sorting) */}
|
{/* Performance List Component (Handles Client Sorting) */}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue