Serve uploaded MP4s at /uploads/videos with Range support

Video records store url = /uploads/videos/<id>.mp4, but nothing served
that path: Next.js only serves static files from public/, so the
player's MP4 fallback 404'd and videos were unplayable until HLS
transcoding finished. (Thumbnails hit the same problem historically —
see scripts/update-thumbnail-urls.ts.)

Add a route handler at the exact stored URL so no DB migration is
needed. Unlike the HLS route it streams from disk via createReadStream
instead of buffering the file, and implements HTTP Range requests
(206/416, open-ended and suffix ranges) — browsers require ranges for
<video> seeking, and files can be multi-GB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
twotalesanimation
2026-08-01 17:41:37 +02:00
parent cc37509cce
commit a863a5cbf3
+149
View File
@@ -0,0 +1,149 @@
// app/uploads/videos/[...path]/route.ts
// Serves uploaded MP4s from UPLOADS_DIR/videos at the /uploads/videos/* URLs
// stored in the database. Streams from disk with HTTP Range support — browsers
// require range requests for <video> seeking, and multi-GB files must never be
// buffered in memory.
import { NextResponse } from "next/server";
import fs from "fs";
import path from "path";
import { Readable } from "stream";
const VIDEOS_DIR = path.join(process.env.UPLOADS_DIR || "/uploads", "videos");
const CONTENT_TYPES: Record<string, string> = {
".mp4": "video/mp4",
".m4v": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".mkv": "video/x-matroska",
};
function resolveVideoPath(segments: string[]): string | null {
if (segments.length === 0) return null;
const requested = path.resolve(VIDEOS_DIR, ...segments);
// Security: prevent directory traversal attacks
if (!requested.startsWith(path.resolve(VIDEOS_DIR) + path.sep)) return null;
return requested;
}
async function statFile(filePath: string): Promise<fs.Stats | null> {
try {
const stats = await fs.promises.stat(filePath);
return stats.isFile() ? stats : null;
} catch {
return null;
}
}
// Parses "bytes=start-end" | "bytes=start-" | "bytes=-suffix".
// Returns null for absent/invalid headers (caller serves the full file) and
// "unsatisfiable" when the range is outside the file (caller returns 416).
function parseRange(
header: string | null,
size: number
): { start: number; end: number } | "unsatisfiable" | null {
if (!header) return null;
const m = header.match(/^bytes=(\d*)-(\d*)$/);
if (!m || (m[1] === "" && m[2] === "")) return null;
let start: number;
let end: number;
if (m[1] === "") {
// suffix range: last N bytes
const suffix = parseInt(m[2], 10);
if (suffix === 0) return "unsatisfiable";
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = parseInt(m[1], 10);
end = m[2] === "" ? size - 1 : Math.min(parseInt(m[2], 10), size - 1);
}
if (start >= size || start > end) return "unsatisfiable";
return { start, end };
}
function baseHeaders(filePath: string, stats: fs.Stats): Record<string, string> {
const ext = path.extname(filePath).toLowerCase();
return {
"Content-Type": CONTENT_TYPES[ext] ?? "application/octet-stream",
"Accept-Ranges": "bytes",
"Cache-Control": "public, max-age=3600",
"Last-Modified": stats.mtime.toUTCString(),
};
}
export async function GET(
request: Request,
context: { params: Promise<{ path?: string[] }> }
) {
try {
const params = await context.params;
const filePath = resolveVideoPath(params?.path || []);
if (!filePath) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 });
}
const stats = await statFile(filePath);
if (!stats) {
console.log(`[Videos] File not found: ${filePath}`);
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const headers = baseHeaders(filePath, stats);
const range = parseRange(request.headers.get("range"), stats.size);
if (range === "unsatisfiable") {
return new NextResponse(null, {
status: 416,
headers: { ...headers, "Content-Range": `bytes */${stats.size}` },
});
}
if (range) {
const stream = fs.createReadStream(filePath, { start: range.start, end: range.end });
return new NextResponse(Readable.toWeb(stream) as unknown as BodyInit, {
status: 206,
headers: {
...headers,
"Content-Range": `bytes ${range.start}-${range.end}/${stats.size}`,
"Content-Length": String(range.end - range.start + 1),
},
});
}
const stream = fs.createReadStream(filePath);
return new NextResponse(Readable.toWeb(stream) as unknown as BodyInit, {
status: 200,
headers: { ...headers, "Content-Length": String(stats.size) },
});
} catch (error) {
console.error("[Videos] Error serving video file:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function HEAD(
request: Request,
context: { params: Promise<{ path?: string[] }> }
) {
try {
const params = await context.params;
const filePath = resolveVideoPath(params?.path || []);
if (!filePath) return new NextResponse(null, { status: 400 });
const stats = await statFile(filePath);
if (!stats) return new NextResponse(null, { status: 404 });
return new NextResponse(null, {
status: 200,
headers: {
...baseHeaders(filePath, stats),
"Content-Length": String(stats.size),
},
});
} catch (error) {
console.error("[Videos] Error in HEAD request:", error);
return new NextResponse(null, { status: 500 });
}
}