import { type Video, type InsertVideo } from "@shared/schema"; import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3"; const s3 = new S3Client({ region: process.env.S3_REGION || "fsn1", endpoint: process.env.S3_ENDPOINT || "https://fsn1.your-objectstorage.com", forcePathStyle: true, credentials: { accessKeyId: process.env.S3_ACCESS_KEY_ID || "", secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "", }, }); const S3_BUCKET = process.env.S3_BUCKET || "folxspeed"; const S3_FOLX_PREFIX = "folx-tv/"; 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; } interface BunnyLibraryResponse { items: BunnyVideo[]; currentPage: number; itemsPerPage: number; totalItems: number; } export class BunnyService { disabled: boolean = false; private migratedGuids: Set = new Set(); private migratedGuidsRefreshedAt: number = 0; // Refresh list of GUIDs that exist in S3 (folx-tv/*/master.m3u8). Cached for 60s. async refreshMigratedGuids(force: boolean = false): Promise { const ageMs = Date.now() - this.migratedGuidsRefreshedAt; if (!force && ageMs < 60_000 && this.migratedGuids.size > 0) return; const newSet = new Set(); let continuationToken: string | undefined = undefined; try { do { const cmd: any = new ListObjectsV2Command({ Bucket: S3_BUCKET, Prefix: S3_FOLX_PREFIX, MaxKeys: 1000, ContinuationToken: continuationToken, }); const res: any = await s3.send(cmd); for (const obj of res.Contents || []) { const k = obj.Key || ""; if (k.endsWith("/master.m3u8")) { const guid = k.slice(S3_FOLX_PREFIX.length, -"/master.m3u8".length); if (guid) newSet.add(guid); } } continuationToken = res.IsTruncated ? res.NextContinuationToken : undefined; } while (continuationToken); this.migratedGuids = newSet; this.migratedGuidsRefreshedAt = Date.now(); console.log(`📦 Refreshed migrated GUIDs: ${newSet.size} videos available in S3`); } catch (e) { console.error("Failed to refresh migrated GUIDs:", e); } } isMigrated(videoId: string): boolean { return this.migratedGuids.has(videoId); } 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) { console.warn("⚠️ Missing Bunny.net configuration — BunnyService running in disabled mode (uses DB-only)"); this.disabled = true; this.apiKey = ""; this.libraryId = ""; this.cdnHostname = ""; return; } } 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' }, // 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 || "" : ""; // Extract artist from metaTags if available let artist = null; // Always check metaTags for description and artist 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; } // Look for artist in metaTags const artistTag = bunnyVideo.metaTags.find((tag: any) => tag.property?.toLowerCase() === 'artist' || tag.property?.toLowerCase() === 'performer' ); if (artistTag && artistTag.value) { artist = artistTag.value; } } // Clean title - remove .mpg4, .mp4, .MPG4, .MP4 extensions let cleanTitle = bunnyVideo.title || 'Untitled Video'; cleanTitle = cleanTitle.replace(/\.(mpg4|mp4|MPG4|MP4)$/i, ''); // Clean artist - remove .mpg4, .mp4, .MPG4, .MP4 extensions if artist exists if (artist) { artist = artist.replace(/\.(mpg4|mp4|MPG4|MP4)$/i, ''); } // 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: cleanTitle, artist: artist, description: description, filename: null, episodeNumber: null, episodeTitle: null, 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, genre: 'other' as const, 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