diff --git a/models/Movie.js b/models/Movie.js
index 407ed1a..38f2cc0 100644
--- a/models/Movie.js
+++ b/models/Movie.js
@@ -21,6 +21,9 @@ const movieSchema = new mongoose.Schema({
// 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 },
+ // Optional low-quality server file (relative path under VIDEO_FILES_DIR, default public/video)
+ // Served when the player requests ?quality=low on /api/video/:movieId.
+ video_file_low: { type: String, required: false },
watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]
}, { timestamps: true });
diff --git a/public/control.html b/public/control.html
index c83ed79..fec3091 100644
--- a/public/control.html
+++ b/public/control.html
@@ -690,6 +690,11 @@
Chemin relatif sous VIDEO_FILES_DIR (défaut: public/video).
+
+
+
+
Chemin relatif sous VIDEO_FILES_DIR. Ex: LOW/123.LOW.mp4.
+
@@ -723,6 +728,7 @@
- Auto: utilise
video_src → embed_src → vod_link.
- Server file: mets
video_file (chemin relatif sous public/video).
+ - Low quality: mets
video_file_low (ex: LOW/mon_film.LOW.mp4).
- Édition: le bouton “Éditer” ouvre une modale avec des onglets Info / Player.
@@ -1385,6 +1391,11 @@
Relative path under VIDEO_FILES_DIR (default: public/video).
+
+
+
+
Relative path under VIDEO_FILES_DIR. Example: LOW/123.LOW.mp4.
+
diff --git a/public/js/app_control.js b/public/js/app_control.js
index 4b61ff8..04dcf25 100644
--- a/public/js/app_control.js
+++ b/public/js/app_control.js
@@ -112,6 +112,7 @@ window.onload = async function () {
const editPlayerModeEl = document.getElementById('edit_player_mode');
const editVideoSrcEl = document.getElementById('edit_video_src');
const editVideoFileEl = document.getElementById('edit_video_file');
+ const editVideoFileLowEl = document.getElementById('edit_video_file_low');
const editEmbedSrcEl = document.getElementById('edit_embed_src');
const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb');
const editTitleEl = document.getElementById('edit_title');
@@ -1927,6 +1928,7 @@ window.onload = async function () {
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 video_file_low = document.getElementById('video_file_low')?.value?.trim() || '';
const embed_src = document.getElementById('embed_src')?.value?.trim() || '';
if (!year) {
@@ -1946,7 +1948,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, video_file, embed_src, cam })
+ body: JSON.stringify({ year, imdb_id, category, vod_link, player_mode, video_src, video_file, video_file_low, embed_src, cam })
});
if (res.ok) {
@@ -2194,6 +2196,7 @@ window.onload = async function () {
if (editPlayerModeEl) editPlayerModeEl.value = movie.player_mode || 'auto';
if (editVideoSrcEl) editVideoSrcEl.value = movie.video_src || '';
if (editVideoFileEl) editVideoFileEl.value = movie.video_file || '';
+ if (editVideoFileLowEl) editVideoFileLowEl.value = movie.video_file_low || '';
if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || '';
editRefreshOmdbEl.checked = false;
@@ -2222,6 +2225,7 @@ window.onload = async function () {
const player_mode = (editPlayerModeEl?.value || 'auto').trim();
const video_src = (editVideoSrcEl?.value || '').trim();
const video_file = (editVideoFileEl?.value || '').trim();
+ const video_file_low = (editVideoFileLowEl?.value || '').trim();
const embed_src = (editEmbedSrcEl?.value || '').trim();
const refreshOmdb = !!editRefreshOmdbEl.checked;
const cam = !!editCamFlagEl?.checked;
@@ -2258,6 +2262,7 @@ window.onload = async function () {
player_mode,
video_src,
video_file,
+ video_file_low,
embed_src,
refreshOmdb,
cam
@@ -2333,11 +2338,13 @@ window.onload = async function () {
const badges = [];
const hasVideo = !!(m && m.video_src);
const hasFile = !!(m && m.video_file);
+ const hasLowFile = !!(m && m.video_file_low);
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 (hasLowFile) badges.push({ text: 'Low file', cls: 'bg-secondary' });
if (hasEmbed) badges.push({ text: 'Embed', cls: 'bg-info text-dark' });
if (hasLegacy) badges.push({ text: 'Legacy', cls: 'bg-secondary' });
diff --git a/public/js/app_player.js b/public/js/app_player.js
index 3c359b8..1a951f8 100644
--- a/public/js/app_player.js
+++ b/public/js/app_player.js
@@ -753,6 +753,21 @@ async function probeApiVideoReadable(rawUrl) {
}
}
+async function probeApiVideoExists(rawUrl) {
+ try {
+ const res = await fetch(rawUrl, {
+ method: 'GET',
+ headers: { Range: 'bytes=0-0' },
+ credentials: 'include',
+ cache: 'no-store',
+ });
+ if (res.status === 401 || res.status === 403 || res.status === 404) return false;
+ return res.ok;
+ } catch (_) {
+ return false;
+ }
+}
+
async function ensurePlayableApiVideoUrl(rawUrl, token) {
if (!isApiVideoUrl(rawUrl) || !token) return rawUrl;
// IMPORTANT: Prefer query-token auth for media elements.
@@ -883,6 +898,109 @@ function resolveMovieSource(movie) {
return { src: null, isLegacy: false };
}
+function qualityStorageKey(movieId) {
+ return `player_quality:${String(movieId || '')}`;
+}
+
+function getStoredQuality(movieId) {
+ const raw = localStorage.getItem(qualityStorageKey(movieId));
+ return raw === 'low' ? 'low' : 'high';
+}
+
+function setStoredQuality(movieId, quality) {
+ const value = quality === 'low' ? 'low' : 'high';
+ try { localStorage.setItem(qualityStorageKey(movieId), value); } catch (_) {}
+}
+
+function addQualityParam(rawUrl, quality) {
+ if (!rawUrl) return rawUrl;
+ try {
+ const isRelative = String(rawUrl).trim().startsWith('/');
+ const u = new URL(String(rawUrl), window.location.origin);
+ if (quality) u.searchParams.set('quality', quality);
+ else u.searchParams.delete('quality');
+ return isRelative ? `${u.pathname}${u.search}${u.hash}` : u.toString();
+ } catch (_) {
+ return rawUrl;
+ }
+}
+
+function setQualityToggleUi({ visible, isLow }) {
+ const wrap = document.getElementById('quality-toggle');
+ const toggle = document.getElementById('quality-switch');
+ const label = document.getElementById('quality-switch-label');
+ if (!wrap || !toggle || !label) return;
+ if (visible) {
+ wrap.classList.remove('d-none');
+ toggle.checked = !!isLow;
+ label.textContent = isLow ? 'Basse' : 'Haute';
+ } else {
+ wrap.classList.add('d-none');
+ toggle.checked = false;
+ label.textContent = 'Haute';
+ }
+}
+
+async function switchVideoSource({ src, token, preserveTime }) {
+ const videoEl = document.getElementById('video');
+ if (!videoEl || !src) return;
+
+ const shouldPreserve = !!preserveTime && Number.isFinite(videoEl.currentTime) && videoEl.currentTime > 0;
+ const previousTime = shouldPreserve ? Number(videoEl.currentTime) : 0;
+ const wasPlaying = !videoEl.paused && !videoEl.ended;
+
+ const onLoaded = () => {
+ if (previousTime > 1) {
+ try { videoEl.currentTime = previousTime; } catch (_) {}
+ }
+ if (wasPlaying) {
+ const playPromise = videoEl.play();
+ if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
+ }
+ };
+ videoEl.addEventListener('loadedmetadata', onLoaded, { once: true });
+
+ await playVodLink(src, { token });
+}
+
+async function initQualityToggle({ movieId, token, fallbackSrc }) {
+ const wrap = document.getElementById('quality-toggle');
+ const toggle = document.getElementById('quality-switch');
+ if (!wrap || !toggle) return { src: fallbackSrc };
+
+ if (!isApiVideoUrl(fallbackSrc)) {
+ setQualityToggleUi({ visible: false, isLow: false });
+ return { src: fallbackSrc };
+ }
+
+ const lowSrcRaw = addQualityParam(fallbackSrc, 'low');
+ const playableLow = await ensurePlayableApiVideoUrl(lowSrcRaw, token);
+ const hasLow = await probeApiVideoExists(playableLow);
+ if (!hasLow) {
+ setQualityToggleUi({ visible: false, isLow: false });
+ return { src: fallbackSrc };
+ }
+
+ const preferred = getStoredQuality(movieId);
+ const shouldUseLow = preferred === 'low';
+ const pickedSrc = shouldUseLow ? lowSrcRaw : fallbackSrc;
+
+ setQualityToggleUi({ visible: true, isLow: shouldUseLow });
+
+ toggle.addEventListener('change', async () => {
+ const nextIsLow = !!toggle.checked;
+ setStoredQuality(movieId, nextIsLow ? 'low' : 'high');
+ setQualityToggleUi({ visible: true, isLow: nextIsLow });
+ await switchVideoSource({
+ src: nextIsLow ? lowSrcRaw : fallbackSrc,
+ token,
+ preserveTime: true,
+ });
+ });
+
+ return { src: pickedSrc };
+}
+
window.onload = async function () {
const pageLoader = createPageLoader({ title: 'Chargement du lecteur' });
@@ -987,7 +1105,9 @@ window.onload = async function () {
}
pageLoader.setProgress(70);
- await playVodLink(resolved.src, { token });
+ const qualityChoice = await initQualityToggle({ movieId: id, token, fallbackSrc: resolved.src });
+ const selectedSrc = qualityChoice?.src || resolved.src;
+ await playVodLink(selectedSrc, { token });
pageLoader.setProgress(82);
// Only the
-
-
-
-
Source
-
—
+
+
-
-
Progress
-
-
- —
-
+
+
+
+ Source
+ —
+
+
+
+ Progress
+
+
+ —
+
+
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..c33e3fc 100644
--- a/routes/movies.js
+++ b/routes/movies.js
@@ -148,7 +148,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 updatedAt createdAt";
const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`;
const cached = cacheGet(cacheKey);
@@ -333,7 +333,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, video_file, cam } = req.body;
+ const { imdb_id, category, vod_link, year, player_mode, video_src, embed_src, video_file, video_file_low, cam } = req.body;
try {
// Get the token from the Authorization header
@@ -360,6 +360,7 @@ 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)" });
@@ -398,6 +399,7 @@ router.post("/add", async (req, res) => {
video_src: normalizedVideo || undefined,
embed_src: normalizedEmbed || undefined,
video_file: normalizedFile || undefined,
+ video_file_low: normalizedLowFile || undefined,
});
await movie.save();
@@ -491,6 +493,7 @@ router.put("/:id", async (req, res) => {
video_src,
embed_src,
video_file,
+ video_file_low,
refreshOmdb,
cam,
} = req.body || {};
@@ -550,6 +553,10 @@ 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;
+ }
if (year !== undefined && year !== null && year !== "") {
movie.year = parseOscarYear(year);
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" });
}