bunker-admin 99a6abab06 Add video card insert feature + MkDocs video hydration + fixes
- 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
2026-02-17 15:42:32 -07:00

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
);
}