165 lines
5.4 KiB
TypeScript
165 lines
5.4 KiB
TypeScript
import { NextRequest } from "next/server";
|
|
import path from "node:path";
|
|
import fs from "node:fs";
|
|
import fsp from "node:fs/promises";
|
|
import { findVideosForCode } from "@/lib/video";
|
|
import { assertLocalRequest } from "@/lib/api/localOnly";
|
|
|
|
export const runtime = "nodejs";
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const MIME_BY_EXT: Record<string, string> = {
|
|
".mp4": "video/mp4",
|
|
".m4v": "video/mp4",
|
|
".mov": "video/quicktime",
|
|
".webm": "video/webm",
|
|
".mkv": "video/x-matroska",
|
|
".avi": "video/x-msvideo",
|
|
".wmv": "video/x-ms-wmv",
|
|
".ts": "video/mp2t",
|
|
".mpg": "video/mpeg",
|
|
".mpeg": "video/mpeg",
|
|
".flv": "video/x-flv",
|
|
};
|
|
|
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ code: string }> }) {
|
|
const blocked = assertLocalRequest(req);
|
|
if (blocked) return blocked;
|
|
|
|
const { code } = await ctx.params;
|
|
const decoded = decodeURIComponent(code);
|
|
const url = new URL(req.url);
|
|
const partRaw = url.searchParams.get("part");
|
|
const part = partRaw == null ? 0 : Math.max(0, parseInt(partRaw, 10) || 0);
|
|
|
|
const files = findVideosForCode(decoded);
|
|
if (files.length === 0) return new Response("not found", { status: 404 });
|
|
const file = files[Math.min(part, files.length - 1)];
|
|
|
|
let stat: import("node:fs").Stats;
|
|
try {
|
|
stat = await fsp.stat(file.abs);
|
|
} catch {
|
|
return new Response("not found", { status: 404 });
|
|
}
|
|
|
|
const total = stat.size;
|
|
const ext = path.extname(file.abs).toLowerCase();
|
|
const mime = MIME_BY_EXT[ext] ?? "application/octet-stream";
|
|
// Stable identity for the byte stream — lets the browser's HTTP cache
|
|
// hold onto previously fetched ranges (the moov tail in particular)
|
|
// instead of re-hitting our endpoint on every seek / buffer-ahead.
|
|
const etag = `"${stat.size.toString(36)}-${Math.floor(stat.mtimeMs).toString(36)}"`;
|
|
const lastModified = new Date(stat.mtimeMs).toUTCString();
|
|
|
|
const range = req.headers.get("range");
|
|
const baseHeaders: Record<string, string> = {
|
|
"Content-Type": mime,
|
|
"Accept-Ranges": "bytes",
|
|
"Cache-Control": "private, max-age=3600",
|
|
"ETag": etag,
|
|
"Last-Modified": lastModified,
|
|
"Content-Disposition": `inline; filename="${encodeURIComponent(file.filename)}"`,
|
|
};
|
|
|
|
if (!range) {
|
|
return new Response(streamFile(file.abs, undefined, undefined, req.signal), {
|
|
status: 200,
|
|
headers: { ...baseHeaders, "Content-Length": String(total) },
|
|
});
|
|
}
|
|
|
|
// Parse "bytes=START-END"; END may be empty for "until end", and
|
|
// START may be empty for HTTP suffix ranges ("last N bytes").
|
|
const m = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
if (!m) return new Response("bad range", { status: 416 });
|
|
let start: number;
|
|
let end: number;
|
|
if (m[1] === "") {
|
|
const suffixLen = Number(m[2]);
|
|
if (!Number.isFinite(suffixLen) || suffixLen <= 0) {
|
|
return new Response("bad range", { status: 416, headers: { "Content-Range": `bytes */${total}` } });
|
|
}
|
|
start = Math.max(total - suffixLen, 0);
|
|
end = total - 1;
|
|
} else {
|
|
start = Number(m[1]);
|
|
end = m[2] === "" ? total - 1 : Number(m[2]);
|
|
}
|
|
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || end >= total) {
|
|
return new Response("bad range", { status: 416, headers: { "Content-Range": `bytes */${total}` } });
|
|
}
|
|
const len = end - start + 1;
|
|
return new Response(streamFile(file.abs, start, end, req.signal), {
|
|
status: 206,
|
|
headers: {
|
|
...baseHeaders,
|
|
"Content-Range": `bytes ${start}-${end}/${total}`,
|
|
"Content-Length": String(len),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Pipe a file slice into a Web ReadableStream that the runtime can hand
|
|
* to fetch's Response. Tying the read stream to the request's AbortSignal
|
|
* is the bit that fixes "Invalid state: Controller is already closed":
|
|
* when the browser cancels (modal close, seek, network blip) the Node
|
|
* stream is destroyed before it can push more bytes into a stream the
|
|
* runtime has already closed.
|
|
*/
|
|
function streamFile(
|
|
abs: string,
|
|
start: number | undefined,
|
|
end: number | undefined,
|
|
signal: AbortSignal,
|
|
): ReadableStream<Uint8Array> {
|
|
let node: fs.ReadStream | null = null;
|
|
let closed = false;
|
|
return new ReadableStream<Uint8Array>({
|
|
start(controller) {
|
|
node = fs.createReadStream(abs, { start, end });
|
|
const finish = () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
try { controller.close(); } catch { /* already closed */ }
|
|
};
|
|
const fail = (err: Error) => {
|
|
if (closed) return;
|
|
closed = true;
|
|
try { controller.error(err); } catch { /* already closed */ }
|
|
};
|
|
|
|
node.on("data", (chunk: unknown) => {
|
|
if (closed) return;
|
|
try {
|
|
const u8 = chunk instanceof Uint8Array
|
|
? chunk
|
|
: new Uint8Array(chunk as ArrayBufferLike);
|
|
controller.enqueue(u8);
|
|
} catch {
|
|
closed = true;
|
|
node?.destroy();
|
|
}
|
|
});
|
|
node.on("end", finish);
|
|
node.on("error", (err) => fail(err as Error));
|
|
|
|
const onAbort = () => {
|
|
closed = true;
|
|
node?.destroy();
|
|
};
|
|
if (signal.aborted) onAbort();
|
|
else signal.addEventListener("abort", onAbort, { once: true });
|
|
},
|
|
cancel() {
|
|
// ReadableStream.cancel() fires when the consumer is done before
|
|
// req.signal aborts (e.g. browser closes the response body cleanly
|
|
// after a Range fulfill). Without destroying the node stream here,
|
|
// the open file handle leaks until GC.
|
|
closed = true;
|
|
node?.destroy();
|
|
},
|
|
});
|
|
}
|