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
This commit is contained in:
sebastjanartic 2025-08-28 16:47:09 +00:00
parent ba6cf17bfc
commit 4e4fe369c1
5 changed files with 254 additions and 0 deletions

View File

@ -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 (
<Card className="p-4">
<div className="animate-pulse">
<div className="h-4 bg-gray-300 rounded w-3/4 mb-2"></div>
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
</Card>
);
}
if (error) {
return (
<Card className="p-4 border-red-200 bg-red-50">
<p className="text-red-600 text-sm">Failed to load ad metadata</p>
</Card>
);
}
const { ads = [], totalAds = 0 } = data || {};
if (totalAds === 0) {
return (
<Card className="p-4 border-gray-200 bg-gray-50">
<p className="text-gray-600 text-sm flex items-center gap-2">
<PlayCircle className="w-4 h-4" />
No ad spots configured for this video
</p>
</Card>
);
}
return (
<div className="space-y-3">
<div className="flex items-center gap-2 mb-3">
<PlayCircle className="w-5 h-5 text-blue-600" />
<h3 className="font-semibold text-gray-900">
Video Ads ({totalAds} spots)
</h3>
</div>
{ads.map((ad: VideoAd, index: number) => (
<Card key={index} className="p-4 border border-gray-200 hover:border-blue-300 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<Badge
variant={ad.adType === 'preroll' ? 'default' : ad.adType === 'postroll' ? 'secondary' : 'outline'}
className="text-xs"
>
{ad.adType.toUpperCase()}
</Badge>
<span className="text-sm font-medium text-gray-900">
{ad.adTitle || `${ad.adType} Advertisement`}
</span>
</div>
<div className="grid grid-cols-2 gap-3 text-sm text-gray-600">
{ad.adDuration && (
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
<span>{ad.adDuration}s duration</span>
</div>
)}
{ad.position !== undefined && ad.adType === 'midroll' && (
<div className="flex items-center gap-1">
<PlayCircle className="w-3 h-3" />
<span>At {Math.floor(ad.position / 60)}:{(ad.position % 60).toString().padStart(2, '0')}</span>
</div>
)}
{ad.adNetwork && (
<div className="flex items-center gap-1">
<Network className="w-3 h-3" />
<span>{ad.adNetwork}</span>
</div>
)}
<div className="flex items-center gap-1">
<Tag className="w-3 h-3" />
<span>Priority {ad.priority}</span>
</div>
</div>
{ad.vastTag && (
<div className="mt-2 p-2 bg-gray-50 rounded text-xs font-mono text-gray-700 break-all">
VAST: {ad.vastTag.substring(0, 80)}...
</div>
)}
</div>
</div>
</Card>
))}
</div>
);
}

View File

@ -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() {
<p className="text-sm leading-relaxed">{currentVideo.description}</p>
</div>
)}
{/* Video ads metadata section */}
<div className="mt-6 pt-4 border-t border-gray-600">
<VideoAds videoId={currentVideo.id} />
</div>
</div>
</div>

View File

@ -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<string, any>;
}
interface BunnyLibraryResponse {
@ -124,6 +141,52 @@ export class BunnyService {
}
}
async getVideoDetails(videoId: string): Promise<BunnyVideoDetails | null> {
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<Array<{
adType: string;
adUrl?: string;
adTitle?: string;
adDuration?: number;
position?: number;
vastTag?: string;
adNetwork?: string;
priority: number;
}>> {
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<string[]> {
try {
// Get all videos and extract unique categories

View File

@ -233,6 +233,41 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// 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 {

View File

@ -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<typeof insertVideoSchema>;
export type UpdateVideo = z.infer<typeof updateVideoSchema>;
export type VideoAd = typeof videoAds.$inferSelect;
export type InsertVideoAd = z.infer<typeof insertVideoAdSchema>;
export type User = typeof users.$inferSelect;
export type InsertUser = z.infer<typeof insertUserSchema>;