Integrate videos directly from Bunny.net CDN for faster streaming
Implements Bunny.net CDN integration for video streaming and utilizes its API to fetch and display videos. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 50814a1e-92e4-4968-856f-7bc7eedf5e8f Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/50814a1e-92e4-4968-856f-7bc7eedf5e8f/DXDnT5V
This commit is contained in:
parent
edf1e36795
commit
b77e18915f
@ -109,8 +109,8 @@ export default function VideoModal({ video, isOpen, onClose }: VideoModalProps)
|
|||||||
preload="metadata"
|
preload="metadata"
|
||||||
onPlay={handleVideoPlay}
|
onPlay={handleVideoPlay}
|
||||||
data-testid="video-player"
|
data-testid="video-player"
|
||||||
|
src={video.videoUrl}
|
||||||
>
|
>
|
||||||
<source src={video.videoUrl} type="video/mp4" />
|
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
|
|||||||
122
server/bunny.ts
Normal file
122
server/bunny.ts
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { type Video, type InsertVideo } from "@shared/schema";
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Bunny API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
private bunnyVideoToVideo(bunnyVideo: BunnyVideo): Video {
|
||||||
|
// Generate thumbnail URL from Bunny CDN
|
||||||
|
const thumbnailUrl = bunnyVideo.thumbnailFileName
|
||||||
|
? `https://${this.hostname}/${bunnyVideo.guid}/${bunnyVideo.thumbnailFileName}`
|
||||||
|
: `https://${this.hostname}/${bunnyVideo.guid}/thumbnail.jpg`;
|
||||||
|
|
||||||
|
// Generate video URL for streaming
|
||||||
|
const videoUrl = `https://${this.hostname}/${bunnyVideo.guid}/playlist.m3u8`;
|
||||||
|
|
||||||
|
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<Video | null> {
|
||||||
|
try {
|
||||||
|
const bunnyVideo: BunnyVideo = await this.makeRequest(`videos/${guid}`);
|
||||||
|
return this.bunnyVideoToVideo(bunnyVideo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching video ${guid} from Bunny:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -55,6 +55,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
const categories = Array.from(new Set(videos.map(v => v.category).filter(Boolean)));
|
const categories = Array.from(new Set(videos.map(v => v.category).filter(Boolean)));
|
||||||
res.json(categories);
|
res.json(categories);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Error fetching categories:", error);
|
||||||
res.status(500).json({ message: "Failed to fetch categories" });
|
res.status(500).json({ message: "Failed to fetch categories" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { type Video, type InsertVideo } from "@shared/schema";
|
import { type Video, type InsertVideo } from "@shared/schema";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
import { BunnyService } from "./bunny";
|
||||||
|
|
||||||
export interface IStorage {
|
export interface IStorage {
|
||||||
getVideos(limit?: number, offset?: number, search?: string, category?: string): Promise<Video[]>;
|
getVideos(limit?: number, offset?: number, search?: string, category?: string): Promise<Video[]>;
|
||||||
@ -146,4 +147,99 @@ export class MemStorage implements IStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storage = new MemStorage();
|
// Use Bunny.net storage if API keys are available, otherwise fallback to memory storage
|
||||||
|
class BunnyStorage implements IStorage {
|
||||||
|
private bunnyService: BunnyService;
|
||||||
|
private viewsCache: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.bunnyService = new BunnyService();
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVideos(limit = 20, offset = 0, search?: string, category?: string): Promise<Video[]> {
|
||||||
|
try {
|
||||||
|
const page = Math.floor(offset / limit) + 1;
|
||||||
|
const { videos } = await this.bunnyService.getVideos(page, limit, search);
|
||||||
|
|
||||||
|
// Filter by category if specified
|
||||||
|
let filteredVideos = videos;
|
||||||
|
if (category && category !== "All Categories") {
|
||||||
|
filteredVideos = videos.filter(video => video.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply cached view counts
|
||||||
|
filteredVideos.forEach(video => {
|
||||||
|
if (this.viewsCache.has(video.id)) {
|
||||||
|
video.views += this.viewsCache.get(video.id)!;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return filteredVideos;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching videos from Bunny:', error);
|
||||||
|
// Fallback to empty array on error
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVideo(id: string): Promise<Video | undefined> {
|
||||||
|
try {
|
||||||
|
const video = await this.bunnyService.getVideo(id);
|
||||||
|
if (!video) return undefined;
|
||||||
|
|
||||||
|
// Apply cached view counts
|
||||||
|
if (this.viewsCache.has(video.id)) {
|
||||||
|
video.views += this.viewsCache.get(video.id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
return video;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error fetching video ${id} from Bunny:`, error);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createVideo(video: InsertVideo): Promise<Video> {
|
||||||
|
// Note: Creating videos would require uploading to Bunny.net
|
||||||
|
// For now, we'll throw an error as this operation is not supported
|
||||||
|
throw new Error("Creating videos is not supported with Bunny.net integration");
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateVideoViews(id: string): Promise<void> {
|
||||||
|
// Since we can't update views in Bunny.net directly, we'll cache them locally
|
||||||
|
const currentViews = this.viewsCache.get(id) || 0;
|
||||||
|
this.viewsCache.set(id, currentViews + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVideoCount(search?: string, category?: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const { total } = await this.bunnyService.getVideos(1, 1, search);
|
||||||
|
return total;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting video count from Bunny:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to use Bunny.net storage, fallback to memory storage if not configured
|
||||||
|
let storage: IStorage;
|
||||||
|
|
||||||
|
// Check if Bunny.net environment variables are available
|
||||||
|
const hasBunnyConfig = process.env.BUNNY_API_KEY && process.env.BUNNY_LIBRARY_ID && process.env.BUNNY_HOSTNAME;
|
||||||
|
|
||||||
|
if (hasBunnyConfig) {
|
||||||
|
try {
|
||||||
|
storage = new BunnyStorage();
|
||||||
|
console.log('✅ Using Bunny.net storage with library ID:', process.env.BUNNY_LIBRARY_ID);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to initialize Bunny.net storage:', error);
|
||||||
|
console.log('📁 Falling back to memory storage');
|
||||||
|
storage = new MemStorage();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('📁 Bunny.net environment variables not found, using memory storage');
|
||||||
|
storage = new MemStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { storage };
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user