320 lines
11 KiB
TypeScript
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}`;
|
|
}
|
|
} |