- New video card block for GrapesJS landing pages, email templates, MkDocs export, and documentation editor Insert dropdown - Shared HTML generators in admin/src/utils/videoCardHtml.ts - MkDocs video-player.js hydrates .video-card-block elements: thumbnail fix via MEDIA_API_URL, click-to-play inline, Gallery link - Media API CORS: auto-add MkDocs + docs subdomain origins - env_config_hook.py: smart Docker hostname detection, ADMIN_PORT resolution, pass env vars to MkDocs container - Gallery URL uses /gallery?expanded=ID format - VideoPickerModal: fix double /api prefix and Docker hostname thumbs - Seed: default-video-card PageBlock - Remove V1 legacy code (influence/, map/) Bunker Admin
311 lines
9.6 KiB
TypeScript
311 lines
9.6 KiB
TypeScript
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<string, { value: string }>;
|
|
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
|
|
);
|
|
}
|