feat: Add YouTube link support for shows, songs, and performances

This commit is contained in:
fullsizemalt 2025-12-21 12:58:32 -08:00
parent 958f097068
commit 8df513b84f
4 changed files with 88 additions and 1 deletions

View file

@ -0,0 +1,24 @@
"""
Migration to add youtube_link column to show, song, and performance tables.
"""
from sqlmodel import Session, create_engine, text
from database import DATABASE_URL
def add_youtube_link_columns():
engine = create_engine(DATABASE_URL)
tables = ['show', 'song', 'performance']
with Session(engine) as session:
for table in tables:
try:
session.exec(text(f"""
ALTER TABLE "{table}" ADD COLUMN IF NOT EXISTS youtube_link VARCHAR
"""))
session.commit()
print(f"✅ Added youtube_link to {table}")
except Exception as e:
print(f"⚠️ {table}: {e}")
if __name__ == "__main__":
add_youtube_link_columns()

View file

@ -13,6 +13,7 @@ class Performance(SQLModel, table=True):
segue: bool = Field(default=False, description="Transition to next song >") segue: bool = Field(default=False, description="Transition to next song >")
notes: Optional[str] = Field(default=None) notes: Optional[str] = Field(default=None)
track_url: Optional[str] = Field(default=None, description="Deep link to track audio") track_url: Optional[str] = Field(default=None, description="Deep link to track audio")
youtube_link: Optional[str] = Field(default=None, description="YouTube video URL")
nicknames: List["PerformanceNickname"] = Relationship(back_populates="performance") nicknames: List["PerformanceNickname"] = Relationship(back_populates="performance")
show: "Show" = Relationship(back_populates="performances") show: "Show" = Relationship(back_populates="performances")
@ -97,6 +98,7 @@ class Show(SQLModel, table=True):
# External Links # External Links
bandcamp_link: Optional[str] = Field(default=None) bandcamp_link: Optional[str] = Field(default=None)
nugs_link: Optional[str] = Field(default=None) nugs_link: Optional[str] = Field(default=None)
youtube_link: Optional[str] = Field(default=None)
vertical: Vertical = Relationship(back_populates="shows") vertical: Vertical = Relationship(back_populates="shows")
venue: Optional[Venue] = Relationship(back_populates="shows") venue: Optional[Venue] = Relationship(back_populates="shows")
@ -110,6 +112,7 @@ class Song(SQLModel, table=True):
original_artist: Optional[str] = Field(default=None) original_artist: Optional[str] = Field(default=None)
vertical_id: int = Field(foreign_key="vertical.id") vertical_id: int = Field(foreign_key="vertical.id")
notes: Optional[str] = Field(default=None) notes: Optional[str] = Field(default=None)
youtube_link: Optional[str] = Field(default=None)
vertical: Vertical = Relationship(back_populates="songs") vertical: Vertical = Relationship(back_populates="songs")

View file

@ -1,7 +1,7 @@
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ArrowLeft, Calendar, MapPin, Music2, Disc, PlayCircle, ExternalLink } from "lucide-react" import { ArrowLeft, Calendar, MapPin, Music2, Disc, PlayCircle, ExternalLink, Youtube } from "lucide-react"
import Link from "next/link" import Link from "next/link"
import { CommentSection } from "@/components/social/comment-section" import { CommentSection } from "@/components/social/comment-section"
import { EntityRating } from "@/components/social/entity-rating" import { EntityRating } from "@/components/social/entity-rating"
@ -13,6 +13,7 @@ import { notFound } from "next/navigation"
import { SuggestNicknameDialog } from "@/components/shows/suggest-nickname-dialog" import { SuggestNicknameDialog } from "@/components/shows/suggest-nickname-dialog"
import { EntityReviews } from "@/components/reviews/entity-reviews" import { EntityReviews } from "@/components/reviews/entity-reviews"
import { getApiUrl } from "@/lib/api-config" import { getApiUrl } from "@/lib/api-config"
import { YouTubeEmbed } from "@/components/ui/youtube-embed"
async function getShow(id: string) { async function getShow(id: string) {
try { try {
@ -136,6 +137,20 @@ export default async function ShowDetailPage({ params }: { params: Promise<{ id:
</div> </div>
)} )}
{show.youtube_link && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Youtube className="h-5 w-5 text-red-500" />
Video
</CardTitle>
</CardHeader>
<CardContent>
<YouTubeEmbed url={show.youtube_link} title={`${show.date?.split('T')[0]} - ${show.venue?.name}`} />
</CardContent>
</Card>
)}
<div className="grid gap-6 md:grid-cols-[2fr_1fr]"> <div className="grid gap-6 md:grid-cols-[2fr_1fr]">
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<Card> <Card>

View file

@ -0,0 +1,45 @@
"use client"
import { useMemo } from "react"
interface YouTubeEmbedProps {
url: string
title?: string
}
function extractVideoId(url: string): string | null {
// Handle various YouTube URL formats
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&?/]+)/,
/youtube\.com\/v\/([^&?/]+)/,
/youtube\.com\/shorts\/([^&?/]+)/
]
for (const pattern of patterns) {
const match = url.match(pattern)
if (match && match[1]) {
return match[1]
}
}
return null
}
export function YouTubeEmbed({ url, title = "YouTube video" }: YouTubeEmbedProps) {
const videoId = useMemo(() => extractVideoId(url), [url])
if (!videoId) {
return null
}
return (
<div className="relative w-full aspect-video rounded-lg overflow-hidden bg-muted">
<iframe
src={`https://www.youtube.com/embed/${videoId}`}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute inset-0 w-full h-full"
/>
</div>
)
}