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;