diff --git a/backend/scripts/import_videos.py b/backend/scripts/import_videos.py new file mode 100644 index 0000000..65a32ab --- /dev/null +++ b/backend/scripts/import_videos.py @@ -0,0 +1,79 @@ +""" +Import YouTube links from elmeg as Video entities +""" +import csv +import sys +sys.path.insert(0, '/Users/ten/DEV/fediversion/backend') + +import psycopg2 +from datetime import datetime + +# Connect to fediversion database via SSH tunnel +# Run: ssh -L 5433:localhost:5432 nexus-vector +conn = psycopg2.connect( + host="localhost", + port=5433, + database="fediversion", + user="fediversion", + password="fediversion_password" +) +conn.autocommit = True +cur = conn.cursor() + +# Get Goose vertical_id +cur.execute("SELECT id FROM vertical WHERE slug = 'goose'") +goose_vertical_id = cur.fetchone()[0] +print(f"Goose vertical_id: {goose_vertical_id}") + +# Read CSV +videos_created = 0 +links_created = 0 +skipped = 0 + +with open('/tmp/perf_youtube_full.csv', 'r') as f: + reader = csv.DictReader(f) + for row in reader: + youtube_url = row['youtube_link'] + show_slug = row['show_slug'] + song_title = row['song_title'] + show_date = row['show_date'] + + # Check if video already exists + cur.execute("SELECT id FROM video WHERE url = %s", (youtube_url,)) + existing = cur.fetchone() + + if existing: + video_id = existing[0] + else: + # Create video + cur.execute(""" + INSERT INTO video (url, title, platform, video_type, vertical_id, created_at) + VALUES (%s, %s, 'youtube', 'single_song', %s, NOW()) + RETURNING id + """, (youtube_url, f"{song_title} - {show_date}", goose_vertical_id)) + video_id = cur.fetchone()[0] + videos_created += 1 + + # Find the show in fediversion + cur.execute("SELECT id FROM show WHERE slug = %s AND vertical_id = %s", (show_slug, goose_vertical_id)) + show_result = cur.fetchone() + + if show_result: + show_id = show_result[0] + # Link video to show + cur.execute(""" + INSERT INTO videoshow (video_id, show_id) + VALUES (%s, %s) + ON CONFLICT DO NOTHING + """, (video_id, show_id)) + links_created += 1 + else: + skipped += 1 + +print(f"\nResults:") +print(f" Videos created: {videos_created}") +print(f" Show links created: {links_created}") +print(f" Shows not found: {skipped}") + +cur.close() +conn.close() diff --git a/frontend/app/[vertical]/page.tsx b/frontend/app/[vertical]/page.tsx index 087fb84..7aa4750 100644 --- a/frontend/app/[vertical]/page.tsx +++ b/frontend/app/[vertical]/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link" import { Calendar, MapPin, Music, Trophy, Video, Ticket, Building, ChevronRight, Play } from "lucide-react" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" +import { VideoGallery } from "@/components/videos/video-gallery" interface Props { params: Promise<{ vertical: string }> @@ -173,6 +174,15 @@ export default async function VerticalPage({ params }: Props) { )} + {/* Videos Section */} +
+ +
+ {/* Navigation Cards */}

Explore

diff --git a/frontend/components/videos/video-gallery.tsx b/frontend/components/videos/video-gallery.tsx new file mode 100644 index 0000000..bd8ee5b --- /dev/null +++ b/frontend/components/videos/video-gallery.tsx @@ -0,0 +1,205 @@ +"use client" + +import { useState, useEffect } from "react" +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Play, ExternalLink, Video, Music } from "lucide-react" +import { getApiUrl } from "@/lib/api-config" + +interface VideoItem { + id: number + url: string + title: string | null + description: string | null + platform: string + video_type: string + duration_seconds: number | null + thumbnail_url: string | null + external_id: string | null +} + +interface VideoGalleryProps { + bandSlug?: string + showId?: number + songId?: number + limit?: number + title?: string +} + +function extractYouTubeId(url: string): string | null { + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/, + ] + for (const pattern of patterns) { + const match = url.match(pattern) + if (match) return match[1] + } + return null +} + +function getThumbnailUrl(video: VideoItem): string { + if (video.thumbnail_url) return video.thumbnail_url + + if (video.platform === "youtube") { + const videoId = extractYouTubeId(video.url) + if (videoId) return `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` + } + + return "/placeholder-video.jpg" +} + +export function VideoGallery({ + bandSlug, + showId, + songId, + limit = 12, + title = "Videos" +}: VideoGalleryProps) { + const [videos, setVideos] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedVideo, setSelectedVideo] = useState(null) + + useEffect(() => { + async function fetchVideos() { + try { + let endpoint = "" + if (bandSlug) { + endpoint = `${getApiUrl()}/videos/by-band/${bandSlug}?limit=${limit}` + } else if (showId) { + endpoint = `${getApiUrl()}/videos/by-show/${showId}` + } else if (songId) { + endpoint = `${getApiUrl()}/videos/by-song/${songId}` + } else { + endpoint = `${getApiUrl()}/videos/?limit=${limit}` + } + + const res = await fetch(endpoint) + if (res.ok) { + const data = await res.json() + setVideos(data) + } + } catch (error) { + console.error("Failed to fetch videos:", error) + } finally { + setLoading(false) + } + } + + fetchVideos() + }, [bandSlug, showId, songId, limit]) + + if (loading) { + return ( +
+

+

+
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+ ) + } + + if (videos.length === 0) { + return null // Don't show empty section + } + + return ( +
+

+

+ + {/* Video modal */} + {selectedVideo && ( +
setSelectedVideo(null)} + > +
e.stopPropagation()}> +
+ {selectedVideo.platform === "youtube" && ( +