videofolxtv/server/bunny.ts

320 lines
11 KiB
TypeScript

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<string, any>;
}
interface BunnyLibraryResponse {
items: BunnyVideo[];
currentPage: number;
itemsPerPage: number;
totalItems: number;
}
export class BunnyService {
private migratedGuids: Set<string> = 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<void> {
const ageMs = Date.now() - this.migratedGuidsRefreshedAt;
if (!force && ageMs < 60_000 && this.migratedGuids.size > 0) return;
const newSet = new Set<string>();
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) {
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 || "" : "";
// 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<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 {
// If video is migrated to S3, use folxvideos.b-cdn.net (no token).
// Otherwise fall back to legacy Bunny Stream URL with token.
if (this.migratedGuids.has(videoId)) {
return `https://folxvideos.b-cdn.net/folx-tv/${videoId}/master.m3u8`;
}
const baseUrl = `https://${this.hostname}/${videoId}/playlist.m3u8`;
const expires = Math.floor(Date.now() / 1000) + expirationTime;
const token = Buffer.from(`${videoId}:${expires}:${this.apiKey.substring(0, 8)}`).toString("base64");
return `${baseUrl}?token=${token}&expires=${expires}`;
}
}