add video backend server, fix styling and more #54

Merged
quoije merged 131 commits from 2026-video.backend into prod 2025-12-22 05:42:04 -05:00
7 changed files with 306 additions and 16 deletions
Showing only changes of commit 837e02ef4c - Show all commits
+2
View File
@@ -24,6 +24,7 @@ const movieRoutes = require("./routes/movies");
const versionRoutes = require("./routes/version"); const versionRoutes = require("./routes/version");
const settingsRoutes = require("./routes/settings"); const settingsRoutes = require("./routes/settings");
const adminDbRoutes = require("./routes/adminDb"); const adminDbRoutes = require("./routes/adminDb");
const videoRoutes = require("./routes/video");
app.use(express.static('public')); app.use(express.static('public'));
app.use("/api/users", userRoutes); app.use("/api/users", userRoutes);
@@ -31,6 +32,7 @@ app.use("/api/movies", movieRoutes);
app.use("/api/version", versionRoutes); app.use("/api/version", versionRoutes);
app.use("/api/settings", settingsRoutes); app.use("/api/settings", settingsRoutes);
app.use("/api/admin", adminDbRoutes); app.use("/api/admin", adminDbRoutes);
app.use("/api/video", videoRoutes);
// Start Server // Start Server
const PORT = process.env.PORT || 5000; const PORT = process.env.PORT || 5000;
+3
View File
@@ -17,6 +17,9 @@ const movieSchema = new mongoose.Schema({
player_mode: { type: String, required: false, enum: ["auto", "video", "embed"], default: "auto" }, player_mode: { type: String, required: false, enum: ["auto", "video", "embed"], default: "auto" },
video_src: { type: String, required: false }, // direct video file URL or HLS (.m3u8) video_src: { type: String, required: false }, // direct video file URL or HLS (.m3u8)
embed_src: { type: String, required: false }, // URL or iframe code embed_src: { type: String, required: false }, // URL or iframe code
// Server-hosted file (relative path under VIDEO_FILES_DIR, default public/video)
// If set, the backend can stream it via /api/video/:movieId (Range/seek supported).
video_file: { type: String, required: false },
watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }] watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]
}, { timestamps: true }); }, { timestamps: true });
+14
View File
@@ -354,6 +354,14 @@
<div class="col-12 col-lg-8"> <div class="col-12 col-lg-8">
<label for="video_src" class="form-label">Video URL (mp4/webm/m3u8)</label> <label for="video_src" class="form-label">Video URL (mp4/webm/m3u8)</label>
<input type="text" class="form-control" id="video_src" placeholder="https://.../movie.mp4 or .../playlist.m3u8"> <input type="text" class="form-control" id="video_src" placeholder="https://.../movie.mp4 or .../playlist.m3u8">
<div class="form-text">If you use a server file (below) and leave this empty, it will auto-use <code>/api/video/&lt;movieId&gt;</code>.</div>
</div>
<div class="col-12">
<label for="video_file" class="form-label">Server video file (optional)</label>
<input type="text" class="form-control font-monospace" id="video_file" placeholder="my_movie.mp4 or 2026/my_movie.mp4">
<div class="form-text">
Relative path under <code>VIDEO_FILES_DIR</code> (default: <code>public/video</code>). Supports seeking (Range).
</div>
</div> </div>
<div class="col-12"> <div class="col-12">
<label for="embed_src" class="form-label">Embed URL or iframe code</label> <label for="embed_src" class="form-label">Embed URL or iframe code</label>
@@ -700,6 +708,12 @@
<div class="col-12"> <div class="col-12">
<label for="edit_video_src" class="form-label">Video URL (mp4/webm/m3u8)</label> <label for="edit_video_src" class="form-label">Video URL (mp4/webm/m3u8)</label>
<input type="text" class="form-control" id="edit_video_src" placeholder="https://.../movie.mp4 or .../playlist.m3u8"> <input type="text" class="form-control" id="edit_video_src" placeholder="https://.../movie.mp4 or .../playlist.m3u8">
<div class="form-text">If you use a server file (below) and leave this empty, it will auto-use <code>/api/video/&lt;movieId&gt;</code>.</div>
</div>
<div class="col-12">
<label for="edit_video_file" class="form-label">Server video file (optional)</label>
<input type="text" class="form-control font-monospace" id="edit_video_file" placeholder="my_movie.mp4 or 2026/my_movie.mp4">
<div class="form-text">Relative path under <code>VIDEO_FILES_DIR</code> (default: <code>public/video</code>).</div>
</div> </div>
<div class="col-12"> <div class="col-12">
<label for="edit_embed_src" class="form-label">Embed URL or iframe code</label> <label for="edit_embed_src" class="form-label">Embed URL or iframe code</label>
+12 -5
View File
@@ -104,6 +104,7 @@ window.onload = async function () {
const editVodLinkEl = document.getElementById('edit_vod_link'); const editVodLinkEl = document.getElementById('edit_vod_link');
const editPlayerModeEl = document.getElementById('edit_player_mode'); const editPlayerModeEl = document.getElementById('edit_player_mode');
const editVideoSrcEl = document.getElementById('edit_video_src'); const editVideoSrcEl = document.getElementById('edit_video_src');
const editVideoFileEl = document.getElementById('edit_video_file');
const editEmbedSrcEl = document.getElementById('edit_embed_src'); const editEmbedSrcEl = document.getElementById('edit_embed_src');
const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb'); const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb');
const editTitleEl = document.getElementById('edit_title'); const editTitleEl = document.getElementById('edit_title');
@@ -1817,6 +1818,7 @@ window.onload = async function () {
const vod_link = document.getElementById('vod_link').value.trim(); const vod_link = document.getElementById('vod_link').value.trim();
const player_mode = (document.getElementById('player_mode')?.value || 'auto').trim(); const player_mode = (document.getElementById('player_mode')?.value || 'auto').trim();
const video_src = document.getElementById('video_src')?.value?.trim() || ''; const video_src = document.getElementById('video_src')?.value?.trim() || '';
const video_file = document.getElementById('video_file')?.value?.trim() || '';
const embed_src = document.getElementById('embed_src')?.value?.trim() || ''; const embed_src = document.getElementById('embed_src')?.value?.trim() || '';
if (!year) { if (!year) {
@@ -1829,8 +1831,8 @@ window.onload = async function () {
return; return;
} }
if (!vod_link && !video_src && !embed_src) { if (!vod_link && !video_src && !video_file && !embed_src) {
showResponse('warning', 'Ajoute au moins une source (VOD / video_src / embed_src).'); showResponse('warning', 'Ajoute au moins une source (VOD / video_src / video_file / embed_src).');
return; return;
} }
@@ -1841,7 +1843,7 @@ window.onload = async function () {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
}, },
body: JSON.stringify({ year, imdb_id, category, vod_link, player_mode, video_src, embed_src }) body: JSON.stringify({ year, imdb_id, category, vod_link, player_mode, video_src, video_file, embed_src })
}); });
if (res.ok) { if (res.ok) {
@@ -2025,6 +2027,7 @@ window.onload = async function () {
editVodLinkEl.value = movie.vod_link || ''; editVodLinkEl.value = movie.vod_link || '';
if (editPlayerModeEl) editPlayerModeEl.value = movie.player_mode || 'auto'; if (editPlayerModeEl) editPlayerModeEl.value = movie.player_mode || 'auto';
if (editVideoSrcEl) editVideoSrcEl.value = movie.video_src || ''; if (editVideoSrcEl) editVideoSrcEl.value = movie.video_src || '';
if (editVideoFileEl) editVideoFileEl.value = movie.video_file || '';
if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || ''; if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || '';
editRefreshOmdbEl.checked = false; editRefreshOmdbEl.checked = false;
@@ -2051,6 +2054,7 @@ window.onload = async function () {
const vod_link = (editVodLinkEl.value || '').trim(); const vod_link = (editVodLinkEl.value || '').trim();
const player_mode = (editPlayerModeEl?.value || 'auto').trim(); const player_mode = (editPlayerModeEl?.value || 'auto').trim();
const video_src = (editVideoSrcEl?.value || '').trim(); const video_src = (editVideoSrcEl?.value || '').trim();
const video_file = (editVideoFileEl?.value || '').trim();
const embed_src = (editEmbedSrcEl?.value || '').trim(); const embed_src = (editEmbedSrcEl?.value || '').trim();
const refreshOmdb = !!editRefreshOmdbEl.checked; const refreshOmdb = !!editRefreshOmdbEl.checked;
@@ -2078,8 +2082,8 @@ window.onload = async function () {
return; return;
} }
if (!vod_link && !video_src && !embed_src) { if (!vod_link && !video_src && !video_file && !embed_src) {
showResponse('warning', 'Ajoute au moins une source (VOD / video_src / embed_src).'); showResponse('warning', 'Ajoute au moins une source (VOD / video_src / video_file / embed_src).');
return; return;
} }
@@ -2090,6 +2094,7 @@ window.onload = async function () {
vod_link, vod_link,
player_mode, player_mode,
video_src, video_src,
video_file,
embed_src, embed_src,
refreshOmdb refreshOmdb
}; };
@@ -2163,10 +2168,12 @@ window.onload = async function () {
function buildPlayerBadges(m) { function buildPlayerBadges(m) {
const badges = []; const badges = [];
const hasVideo = !!(m && m.video_src); const hasVideo = !!(m && m.video_src);
const hasFile = !!(m && m.video_file);
const hasEmbed = !!(m && m.embed_src); const hasEmbed = !!(m && m.embed_src);
const hasLegacy = !!(m && m.vod_link); const hasLegacy = !!(m && m.vod_link);
if (hasVideo) badges.push({ text: 'Video', cls: 'bg-primary' }); if (hasVideo) badges.push({ text: 'Video', cls: 'bg-primary' });
if (hasFile) badges.push({ text: 'Server file', cls: 'bg-dark' });
if (hasEmbed) badges.push({ text: 'Embed', cls: 'bg-info text-dark' }); if (hasEmbed) badges.push({ text: 'Embed', cls: 'bg-info text-dark' });
if (hasLegacy) badges.push({ text: 'Legacy', cls: 'bg-secondary' }); if (hasLegacy) badges.push({ text: 'Legacy', cls: 'bg-secondary' });
+15 -2
View File
@@ -428,13 +428,16 @@ function resolveMovieSource(movie) {
const legacy = cleanUrlValue(movie?.vod_link); const legacy = cleanUrlValue(movie?.vod_link);
const video = cleanUrlValue(movie?.video_src); const video = cleanUrlValue(movie?.video_src);
const embed = cleanUrlValue(movie?.embed_src); const embed = cleanUrlValue(movie?.embed_src);
const serverFile = cleanUrlValue(movie?.video_file);
// Legacy should never embed in the player (open in new tab only). // Legacy should never embed in the player (open in new tab only).
// So we only pick legacy if there is no other option, and mark it. // So we only pick legacy if there is no other option, and mark it.
let picked = null; let picked = null;
if (mode === 'video') picked = video || null; const serverVideoUrl = serverFile ? `/api/video/${encodeURIComponent(String(movie?._id || ''))}` : null;
if (mode === 'video') picked = video || serverVideoUrl || null;
else if (mode === 'embed') picked = embed || null; else if (mode === 'embed') picked = embed || null;
else picked = video || embed || null; // auto else picked = video || serverVideoUrl || embed || null; // auto
if (picked) return { src: picked, isLegacy: false }; if (picked) return { src: picked, isLegacy: false };
if (legacy) return { src: legacy, isLegacy: true }; if (legacy) return { src: legacy, isLegacy: true };
@@ -500,6 +503,16 @@ window.onload = async function () {
} }
setHeader(movie); setHeader(movie);
// Allow <video> to call /api/video/* without Authorization headers by using a cookie.
// Best-effort: if it fails, external video_src URLs can still work.
try {
await fetch('/api/video/session', {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
});
} catch (_) {}
const resolved = resolveMovieSource(movie); const resolved = resolveMovieSource(movie);
if (!resolved?.src) { if (!resolved?.src) {
setOpenOriginal(null); setOpenOriginal(null);
+54 -9
View File
@@ -52,6 +52,16 @@ function normalizeOptionalString(v, maxLen = 2048) {
return s.slice(0, maxLen); return s.slice(0, maxLen);
} }
function normalizeVideoFile(v) {
// Stored as a relative path (no leading slash) like: "my_movie.mp4" or "2026/my_movie.mp4"
// Backend streaming endpoint enforces traversal protection too.
if (v === undefined) return undefined;
if (v === null) return null;
const s = String(v).trim();
if (!s) return "";
return s.slice(0, 1024);
}
function normalizePlayerMode(v) { function normalizePlayerMode(v) {
const s = String(v || "").trim().toLowerCase(); const s = String(v || "").trim().toLowerCase();
if (!s) return "auto"; if (!s) return "auto";
@@ -66,6 +76,14 @@ function hasAnySource({ vod_link, video_src, embed_src }) {
return !!(a || b || c); return !!(a || b || c);
} }
function hasAnySourceIncludingFile({ vod_link, video_src, embed_src, video_file }) {
const a = typeof vod_link === "string" ? vod_link.trim() : "";
const b = typeof video_src === "string" ? video_src.trim() : "";
const c = typeof embed_src === "string" ? embed_src.trim() : "";
const d = typeof video_file === "string" ? video_file.trim() : "";
return !!(a || b || c || d);
}
// Fetch movie details from OMDb API // Fetch movie details from OMDb API
async function fetchMovieDetailsFromOmdb(imdb_id) { async function fetchMovieDetailsFromOmdb(imdb_id) {
const apiKey = typeof process.env.OMDB_API === "string" ? process.env.OMDB_API.trim() : ""; const apiKey = typeof process.env.OMDB_API === "string" ? process.env.OMDB_API.trim() : "";
@@ -130,7 +148,7 @@ router.get("/", async (req, res) => {
const projection = isChecklist const projection = isChecklist
? "imdb_id title" ? "imdb_id title"
: "imdb_id title description rating poster year category vod_link player_mode video_src embed_src updatedAt createdAt"; : "imdb_id title description rating poster year category vod_link player_mode video_src embed_src video_file updatedAt createdAt";
const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`; const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`;
const cached = cacheGet(cacheKey); const cached = cacheGet(cacheKey);
@@ -312,7 +330,7 @@ router.patch("/users/updateWatchedMovies", async (req, res) => {
// Add movie // Add movie
router.post("/add", async (req, res) => { router.post("/add", async (req, res) => {
const { imdb_id, category, vod_link, year, player_mode, video_src, embed_src } = req.body; const { imdb_id, category, vod_link, year, player_mode, video_src, embed_src, video_file } = req.body;
try { try {
// Get the token from the Authorization header // Get the token from the Authorization header
@@ -338,11 +356,19 @@ router.post("/add", async (req, res) => {
const normalizedVod = normalizeOptionalString(vod_link, 4096); const normalizedVod = normalizeOptionalString(vod_link, 4096);
const normalizedVideo = normalizeOptionalString(video_src, 4096); const normalizedVideo = normalizeOptionalString(video_src, 4096);
const normalizedEmbed = normalizeOptionalString(embed_src, 20000); const normalizedEmbed = normalizeOptionalString(embed_src, 20000);
const normalizedFile = normalizeVideoFile(video_file);
const normalizedMode = normalizePlayerMode(player_mode); const normalizedMode = normalizePlayerMode(player_mode);
if (normalizedMode === null) { if (normalizedMode === null) {
return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" }); return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" });
} }
if (!hasAnySource({ vod_link: normalizedVod || "", video_src: normalizedVideo || "", embed_src: normalizedEmbed || "" })) { if (
!hasAnySourceIncludingFile({
vod_link: normalizedVod || "",
video_src: normalizedVideo || "",
embed_src: normalizedEmbed || "",
video_file: normalizedFile || "",
})
) {
return res.status(400).json({ message: "Ajoute au moins une source (VOD / video_src / embed_src)." }); return res.status(400).json({ message: "Ajoute au moins une source (VOD / video_src / embed_src)." });
} }
@@ -377,9 +403,16 @@ router.post("/add", async (req, res) => {
player_mode: normalizedMode, player_mode: normalizedMode,
video_src: normalizedVideo || undefined, video_src: normalizedVideo || undefined,
embed_src: normalizedEmbed || undefined, embed_src: normalizedEmbed || undefined,
video_file: normalizedFile || undefined,
}); });
await movie.save(); await movie.save();
// Convenience: if admin provided a server video_file but no video_src, auto-wire video_src to our streaming endpoint.
if ((normalizedFile || "").trim() && !String(movie.video_src || "").trim()) {
movie.video_src = `/api/video/${movie._id}`;
await movie.save();
}
cacheClear(); cacheClear();
res.status(201).json({ message: "Movie added successfully!" }); res.status(201).json({ message: "Movie added successfully!" });
@@ -463,6 +496,7 @@ router.put("/:id", async (req, res) => {
player_mode, player_mode,
video_src, video_src,
embed_src, embed_src,
video_file,
refreshOmdb, refreshOmdb,
} = req.body || {}; } = req.body || {};
@@ -513,6 +547,14 @@ router.put("/:id", async (req, res) => {
const v = normalizeOptionalString(embed_src, 20000); const v = normalizeOptionalString(embed_src, 20000);
movie.embed_src = v ? v : null; movie.embed_src = v ? v : null;
} }
if (video_file !== undefined) {
const v = normalizeVideoFile(video_file);
movie.video_file = v ? v : null;
// If we have a server file and no explicit video_src, set it to our streaming endpoint.
if (movie.video_file && !String(movie.video_src || "").trim()) {
movie.video_src = `/api/video/${movie._id}`;
}
}
if (year !== undefined && year !== null && year !== "") { if (year !== undefined && year !== null && year !== "") {
movie.year = parseOscarYear(year); movie.year = parseOscarYear(year);
@@ -542,12 +584,15 @@ router.put("/:id", async (req, res) => {
if (poster !== undefined) movie.poster = poster; if (poster !== undefined) movie.poster = poster;
} }
if (!hasAnySource({ if (
vod_link: movie.vod_link || "", !hasAnySourceIncludingFile({
video_src: movie.video_src || "", vod_link: movie.vod_link || "",
embed_src: movie.embed_src || "", video_src: movie.video_src || "",
})) { embed_src: movie.embed_src || "",
return res.status(400).json({ message: "Ajoute au moins une source (VOD / video_src / embed_src)." }); video_file: movie.video_file || "",
})
) {
return res.status(400).json({ message: "Ajoute au moins une source (VOD / video_src / embed_src / video_file)." });
} }
await movie.save(); await movie.save();
+206
View File
@@ -0,0 +1,206 @@
const express = require("express");
const fs = require("fs");
const path = require("path");
const jwt = require("jsonwebtoken");
const mongoose = require("mongoose");
const Movie = require("../models/Movie");
const router = express.Router();
function parseCookies(header) {
const cookies = {};
const raw = String(header || "");
if (!raw) return cookies;
raw.split(";").forEach((part) => {
const idx = part.indexOf("=");
if (idx <= 0) return;
const k = part.slice(0, idx).trim();
const v = part.slice(idx + 1).trim();
if (!k) return;
cookies[k] = decodeURIComponent(v);
});
return cookies;
}
function isSafeRelativeFile(p) {
if (typeof p !== "string") return false;
const s = p.trim();
if (!s) return false;
if (s.includes("\0")) return false;
// No absolute paths
if (path.isAbsolute(s)) return false;
// Normalize and prevent traversal
const norm = path.posix.normalize(s.replaceAll("\\", "/"));
if (norm.startsWith("../") || norm === "..") return false;
return true;
}
function guessContentType(filePath) {
const ext = String(path.extname(filePath) || "").toLowerCase();
if (ext === ".mp4") return "video/mp4";
if (ext === ".webm") return "video/webm";
if (ext === ".ogv" || ext === ".ogg") return "video/ogg";
if (ext === ".m4v") return "video/x-m4v";
if (ext === ".mov") return "video/quicktime";
if (ext === ".m3u8") return "application/vnd.apple.mpegurl";
if (ext === ".ts") return "video/mp2t";
return "application/octet-stream";
}
function getVideoBaseDir() {
// Default to your existing public/video folder.
const fromEnv = typeof process.env.VIDEO_FILES_DIR === "string" ? process.env.VIDEO_FILES_DIR.trim() : "";
const base = fromEnv || path.join(process.cwd(), "public", "video");
return path.resolve(base);
}
function getAuthToken(req) {
// <video> can't send Authorization headers, so we use a cookie.
const cookies = parseCookies(req.headers.cookie);
const cookieToken = cookies.video_auth ? String(cookies.video_auth) : "";
if (cookieToken) return cookieToken;
// Optional fallback for non-browser clients:
const authHeader = String(req.headers.authorization || "");
const headerToken = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : "";
if (headerToken) return headerToken;
// Last resort (not recommended, but handy for debugging):
const qs = typeof req.query?.token === "string" ? req.query.token.trim() : "";
return qs || "";
}
function requireAuth(req, res) {
const token = getAuthToken(req);
if (!token) return { ok: false, error: res.status(401).json({ message: "Missing video auth token" }) };
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
return { ok: true, decoded };
} catch (_) {
return { ok: false, error: res.status(401).json({ message: "Invalid or expired video auth token" }) };
}
}
// Called by player.html via fetch() (with Authorization header) to set a cookie usable by <video>.
router.post("/session", (req, res) => {
const authHeader = String(req.headers.authorization || "");
const token = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : "";
if (!token) return res.status(401).json({ message: "Token is required" });
try {
jwt.verify(token, process.env.JWT_SECRET);
// Cookie scoped to /api/video so it doesn't leak broadly.
// httpOnly helps a bit, even though the JWT originally lives in localStorage.
const maxAgeSeconds = 60 * 60 * 8; // 8h
const cookie = [
`video_auth=${encodeURIComponent(token)}`,
"Path=/api/video",
`Max-Age=${maxAgeSeconds}`,
"SameSite=Lax",
// If behind HTTPS (recommended), enable Secure.
...(req.secure || req.headers["x-forwarded-proto"] === "https" ? ["Secure"] : []),
"HttpOnly",
].join("; ");
res.setHeader("Set-Cookie", cookie);
return res.status(204).end();
} catch (_) {
return res.status(401).json({ message: "Invalid or expired token" });
}
});
async function streamMovieFile(req, res) {
const auth = requireAuth(req, res);
if (!auth.ok) return;
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(400).json({ message: "Invalid movie id" });
}
const movie = await Movie.findById(id).select("video_file").lean();
const videoFile = typeof movie?.video_file === "string" ? movie.video_file.trim() : "";
if (!videoFile) {
return res.status(404).json({ message: "No server video file configured for this movie" });
}
if (!isSafeRelativeFile(videoFile)) {
return res.status(400).json({ message: "Invalid video_file path" });
}
const baseDir = getVideoBaseDir();
const fullPath = path.resolve(baseDir, videoFile);
if (!fullPath.startsWith(baseDir + path.sep) && fullPath !== baseDir) {
return res.status(400).json({ message: "Invalid video_file path" });
}
let stat;
try {
stat = await fs.promises.stat(fullPath);
} catch (_) {
return res.status(404).json({ message: "Video file not found on server" });
}
if (!stat.isFile()) return res.status(404).json({ message: "Video file not found on server" });
const fileSize = stat.size;
const range = String(req.headers.range || "");
const contentType = guessContentType(fullPath);
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
// HEAD: only headers (no body)
if (req.method === "HEAD") {
res.setHeader("Content-Length", String(fileSize));
return res.status(200).end();
}
if (!range) {
res.setHeader("Content-Length", String(fileSize));
return fs.createReadStream(fullPath).pipe(res);
}
// Example: "bytes=0-1023"
const match = /^bytes=(\d*)-(\d*)$/i.exec(range);
if (!match) {
res.setHeader("Content-Range", `bytes */${fileSize}`);
return res.status(416).end();
}
const startRaw = match[1];
const endRaw = match[2];
let start = startRaw ? Number(startRaw) : 0;
let end = endRaw ? Number(endRaw) : fileSize - 1;
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < 0 || start > end) {
res.setHeader("Content-Range", `bytes */${fileSize}`);
return res.status(416).end();
}
if (start >= fileSize) {
res.setHeader("Content-Range", `bytes */${fileSize}`);
return res.status(416).end();
}
end = Math.min(end, fileSize - 1);
const chunkSize = end - start + 1;
res.status(206);
res.setHeader("Content-Range", `bytes ${start}-${end}/${fileSize}`);
res.setHeader("Content-Length", String(chunkSize));
return fs.createReadStream(fullPath, { start, end }).pipe(res);
}
router.get("/:id", (req, res) => {
streamMovieFile(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Video streaming error" });
});
});
router.head("/:id", (req, res) => {
streamMovieFile(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Video streaming error" });
});
});
module.exports = router;