import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { pipeline } from 'stream/promises'; import { createWriteStream } from 'fs'; import { unlink, mkdir } from 'fs/promises'; import { join, extname } from 'path'; import { randomUUID } from 'crypto'; import { prisma } from '../../../config/database'; import { extractVideoMetadata, validateVideoFile } from '../services/ffprobe.service'; import { ThumbnailService } from '../services/thumbnail.service'; import { logger } from '../../../utils/logger'; import { z } from 'zod'; import { requireAdminRole } from '../middleware/auth'; // Allowed video extensions const ALLOWED_EXTENSIONS = ['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v', '.flv']; // Zod schema for upload metadata const UploadMetadataSchema = z.object({ title: z.string().optional(), producer: z.string().optional(), creator: z.string().optional(), }); /** * Upload a single video file */ async function uploadVideo(request: FastifyRequest, reply: FastifyReply) { let tempFilePath: string | null = null; try { // Get the uploaded file const data = await request.file(); if (!data) { return reply.code(400).send({ message: 'No file uploaded' }); } // Validate file extension const ext = extname(data.filename).toLowerCase(); if (!ALLOWED_EXTENSIONS.includes(ext)) { return reply.code(400).send({ message: `Invalid file type. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`, }); } // Generate unique filename const filename = `${randomUUID()}${ext}`; const inboxDir = '/media/local/inbox'; // Ensure inbox directory exists await mkdir(inboxDir, { recursive: true }); const filePath = join(inboxDir, filename); tempFilePath = filePath; // Stream file to disk (must consume file stream BEFORE reading metadata fields, // because Fastify multipart/busboy only makes fields available after the file stream ends) logger.info(`Uploading video to ${filePath}`); await pipeline(data.file, createWriteStream(filePath)); // Extract metadata fields from form data (now available after file stream consumed) const metadataFields = data.fields as Record; const metadata = { title: metadataFields.title?.value, producer: metadataFields.producer?.value, creator: metadataFields.creator?.value, }; // Validate metadata const validatedMetadata = UploadMetadataSchema.parse(metadata); // Validate video file logger.info(`Validating video file: ${filePath}`); const isValid = await validateVideoFile(filePath); if (!isValid) { await unlink(filePath); return reply.code(400).send({ message: 'Invalid or corrupted video file' }); } // Extract metadata logger.info(`Extracting metadata from: ${filePath}`); const videoMetadata = await extractVideoMetadata(filePath); // Insert into database const video = await prisma.video.create({ data: { path: filePath, // Store full path: /media/local/inbox/uuid.mp4 filename, originalFilename: data.filename, title: validatedMetadata.title || data.filename, durationSeconds: videoMetadata.durationSeconds, width: videoMetadata.width, height: videoMetadata.height, orientation: videoMetadata.orientation, quality: videoMetadata.quality, hasAudio: videoMetadata.hasAudio, fileSize: videoMetadata.fileSize, directoryType: 'inbox', isValid: true, isShort: videoMetadata.durationSeconds != null && videoMetadata.durationSeconds <= 60, producer: validatedMetadata.producer || null, creator: validatedMetadata.creator || null, }, }); logger.info(`Video uploaded successfully: ${video.id}`); // Generate thumbnail try { const thumbnailPath = await ThumbnailService.generateThumbnail({ videoPath: filePath, videoId: video.id, duration: videoMetadata.durationSeconds, orientation: videoMetadata.orientation, }); // Update video with thumbnail path await prisma.video.update({ where: { id: video.id }, data: { thumbnailPath }, }); logger.info(`Thumbnail generated for video ${video.id}`); } catch (thumbnailError) { // Log error but don't fail the upload logger.error(`Failed to generate thumbnail for video ${video.id}:`, thumbnailError); } return reply.code(201).send({ message: 'Video uploaded successfully', video, }); } catch (error) { // Clean up file on error if (tempFilePath) { try { await unlink(tempFilePath); } catch (cleanupError) { logger.error('Failed to clean up file after error:', cleanupError); } } logger.error('Video upload failed:', error); if (error instanceof z.ZodError) { return reply.code(400).send({ message: 'Invalid metadata', errors: error.errors, }); } return reply.code(500).send({ message: error instanceof Error ? error.message : 'Upload failed', }); } } /** * Upload multiple video files in batch */ async function uploadBatch(request: FastifyRequest, reply: FastifyReply) { try { const files = request.files(); const results: Array<{ filename: string; success: boolean; error?: string; video?: any }> = []; for await (const file of files) { let tempFilePath: string | null = null; try { // Validate file extension const ext = extname(file.filename).toLowerCase(); if (!ALLOWED_EXTENSIONS.includes(ext)) { results.push({ filename: file.filename, success: false, error: `Invalid file type. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`, }); continue; } // Generate unique filename const filename = `${randomUUID()}${ext}`; const inboxDir = '/media/local/inbox'; // Ensure inbox directory exists await mkdir(inboxDir, { recursive: true }); const filePath = join(inboxDir, filename); tempFilePath = filePath; // Stream file to disk await pipeline(file.file, createWriteStream(filePath)); // Validate video file const isValid = await validateVideoFile(filePath); if (!isValid) { await unlink(filePath); results.push({ filename: file.filename, success: false, error: 'Invalid or corrupted video file', }); continue; } // Extract metadata const videoMetadata = await extractVideoMetadata(filePath); // Insert into database const video = await prisma.video.create({ data: { path: filePath, // Store full path: /media/local/inbox/uuid.mp4 filename, originalFilename: file.filename, title: file.filename, durationSeconds: videoMetadata.durationSeconds, width: videoMetadata.width, height: videoMetadata.height, orientation: videoMetadata.orientation, quality: videoMetadata.quality, hasAudio: videoMetadata.hasAudio, fileSize: videoMetadata.fileSize, directoryType: 'inbox', isValid: true, isShort: videoMetadata.durationSeconds != null && videoMetadata.durationSeconds <= 60, }, }); // Generate thumbnail try { const thumbnailPath = await ThumbnailService.generateThumbnail({ videoPath: filePath, videoId: video.id, duration: videoMetadata.durationSeconds, orientation: videoMetadata.orientation, }); // Update video with thumbnail path await prisma.video.update({ where: { id: video.id }, data: { thumbnailPath }, }); logger.info(`Thumbnail generated for video ${video.id}`); } catch (thumbnailError) { // Log error but don't fail the upload logger.error(`Failed to generate thumbnail for video ${video.id}:`, thumbnailError); } results.push({ filename: file.filename, success: true, video, }); logger.info(`Batch upload successful: ${file.filename} -> ${video.id}`); } catch (error) { // Clean up file on error if (tempFilePath) { try { await unlink(tempFilePath); } catch (cleanupError) { logger.error('Failed to clean up file after error:', cleanupError); } } logger.error(`Batch upload failed for ${file.filename}:`, error); results.push({ filename: file.filename, success: false, error: error instanceof Error ? error.message : 'Upload failed', }); } } const successCount = results.filter((r) => r.success).length; const failCount = results.length - successCount; return reply.code(207).send({ message: `Batch upload complete: ${successCount} succeeded, ${failCount} failed`, results, }); } catch (error) { logger.error('Batch upload failed:', error); return reply.code(500).send({ message: error instanceof Error ? error.message : 'Batch upload failed', }); } } export async function uploadRoutes(fastify: FastifyInstance) { // Single file upload fastify.post( '/upload', { preHandler: requireAdminRole, }, uploadVideo ); // Batch upload fastify.post( '/upload/batch', { preHandler: requireAdminRole, }, uploadBatch ); }