videofolxtv/server/bunny.ts
sebastjanartic 4e4fe369c1 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
2025-08-28 16:47:09 +00:00

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}`;
}
}