-
Source
-
—
+
+
+
Qualité
+
+
+
+
-
-
Progress
-
-
- —
-
+
+
+
+ Source
+ —
+
+
+
+ Progress
+
+
+ —
+
+
diff --git a/python_video_server/README.md b/python_video_server/README.md
index 8091ee3..e0ba9a7 100644
--- a/python_video_server/README.md
+++ b/python_video_server/README.md
@@ -15,7 +15,10 @@ export MONGO_DB_NAME="..." # optional; required if MO
export JWT_SECRET="..."
export VIDEO_FILES_DIR="/absolute/path/to/public/video" # optional (default: ./public/video)
export VIDEO_SESSION_MAX_AGE_SECONDS="28800" # optional (default: 8h)
+
uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000
+or
+python3 -m uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000 --ssl-certfile python_video_server/certs/config/live/[site]/fullchain.pem --ssl-keyfile python_video_server/certs/config/live/[site]/privkey.pem
```
## Extra environment variables (optional)
diff --git a/python_video_server/server.py b/python_video_server/server.py
index fee364f..ff2a48c 100644
--- a/python_video_server/server.py
+++ b/python_video_server/server.py
@@ -274,7 +274,7 @@ def stream_video(
try:
client = _mongo_client_cached()
db = client[db_name]
- movie = db["movies"].find_one({"_id": oid}, {"video_file": 1})
+ movie = db["movies"].find_one({"_id": oid}, {"video_file": 1, "video_file_low": 1})
except PyMongoError as e:
raise HTTPException(
status_code=502,
@@ -285,15 +285,23 @@ def stream_video(
),
)
+ quality = (request.query_params.get("quality") or "").strip().lower()
+ wants_low = quality == "low"
+
video_file = (movie or {}).get("video_file") or ""
- if not isinstance(video_file, str) or not video_file.strip():
+ video_file_low = (movie or {}).get("video_file_low") or ""
+ chosen = video_file_low if wants_low else video_file
+
+ if not isinstance(chosen, str) or not chosen.strip():
+ if wants_low:
+ raise HTTPException(status_code=404, detail="No low-quality server video file configured for this movie")
raise HTTPException(status_code=404, detail="No server video file configured for this movie")
- video_file = video_file.strip()
- if not _is_safe_relative_file(video_file):
+ chosen = chosen.strip()
+ if not _is_safe_relative_file(chosen):
raise HTTPException(status_code=400, detail="Invalid video_file path")
video_dir = _video_files_dir()
- full_path = (video_dir / video_file).resolve()
+ full_path = (video_dir / chosen).resolve()
if not str(full_path).startswith(str(video_dir) + os.sep) and full_path != video_dir:
raise HTTPException(status_code=400, detail="Invalid video_file path")
if not full_path.exists() or not full_path.is_file():
diff --git a/routes/movies.js b/routes/movies.js
index 43094e8..f60beea 100644
--- a/routes/movies.js
+++ b/routes/movies.js
@@ -1,4 +1,7 @@
const express = require("express");
+const fs = require("fs");
+const path = require("path");
+const multer = require("multer");
const Movie = require("../models/Movie");
const User = require("../models/User");
const PlaybackProgress = require("../models/PlaybackProgress");
@@ -13,6 +16,8 @@ const router = express.Router();
// This is safe because movie lists are identical for all users and change rarely.
const MOVIES_CACHE_TTL_MS = Number(process.env.MOVIES_CACHE_TTL_MS || "30000"); // 30s
const _cache = new Map(); // key -> { expiresAt:number, value:any }
+const SUBTITLE_FILES_DIR = path.resolve(process.env.SUBTITLE_FILES_DIR || path.join(process.cwd(), "subtitles"));
+const MAX_SUBTITLE_UPLOAD_BYTES = 10 * 1024 * 1024; // 10MB
function cacheGet(key) {
const hit = _cache.get(key);
@@ -62,6 +67,132 @@ function normalizeVideoFile(v) {
return s.slice(0, 1024);
}
+function normalizeSubtitleLang(v) {
+ return normalizeOptionalString(v, 24);
+}
+
+function normalizeSubtitleLabel(v) {
+ return normalizeOptionalString(v, 80);
+}
+
+function parseSubtitleDefault(v) {
+ if (v === undefined) return undefined;
+ if (typeof v === "boolean") return v;
+ const s = String(v).trim().toLowerCase();
+ if (!s) return undefined;
+ if (["true", "1", "yes", "on"].includes(s)) return true;
+ if (["false", "0", "no", "off"].includes(s)) return false;
+ return undefined;
+}
+
+function parseBooleanFlag(v) {
+ if (v === undefined) return undefined;
+ if (typeof v === "boolean") return v;
+ const s = String(v).trim().toLowerCase();
+ if (!s) return undefined;
+ if (["true", "1", "yes", "on"].includes(s)) return true;
+ if (["false", "0", "no", "off"].includes(s)) return false;
+ return undefined;
+}
+
+function normalizeSubtitleEntry({ file, lang, label, isDefault } = {}) {
+ const safeFile = typeof file === "string" ? file.trim() : "";
+ if (!safeFile) return null;
+ return {
+ file: safeFile,
+ lang: normalizeSubtitleLang(lang) || null,
+ label: normalizeSubtitleLabel(label) || null,
+ default: !!isDefault,
+ };
+}
+
+function getLegacySubtitle(movie) {
+ const file = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
+ if (!file) return null;
+ return {
+ _id: "legacy",
+ file,
+ lang: typeof movie?.subtitle_lang === "string" ? movie.subtitle_lang : null,
+ label: typeof movie?.subtitle_label === "string" ? movie.subtitle_label : null,
+ default: !!movie?.subtitle_default,
+ };
+}
+
+function getSubtitlesForResponse(movie) {
+ const subs = Array.isArray(movie?.subtitles) ? movie.subtitles.filter(Boolean) : [];
+ if (subs.length > 0) return subs;
+ const legacy = getLegacySubtitle(movie);
+ return legacy ? [legacy] : [];
+}
+
+function syncLegacyFromSubtitles(movie) {
+ const subs = Array.isArray(movie?.subtitles) ? movie.subtitles : [];
+ if (!subs.length) {
+ movie.subtitle_file = null;
+ movie.subtitle_lang = null;
+ movie.subtitle_label = null;
+ movie.subtitle_default = false;
+ return;
+ }
+ const preferred = subs.find((s) => s?.default) || subs[0];
+ movie.subtitle_file = preferred?.file || null;
+ movie.subtitle_lang = preferred?.lang || null;
+ movie.subtitle_label = preferred?.label || null;
+ movie.subtitle_default = !!preferred?.default;
+}
+
+async function ensureSubtitleDir() {
+ await fs.promises.mkdir(SUBTITLE_FILES_DIR, { recursive: true });
+}
+
+function safeFilenameBase(raw) {
+ return String(raw || "").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 32) || "movie";
+}
+
+const subtitleUpload = multer({
+ storage: multer.diskStorage({
+ destination: async (_req, _file, cb) => {
+ try {
+ await ensureSubtitleDir();
+ cb(null, SUBTITLE_FILES_DIR);
+ } catch (err) {
+ cb(err);
+ }
+ },
+ filename: (req, file, cb) => {
+ const safeId = safeFilenameBase(req.params?.id);
+ const ext = path.extname(file.originalname || "").toLowerCase() || ".vtt";
+ const name = `${safeId}-${Date.now()}-${Math.random().toString(16).slice(2)}${ext}`;
+ cb(null, name);
+ },
+ }),
+ limits: { fileSize: MAX_SUBTITLE_UPLOAD_BYTES },
+ fileFilter: (_req, file, cb) => {
+ const ext = path.extname(file.originalname || "").toLowerCase();
+ if (ext !== ".vtt" && ext !== ".srt") return cb(new Error("Only .srt or .vtt subtitle files are allowed"));
+ return cb(null, true);
+ },
+});
+
+async function convertSrtToVtt(filePath) {
+ const content = await fs.promises.readFile(filePath, "utf8");
+ const normalized = content.replace(/\r\n/g, "\n");
+ const body = normalized.replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, "$1.$2");
+ const positioned = body.replace(
+ /^(\d{2}:\d{2}:\d{2}\.\d{3}\s+-->\s+\d{2}:\d{2}:\d{2}\.\d{3})(.*)$/gm,
+ (_match, timing, settings) => {
+ const tail = String(settings || "");
+ if (/\bline:/i.test(tail)) return `${timing}${tail}`;
+ return `${timing}${tail} line:90%`;
+ },
+ );
+ const vtt = `WEBVTT\n\n${positioned.trim()}\n`;
+ const vttPath = filePath.replace(/\.srt$/i, ".vtt");
+ await fs.promises.writeFile(vttPath, vtt, "utf8");
+ try { await fs.promises.unlink(filePath); } catch (_) {}
+ return vttPath;
+}
+
function normalizePlayerMode(v) {
const s = String(v || "").trim().toLowerCase();
if (!s) return "auto";
@@ -148,7 +279,7 @@ router.get("/", async (req, res) => {
const projection = isChecklist
? "imdb_id title"
- : "imdb_id title description rating poster cam year category vod_link player_mode video_src embed_src video_file updatedAt createdAt";
+ : "imdb_id title description rating poster cam year category vod_link player_mode video_src embed_src video_file video_file_low subtitles subtitle_file subtitle_lang subtitle_label subtitle_default updatedAt createdAt";
const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`;
const cached = cacheGet(cacheKey);
@@ -162,9 +293,10 @@ router.get("/", async (req, res) => {
.sort({ title: 1 })
.lean();
- cacheSet(cacheKey, movies);
+ const payload = movies.map((m) => ({ ...m, subtitles: getSubtitlesForResponse(m) }));
+ cacheSet(cacheKey, payload);
res.set("Cache-Control", "public, max-age=30");
- res.status(200).json(movies);
+ res.status(200).json(payload);
} catch (err) {
res.status(500).json({ error: err.message });
}
@@ -333,7 +465,21 @@ 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, video_file, cam } = req.body;
+ const {
+ imdb_id,
+ category,
+ vod_link,
+ year,
+ player_mode,
+ video_src,
+ embed_src,
+ video_file,
+ video_file_low,
+ subtitle_lang,
+ subtitle_label,
+ subtitle_default,
+ cam,
+ } = req.body;
try {
// Get the token from the Authorization header
@@ -360,10 +506,14 @@ router.post("/add", async (req, res) => {
const normalizedVideo = normalizeOptionalString(video_src, 4096);
const normalizedEmbed = normalizeOptionalString(embed_src, 20000);
const normalizedFile = normalizeVideoFile(video_file);
+ const normalizedLowFile = normalizeVideoFile(video_file_low);
const normalizedMode = normalizePlayerMode(player_mode);
if (normalizedMode === null) {
return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" });
}
+ const normalizedSubtitleLang = normalizeSubtitleLang(subtitle_lang);
+ const normalizedSubtitleLabel = normalizeSubtitleLabel(subtitle_label);
+ const normalizedSubtitleDefault = parseSubtitleDefault(subtitle_default) ?? false;
// Fetch movie details from OMDb API
let movieDetails;
@@ -398,6 +548,10 @@ router.post("/add", async (req, res) => {
video_src: normalizedVideo || undefined,
embed_src: normalizedEmbed || undefined,
video_file: normalizedFile || undefined,
+ video_file_low: normalizedLowFile || undefined,
+ subtitle_lang: normalizedSubtitleLang || undefined,
+ subtitle_label: normalizedSubtitleLabel || undefined,
+ subtitle_default: normalizedSubtitleDefault,
});
await movie.save();
@@ -409,7 +563,7 @@ router.post("/add", async (req, res) => {
}
cacheClear();
- res.status(201).json({ message: "Movie added successfully!" });
+ res.status(201).json({ message: "Movie added successfully!", movie });
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
@@ -418,6 +572,164 @@ router.post("/add", async (req, res) => {
}
});
+// Admin: upload subtitles for a movie (VTT)
+router.post("/:id/subtitles", (req, res) => {
+ const token = req.headers.authorization?.split(" ")[1];
+ if (!token) return res.status(401).json({ message: "Authentication token is required" });
+
+ try {
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
+ if (!decoded.admin) {
+ return res.status(403).json({ message: "You do not have admin privileges" });
+ }
+ } catch (err) {
+ if (err instanceof jwt.JsonWebTokenError) {
+ return res.status(401).json({ message: "Invalid or expired token" });
+ }
+ return res.status(500).json({ error: err.message });
+ }
+
+ subtitleUpload.single("subtitle")(req, res, async (err) => {
+ if (err) {
+ const isSize = err instanceof multer.MulterError && err.code === "LIMIT_FILE_SIZE";
+ return res.status(isSize ? 413 : 400).json({ message: err.message || "Upload failed" });
+ }
+
+ const { id } = req.params;
+ if (!mongoose.Types.ObjectId.isValid(id)) {
+ if (req.file?.path) {
+ try { await fs.promises.unlink(req.file.path); } catch (_) {}
+ }
+ return res.status(400).json({ message: "Invalid movie id" });
+ }
+
+ const movie = await Movie.findById(id);
+ if (!movie) {
+ if (req.file?.path) {
+ try { await fs.promises.unlink(req.file.path); } catch (_) {}
+ }
+ return res.status(404).json({ message: "Movie not found" });
+ }
+
+ if (!req.file?.path) {
+ return res.status(400).json({ message: "No subtitle file uploaded" });
+ }
+
+ let storedPath = req.file.path;
+ if (storedPath.toLowerCase().endsWith(".srt")) {
+ try {
+ storedPath = await convertSrtToVtt(storedPath);
+ } catch (e) {
+ return res.status(400).json({ message: "Failed to convert .srt to .vtt" });
+ }
+ }
+
+ if (!Array.isArray(movie.subtitles)) movie.subtitles = [];
+ if (movie.subtitles.length === 0) {
+ const legacy = normalizeSubtitleEntry({
+ file: movie.subtitle_file,
+ lang: movie.subtitle_lang,
+ label: movie.subtitle_label,
+ isDefault: movie.subtitle_default,
+ });
+ if (legacy) movie.subtitles.push(legacy);
+ }
+
+ const rel = path.relative(SUBTITLE_FILES_DIR, storedPath).replace(/\\/g, "/");
+ const incomingLang = normalizeSubtitleLang(req.body?.subtitle_lang);
+ const incomingLabel = normalizeSubtitleLabel(req.body?.subtitle_label);
+ const incomingDefault = parseSubtitleDefault(req.body?.subtitle_default);
+
+ const shouldDefault = incomingDefault !== undefined
+ ? incomingDefault
+ : movie.subtitles.length === 0;
+
+ if (shouldDefault) {
+ movie.subtitles.forEach((s) => { s.default = false; });
+ }
+
+ const entry = normalizeSubtitleEntry({
+ file: rel,
+ lang: incomingLang,
+ label: incomingLabel,
+ isDefault: shouldDefault,
+ });
+ if (entry) movie.subtitles.push(entry);
+
+ syncLegacyFromSubtitles(movie);
+ await movie.save();
+ cacheClear();
+ return res.status(200).json(movie);
+ });
+});
+
+// Admin: delete one subtitle track from a movie
+router.delete("/:id/subtitles/:subtitleId", async (req, res) => {
+ const token = req.headers.authorization?.split(" ")[1];
+ if (!token) return res.status(401).json({ message: "Authentication token is required" });
+
+ try {
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
+ if (!decoded.admin) {
+ return res.status(403).json({ message: "You do not have admin privileges" });
+ }
+ } catch (err) {
+ if (err instanceof jwt.JsonWebTokenError) {
+ return res.status(401).json({ message: "Invalid or expired token" });
+ }
+ return res.status(500).json({ error: err.message });
+ }
+
+ const { id, subtitleId } = req.params;
+ if (!mongoose.Types.ObjectId.isValid(id)) {
+ return res.status(400).json({ message: "Invalid movie id" });
+ }
+
+ const movie = await Movie.findById(id);
+ if (!movie) return res.status(404).json({ message: "Movie not found" });
+
+ if (subtitleId === "legacy") {
+ const existing = typeof movie.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
+ if (!existing) return res.status(404).json({ message: "Subtitle not found" });
+ const oldPath = path.resolve(SUBTITLE_FILES_DIR, existing);
+ if (oldPath.startsWith(SUBTITLE_FILES_DIR + path.sep)) {
+ try { await fs.promises.unlink(oldPath); } catch (_) {}
+ }
+ movie.subtitle_file = null;
+ movie.subtitle_lang = null;
+ movie.subtitle_label = null;
+ movie.subtitle_default = false;
+ await movie.save();
+ cacheClear();
+ return res.status(200).json(movie);
+ }
+
+ const subs = Array.isArray(movie.subtitles) ? movie.subtitles : [];
+ const idx = subs.findIndex((s) => String(s?._id || "") === String(subtitleId));
+ if (idx < 0) {
+ return res.status(404).json({ message: "Subtitle not found" });
+ }
+
+ const removed = subs[idx];
+ if (removed?.file) {
+ const full = path.resolve(SUBTITLE_FILES_DIR, String(removed.file));
+ if (full.startsWith(SUBTITLE_FILES_DIR + path.sep)) {
+ try { await fs.promises.unlink(full); } catch (_) {}
+ }
+ }
+
+ subs.splice(idx, 1);
+ if (subs.length > 0 && !subs.some((s) => s?.default)) {
+ subs[0].default = true;
+ }
+ movie.subtitles = subs;
+ syncLegacyFromSubtitles(movie);
+
+ await movie.save();
+ cacheClear();
+ return res.status(200).json(movie);
+});
+
// Admin: delete one or more movies (by Mongo _id or imdb_id)
router.delete("/delete", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
@@ -491,6 +803,11 @@ router.put("/:id", async (req, res) => {
video_src,
embed_src,
video_file,
+ video_file_low,
+ subtitle_lang,
+ subtitle_label,
+ subtitle_default,
+ subtitle_remove,
refreshOmdb,
cam,
} = req.body || {};
@@ -550,6 +867,73 @@ router.put("/:id", async (req, res) => {
movie.video_src = `/api/video/${movie._id}`;
}
}
+ if (video_file_low !== undefined) {
+ const v = normalizeVideoFile(video_file_low);
+ movie.video_file_low = v ? v : null;
+ }
+
+ const removeSubtitles = parseBooleanFlag(subtitle_remove);
+ if (removeSubtitles) {
+ const entries = Array.isArray(movie.subtitles) ? movie.subtitles : [];
+ for (const entry of entries) {
+ const rel = typeof entry?.file === "string" ? entry.file.trim() : "";
+ if (!rel) continue;
+ const full = path.resolve(SUBTITLE_FILES_DIR, rel);
+ if (full.startsWith(SUBTITLE_FILES_DIR + path.sep)) {
+ try { await fs.promises.unlink(full); } catch (_) {}
+ }
+ }
+
+ const existing = typeof movie.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
+ if (existing) {
+ const oldPath = path.resolve(SUBTITLE_FILES_DIR, existing);
+ if (oldPath.startsWith(SUBTITLE_FILES_DIR + path.sep)) {
+ try { await fs.promises.unlink(oldPath); } catch (_) {}
+ }
+ }
+
+ movie.subtitles = [];
+ movie.subtitle_file = null;
+ movie.subtitle_lang = null;
+ movie.subtitle_label = null;
+ movie.subtitle_default = false;
+ } else {
+ const subs = Array.isArray(movie.subtitles) ? movie.subtitles : [];
+ if (subs.length > 0) {
+ const target = subs.find((s) => s?.default) || subs[0];
+ if (subtitle_lang !== undefined) {
+ const v = normalizeSubtitleLang(subtitle_lang);
+ target.lang = v ? v : null;
+ }
+ if (subtitle_label !== undefined) {
+ const v = normalizeSubtitleLabel(subtitle_label);
+ target.label = v ? v : null;
+ }
+ if (subtitle_default !== undefined) {
+ const v = parseSubtitleDefault(subtitle_default);
+ if (v !== undefined) {
+ subs.forEach((s) => { s.default = false; });
+ target.default = !!v;
+ }
+ }
+ syncLegacyFromSubtitles(movie);
+ } else {
+ if (subtitle_lang !== undefined) {
+ const v = normalizeSubtitleLang(subtitle_lang);
+ movie.subtitle_lang = v ? v : null;
+ }
+
+ if (subtitle_label !== undefined) {
+ const v = normalizeSubtitleLabel(subtitle_label);
+ movie.subtitle_label = v ? v : null;
+ }
+
+ if (subtitle_default !== undefined) {
+ const v = parseSubtitleDefault(subtitle_default);
+ if (v !== undefined) movie.subtitle_default = v;
+ }
+ }
+ }
if (year !== undefined && year !== null && year !== "") {
movie.year = parseOscarYear(year);
@@ -774,7 +1158,7 @@ router.get("/:id", async (req, res) => {
const movie = await Movie.findById(id).lean();
if (!movie) return res.status(404).json({ message: "Movie not found" });
- return res.status(200).json(movie);
+ return res.status(200).json({ ...movie, subtitles: getSubtitlesForResponse(movie) });
} catch (err) {
return res.status(500).json({ error: err.message });
}
diff --git a/routes/subtitles.js b/routes/subtitles.js
new file mode 100644
index 0000000..5f9d6db
--- /dev/null
+++ b/routes/subtitles.js
@@ -0,0 +1,139 @@
+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();
+const SUBTITLE_FILES_DIR = path.resolve(process.env.SUBTITLE_FILES_DIR || path.join(process.cwd(), "subtitles"));
+
+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 getAuthToken(req) {
+ const cookies = parseCookies(req.headers.cookie);
+ const cookieToken = cookies.video_auth ? String(cookies.video_auth) : "";
+ if (cookieToken) return cookieToken;
+ const authHeader = String(req.headers.authorization || "");
+ const headerToken = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : "";
+ if (headerToken) return headerToken;
+ 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 subtitle 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 subtitle auth token" }) };
+ }
+}
+
+function isSafeRelativeFile(p) {
+ if (typeof p !== "string") return false;
+ const s = p.trim();
+ if (!s) return false;
+ if (s.includes("\0")) return false;
+ if (path.isAbsolute(s)) return false;
+ const norm = path.posix.normalize(s.replaceAll("\\", "/"));
+ if (norm.startsWith("../") || norm === "..") return false;
+ return true;
+}
+
+async function streamSubtitle(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("subtitles subtitle_file subtitle_lang subtitle_label subtitle_default").lean();
+ if (!movie) return res.status(404).json({ message: "Movie not found" });
+ let subtitleFile = "";
+ const subtitleId = req.params?.subtitleId;
+ const subs = Array.isArray(movie?.subtitles) ? movie.subtitles : [];
+
+ if (subtitleId) {
+ if (subtitleId === "legacy") {
+ subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
+ } else {
+ const match = subs.find((s) => String(s?._id || "") === String(subtitleId));
+ subtitleFile = typeof match?.file === "string" ? match.file.trim() : "";
+ }
+ } else if (subs.length > 0) {
+ const preferred = subs.find((s) => s?.default) || subs[0];
+ subtitleFile = typeof preferred?.file === "string" ? preferred.file.trim() : "";
+ } else {
+ subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
+ }
+
+ if (!subtitleFile) return res.status(404).json({ message: "No subtitles configured for this movie" });
+ if (!isSafeRelativeFile(subtitleFile)) return res.status(400).json({ message: "Invalid subtitle_file path" });
+
+ const fullPath = path.resolve(SUBTITLE_FILES_DIR, subtitleFile);
+ if (!fullPath.startsWith(SUBTITLE_FILES_DIR + path.sep) && fullPath !== SUBTITLE_FILES_DIR) {
+ return res.status(400).json({ message: "Invalid subtitle_file path" });
+ }
+
+ let stat;
+ try {
+ stat = await fs.promises.stat(fullPath);
+ } catch (_) {
+ return res.status(404).json({ message: "Subtitle file not found on server" });
+ }
+ if (!stat.isFile()) return res.status(404).json({ message: "Subtitle file not found on server" });
+
+ res.setHeader("Content-Type", "text/vtt; charset=utf-8");
+ res.setHeader("Content-Length", String(stat.size));
+ res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
+
+ if (req.method === "HEAD") {
+ return res.status(200).end();
+ }
+
+ return fs.createReadStream(fullPath).pipe(res);
+}
+
+router.get("/:id/:subtitleId", (req, res) => {
+ streamSubtitle(req, res).catch((err) => {
+ return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
+ });
+});
+
+router.head("/:id/:subtitleId", (req, res) => {
+ streamSubtitle(req, res).catch((err) => {
+ return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
+ });
+});
+
+router.get("/:id", (req, res) => {
+ streamSubtitle(req, res).catch((err) => {
+ return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
+ });
+});
+
+router.head("/:id", (req, res) => {
+ streamSubtitle(req, res).catch((err) => {
+ return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
+ });
+});
+
+module.exports = router;
diff --git a/routes/video.js b/routes/video.js
index ef10361..8b77be9 100644
--- a/routes/video.js
+++ b/routes/video.js
@@ -119,17 +119,23 @@ async function streamMovieFile(req, res) {
return res.status(400).json({ message: "Invalid movie id" });
}
- const movie = await Movie.findById(id).select("video_file").lean();
+ const qualityRaw = typeof req.query?.quality === "string" ? req.query.quality.trim().toLowerCase() : "";
+ const wantsLow = qualityRaw === "low";
+
+ const movie = await Movie.findById(id).select("video_file video_file_low").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" });
+ const videoFileLow = typeof movie?.video_file_low === "string" ? movie.video_file_low.trim() : "";
+
+ const chosen = wantsLow ? videoFileLow : videoFile;
+ if (!chosen) {
+ return res.status(404).json({ message: wantsLow ? "No low-quality server video file configured for this movie" : "No server video file configured for this movie" });
}
- if (!isSafeRelativeFile(videoFile)) {
+ if (!isSafeRelativeFile(chosen)) {
return res.status(400).json({ message: "Invalid video_file path" });
}
const baseDir = getVideoBaseDir();
- const fullPath = path.resolve(baseDir, videoFile);
+ const fullPath = path.resolve(baseDir, chosen);
if (!fullPath.startsWith(baseDir + path.sep) && fullPath !== baseDir) {
return res.status(400).json({ message: "Invalid video_file path" });
}