From 4e4fe369c1259044b42ac72869a691be1f4c52c1 Mon Sep 17 00:00:00 2001
From: sebastjanartic <45803536-sebastjanartic@users.noreply.replit.com>
Date: Thu, 28 Aug 2025 16:47:09 +0000
Subject: [PATCH] Add support for displaying video advertisement metadata
Integrate Bunny.net API to fetch and display video ad spots, including type, duration, and priority, on the video page. Define new database schema for video ads and create API endpoints for retrieving ad information.
Replit-Commit-Author: Agent
Replit-Commit-Session-Id: d7424866-83d1-4486-a212-ac12b4c7becf
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/d7424866-83d1-4486-a212-ac12b4c7becf/jsdCVZt
---
client/src/components/video-ads.tsx | 123 ++++++++++++++++++++++++++++
client/src/pages/VideoPage.tsx | 6 ++
server/bunny.ts | 63 ++++++++++++++
server/routes.ts | 35 ++++++++
shared/schema.ts | 27 ++++++
5 files changed, 254 insertions(+)
create mode 100644 client/src/components/video-ads.tsx
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;