Initial commit
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import fsp from "node:fs/promises";
|
||||
import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video";
|
||||
import { getAppSetting } from "@/lib/db/appSettings";
|
||||
import { probeDuration } from "@/lib/video/duration";
|
||||
import { assertLocalRequest } from "@/lib/api/localOnly";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* HLS playlist generator. Returns an m3u8 with N segment URLs covering
|
||||
* the full video duration. Segments are produced on demand by the
|
||||
* sibling /segment endpoint (each one is a fresh NVENC transcode of a
|
||||
* fixed time window). Player (hls.js) requests segments as needed for
|
||||
* playback and seeking.
|
||||
*/
|
||||
const SEGMENT_SECONDS = 6;
|
||||
|
||||
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);
|
||||
|
||||
let files = findVideosForCode(decoded);
|
||||
if (files.length === 0) {
|
||||
const main = (getAppSetting("videoLibraryPath") || "").trim();
|
||||
const extras = getAppSetting("videoExtraPaths") ?? [];
|
||||
const expected = [main, ...extras].filter(Boolean);
|
||||
const idx = getVideoIndex();
|
||||
const haveAll = expected.length === idx.rootsScanned.length
|
||||
&& expected.every((r, i) => r === idx.rootsScanned[i]);
|
||||
if (expected.length > 0 && !haveAll) {
|
||||
await rescanVideoIndex();
|
||||
files = findVideosForCode(decoded);
|
||||
}
|
||||
}
|
||||
if (files.length === 0) return new Response("not found", { status: 404 });
|
||||
const file = files[Math.min(part, files.length - 1)];
|
||||
|
||||
try {
|
||||
await fsp.stat(file.abs);
|
||||
} catch {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
|
||||
const duration = await probeDuration(file.abs, req.signal);
|
||||
if (duration == null) {
|
||||
return new Response("ffprobe failed", { status: 500 });
|
||||
}
|
||||
|
||||
const segCount = Math.ceil(duration / SEGMENT_SECONDS);
|
||||
const lines: string[] = [
|
||||
"#EXTM3U",
|
||||
"#EXT-X-VERSION:3",
|
||||
`#EXT-X-TARGETDURATION:${SEGMENT_SECONDS}`,
|
||||
"#EXT-X-MEDIA-SEQUENCE:0",
|
||||
"#EXT-X-PLAYLIST-TYPE:VOD",
|
||||
];
|
||||
for (let i = 0; i < segCount; i++) {
|
||||
const remaining = duration - i * SEGMENT_SECONDS;
|
||||
const segDur = Math.min(SEGMENT_SECONDS, remaining);
|
||||
lines.push(`#EXTINF:${segDur.toFixed(3)},`);
|
||||
// Relative URL — resolves against the playlist URL's directory.
|
||||
// Playlist is at /api/video-hls/[code]/playlist, so its directory is
|
||||
// /api/video-hls/[code]/ and `segment?...` resolves to the sibling.
|
||||
lines.push(`segment?part=${part}&i=${i}`);
|
||||
}
|
||||
lines.push("#EXT-X-ENDLIST");
|
||||
|
||||
return new Response(lines.join("\n"), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.apple.mpegurl",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import fsp from "node:fs/promises";
|
||||
import { findVideosForCode, getVideoIndex, rescanVideoIndex } from "@/lib/video";
|
||||
import { getAppSetting } from "@/lib/db/appSettings";
|
||||
import { probeDuration } from "@/lib/video/duration";
|
||||
import { assertLocalRequest } from "@/lib/api/localOnly";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* HLS segment endpoint. Each request transcodes a single 6-second
|
||||
* window of the source via NVENC into MPEG-TS and pipes to the
|
||||
* response. -bf 0 keeps Chromium's H.264 sink happy. -force_key_frames
|
||||
* 0 (and NVENC's -forced-idr) ensure the segment opens with an IDR so
|
||||
* it's independently decodable — required by HLS.
|
||||
*/
|
||||
const SEGMENT_SECONDS = 6;
|
||||
|
||||
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 iRaw = url.searchParams.get("i");
|
||||
const segmentIndex = iRaw == null ? 0 : Math.max(0, parseInt(iRaw, 10) || 0);
|
||||
|
||||
let files = findVideosForCode(decoded);
|
||||
if (files.length === 0) {
|
||||
const main = (getAppSetting("videoLibraryPath") || "").trim();
|
||||
const extras = getAppSetting("videoExtraPaths") ?? [];
|
||||
const expected = [main, ...extras].filter(Boolean);
|
||||
const idx = getVideoIndex();
|
||||
const haveAll = expected.length === idx.rootsScanned.length
|
||||
&& expected.every((r, i) => r === idx.rootsScanned[i]);
|
||||
if (expected.length > 0 && !haveAll) {
|
||||
await rescanVideoIndex();
|
||||
files = findVideosForCode(decoded);
|
||||
}
|
||||
}
|
||||
if (files.length === 0) return new Response("not found", { status: 404 });
|
||||
const file = files[Math.min(part, files.length - 1)];
|
||||
|
||||
try {
|
||||
await fsp.stat(file.abs);
|
||||
} catch {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
|
||||
const duration = await probeDuration(file.abs, req.signal);
|
||||
if (duration == null) {
|
||||
return new Response("ffprobe failed", { status: 500 });
|
||||
}
|
||||
|
||||
const startTime = segmentIndex * SEGMENT_SECONDS;
|
||||
if (startTime >= duration) {
|
||||
return new Response("segment out of range", { status: 416 });
|
||||
}
|
||||
const segDur = Math.min(SEGMENT_SECONDS, duration - startTime);
|
||||
|
||||
const ffmpegArgs: string[] = [
|
||||
"-hide_banner", "-loglevel", "error",
|
||||
// -ss before -i = fast container-level seek (lands on the prior key
|
||||
// frame, NVENC's first emitted frame is an IDR by spec).
|
||||
"-ss", startTime.toFixed(3),
|
||||
"-t", segDur.toFixed(3),
|
||||
"-i", file.abs,
|
||||
"-map", "0:v:0",
|
||||
"-map", "0:a:0?",
|
||||
"-c:v", "h264_nvenc",
|
||||
"-preset", "p4",
|
||||
"-tune", "ll",
|
||||
"-profile:v", "high",
|
||||
"-bf", "0",
|
||||
"-forced-idr", "1",
|
||||
"-rc", "cbr",
|
||||
"-b:v", "8M",
|
||||
"-maxrate", "8M",
|
||||
"-bufsize", "16M",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:a", "aac",
|
||||
"-b:a", "192k",
|
||||
"-ac", "2",
|
||||
"-f", "mpegts",
|
||||
"-mpegts_flags", "+resend_headers",
|
||||
// Shift output timestamps so segment N's PTS starts at N*SEGMENT_SECONDS.
|
||||
// Without this, every segment would emit at PTS≈0 and hls.js / MSE
|
||||
// can't lay them out on a continuous timeline (would need
|
||||
// #EXT-X-DISCONTINUITY markers for that). Continuous PTS = clean
|
||||
// append, smooth playback across segment boundaries.
|
||||
"-output_ts_offset", startTime.toFixed(3),
|
||||
"pipe:1",
|
||||
];
|
||||
|
||||
let ffmpeg: ChildProcess;
|
||||
try {
|
||||
ffmpeg = spawn("ffmpeg", ffmpegArgs, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
} catch (e) {
|
||||
return new Response(`ffmpeg spawn failed: ${(e as Error).message}`, { status: 500 });
|
||||
}
|
||||
ffmpeg.stderr?.on("data", (chunk: Buffer) => {
|
||||
const text = chunk.toString();
|
||||
if (text.trim()) console.error(`[hls ${decoded} seg=${segmentIndex}] ${text.trim()}`);
|
||||
});
|
||||
|
||||
return new Response(streamFromFfmpeg(ffmpeg, req.signal), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "video/mp2t",
|
||||
// Allow short-term caching — within a single playback session hls.js
|
||||
// may re-request a segment if its buffer was evicted, and a cache
|
||||
// hit avoids re-spawning ffmpeg.
|
||||
"Cache-Control": "private, max-age=300",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function streamFromFfmpeg(proc: ChildProcess, signal: AbortSignal): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
let closed = false;
|
||||
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 */ }
|
||||
};
|
||||
proc.stdout?.on("data", (chunk: Buffer) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
} catch {
|
||||
closed = true;
|
||||
try { proc.kill("SIGKILL"); } catch { /* ignore */ }
|
||||
}
|
||||
});
|
||||
proc.stdout?.on("end", finish);
|
||||
proc.on("error", (e) => fail(e));
|
||||
proc.on("exit", finish);
|
||||
|
||||
const onAbort = () => {
|
||||
try { proc.kill("SIGKILL"); } catch { /* ignore */ }
|
||||
};
|
||||
if (signal.aborted) onAbort();
|
||||
else signal.addEventListener("abort", onAbort, { once: true });
|
||||
},
|
||||
cancel() {
|
||||
try { proc.kill("SIGKILL"); } catch { /* ignore */ }
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user