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
212 lines
6.8 KiB
TypeScript
212 lines
6.8 KiB
TypeScript
import { type Video, type InsertVideo } from "@shared/schema";
|
|
|
|
interface BunnyVideo {
|
|
guid: string;
|
|
title: string;
|
|
length: number;
|
|
status: number;
|
|
dateUploaded: string;
|
|
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 {
|
|
items: BunnyVideo[];
|
|
currentPage: number;
|
|
itemsPerPage: number;
|
|
totalItems: number;
|
|
}
|
|
|
|
export class BunnyService {
|
|
private apiKey: string;
|
|
private libraryId: string;
|
|
private hostname: string;
|
|
|
|
constructor() {
|
|
this.apiKey = process.env.BUNNY_API_KEY!;
|
|
this.libraryId = process.env.BUNNY_LIBRARY_ID!;
|
|
this.hostname = process.env.BUNNY_HOSTNAME!;
|
|
|
|
if (!this.apiKey || !this.libraryId || !this.hostname) {
|
|
throw new Error("Missing Bunny.net configuration");
|
|
}
|
|
}
|
|
|
|
private async makeRequest(endpoint: string): Promise<any> {
|
|
const url = `https://video.bunnycdn.com/library/${this.libraryId}/${endpoint}`;
|
|
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
'AccessKey': this.apiKey,
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json'
|
|
},
|
|
// Add timeout and connection optimizations
|
|
signal: AbortSignal.timeout(10000), // 10 second timeout
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Bunny API error: ${response.status} ${response.statusText}`);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
private bunnyVideoToVideo(bunnyVideo: BunnyVideo): Video {
|
|
// Generate optimized thumbnail URL from Bunny CDN with WebP format for better performance
|
|
const thumbnailUrl = bunnyVideo.thumbnailFileName
|
|
? `https://${this.hostname}/${bunnyVideo.guid}/${bunnyVideo.thumbnailFileName}?width=400&height=225&format=webp`
|
|
: `https://${this.hostname}/${bunnyVideo.guid}/thumbnail.jpg?width=400&height=225&format=webp`;
|
|
|
|
// Generate signed HLS URL for private video access
|
|
const hlsUrl = this.generateSignedUrl(bunnyVideo.guid);
|
|
const iframeUrl = `https://iframe.mediadelivery.net/embed/${this.libraryId}/${bunnyVideo.guid}?preroll=false&postroll=false&ads=false`;
|
|
|
|
return {
|
|
id: bunnyVideo.guid,
|
|
title: bunnyVideo.title || 'Untitled Video',
|
|
description: "", // Bunny API doesn't return description in list view
|
|
thumbnailUrl,
|
|
customThumbnailUrl: null,
|
|
videoUrl: hlsUrl, // Signed HLS URL
|
|
videoUrlMp4: null, // Remove MP4 since it likely won't work for private videos
|
|
videoUrlIframe: iframeUrl, // iframe fallback
|
|
duration: Math.floor(bunnyVideo.length || 0),
|
|
views: bunnyVideo.views || 0,
|
|
category: "",
|
|
tags: [],
|
|
isPublic: true,
|
|
uploadStatus: "completed",
|
|
originalFileName: null,
|
|
fileSize: null,
|
|
bitrate: null,
|
|
resolution: null,
|
|
format: null,
|
|
encoding: null,
|
|
createdAt: new Date(bunnyVideo.dateUploaded),
|
|
updatedAt: new Date(bunnyVideo.dateUploaded)
|
|
};
|
|
}
|
|
|
|
async getVideos(page: number = 1, itemsPerPage: number = 20, search?: string): Promise<{ videos: Video[], total: number }> {
|
|
try {
|
|
let endpoint = `videos?page=${page}&itemsPerPage=${itemsPerPage}&orderBy=date`;
|
|
|
|
if (search) {
|
|
endpoint += `&search=${encodeURIComponent(search)}`;
|
|
}
|
|
|
|
const response: BunnyLibraryResponse = await this.makeRequest(endpoint);
|
|
|
|
// Filter only successfully processed videos (status 4 = finished)
|
|
const processedVideos = response.items.filter(video => video.status === 4);
|
|
|
|
const videos = processedVideos.map(video => this.bunnyVideoToVideo(video));
|
|
|
|
return {
|
|
videos,
|
|
total: response.totalItems
|
|
};
|
|
} catch (error) {
|
|
console.error('Error fetching videos from Bunny:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async getVideo(guid: string): Promise<Video | null> {
|
|
try {
|
|
const bunnyVideo: BunnyVideo = await this.makeRequest(`videos/${guid}`);
|
|
return this.bunnyVideoToVideo(bunnyVideo);
|
|
} catch (error) {
|
|
console.error(`Error fetching video ${guid} from Bunny:`, error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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
|
|
const { videos } = await this.getVideos(1, 1000);
|
|
const categories = Array.from(new Set(videos.map(v => v.category).filter(Boolean) as string[]));
|
|
return categories;
|
|
} catch (error) {
|
|
console.error('Error fetching categories from Bunny:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// Generate signed URL for private video access
|
|
generateSignedUrl(videoId: string, expirationTime: number = 3600): string {
|
|
const baseUrl = `https://${this.hostname}/${videoId}/playlist.m3u8`;
|
|
const expires = Math.floor(Date.now() / 1000) + expirationTime;
|
|
|
|
// Simple token generation (in production, use proper HMAC signing)
|
|
const token = Buffer.from(`${videoId}:${expires}:${this.apiKey.substring(0, 8)}`).toString('base64');
|
|
|
|
return `${baseUrl}?token=${token}&expires=${expires}`;
|
|
}
|
|
} |