changemaker.lite/api/src/modules/media/routes/video-streaming.routes.ts
bunker-admin 21208b58c7 feat(media): HLS adaptive bitrate streaming with MP4 fallback
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
2026-04-30 19:03:29 -06:00

359 lines
12 KiB
TypeScript

import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { createReadStream, stat } from 'fs';
import { access, readFile } from 'fs/promises';
import { join, resolve } from 'path';
import { lookup } from 'mime-types';
import jwt from 'jsonwebtoken';
import { UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { logger } from '../../../utils/logger';
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
import { signMediaPath, verifyMediaSignature } from '../../../utils/signed-url';
/**
* Check if the request is from an authenticated admin user.
* Accepts either (1) Bearer JWT or (2) path-scoped signed URL params
* (`?sig=&exp=&uid=`). The legacy `?token=<JWT>` path was removed on
* 2026-04-12 — full JWTs in query strings were leaking via access logs
* and referer headers.
*/
async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
try {
let userId: string | undefined;
const authHeader = request.headers.authorization;
const query = request.query as Record<string, string | undefined>;
if (authHeader?.startsWith('Bearer ')) {
const payload = jwt.verify(authHeader.substring(7), env.JWT_ACCESS_SECRET, { algorithms: ['HS256'] }) as {
id: string; role: UserRole; roles?: UserRole[];
};
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
userId = payload.id;
} else if (query.sig && query.exp && query.uid) {
const result = verifyMediaSignature(request.url, query);
if (!result.valid) return false;
userId = result.userId;
}
if (!userId) return false;
// Verify user still active AND has media role (signed URLs carry only uid,
// so we re-check the role from DB to avoid stale-role privilege escalation).
const user = await prisma.user.findUnique({
where: { id: userId },
select: { status: true, role: true, roles: true },
});
if (!user || user.status !== UserStatus.ACTIVE) return false;
return hasAnyRole({ role: user.role as UserRole, roles: getUserRoles(user) }, MEDIA_ROLES);
} catch {
return false;
}
}
/**
* Parse range header for video seeking
* Example: "bytes=0-1024" or "bytes=1024-"
*/
function parseRange(range: string, fileSize: number): { start: number; end: number } | null {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
if (isNaN(start) || isNaN(end) || start > end || end >= fileSize) {
return null;
}
return { start, end };
}
/**
* Get video file stats safely
*/
async function getFileStats(filePath: string): Promise<{ size: number } | null> {
return new Promise((resolve) => {
stat(filePath, (err, stats) => {
if (err) {
resolve(null);
} else {
resolve({ size: stats.size });
}
});
});
}
export async function videoStreamingRoutes(fastify: FastifyInstance) {
/**
* Stream video file with HTTP range support for seeking
* GET /api/videos/:id/stream
* Public endpoint (no auth) - videos are public by default
*/
fastify.get(
'/:id/stream',
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
try {
const videoId = parseInt(request.params.id);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Admin bypass: skip publication filter for authenticated admin users
const admin = await isAdminRequest(request);
const video = await prisma.video.findFirst({
where: admin
? { id: videoId }
: { id: videoId, isPublished: true, isLocked: false },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found or not published' });
}
// Security: Validate path is within allowed media directory
const MEDIA_BASE = '/media/local';
const candidatePath = video.path.endsWith(video.filename)
? video.path
: join(video.path, video.filename);
const filePath = resolve(candidatePath);
if (!filePath.startsWith(resolve(MEDIA_BASE))) {
logger.warn(`Path traversal attempt detected: ${filePath}`);
return reply.code(403).send({ message: 'Access denied' });
}
// Check file exists
try {
await access(filePath);
} catch {
logger.error(`Video file not found on disk: ${filePath}`);
return reply.code(404).send({ message: 'Video file not found' });
}
// Get file stats
const stats = await getFileStats(filePath);
if (!stats) {
return reply.code(500).send({ message: 'Failed to read video file' });
}
const fileSize = stats.size;
// Determine MIME type
const mimeType = lookup(video.filename) || 'video/mp4';
// Handle range requests for seeking
const rangeHeader = request.headers.range;
if (rangeHeader) {
const range = parseRange(rangeHeader, fileSize);
if (!range) {
return reply.code(416).send({ message: 'Invalid range' });
}
const { start, end } = range;
const contentLength = end - start + 1;
// Set partial content headers
reply.code(206);
reply.header('Content-Range', `bytes ${start}-${end}/${fileSize}`);
reply.header('Accept-Ranges', 'bytes');
reply.header('Content-Length', contentLength);
reply.header('Content-Type', mimeType);
reply.header('Cache-Control', 'public, max-age=31536000'); // 1 year
// Stream the requested range
const stream = createReadStream(filePath, { start, end });
return reply.send(stream);
} else {
// No range header - stream entire file
reply.header('Content-Length', fileSize);
reply.header('Content-Type', mimeType);
reply.header('Accept-Ranges', 'bytes');
reply.header('Cache-Control', 'public, max-age=31536000'); // 1 year
const stream = createReadStream(filePath);
return reply.send(stream);
}
} catch (error) {
logger.error('Video streaming error:', error);
return reply.code(500).send({
message: error instanceof Error ? error.message : 'Failed to stream video',
});
}
}
);
/**
* Serve video thumbnail
* GET /api/videos/:id/thumbnail
* Public endpoint - returns thumbnail image or 404
*/
fastify.get(
'/:id/thumbnail',
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
try {
const videoId = parseInt(request.params.id);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Admin bypass: skip publication filter for authenticated admin users
const admin = await isAdminRequest(request);
const video = await prisma.video.findFirst({
where: admin
? { id: videoId }
: { id: videoId, isPublished: true, isLocked: false },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found or not published' });
}
// Check if thumbnail exists
if (!video.thumbnailPath) {
return reply.code(404).send({ message: 'Thumbnail not found' });
}
// Security: Validate path is within allowed media directory
const resolvedThumb = resolve(video.thumbnailPath);
if (!resolvedThumb.startsWith(resolve('/media/local'))) {
logger.warn(`Path traversal attempt detected: ${resolvedThumb}`);
return reply.code(403).send({ message: 'Access denied' });
}
// Check file exists
try {
await access(resolvedThumb);
} catch {
logger.error(`Thumbnail file not found on disk: ${resolvedThumb}`);
return reply.code(404).send({ message: 'Thumbnail file not found' });
}
// Determine MIME type
const mimeType = lookup(resolvedThumb) || 'image/jpeg';
// Read and send thumbnail
const thumbnailBuffer = await readFile(video.thumbnailPath);
reply.header('Content-Type', mimeType);
reply.header('Content-Length', thumbnailBuffer.length);
reply.header('Cache-Control', 'public, max-age=31536000'); // 1 year
return reply.send(thumbnailBuffer);
} catch (error) {
logger.error('Thumbnail serving error:', error);
return reply.code(500).send({
message: error instanceof Error ? error.message : 'Failed to serve thumbnail',
});
}
}
);
/**
* Get public video metadata for embedding
* GET /api/videos/:id/metadata
* Public endpoint - returns essential metadata for video players
*/
fastify.get(
'/:id/metadata',
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
try {
const videoId = parseInt(request.params.id);
if (isNaN(videoId)) {
return reply.code(400).send({ message: 'Invalid video ID' });
}
// Admin bypass: skip publication filter for authenticated admin users
const admin = await isAdminRequest(request);
const video = await prisma.video.findFirst({
where: admin
? { id: videoId }
: { id: videoId, isPublished: true, isLocked: false },
});
if (!video) {
return reply.code(404).send({ message: 'Video not found or not published' });
}
// Construct public URLs (use relative paths for nginx proxy routing)
const streamUrl = `/media/videos/${video.id}/stream`;
const thumbnailUrl = video.thumbnailPath
? `/media/videos/${video.id}/thumbnail`
: null;
// HLS manifest URL — only present when transcoding has completed.
// We emit browser-facing `/media/*` paths (rewritten by nginx to
// `/api/*` and proxied to media-api). For admin previews we sign
// against the post-rewrite server-side path so the verifier matches.
let hlsManifestUrl: string | null = null;
if (video.hlsStatus === 'READY' && video.hlsManifestPath) {
const clientPath = admin
? `/media/videos/${video.id}/hls/master.m3u8`
: `/media/public/${video.id}/hls/master.m3u8`;
const serverPath = admin
? `/api/videos/${video.id}/hls/master.m3u8`
: `/api/public/${video.id}/hls/master.m3u8`;
if (admin) {
let uid = 'admin';
try {
const authHeader = request.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const payload = jwt.verify(
authHeader.substring(7),
env.JWT_ACCESS_SECRET,
{ algorithms: ['HS256'] },
) as { id?: string };
if (payload.id) uid = payload.id;
} else {
const q = request.query as Record<string, string | undefined>;
if (q.uid) uid = q.uid;
}
} catch { /* keep default */ }
const signed = signMediaPath(serverPath, uid, 2 * 60 * 60);
hlsManifestUrl = `${clientPath}?sig=${signed.sig}&exp=${signed.exp}&uid=${signed.uid}`;
} else {
// Public manifest: nginx-rewritten path; the public master route
// is unsigned (gated by isPublished + access level on the server).
hlsManifestUrl = clientPath;
}
}
// Return public metadata
return {
id: video.id,
title: video.title || video.filename,
durationSeconds: video.durationSeconds,
width: video.width,
height: video.height,
orientation: video.orientation,
hasAudio: video.hasAudio,
quality: video.quality,
streamUrl,
thumbnailUrl,
hlsStatus: video.hlsStatus,
hlsManifestUrl,
createdAt: video.createdAt,
};
} catch (error) {
logger.error('Metadata retrieval error:', error);
return reply.code(500).send({
message: error instanceof Error ? error.message : 'Failed to retrieve metadata',
});
}
}
);
/**
* Health check for streaming routes
*/
fastify.get('/stream/health', async () => {
return {
status: 'ok',
service: 'video-streaming',
};
});
}