add video backend server, fix styling and more #54
@@ -24,6 +24,7 @@ const movieRoutes = require("./routes/movies");
|
||||
const versionRoutes = require("./routes/version");
|
||||
const settingsRoutes = require("./routes/settings");
|
||||
const adminDbRoutes = require("./routes/adminDb");
|
||||
const videoRoutes = require("./routes/video");
|
||||
|
||||
app.use(express.static('public'));
|
||||
app.use("/api/users", userRoutes);
|
||||
@@ -31,6 +32,7 @@ app.use("/api/movies", movieRoutes);
|
||||
app.use("/api/version", versionRoutes);
|
||||
app.use("/api/settings", settingsRoutes);
|
||||
app.use("/api/admin", adminDbRoutes);
|
||||
app.use("/api/video", videoRoutes);
|
||||
|
||||
// Start Server
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
@@ -17,6 +17,9 @@ const movieSchema = new mongoose.Schema({
|
||||
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)
|
||||
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' }]
|
||||
}, { timestamps: true });
|
||||
|
||||
|
||||
@@ -354,6 +354,14 @@
|
||||
<div class="col-12 col-lg-8">
|
||||
<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">
|
||||
<div class="form-text">If you use a server file (below) and leave this empty, it will auto-use <code>/api/video/<movieId></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 class="col-12">
|
||||
<label for="embed_src" class="form-label">Embed URL or iframe code</label>
|
||||
@@ -700,6 +708,12 @@
|
||||
<div class="col-12">
|
||||
<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">
|
||||
<div class="form-text">If you use a server file (below) and leave this empty, it will auto-use <code>/api/video/<movieId></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 class="col-12">
|
||||
<label for="edit_embed_src" class="form-label">Embed URL or iframe code</label>
|
||||
|
||||
@@ -104,6 +104,7 @@ window.onload = async function () {
|
||||
const editVodLinkEl = document.getElementById('edit_vod_link');
|
||||
const editPlayerModeEl = document.getElementById('edit_player_mode');
|
||||
const editVideoSrcEl = document.getElementById('edit_video_src');
|
||||
const editVideoFileEl = document.getElementById('edit_video_file');
|
||||
const editEmbedSrcEl = document.getElementById('edit_embed_src');
|
||||
const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb');
|
||||
const editTitleEl = document.getElementById('edit_title');
|
||||
@@ -1817,6 +1818,7 @@ window.onload = async function () {
|
||||
const vod_link = document.getElementById('vod_link').value.trim();
|
||||
const player_mode = (document.getElementById('player_mode')?.value || 'auto').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() || '';
|
||||
|
||||
if (!year) {
|
||||
@@ -1829,8 +1831,8 @@ window.onload = async function () {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vod_link && !video_src && !embed_src) {
|
||||
showResponse('warning', 'Ajoute au moins une source (VOD / video_src / embed_src).');
|
||||
if (!vod_link && !video_src && !video_file && !embed_src) {
|
||||
showResponse('warning', 'Ajoute au moins une source (VOD / video_src / video_file / embed_src).');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1841,7 +1843,7 @@ window.onload = async function () {
|
||||
'Content-Type': 'application/json',
|
||||
'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) {
|
||||
@@ -2025,6 +2027,7 @@ window.onload = async function () {
|
||||
editVodLinkEl.value = movie.vod_link || '';
|
||||
if (editPlayerModeEl) editPlayerModeEl.value = movie.player_mode || 'auto';
|
||||
if (editVideoSrcEl) editVideoSrcEl.value = movie.video_src || '';
|
||||
if (editVideoFileEl) editVideoFileEl.value = movie.video_file || '';
|
||||
if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || '';
|
||||
editRefreshOmdbEl.checked = false;
|
||||
|
||||
@@ -2051,6 +2054,7 @@ window.onload = async function () {
|
||||
const vod_link = (editVodLinkEl.value || '').trim();
|
||||
const player_mode = (editPlayerModeEl?.value || 'auto').trim();
|
||||
const video_src = (editVideoSrcEl?.value || '').trim();
|
||||
const video_file = (editVideoFileEl?.value || '').trim();
|
||||
const embed_src = (editEmbedSrcEl?.value || '').trim();
|
||||
const refreshOmdb = !!editRefreshOmdbEl.checked;
|
||||
|
||||
@@ -2078,8 +2082,8 @@ window.onload = async function () {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vod_link && !video_src && !embed_src) {
|
||||
showResponse('warning', 'Ajoute au moins une source (VOD / video_src / embed_src).');
|
||||
if (!vod_link && !video_src && !video_file && !embed_src) {
|
||||
showResponse('warning', 'Ajoute au moins une source (VOD / video_src / video_file / embed_src).');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2090,6 +2094,7 @@ window.onload = async function () {
|
||||
vod_link,
|
||||
player_mode,
|
||||
video_src,
|
||||
video_file,
|
||||
embed_src,
|
||||
refreshOmdb
|
||||
};
|
||||
@@ -2163,10 +2168,12 @@ window.onload = async function () {
|
||||
function buildPlayerBadges(m) {
|
||||
const badges = [];
|
||||
const hasVideo = !!(m && m.video_src);
|
||||
const hasFile = !!(m && m.video_file);
|
||||
const hasEmbed = !!(m && m.embed_src);
|
||||
const hasLegacy = !!(m && m.vod_link);
|
||||
|
||||
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 (hasLegacy) badges.push({ text: 'Legacy', cls: 'bg-secondary' });
|
||||
|
||||
|
||||
+15
-2
@@ -428,13 +428,16 @@ function resolveMovieSource(movie) {
|
||||
const legacy = cleanUrlValue(movie?.vod_link);
|
||||
const video = cleanUrlValue(movie?.video_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).
|
||||
// So we only pick legacy if there is no other option, and mark it.
|
||||
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 picked = video || embed || null; // auto
|
||||
else picked = video || serverVideoUrl || embed || null; // auto
|
||||
|
||||
if (picked) return { src: picked, isLegacy: false };
|
||||
if (legacy) return { src: legacy, isLegacy: true };
|
||||
@@ -500,6 +503,16 @@ window.onload = async function () {
|
||||
}
|
||||
|
||||
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);
|
||||
if (!resolved?.src) {
|
||||
setOpenOriginal(null);
|
||||
|
||||
+54
-9
@@ -52,6 +52,16 @@ function normalizeOptionalString(v, maxLen = 2048) {
|
||||
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) {
|
||||
const s = String(v || "").trim().toLowerCase();
|
||||
if (!s) return "auto";
|
||||
@@ -66,6 +76,14 @@ function hasAnySource({ vod_link, video_src, embed_src }) {
|
||||
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
|
||||
async function fetchMovieDetailsFromOmdb(imdb_id) {
|
||||
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
|
||||
? "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 cached = cacheGet(cacheKey);
|
||||
@@ -312,7 +330,7 @@ router.patch("/users/updateWatchedMovies", async (req, res) => {
|
||||
|
||||
// Add movie
|
||||
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 {
|
||||
// Get the token from the Authorization header
|
||||
@@ -338,11 +356,19 @@ router.post("/add", async (req, res) => {
|
||||
const normalizedVod = normalizeOptionalString(vod_link, 4096);
|
||||
const normalizedVideo = normalizeOptionalString(video_src, 4096);
|
||||
const normalizedEmbed = normalizeOptionalString(embed_src, 20000);
|
||||
const normalizedFile = normalizeVideoFile(video_file);
|
||||
const normalizedMode = normalizePlayerMode(player_mode);
|
||||
if (normalizedMode === null) {
|
||||
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)." });
|
||||
}
|
||||
|
||||
@@ -377,9 +403,16 @@ router.post("/add", async (req, res) => {
|
||||
player_mode: normalizedMode,
|
||||
video_src: normalizedVideo || undefined,
|
||||
embed_src: normalizedEmbed || undefined,
|
||||
video_file: normalizedFile || undefined,
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
res.status(201).json({ message: "Movie added successfully!" });
|
||||
@@ -463,6 +496,7 @@ router.put("/:id", async (req, res) => {
|
||||
player_mode,
|
||||
video_src,
|
||||
embed_src,
|
||||
video_file,
|
||||
refreshOmdb,
|
||||
} = req.body || {};
|
||||
|
||||
@@ -513,6 +547,14 @@ router.put("/:id", async (req, res) => {
|
||||
const v = normalizeOptionalString(embed_src, 20000);
|
||||
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 !== "") {
|
||||
movie.year = parseOscarYear(year);
|
||||
@@ -542,12 +584,15 @@ router.put("/:id", async (req, res) => {
|
||||
if (poster !== undefined) movie.poster = poster;
|
||||
}
|
||||
|
||||
if (!hasAnySource({
|
||||
vod_link: movie.vod_link || "",
|
||||
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)." });
|
||||
if (
|
||||
!hasAnySourceIncludingFile({
|
||||
vod_link: movie.vod_link || "",
|
||||
video_src: movie.video_src || "",
|
||||
embed_src: movie.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();
|
||||
|
||||
+206
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user