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:
parent
ba6cf17bfc
commit
4e4fe369c1
123
client/src/components/video-ads.tsx
Normal file
123
client/src/components/video-ads.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user