This commit introduces face detection capabilities to the video platform, enabling automatic identification of faces in video thumbnails. It integrates face-api.js and sharp for image analysis, allowing for face-centered thumbnail crops and dynamic object-positioning. New API endpoints are added to process thumbnails individually and in batches. The database schema is updated to store face detection data, and the storage layer is modified to support these updates and cache face data. The project's dependencies are also updated to include necessary libraries for these new features. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 2eb1084e-b728-4449-9231-f1665924c8d5 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8cc42625-c1f5-4e43-99bd-77f2c4dedee2/2eb1084e-b728-4449-9231-f1665924c8d5/xF0EUqR
632 lines
20 KiB
TypeScript
632 lines
20 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" });
|
|
}
|
|
});
|
|
|
|
// Face detection endpoint for thumbnails
|
|
app.post("/api/videos/:id/analyze-face", async (req, res) => {
|
|
try {
|
|
const { id } = req.params;
|
|
const video = await storage.getVideo(id);
|
|
|
|
if (!video) {
|
|
return res.status(404).json({ message: "Video not found" });
|
|
}
|
|
|
|
if (!video.thumbnailUrl) {
|
|
return res.status(400).json({ message: "Video has no thumbnail" });
|
|
}
|
|
|
|
// Import smart thumbnail service (no external dependencies)
|
|
const { smartThumbnailService } = await import("./smart-thumbnail-service");
|
|
const result = await smartThumbnailService.getOptimizedThumbnailInfo(video.thumbnailUrl);
|
|
|
|
// Update video with face position data
|
|
await storage.updateVideo(id, {
|
|
faceCenterPosition: result.faceCenterPosition,
|
|
facesDetected: result.facesDetected,
|
|
faceConfidence: result.confidence
|
|
});
|
|
|
|
res.json({
|
|
videoId: id,
|
|
...result
|
|
});
|
|
} catch (error) {
|
|
console.error("Error analyzing face in thumbnail:", error);
|
|
res.status(500).json({ message: "Failed to analyze face" });
|
|
}
|
|
});
|
|
|
|
// Batch face analysis for all videos
|
|
app.post("/api/videos/batch-analyze-faces", async (req, res) => {
|
|
try {
|
|
// Get all videos that don't have face analysis yet
|
|
const allVideos = await storage.getVideos(1000, 0); // Get up to 1000 videos
|
|
const videosToProcess = allVideos.filter(video => !video.faceCenterPosition && video.thumbnailUrl);
|
|
|
|
if (videosToProcess.length === 0) {
|
|
return res.json({
|
|
message: "All videos already processed or no thumbnails available",
|
|
processed: 0,
|
|
total: allVideos.length
|
|
});
|
|
}
|
|
|
|
console.log(`Starting batch face analysis for ${videosToProcess.length} videos...`);
|
|
|
|
// Import smart thumbnail service (no external dependencies)
|
|
const { smartThumbnailService } = await import("./smart-thumbnail-service");
|
|
let processed = 0;
|
|
let failed = 0;
|
|
|
|
// Process videos in batches to avoid overwhelming the system
|
|
const batchSize = 5;
|
|
for (let i = 0; i < videosToProcess.length; i += batchSize) {
|
|
const batch = videosToProcess.slice(i, i + batchSize);
|
|
|
|
await Promise.all(batch.map(async (video) => {
|
|
try {
|
|
console.log(`Processing face detection for video: ${video.title} (${video.id})`);
|
|
const result = await smartThumbnailService.getOptimizedThumbnailInfo(video.thumbnailUrl);
|
|
|
|
await storage.updateVideo(video.id, {
|
|
faceCenterPosition: result.faceCenterPosition,
|
|
facesDetected: result.facesDetected,
|
|
faceConfidence: result.confidence
|
|
});
|
|
|
|
processed++;
|
|
console.log(`✅ Face analysis completed for ${video.title} - Faces detected: ${result.facesDetected}`);
|
|
} catch (error) {
|
|
console.error(`❌ Face analysis failed for ${video.title}:`, error);
|
|
failed++;
|
|
}
|
|
}));
|
|
|
|
// Small delay between batches to be respectful
|
|
if (i + batchSize < videosToProcess.length) {
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
}
|
|
}
|
|
|
|
console.log(`Batch face analysis completed: ${processed} processed, ${failed} failed`);
|
|
|
|
res.json({
|
|
message: "Batch face analysis completed",
|
|
processed,
|
|
failed,
|
|
total: videosToProcess.length
|
|
});
|
|
} catch (error) {
|
|
console.error("Error in batch face analysis:", error);
|
|
res.status(500).json({ message: "Failed to perform batch face analysis" });
|
|
}
|
|
});
|
|
|
|
// 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;
|
|
}
|