Replaces single-MP4 + range-request streaming with HLS multi-bitrate
segments to fix video stutter through the Newt tunnel. Range-request
bursts were the root cause; HLS chunks are small and tunnel-friendly,
plus the player adapts bitrate to bandwidth.
Backend
- New BullMQ `hls-transcode` queue (in-process worker, concurrency 1)
- FFmpeg single-pass transcode → 360p/720p/1080p variants with aligned
keyframes; output at /media/local/hls/{id}/master.m3u8
- New /api/{videos|public}/{id}/hls/* routes serving signed manifests
and segments (URLs emitted as /media/* so nginx rewrites to media-api)
- Prisma: HlsStatus enum + 6 fields on Video + index, migration
- Upload + yt-dlp fetch paths enqueue transcode jobs
- ENABLE_HLS_TRANSCODE flag (default off; gates enqueue only)
- Backfill script: `npm run backfill:hls`
- media-api bumped to 4 CPU / 2G for FFmpeg headroom
Frontend
- New useHls hook: lazy-imports hls.js (kept out of main bundle),
native HLS on Safari/iOS, gives up after 2 NETWORK_ERRORs so MP4
fallback engages cleanly
- VideoPlayer, VideoViewerModal, ShortsPage, ProductDetailPage now
prefer HLS when ready; MP4 fallback is automatic
- ShortsPage prefetches next-3 master manifests via <link rel="prefetch">
- PublicVideoCard hover preview stays MP4 (avoids hls.js init latency)
Bunker Admin
326 lines
10 KiB
TypeScript
326 lines
10 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 { hlsTranscodeQueueService } from '../../../services/hls-transcode-queue.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);
|
|
}
|
|
|
|
// Enqueue HLS transcode (no-op when ENABLE_HLS_TRANSCODE=false; sets SKIPPED).
|
|
try {
|
|
await hlsTranscodeQueueService.submitTranscode(video.id);
|
|
} catch (hlsErr) {
|
|
logger.error(`Failed to enqueue HLS transcode for video ${video.id}:`, hlsErr);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Enqueue HLS transcode (no-op when flag off).
|
|
try {
|
|
await hlsTranscodeQueueService.submitTranscode(video.id);
|
|
} catch (hlsErr) {
|
|
logger.error(`Failed to enqueue HLS transcode for video ${video.id}:`, hlsErr);
|
|
}
|
|
|
|
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
|
|
);
|
|
}
|