videofolxtv/server/bunny.ts
sebastjanartic 2a67922121 Update video data structure to align with new metadata fields
Modify the BunnyService to include new metadata fields like artist, filename, episode details, and content type, and remove unused face-related fields.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: c5d5630c-85af-4c2b-bf4e-7e8006d34256
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/60d372ff-2c10-46c7-b01b-10c3435136b0/c5d5630c-85af-4c2b-bf4e-7e8006d34256/2sCJqjx
2025-09-06 20:14:35 +00:00

245 lines
8.1 KiB
TypeScript

import { type Video, type InsertVideo } from "@shared/schema";
interface BunnyVideo {
guid: string;
title: string;
description?: 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 | BunnyVideoDetails): 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&controls=false`;
// Extract description from BunnyVideoDetails if available
let description = 'description' in bunnyVideo ? bunnyVideo.description || "" : "";
// Always check metaTags for description since Bunny.net stores it there
if (bunnyVideo.metaTags && bunnyVideo.metaTags.length > 0) {
const descriptionTag = bunnyVideo.metaTags.find((tag: any) =>
tag.property?.toLowerCase() === 'description'
);
if (descriptionTag && descriptionTag.value) {
description = descriptionTag.value;
}
}
// No category from Bunny.net - keeping category empty
const category = "";
// No tags from Bunny.net - keeping tags empty
const tags: string[] = [];
return {
id: bunnyVideo.guid,
title: bunnyVideo.title || 'Untitled Video',
artist: null, // No artist data from Bunny.net
description: description,
filename: null, // No filename data from Bunny.net
episodeNumber: null, // No episode number from Bunny.net
episodeTitle: null, // No episode title from Bunny.net
thumbnailUrl,
customThumbnailUrl: null,
faceCenterPosition: null,
facesDetected: null,
faceConfidence: null,
videoUrl: hlsUrl, // Signed HLS URL
videoUrlMp4: hlsUrl, // Use signed HLS URL for preview as well
videoUrlIframe: iframeUrl, // iframe fallback
duration: Math.floor(bunnyVideo.length || 0),
views: bunnyVideo.views || 0,
category: category,
contentType: "video" as const, // Default content type
genre: "other" as const, // Default genre
tags: 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 {
console.log(`Fetching video with description from Bunny: ${guid}`);
const bunnyVideo: BunnyVideoDetails = await this.makeRequest(`videos/${guid}`);
console.log(`Fetching video: ${bunnyVideo.title} - Description available: ${!!bunnyVideo.description}`);
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}`;
}
}