feat: Add server-hosted video streaming support

Introduces a new /api/video route for streaming video files stored on the server. This allows for direct playback of local video files with seeking capabilities. The Movie model and UI have been updated to support this new feature.

Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
Cursor Agent
2025-12-22 05:12:06 +00:00
co-authored by mathieumoran1
parent f54b1d91c5
commit 2f767e15a6
7 changed files with 306 additions and 16 deletions
+54 -9
View File
@@ -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
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;