feat: Add /videos page listing all YouTube videos without thumbnails
This commit is contained in:
parent
0ad89105b3
commit
171b8a38ca
3 changed files with 337 additions and 1 deletions
|
|
@ -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
124
backend/routers/videos.py
Normal 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
|
||||||
|
}
|
||||||
211
frontend/app/videos/page.tsx
Normal file
211
frontend/app/videos/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue