From 171b8a38cabcbb8f9e22beb6b446ac5860807105 Mon Sep 17 00:00:00 2001 From: fullsizemalt <106900403+fullsizemalt@users.noreply.github.com> Date: Mon, 22 Dec 2025 23:16:04 -0800 Subject: [PATCH] feat: Add /videos page listing all YouTube videos without thumbnails --- backend/main.py | 3 +- backend/routers/videos.py | 124 ++++++++++++++++++++ frontend/app/videos/page.tsx | 211 +++++++++++++++++++++++++++++++++++ 3 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 backend/routers/videos.py create mode 100644 frontend/app/videos/page.tsx diff --git a/backend/main.py b/backend/main.py index 2c9bd49..8f3be18 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,5 +1,5 @@ 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 @@ -37,6 +37,7 @@ app.include_router(stats.router) app.include_router(admin.router) app.include_router(chase.router) app.include_router(gamification.router) +app.include_router(videos.router) @app.get("/") def read_root(): diff --git a/backend/routers/videos.py b/backend/routers/videos.py new file mode 100644 index 0000000..62961d0 --- /dev/null +++ b/backend/routers/videos.py @@ -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 + } diff --git a/frontend/app/videos/page.tsx b/frontend/app/videos/page.tsx new file mode 100644 index 0000000..d776ca7 --- /dev/null +++ b/frontend/app/videos/page.tsx @@ -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([]) + const [shows, setShows] = useState([]) + const [stats, setStats] = useState(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 ( +
+ +
+ {Array.from({ length: 10 }).map((_, i) => ( + + ))} +
+
+ ) + } + + 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 ( +
+
+
+ +

Videos

+
+ {stats && ( +

+ {stats.total} videos available • {stats.full_show_videos} full shows • {stats.performance_videos} song performances +

+ )} +
+ + {/* Filter Tabs */} +
+ + + +
+ + {/* Video List */} + + +
+ {allVideos.map((video, idx) => ( +
+ {/* YouTube Icon/Link */} + + + + + {/* Content */} +
+
+ {video.type === "full_show" ? ( + + Full Show + + ) : ( + + {(video as PerformanceVideo).song_title} + + )} + + {video.type === "full_show" ? "Full Show" : "Song"} + +
+
+ + + {new Date(video.date).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric" + })} + + + + {video.venue_name}, {video.venue_city} + {video.venue_state && `, ${video.venue_state}`} + +
+
+ + {/* Show Link */} + + View Show → + +
+ ))} +
+
+
+
+ ) +}