Integrate Bunny.net API to fetch and display video ad spots, including type, duration, and priority, on the video page. Define new database schema for video ads and create API endpoints for retrieving ad information. Replit-Commit-Author: Agent Replit-Commit-Session-Id: d7424866-83d1-4486-a212-ac12b4c7becf Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/d7424866-83d1-4486-a212-ac12b4c7becf/jsdCVZt
529 lines
16 KiB
TypeScript
529 lines
16 KiB
TypeScript
import type { Express, Request, Response } from "express";
|
|
import { createServer, type Server } from "http";
|
|
import express from "express";
|
|
import compression from "compression";
|
|
import { storage } from "./storage";
|
|
import { z } from "zod";
|
|
import {
|
|
updateVideoSchema, insertVideoSchema, insertUserSchema,
|
|
insertVideoUploadSchema, insertCategorySchema, insertTagSchema,
|
|
type User, type VideoUpload
|
|
} from "@shared/schema";
|
|
import multer from "multer";
|
|
import { randomUUID } from "crypto";
|
|
import path from "path";
|
|
import session from "express-session";
|
|
|
|
// Extend express session
|
|
declare module "express-session" {
|
|
interface SessionData {
|
|
userId: string;
|
|
}
|
|
}
|
|
|
|
// Configure multer for video uploads
|
|
const upload = multer({
|
|
storage: multer.diskStorage({
|
|
destination: './uploads/videos',
|
|
filename: (req, file, cb) => {
|
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
|
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
|
|
}
|
|
}),
|
|
limits: {
|
|
fileSize: 500 * 1024 * 1024, // 500MB max file size
|
|
},
|
|
fileFilter: (req, file, cb) => {
|
|
// Allow video files only
|
|
if (file.mimetype.startsWith('video/')) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Only video files are allowed'));
|
|
}
|
|
}
|
|
});
|
|
|
|
// Simple session-based authentication middleware
|
|
const authenticate = (req: Request, res: Response, next: any) => {
|
|
if (req.session?.userId) {
|
|
next();
|
|
} else {
|
|
res.status(401).json({ message: "Authentication required" });
|
|
}
|
|
};
|
|
|
|
export async function registerRoutes(app: Express): Promise<Server> {
|
|
// Add compression middleware for better performance
|
|
app.use(compression({
|
|
level: 6,
|
|
threshold: 1024, // Only compress responses larger than 1KB
|
|
filter: (req, res) => {
|
|
// Don't compress video files
|
|
if (req.headers['accept']?.includes('video/')) {
|
|
return false;
|
|
}
|
|
return compression.filter(req, res);
|
|
}
|
|
}));
|
|
|
|
// Configure session middleware
|
|
app.use(session({
|
|
secret: process.env.SESSION_SECRET || 'dev-secret-key',
|
|
resave: false,
|
|
saveUninitialized: false,
|
|
cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 } // 24 hours
|
|
}));
|
|
|
|
// Authentication Routes
|
|
app.post("/api/auth/register", async (req, res) => {
|
|
try {
|
|
const userData = insertUserSchema.parse(req.body);
|
|
|
|
// Check if user already exists
|
|
const existingUser = await storage.getUserByEmail(userData.email);
|
|
if (existingUser) {
|
|
return res.status(400).json({ message: "User already exists with this email" });
|
|
}
|
|
|
|
const user = await storage.createUser(userData);
|
|
|
|
// Remove password from response
|
|
const { password, ...userResponse } = user;
|
|
res.status(201).json(userResponse);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ message: "Invalid user data", errors: error.errors });
|
|
}
|
|
res.status(500).json({ message: "Failed to create user" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/auth/login", async (req, res) => {
|
|
try {
|
|
const { email, password } = req.body;
|
|
|
|
if (!email || !password) {
|
|
return res.status(400).json({ message: "Email and password are required" });
|
|
}
|
|
|
|
const user = await storage.validateUserPassword(email, password);
|
|
if (!user) {
|
|
return res.status(401).json({ message: "Invalid credentials" });
|
|
}
|
|
|
|
// Set session
|
|
req.session.userId = user.id;
|
|
|
|
// Remove password from response
|
|
const { password: _, ...userResponse } = user;
|
|
res.json(userResponse);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to login" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/auth/logout", (req, res) => {
|
|
req.session?.destroy(() => {
|
|
res.json({ message: "Logged out successfully" });
|
|
});
|
|
});
|
|
|
|
app.get("/api/auth/me", authenticate, async (req, res) => {
|
|
try {
|
|
const user = await storage.getUser(req.session.userId!);
|
|
if (!user) {
|
|
return res.status(404).json({ message: "User not found" });
|
|
}
|
|
|
|
const { password, ...userResponse } = user;
|
|
res.json(userResponse);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to fetch user" });
|
|
}
|
|
});
|
|
|
|
// Video Routes
|
|
// Bunny.net administration routes
|
|
app.get("/api/bunny/stats", async (req, res) => {
|
|
try {
|
|
const videos = await storage.getVideos(1, 1000);
|
|
const totalViews = videos.length > 0 ? videos.reduce((sum: number, video: any) => sum + video.views, 0) : 0;
|
|
|
|
const stats = {
|
|
totalVideos: videos.length,
|
|
totalViews,
|
|
totalStorage: 0, // Would need separate Bunny API call
|
|
bandwidth: 0 // Would need separate Bunny API call
|
|
};
|
|
|
|
res.json(stats);
|
|
} catch (error) {
|
|
console.error("Error fetching Bunny stats:", error);
|
|
res.status(500).json({ message: "Failed to fetch statistics" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/bunny/videos/:id", async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Note: This would need implementation in BunnyService
|
|
// For now, return success (deletion would happen in Bunny dashboard)
|
|
res.json({ message: "Video deletion initiated" });
|
|
} catch (error) {
|
|
console.error("Error deleting video:", error);
|
|
res.status(500).json({ message: "Failed to delete video" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/videos", async (req, res) => {
|
|
try {
|
|
const limit = parseInt(req.query.limit as string) || 20;
|
|
const offset = parseInt(req.query.offset as string) || 0;
|
|
const search = req.query.search as string;
|
|
|
|
// Skip search for queries shorter than 2 characters for performance
|
|
const searchQuery = search && search.length >= 2 ? search : undefined;
|
|
|
|
// Create cache key for ETag
|
|
const cacheKey = `videos-${limit}-${offset}-${searchQuery || 'all'}`;
|
|
const etag = `"${cacheKey}"`;
|
|
|
|
// Check if client has cached version
|
|
if (req.headers['if-none-match'] === etag) {
|
|
return res.status(304).end();
|
|
}
|
|
|
|
// Set optimized cache headers
|
|
res.set({
|
|
'Cache-Control': 'public, max-age=120, stale-while-revalidate=300', // 2 min cache, 5 min stale
|
|
'ETag': etag,
|
|
'X-Content-Type-Options': 'nosniff',
|
|
'Vary': 'Accept-Encoding'
|
|
});
|
|
|
|
console.log(`Fetching videos: limit=${limit}, offset=${offset}, search=${searchQuery}`);
|
|
|
|
const videos = await storage.getVideos(limit, offset, searchQuery);
|
|
const total = await storage.getVideoCount(searchQuery);
|
|
|
|
console.log(`Returning ${videos.length} videos`);
|
|
|
|
res.json({
|
|
videos,
|
|
total,
|
|
hasMore: offset + limit < total
|
|
});
|
|
} catch (error) {
|
|
console.error("Error fetching videos:", error);
|
|
res.status(500).json({ message: "Failed to fetch videos" });
|
|
}
|
|
});
|
|
|
|
// Get single video by ID
|
|
app.get("/api/videos/:id", async (req, res) => {
|
|
try {
|
|
const video = await storage.getVideo(req.params.id);
|
|
if (!video) {
|
|
return res.status(404).json({ message: "Video not found" });
|
|
}
|
|
res.json(video);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to fetch video" });
|
|
}
|
|
});
|
|
|
|
// Get video ads/spots metadata from Bunny.net
|
|
app.get("/api/videos/:id/ads", async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
|
|
// Check if video exists first
|
|
const video = await storage.getVideo(id);
|
|
if (!video) {
|
|
return res.status(404).json({ message: "Video not found" });
|
|
}
|
|
|
|
// Get ads from Bunny.net API
|
|
let ads = [];
|
|
try {
|
|
const { BunnyService } = await import("./bunny");
|
|
const bunnyService = new BunnyService();
|
|
ads = await bunnyService.getVideoAds(id);
|
|
console.log(`Retrieved ${ads.length} ad spots for video ${id}`);
|
|
} catch (error) {
|
|
console.error(`Failed to get ads from Bunny.net for video ${id}:`, error);
|
|
// Return empty array if Bunny service fails
|
|
ads = [];
|
|
}
|
|
|
|
res.json({
|
|
videoId: id,
|
|
ads: ads,
|
|
totalAds: ads.length
|
|
});
|
|
} catch (error) {
|
|
console.error(`Error fetching ads for video ${id}:`, error);
|
|
res.status(500).json({ message: "Failed to fetch video ads" });
|
|
}
|
|
});
|
|
|
|
// Update video views
|
|
app.post("/api/videos/:id/view", async (req, res) => {
|
|
try {
|
|
await storage.updateVideoViews(req.params.id);
|
|
res.json({ success: true });
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to update views" });
|
|
}
|
|
});
|
|
|
|
// Create new video
|
|
app.post("/api/videos", authenticate, async (req, res) => {
|
|
try {
|
|
const videoData = insertVideoSchema.parse(req.body);
|
|
const video = await storage.createVideo(videoData);
|
|
res.status(201).json(video);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ message: "Invalid video data", errors: error.errors });
|
|
}
|
|
res.status(500).json({ message: "Failed to create video" });
|
|
}
|
|
});
|
|
|
|
// Update video metadata (title, description, etc.)
|
|
app.patch("/api/videos/:id", authenticate, async (req, res) => {
|
|
try {
|
|
const updates = updateVideoSchema.parse(req.body);
|
|
const video = await storage.updateVideo(req.params.id, updates);
|
|
|
|
if (!video) {
|
|
return res.status(404).json({ message: "Video not found" });
|
|
}
|
|
|
|
res.json(video);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ message: "Invalid request data", errors: error.errors });
|
|
}
|
|
res.status(500).json({ message: "Failed to update video" });
|
|
}
|
|
});
|
|
|
|
// Delete video
|
|
app.delete("/api/videos/:id", authenticate, async (req, res) => {
|
|
try {
|
|
const success = await storage.deleteVideo(req.params.id);
|
|
if (!success) {
|
|
return res.status(404).json({ message: "Video not found" });
|
|
}
|
|
res.json({ success: true, message: "Video deleted successfully" });
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to delete video" });
|
|
}
|
|
});
|
|
|
|
// Video Upload Routes
|
|
app.post("/api/uploads/start", authenticate, async (req, res) => {
|
|
try {
|
|
const { originalFileName, fileSize, mimeType } = req.body;
|
|
|
|
if (!originalFileName || !fileSize || !mimeType) {
|
|
return res.status(400).json({
|
|
message: "originalFileName, fileSize, and mimeType are required"
|
|
});
|
|
}
|
|
|
|
const uploadData = {
|
|
userId: req.session.userId!,
|
|
originalFileName,
|
|
fileSize: parseInt(fileSize),
|
|
mimeType,
|
|
uploadStatus: "uploading" as const,
|
|
uploadProgress: 0
|
|
};
|
|
|
|
const upload = await storage.createVideoUpload(uploadData);
|
|
res.status(201).json(upload);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to initialize upload" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/uploads/:id/video", authenticate, upload.single('video'), async (req, res) => {
|
|
try {
|
|
const uploadId = req.params.id;
|
|
const file = req.file;
|
|
|
|
if (!file) {
|
|
return res.status(400).json({ message: "No video file provided" });
|
|
}
|
|
|
|
// Update upload with file information
|
|
await storage.updateVideoUpload(uploadId, {
|
|
uploadStatus: "processing",
|
|
uploadProgress: 1.0
|
|
});
|
|
|
|
// Create video record
|
|
const videoData = {
|
|
title: req.body.title || path.parse(file.originalname).name,
|
|
description: req.body.description || "",
|
|
thumbnailUrl: req.body.thumbnailUrl || "https://via.placeholder.com/800x450",
|
|
videoUrl: `/uploads/videos/${file.filename}`,
|
|
duration: parseInt(req.body.duration) || 0,
|
|
views: 0,
|
|
category: req.body.category || "",
|
|
tags: req.body.tags ? JSON.parse(req.body.tags) : [],
|
|
isPublic: req.body.isPublic !== "false",
|
|
uploadStatus: "completed",
|
|
originalFileName: file.originalname,
|
|
fileSize: file.size,
|
|
format: path.extname(file.originalname).slice(1)
|
|
};
|
|
|
|
const video = await storage.createVideo(videoData);
|
|
|
|
// Link video to upload
|
|
await storage.updateVideoUpload(uploadId, {
|
|
videoId: video.id,
|
|
uploadStatus: "completed"
|
|
});
|
|
|
|
res.json({ video, upload: { id: uploadId, status: "completed" } });
|
|
} catch (error) {
|
|
console.error("Upload error:", error);
|
|
|
|
// Update upload status to failed
|
|
if (req.params.id) {
|
|
await storage.updateVideoUpload(req.params.id, {
|
|
uploadStatus: "failed",
|
|
errorMessage: error instanceof Error ? error.message : "Upload failed"
|
|
});
|
|
}
|
|
|
|
res.status(500).json({ message: "Failed to upload video" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/uploads/:id/status", authenticate, async (req, res) => {
|
|
try {
|
|
const upload = await storage.getVideoUpload(req.params.id);
|
|
if (!upload) {
|
|
return res.status(404).json({ message: "Upload not found" });
|
|
}
|
|
res.json(upload);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to get upload status" });
|
|
}
|
|
});
|
|
|
|
app.get("/api/uploads/user", authenticate, async (req, res) => {
|
|
try {
|
|
const uploads = await storage.getUserVideoUploads(req.session.userId!);
|
|
res.json(uploads);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to fetch user uploads" });
|
|
}
|
|
});
|
|
|
|
// Category Routes
|
|
app.get("/api/categories", async (req, res) => {
|
|
try {
|
|
const categories = await storage.getCategories();
|
|
res.json(categories);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to fetch categories" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/categories", authenticate, async (req, res) => {
|
|
try {
|
|
const categoryData = insertCategorySchema.parse(req.body);
|
|
const category = await storage.createCategory(categoryData);
|
|
res.status(201).json(category);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ message: "Invalid category data", errors: error.errors });
|
|
}
|
|
res.status(500).json({ message: "Failed to create category" });
|
|
}
|
|
});
|
|
|
|
app.patch("/api/categories/:id", authenticate, async (req, res) => {
|
|
try {
|
|
const updates = insertCategorySchema.partial().parse(req.body);
|
|
const category = await storage.updateCategory(req.params.id, updates);
|
|
|
|
if (!category) {
|
|
return res.status(404).json({ message: "Category not found" });
|
|
}
|
|
|
|
res.json(category);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ message: "Invalid category data", errors: error.errors });
|
|
}
|
|
res.status(500).json({ message: "Failed to update category" });
|
|
}
|
|
});
|
|
|
|
app.delete("/api/categories/:id", authenticate, async (req, res) => {
|
|
try {
|
|
const success = await storage.deleteCategory(req.params.id);
|
|
if (!success) {
|
|
return res.status(404).json({ message: "Category not found" });
|
|
}
|
|
res.json({ success: true, message: "Category deleted successfully" });
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to delete category" });
|
|
}
|
|
});
|
|
|
|
// Tag Routes
|
|
app.get("/api/tags", async (req, res) => {
|
|
try {
|
|
const limit = parseInt(req.query.limit as string) || 50;
|
|
const popular = req.query.popular === "true";
|
|
|
|
const tags = popular
|
|
? await storage.getPopularTags(limit)
|
|
: await storage.getTags();
|
|
|
|
res.json(tags);
|
|
} catch (error) {
|
|
res.status(500).json({ message: "Failed to fetch tags" });
|
|
}
|
|
});
|
|
|
|
app.post("/api/tags", authenticate, async (req, res) => {
|
|
try {
|
|
const tagData = insertTagSchema.parse(req.body);
|
|
const tag = await storage.createTag(tagData);
|
|
res.status(201).json(tag);
|
|
} catch (error) {
|
|
if (error instanceof z.ZodError) {
|
|
return res.status(400).json({ message: "Invalid tag data", errors: error.errors });
|
|
}
|
|
res.status(500).json({ message: "Failed to create tag" });
|
|
}
|
|
});
|
|
|
|
// Serve uploaded videos
|
|
app.use('/uploads', express.static('uploads'));
|
|
|
|
|
|
|
|
|
|
|
|
// Serve ads.txt file specifically
|
|
app.get("/ads.txt", (req, res) => {
|
|
res.type("text/plain");
|
|
res.sendFile(path.join(__dirname, "../client/public/ads.txt"));
|
|
});
|
|
|
|
const httpServer = createServer(app);
|
|
return httpServer;
|
|
}
|