84 lines
2.9 KiB
TypeScript
84 lines
2.9 KiB
TypeScript
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",
|
|
},
|
|
});
|
|
}
|