From f9d8722b4e8f7c6f5bed771358859935942af993 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sat, 24 Jan 2026 09:26:23 -0500 Subject: [PATCH] Add low-quality server video support Include low-quality video file paths in movie settings, expose them through the admin UI, and add a player toggle that streams /api/video with quality=low when available. Extend both Node and Python video endpoints to serve the low-quality file. Test: not run (mark for testing) --- models/Movie.js | 3 + public/control.html | 11 +++ public/js/app_control.js | 9 ++- public/js/app_player.js | 122 +++++++++++++++++++++++++++++++++- public/player.html | 32 ++++++--- python_video_server/server.py | 18 +++-- routes/movies.js | 11 ++- routes/video.js | 16 +++-- 8 files changed, 197 insertions(+), 25 deletions(-) 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 @@
@@ -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 - — +
+
+ Qualité +
+ + +
-
- 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" }); }