import { type Video, type InsertVideo } from "@shared/schema"; import crypto from 'crypto'; interface BunnyVideo { guid: string; title: string; length: number; status: number; dateUploaded: string; views: number; thumbnailFileName?: string; category?: string; } interface BunnyLibraryResponse { items: BunnyVideo[]; currentPage: number; itemsPerPage: number; totalItems: number; } export class BunnyService { private apiKey: string; private libraryId: string; private hostname: string; private securityKey: string; constructor() { this.apiKey = process.env.BUNNY_API_KEY!; this.libraryId = process.env.BUNNY_LIBRARY_ID!; this.hostname = process.env.BUNNY_HOSTNAME!; this.securityKey = process.env.BUNNY_SECURITY_KEY || ''; // CDN security key for signing URLs if (!this.apiKey || !this.libraryId || !this.hostname) { throw new Error("Missing Bunny.net configuration"); } } private async makeRequest(endpoint: string): Promise { 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' } }); if (!response.ok) { throw new Error(`Bunny API error: ${response.status} ${response.statusText}`); } return response.json(); } private generateSignedUrl(path: string, expiryHours: number = 1): string { if (!this.securityKey) { // If no security key, return iframe embed as fallback const videoId = path.split('/')[1]; return `https://iframe.mediadelivery.net/embed/${this.libraryId}/${videoId}?controls=true&autoplay=false`; } const expireTimestamp = Math.floor(Date.now() / 1000) + (expiryHours * 3600); const tokenContent = `${this.securityKey}${path}${expireTimestamp}`; const hash = crypto.createHash('md5').update(tokenContent).digest(); const token = Buffer.from(hash).toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); return `https://${this.hostname}${path}?token=${token}&expires=${expireTimestamp}`; } // Public method for generating signed URLs for sharing generatePublicSignedUrl(path: string, expiryHours: number = 1): string { return this.generateSignedUrl(path, expiryHours); } private bunnyVideoToVideo(bunnyVideo: BunnyVideo): Video { // Generate signed URLs for private video access const videoPath = `/${bunnyVideo.guid}/playlist.m3u8`; const thumbnailPath = `/${bunnyVideo.guid}/${bunnyVideo.thumbnailFileName || 'thumbnail.jpg'}`; const videoUrl = this.generateSignedUrl(videoPath); const thumbnailUrl = this.generateSignedUrl(thumbnailPath); return { id: bunnyVideo.guid, title: bunnyVideo.title || 'Untitled Video', description: null, // Bunny API doesn't return description in list view thumbnailUrl, videoUrl, duration: Math.floor(bunnyVideo.length || 0), views: bunnyVideo.views || 0, category: bunnyVideo.category || null, createdAt: 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