feat: Add /videos page listing all YouTube videos without thumbnails

This commit is contained in:
fullsizemalt 2025-12-22 23:16:04 -08:00
parent 0ad89105b3
commit 171b8a38ca
3 changed files with 337 additions and 1 deletions

View file

@ -1,5 +1,5 @@
from fastapi import FastAPI from fastapi import FastAPI
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 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
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
@ -37,6 +37,7 @@ app.include_router(stats.router)
app.include_router(admin.router) app.include_router(admin.router)
app.include_router(chase.router) app.include_router(chase.router)
app.include_router(gamification.router) app.include_router(gamification.router)
app.include_router(videos.router)
@app.get("/") @app.get("/")
def read_root(): def read_root():

124
backend/routers/videos.py Normal file
View file

@ -0,0 +1,124 @@
"""
Videos endpoint - list all performances and shows with YouTube links
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select
from database import get_session
from models import Show, Performance, Song, Venue
router = APIRouter(prefix="/videos", tags=["videos"])
@router.get("/")
def get_all_videos(
limit: int = Query(default=100, le=500),
offset: int = Query(default=0),
session: Session = Depends(get_session)
):
"""Get all performances and shows with YouTube links."""
# Get performances with videos
perf_query = (
select(
Performance.id,
Performance.youtube_link,
Performance.show_id,
Song.id.label("song_id"),
Song.title.label("song_title"),
Song.slug.label("song_slug"),
Show.date,
Show.slug.label("show_slug"),
Venue.name.label("venue_name"),
Venue.city.label("venue_city"),
Venue.state.label("venue_state")
)
.join(Song, Performance.song_id == Song.id)
.join(Show, Performance.show_id == Show.id)
.join(Venue, Show.venue_id == Venue.id)
.where(Performance.youtube_link != None)
.order_by(Show.date.desc())
.limit(limit)
.offset(offset)
)
perf_results = session.exec(perf_query).all()
performances = [
{
"type": "performance",
"id": r[0],
"youtube_link": r[1],
"show_id": r[2],
"song_id": r[3],
"song_title": r[4],
"song_slug": r[5],
"date": r[6].isoformat() if r[6] else None,
"show_slug": r[7],
"venue_name": r[8],
"venue_city": r[9],
"venue_state": r[10]
}
for r in perf_results
]
# Get shows with videos
show_query = (
select(
Show.id,
Show.youtube_link,
Show.date,
Show.slug,
Venue.name.label("venue_name"),
Venue.city.label("venue_city"),
Venue.state.label("venue_state")
)
.join(Venue, Show.venue_id == Venue.id)
.where(Show.youtube_link != None)
.order_by(Show.date.desc())
.limit(limit)
.offset(offset)
)
show_results = session.exec(show_query).all()
shows = [
{
"type": "full_show",
"id": r[0],
"youtube_link": r[1],
"date": r[2].isoformat() if r[2] else None,
"show_slug": r[3],
"venue_name": r[4],
"venue_city": r[5],
"venue_state": r[6]
}
for r in show_results
]
return {
"performances": performances,
"shows": shows,
"total_performances": len(performances),
"total_shows": len(shows)
}
@router.get("/stats")
def get_video_stats(session: Session = Depends(get_session)):
"""Get counts of videos in the database."""
from sqlmodel import func
perf_count = session.exec(
select(func.count(Performance.id)).where(Performance.youtube_link != None)
).one()
show_count = session.exec(
select(func.count(Show.id)).where(Show.youtube_link != None)
).one()
return {
"performance_videos": perf_count,
"full_show_videos": show_count,
"total": perf_count + show_count
}

View file

@ -0,0 +1,211 @@
"use client"
import { useEffect, useState } from "react"
import { getApiUrl } from "@/lib/api-config"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Youtube, Calendar, MapPin, Music, Film } from "lucide-react"
import Link from "next/link"
interface PerformanceVideo {
type: "performance"
id: number
youtube_link: string
show_id: number
song_id: number
song_title: string
song_slug: string
date: string
show_slug: string
venue_name: string
venue_city: string
venue_state: string | null
}
interface ShowVideo {
type: "full_show"
id: number
youtube_link: string
date: string
show_slug: string
venue_name: string
venue_city: string
venue_state: string | null
}
interface VideoStats {
performance_videos: number
full_show_videos: number
total: number
}
export default function VideosPage() {
const [performances, setPerformances] = useState<PerformanceVideo[]>([])
const [shows, setShows] = useState<ShowVideo[]>([])
const [stats, setStats] = useState<VideoStats | null>(null)
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<"all" | "songs" | "shows">("all")
useEffect(() => {
Promise.all([
fetch(`${getApiUrl()}/videos/?limit=500`).then(r => r.json()),
fetch(`${getApiUrl()}/videos/stats`).then(r => r.json())
])
.then(([videoData, statsData]) => {
setPerformances(videoData.performances)
setShows(videoData.shows)
setStats(statsData)
})
.catch(console.error)
.finally(() => setLoading(false))
}, [])
const extractVideoId = (url: string) => {
const match = url.match(/[?&]v=([^&]+)/)
return match ? match[1] : null
}
if (loading) {
return (
<div className="container py-10 space-y-6">
<Skeleton className="h-10 w-48" />
<div className="space-y-2">
{Array.from({ length: 10 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</div>
)
}
const filteredPerformances = activeTab === "shows" ? [] : performances
const filteredShows = activeTab === "songs" ? [] : shows
// Combine and sort by date
const allVideos = [
...filteredPerformances.map(p => ({ ...p, sortDate: p.date })),
...filteredShows.map(s => ({ ...s, sortDate: s.date, song_title: "Full Show" }))
].sort((a, b) => new Date(b.sortDate).getTime() - new Date(a.sortDate).getTime())
return (
<div className="container py-10 space-y-8">
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Youtube className="h-8 w-8 text-red-600" />
<h1 className="text-3xl font-bold tracking-tight">Videos</h1>
</div>
{stats && (
<p className="text-muted-foreground">
{stats.total} videos available {stats.full_show_videos} full shows {stats.performance_videos} song performances
</p>
)}
</div>
{/* Filter Tabs */}
<div className="flex gap-2">
<button
onClick={() => setActiveTab("all")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeTab === "all"
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
}`}
>
All Videos
</button>
<button
onClick={() => setActiveTab("songs")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeTab === "songs"
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
}`}
>
<Music className="h-4 w-4 inline mr-1" />
Songs ({stats?.performance_videos})
</button>
<button
onClick={() => setActiveTab("shows")}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${activeTab === "shows"
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
}`}
>
<Film className="h-4 w-4 inline mr-1" />
Full Shows ({stats?.full_show_videos})
</button>
</div>
{/* Video List */}
<Card>
<CardContent className="p-0">
<div className="divide-y">
{allVideos.map((video, idx) => (
<div
key={`${video.type}-${video.id}-${idx}`}
className="flex items-center gap-4 p-4 hover:bg-muted/50 transition-colors"
>
{/* YouTube Icon/Link */}
<a
href={video.youtube_link}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-red-600 hover:text-red-500 transition-colors"
title="Watch on YouTube"
>
<Youtube className="h-6 w-6" />
</a>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{video.type === "full_show" ? (
<Link
href={`/shows/${video.show_slug || video.id}`}
className="font-medium hover:underline text-primary"
>
Full Show
</Link>
) : (
<Link
href={`/songs/${(video as PerformanceVideo).song_slug || (video as PerformanceVideo).song_id}`}
className="font-medium hover:underline"
>
{(video as PerformanceVideo).song_title}
</Link>
)}
<Badge variant={video.type === "full_show" ? "default" : "secondary"} className="text-xs">
{video.type === "full_show" ? "Full Show" : "Song"}
</Badge>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground mt-1">
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{new Date(video.date).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric"
})}
</span>
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3" />
{video.venue_name}, {video.venue_city}
{video.venue_state && `, ${video.venue_state}`}
</span>
</div>
</div>
{/* Show Link */}
<Link
href={`/shows/${video.show_slug || (video.type === "full_show" ? video.id : (video as PerformanceVideo).show_id)}`}
className="text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
View Show
</Link>
</div>
))}
</div>
</CardContent>
</Card>
</div>
)
}