diff --git a/client/src/components/video-ads.tsx b/client/src/components/video-ads.tsx new file mode 100644 index 0000000..3538fcb --- /dev/null +++ b/client/src/components/video-ads.tsx @@ -0,0 +1,123 @@ +import { useState, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Card } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { PlayCircle, Clock, Tag, Network } from "lucide-react"; + +interface VideoAd { + adType: string; + adUrl?: string; + adTitle?: string; + adDuration?: number; + position?: number; + vastTag?: string; + adNetwork?: string; + priority: number; +} + +interface VideoAdsProps { + videoId: string; +} + +export default function VideoAds({ videoId }: VideoAdsProps) { + const { data, isLoading, error } = useQuery({ + queryKey: [`/api/videos/${videoId}/ads`], + }); + + if (isLoading) { + return ( + +
+
+
+
+
+ ); + } + + if (error) { + return ( + +

Failed to load ad metadata

+
+ ); + } + + const { ads = [], totalAds = 0 } = data || {}; + + if (totalAds === 0) { + return ( + +

+ + No ad spots configured for this video +

+
+ ); + } + + return ( +
+
+ +

+ Video Ads ({totalAds} spots) +

+
+ + {ads.map((ad: VideoAd, index: number) => ( + +
+
+
+ + {ad.adType.toUpperCase()} + + + {ad.adTitle || `${ad.adType} Advertisement`} + +
+ +
+ {ad.adDuration && ( +
+ + {ad.adDuration}s duration +
+ )} + + {ad.position !== undefined && ad.adType === 'midroll' && ( +
+ + At {Math.floor(ad.position / 60)}:{(ad.position % 60).toString().padStart(2, '0')} +
+ )} + + {ad.adNetwork && ( +
+ + {ad.adNetwork} +
+ )} + +
+ + Priority {ad.priority} +
+
+ + {ad.vastTag && ( +
+ VAST: {ad.vastTag.substring(0, 80)}... +
+ )} +
+
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/client/src/pages/VideoPage.tsx b/client/src/pages/VideoPage.tsx index 24a4f34..df5c06a 100644 --- a/client/src/pages/VideoPage.tsx +++ b/client/src/pages/VideoPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { useRoute } from "wouter"; import { type Video } from "@shared/schema"; +import VideoAds from "@/components/video-ads"; import go4LogoPath from "@assets/go4_1756394900352.png"; // Helper functions const formatViews = (views: number): string => { @@ -319,6 +320,11 @@ export default function VideoPage() {

{currentVideo.description}

)} + + {/* Video ads metadata section */} +
+ +
diff --git a/server/bunny.ts b/server/bunny.ts index 3e39005..34e0d3a 100644 --- a/server/bunny.ts +++ b/server/bunny.ts @@ -9,6 +9,23 @@ interface BunnyVideo { views: number; thumbnailFileName?: string; category?: string; + metaTags?: Array<{ + property: string; + value: string; + }>; + moments?: Array<{ + type: string; // "preroll", "midroll", "postroll" + position?: number; // seconds for midroll + vastTag?: string; + duration?: number; + network?: string; + title?: string; + }>; +} + +interface BunnyVideoDetails extends BunnyVideo { + description?: string; + customMetadata?: Record; } interface BunnyLibraryResponse { @@ -124,6 +141,52 @@ export class BunnyService { } } + async getVideoDetails(videoId: string): Promise { + try { + console.log(`Fetching detailed video data for: ${videoId}`); + const videoDetails: BunnyVideoDetails = await this.makeRequest(`videos/${videoId}`); + console.log(`Retrieved video details with ${videoDetails.moments?.length || 0} ad spots`); + return videoDetails; + } catch (error) { + console.error(`Error fetching video details for ${videoId}:`, error); + return null; + } + } + + async getVideoAds(videoId: string): Promise> { + try { + const videoDetails = await this.getVideoDetails(videoId); + if (!videoDetails || !videoDetails.moments) { + console.log(`No ad moments found for video ${videoId}`); + return []; + } + + console.log(`Found ${videoDetails.moments.length} ad moments for video ${videoId}`); + return videoDetails.moments.map((moment, index) => ({ + adType: moment.type, + adUrl: moment.vastTag || '', + adTitle: moment.title || `${moment.type} Ad`, + adDuration: moment.duration || 30, + position: moment.position || 0, + vastTag: moment.vastTag, + adNetwork: moment.network || 'Unknown', + priority: index + 1, + })); + } catch (error) { + console.error(`Error fetching video ads for ${videoId}:`, error); + return []; + } + } + async getCategories(): Promise { try { // Get all videos and extract unique categories diff --git a/server/routes.ts b/server/routes.ts index 17c280d..7548e83 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -233,6 +233,41 @@ export async function registerRoutes(app: Express): Promise { } }); + // Get video ads/spots metadata from Bunny.net + app.get("/api/videos/:id/ads", async (req, res) => { + try { + const { id } = req.params; + + // Check if video exists first + const video = await storage.getVideo(id); + if (!video) { + return res.status(404).json({ message: "Video not found" }); + } + + // Get ads from Bunny.net API + let ads = []; + try { + const { BunnyService } = await import("./bunny"); + const bunnyService = new BunnyService(); + ads = await bunnyService.getVideoAds(id); + console.log(`Retrieved ${ads.length} ad spots for video ${id}`); + } catch (error) { + console.error(`Failed to get ads from Bunny.net for video ${id}:`, error); + // Return empty array if Bunny service fails + ads = []; + } + + res.json({ + videoId: id, + ads: ads, + totalAds: ads.length + }); + } catch (error) { + console.error(`Error fetching ads for video ${id}:`, error); + res.status(500).json({ message: "Failed to fetch video ads" }); + } + }); + // Update video views app.post("/api/videos/:id/view", async (req, res) => { try { diff --git a/shared/schema.ts b/shared/schema.ts index d5995f0..d4ae45e 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -81,6 +81,24 @@ export const videoTags = pgTable("video_tags", { tagId: varchar("tag_id").notNull().references(() => tags.id, { onDelete: "cascade" }), }); +// Video ads/spots table for storing advertisement metadata +export const videoAds = pgTable("video_ads", { + id: varchar("id").primaryKey().default(sql`gen_random_uuid()`), + videoId: varchar("video_id").notNull().references(() => videos.id, { onDelete: "cascade" }), + adType: text("ad_type").notNull(), // preroll, midroll, postroll + adUrl: text("ad_url").notNull(), // VAST URL or direct video URL + adTitle: text("ad_title"), + adDuration: integer("ad_duration"), // in seconds + position: integer("position").default(0), // for midroll ads - position in video (seconds) + skipAfter: integer("skip_after"), // seconds after which ad can be skipped + vastTag: text("vast_tag"), // VAST XML tag URL + adNetwork: text("ad_network"), // network name (e.g., Google AdX, Publift) + isActive: boolean("is_active").default(true).notNull(), + priority: integer("priority").default(1), // ad priority (1 = highest) + createdAt: timestamp("created_at").notNull().default(sql`CURRENT_TIMESTAMP`), + updatedAt: timestamp("updated_at").notNull().default(sql`CURRENT_TIMESTAMP`), +}); + // Schemas for form validation and API export const insertVideoSchema = createInsertSchema(videos).omit({ id: true, @@ -116,11 +134,20 @@ export const insertTagSchema = createInsertSchema(tags).omit({ createdAt: true, }); +export const insertVideoAdSchema = createInsertSchema(videoAds).omit({ + id: true, + createdAt: true, + updatedAt: true, +}); + // Type exports export type Video = typeof videos.$inferSelect; export type InsertVideo = z.infer; export type UpdateVideo = z.infer; +export type VideoAd = typeof videoAds.$inferSelect; +export type InsertVideoAd = z.infer; + export type User = typeof users.$inferSelect; export type InsertUser = z.infer;