From 1c4b18f8aa9569b42b3b551119c04573cf29d7a1 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 22 Dec 2025 21:22:28 -0500 Subject: [PATCH 1/6] fix logo (#65) --- public/img/logo_w.png | Bin 12312 -> 11916 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/public/img/logo_w.png b/public/img/logo_w.png index e848fd30181127e6e5d39cf38ee42a81d64d6a2b..a4ea16eba8d51c4b124c29f16976b81eeb7bbf81 100644 GIT binary patch literal 11916 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 -- 2.54.0 From f9d8722b4e8f7c6f5bed771358859935942af993 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sat, 24 Jan 2026 09:26:23 -0500 Subject: [PATCH 2/6] 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 @@
  • 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 - — +
+
+ 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" }); } -- 2.54.0 From 9076461c7e73d7b64464d9b1633d768cc6763994 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sun, 25 Jan 2026 00:27:46 -0500 Subject: [PATCH 3/6] Ignore subtitles uploads directory Prevents subtitle files from being added to the repository. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 9f84c8d..b78d298 100644 --- a/.gitignore +++ b/.gitignore @@ -147,6 +147,9 @@ backups/ # Video files in public/video/ public/video/* +# Subtitles +subtitles/* + # Uploads directory (temporary files) uploads/ -- 2.54.0 From a9592ece7a41b139511139e2ce2f59c6970ea79c Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sun, 25 Jan 2026 00:29:04 -0500 Subject: [PATCH 4/6] Add subtitle upload and streaming support Adds movie subtitle metadata and admin upload flow with SRT to VTT conversion, plus player/control UI and API route updates for subtitle delivery. --- index.js | 2 + models/Movie.js | 5 ++ public/control.html | 38 ++++++++ public/js/app_control.js | 105 +++++++++++++++++++++- public/js/app_player.js | 87 ++++++++++++++---- public/player.html | 53 +++++++++-- routes/movies.js | 189 ++++++++++++++++++++++++++++++++++++++- routes/subtitles.js | 109 ++++++++++++++++++++++ 8 files changed, 563 insertions(+), 25 deletions(-) create mode 100644 routes/subtitles.js diff --git a/index.js b/index.js index 049b6c3..ffff9e8 100644 --- a/index.js +++ b/index.js @@ -25,6 +25,7 @@ const versionRoutes = require("./routes/version"); const settingsRoutes = require("./routes/settings"); const adminDbRoutes = require("./routes/adminDb"); const videoRoutes = require("./routes/video"); +const subtitlesRoutes = require("./routes/subtitles"); const picksRoutes = require("./routes/picks"); const categoriesRoutes = require("./routes/categories"); @@ -35,6 +36,7 @@ app.use("/api/version", versionRoutes); app.use("/api/settings", settingsRoutes); app.use("/api/admin", adminDbRoutes); app.use("/api/video", videoRoutes); +app.use("/api/subtitles", subtitlesRoutes); app.use("/api/picks", picksRoutes); app.use("/api/categories", categoriesRoutes); diff --git a/models/Movie.js b/models/Movie.js index 38f2cc0..635275d 100644 --- a/models/Movie.js +++ b/models/Movie.js @@ -24,6 +24,11 @@ const movieSchema = new mongoose.Schema({ // 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 }, + // Optional subtitles (server-hosted .vtt, streamed via /api/subtitles/:movieId) + subtitle_file: { type: String, required: false }, + subtitle_lang: { type: String, required: false }, + subtitle_label: { type: String, required: false }, + subtitle_default: { type: Boolean, required: false, default: false }, watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }] }, { timestamps: true }); diff --git a/public/control.html b/public/control.html index fec3091..70036c9 100644 --- a/public/control.html +++ b/public/control.html @@ -695,6 +695,25 @@
Chemin relatif sous VIDEO_FILES_DIR. Ex: LOW/123.LOW.mp4.
+
+ + +
Upload d'un fichier .srt (converti en VTT serveur, interne uniquement).
+
+
+ + +
+
+ + +
+
+
+ + +
+
@@ -1396,6 +1415,25 @@
Relative path under VIDEO_FILES_DIR. Example: LOW/123.LOW.mp4.
+
+ + +
Fichier actuel: —
+
+
+ + +
+
+ + +
+
+
+ + +
+
diff --git a/public/js/app_control.js b/public/js/app_control.js index 04dcf25..0ba40d1 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -113,6 +113,11 @@ window.onload = async function () { const editVideoSrcEl = document.getElementById('edit_video_src'); const editVideoFileEl = document.getElementById('edit_video_file'); const editVideoFileLowEl = document.getElementById('edit_video_file_low'); + const editSubtitleFileEl = document.getElementById('edit_subtitle_file'); + const editSubtitleLangEl = document.getElementById('edit_subtitle_lang'); + const editSubtitleLabelEl = document.getElementById('edit_subtitle_label'); + const editSubtitleDefaultEl = document.getElementById('edit_subtitle_default'); + const editSubtitleCurrentEl = document.getElementById('edit_subtitle_current'); const editEmbedSrcEl = document.getElementById('edit_embed_src'); const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb'); const editTitleEl = document.getElementById('edit_title'); @@ -159,6 +164,37 @@ window.onload = async function () { .replace(/'/g, '''); } + function normalizeSubtitleText(v, maxLen = 80) { + if (v === undefined || v === null) return ''; + const s = String(v).trim(); + if (!s) return ''; + return s.slice(0, maxLen); + } + + async function uploadMovieSubtitle({ movieId, file, lang, label, isDefault }) { + if (!movieId || !file) return null; + const formData = new FormData(); + formData.append('subtitle', file); + if (lang) formData.append('subtitle_lang', lang); + if (label) formData.append('subtitle_label', label); + formData.append('subtitle_default', isDefault ? 'true' : 'false'); + + const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/subtitles`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + }, + body: formData, + }); + + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const msg = data.message || data.error || `Erreur (${res.status})`; + throw new Error(msg); + } + return data; + } + function normalizePlayerUi(value) { const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const showSource = v.showSource === undefined ? DEFAULT_PLAYER_UI.showSource : !!v.showSource; @@ -1930,6 +1966,11 @@ window.onload = async function () { 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() || ''; + const subtitleFileInput = document.getElementById('subtitle_file'); + const subtitleFile = subtitleFileInput?.files?.[0] || null; + const subtitle_lang = normalizeSubtitleText(document.getElementById('subtitle_lang')?.value, 24); + const subtitle_label = normalizeSubtitleText(document.getElementById('subtitle_label')?.value, 80); + const subtitle_default = !!document.getElementById('subtitle_default')?.checked; if (!year) { showResponse('warning', 'Année invalide. Exemple attendu: 2026'); @@ -1948,12 +1989,44 @@ 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, video_file_low, embed_src, cam }) + body: JSON.stringify({ + year, + imdb_id, + category, + vod_link, + player_mode, + video_src, + video_file, + video_file_low, + embed_src, + subtitle_lang, + subtitle_label, + subtitle_default, + cam, + }) }); if (res.ok) { - showResponse('success', 'Film ajouté avec succès.'); + const payload = await res.json().catch(() => ({})); + const movieId = payload?.movie?._id; + if (subtitleFile && movieId) { + try { + await uploadMovieSubtitle({ + movieId, + file: subtitleFile, + lang: subtitle_lang, + label: subtitle_label, + isDefault: subtitle_default, + }); + showResponse('success', 'Film ajouté avec sous-titres.'); + } catch (err) { + showResponse('warning', `Film ajouté, mais sous-titres non uploadés: ${err.message || 'Erreur'}`); + } + } else { + showResponse('success', 'Film ajouté avec succès.'); + } form.reset(); + if (subtitleFileInput) subtitleFileInput.value = ''; await refreshYears(); await loadMoviesForManagement(); return; @@ -2197,6 +2270,14 @@ window.onload = async function () { if (editVideoSrcEl) editVideoSrcEl.value = movie.video_src || ''; if (editVideoFileEl) editVideoFileEl.value = movie.video_file || ''; if (editVideoFileLowEl) editVideoFileLowEl.value = movie.video_file_low || ''; + if (editSubtitleLangEl) editSubtitleLangEl.value = movie.subtitle_lang || ''; + if (editSubtitleLabelEl) editSubtitleLabelEl.value = movie.subtitle_label || ''; + if (editSubtitleDefaultEl) editSubtitleDefaultEl.checked = !!movie.subtitle_default; + if (editSubtitleCurrentEl) { + const current = movie.subtitle_file ? String(movie.subtitle_file) : ''; + editSubtitleCurrentEl.textContent = current || '—'; + } + if (editSubtitleFileEl) editSubtitleFileEl.value = ''; if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || ''; editRefreshOmdbEl.checked = false; @@ -2227,6 +2308,10 @@ window.onload = async function () { const video_file = (editVideoFileEl?.value || '').trim(); const video_file_low = (editVideoFileLowEl?.value || '').trim(); const embed_src = (editEmbedSrcEl?.value || '').trim(); + const subtitle_lang = normalizeSubtitleText(editSubtitleLangEl?.value, 24); + const subtitle_label = normalizeSubtitleText(editSubtitleLabelEl?.value, 80); + const subtitle_default = !!editSubtitleDefaultEl?.checked; + const subtitleFile = editSubtitleFileEl?.files?.[0] || null; const refreshOmdb = !!editRefreshOmdbEl.checked; const cam = !!editCamFlagEl?.checked; @@ -2264,6 +2349,9 @@ window.onload = async function () { video_file, video_file_low, embed_src, + subtitle_lang, + subtitle_label, + subtitle_default, refreshOmdb, cam }; @@ -2300,6 +2388,19 @@ window.onload = async function () { } const updated = await res.json(); + if (subtitleFile) { + try { + await uploadMovieSubtitle({ + movieId, + file: subtitleFile, + lang: subtitle_lang, + label: subtitle_label, + isDefault: subtitle_default, + }); + } catch (err) { + showResponse('warning', `Film mis à jour, mais sous-titres non uploadés: ${err.message || 'Erreur'}`); + } + } moviesById.set(updated._id, updated); showResponse('success', 'Film mis à jour avec succès.'); editMovieModal.hide(); diff --git a/public/js/app_player.js b/public/js/app_player.js index 1a951f8..78b8523 100644 --- a/public/js/app_player.js +++ b/public/js/app_player.js @@ -687,6 +687,7 @@ function toVideoSessionUrl(videoSrc) { // Best-effort cache to avoid spamming /api/video/session. // Keyed by sessionUrl + token (token is already in-memory, not persisted here). const _videoSessionCache = new Map(); // key -> expiresAt ms +let _currentMovie = null; async function ensureVideoSessionForSource(videoSrc, token) { // Only needed for our protected /api/video/* streams. @@ -777,6 +778,50 @@ async function ensurePlayableApiVideoUrl(rawUrl, token) { return addTokenToApiVideoUrl(rawUrl, token); } +function addTokenToApiSubtitleUrl(rawUrl, token) { + if (!rawUrl || !token) return rawUrl; + try { + const isRelative = String(rawUrl).trim().startsWith('/'); + const u = new URL(String(rawUrl), window.location.origin); + u.searchParams.set('token', token); + return isRelative ? `${u.pathname}${u.search}${u.hash}` : u.toString(); + } catch (_) { + const s = String(rawUrl); + const join = s.includes('?') ? '&' : '?'; + return `${s}${join}token=${encodeURIComponent(token)}`; + } +} + +function clearVideoTracks(videoEl) { + if (!videoEl) return; + const tracks = Array.from(videoEl.querySelectorAll('track')); + tracks.forEach((t) => { + try { t.remove(); } catch (_) {} + }); +} + +function applySubtitleTrack({ videoEl, movie, token }) { + if (!videoEl) return; + clearVideoTracks(videoEl); + const movieId = String(movie?._id || ''); + const subtitleFile = String(movie?.subtitle_file || '').trim(); + if (!movieId || !subtitleFile) return; + + const lang = String(movie?.subtitle_lang || 'en').trim() || 'en'; + const label = String(movie?.subtitle_label || 'Subtitles').trim() || 'Subtitles'; + const isDefault = movie?.subtitle_default === undefined ? true : !!movie.subtitle_default; + const rawUrl = `/api/subtitles/${encodeURIComponent(movieId)}`; + const src = addTokenToApiSubtitleUrl(rawUrl, token); + + const track = document.createElement('track'); + track.kind = 'subtitles'; + track.label = label; + track.srclang = lang; + track.src = src; + track.default = isDefault; + videoEl.appendChild(track); +} + async function playVodLink(vodLink, { token } = {}) { const raw = normalizeVodLink(vodLink); if (!raw) { @@ -927,17 +972,22 @@ function addQualityParam(rawUrl, quality) { 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; + const btnHigh = document.getElementById('quality-btn-high'); + const btnLow = document.getElementById('quality-btn-low'); + if (!wrap || !btnHigh || !btnLow) return; if (visible) { wrap.classList.remove('d-none'); - toggle.checked = !!isLow; - label.textContent = isLow ? 'Basse' : 'Haute'; + const useLow = !!isLow; + btnHigh.classList.toggle('is-active', !useLow); + btnLow.classList.toggle('is-active', useLow); + btnHigh.setAttribute('aria-pressed', useLow ? 'false' : 'true'); + btnLow.setAttribute('aria-pressed', useLow ? 'true' : 'false'); } else { wrap.classList.add('d-none'); - toggle.checked = false; - label.textContent = 'Haute'; + btnHigh.classList.remove('is-active'); + btnLow.classList.remove('is-active'); + btnHigh.setAttribute('aria-pressed', 'false'); + btnLow.setAttribute('aria-pressed', 'false'); } } @@ -961,14 +1011,16 @@ async function switchVideoSource({ src, token, preserveTime }) { videoEl.addEventListener('loadedmetadata', onLoaded, { once: true }); await playVodLink(src, { token }); + applySubtitleTrack({ videoEl, movie: _currentMovie, token }); } -async function initQualityToggle({ movieId, token, fallbackSrc }) { +async function initQualityToggle({ movieId, token, fallbackSrc, hasLowFile }) { const wrap = document.getElementById('quality-toggle'); - const toggle = document.getElementById('quality-switch'); - if (!wrap || !toggle) return { src: fallbackSrc }; + const btnHigh = document.getElementById('quality-btn-high'); + const btnLow = document.getElementById('quality-btn-low'); + if (!wrap || !btnHigh || !btnLow) return { src: fallbackSrc }; - if (!isApiVideoUrl(fallbackSrc)) { + if (!isApiVideoUrl(fallbackSrc) || !hasLowFile) { setQualityToggleUi({ visible: false, isLow: false }); return { src: fallbackSrc }; } @@ -987,8 +1039,7 @@ async function initQualityToggle({ movieId, token, fallbackSrc }) { setQualityToggleUi({ visible: true, isLow: shouldUseLow }); - toggle.addEventListener('change', async () => { - const nextIsLow = !!toggle.checked; + const onPickQuality = async (nextIsLow) => { setStoredQuality(movieId, nextIsLow ? 'low' : 'high'); setQualityToggleUi({ visible: true, isLow: nextIsLow }); await switchVideoSource({ @@ -996,7 +1047,10 @@ async function initQualityToggle({ movieId, token, fallbackSrc }) { token, preserveTime: true, }); - }); + }; + + btnHigh.addEventListener('click', () => { void onPickQuality(false); }); + btnLow.addEventListener('click', () => { void onPickQuality(true); }); return { src: pickedSrc }; } @@ -1069,6 +1123,7 @@ window.onload = async function () { pageLoader.fail(); return; } + _currentMovie = movie; const [activeYear, playerUi] = await Promise.all([activeYearPromise, playerUiPromise]); setHeader(movie, { activeYear }); @@ -1105,7 +1160,8 @@ window.onload = async function () { } pageLoader.setProgress(70); - const qualityChoice = await initQualityToggle({ movieId: id, token, fallbackSrc: resolved.src }); + const hasLowFile = !!(movie?.video_file_low); + const qualityChoice = await initQualityToggle({ movieId: id, token, fallbackSrc: resolved.src, hasLowFile }); const selectedSrc = qualityChoice?.src || resolved.src; await playVodLink(selectedSrc, { token }); pageLoader.setProgress(82); @@ -1113,6 +1169,7 @@ window.onload = async function () { // Only the
-
- Qualité -
- - +
+ Qualité +
+ +
diff --git a/routes/movies.js b/routes/movies.js index c33e3fc..f40a982 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,68 @@ 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; +} + +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 vtt = `WEBVTT\n\n${body.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 +215,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 video_file_low updatedAt createdAt"; + : "imdb_id title description rating poster cam year category vod_link player_mode video_src embed_src video_file video_file_low subtitle_file subtitle_lang subtitle_label subtitle_default updatedAt createdAt"; const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`; const cached = cacheGet(cacheKey); @@ -333,7 +400,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, video_file_low, 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 @@ -365,6 +446,9 @@ router.post("/add", async (req, res) => { 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; @@ -400,6 +484,9 @@ router.post("/add", async (req, res) => { 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(); @@ -411,7 +498,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" }); @@ -420,6 +507,84 @@ 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" }); + } + } + + 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 (_) {} + } + } + + const rel = path.relative(SUBTITLE_FILES_DIR, storedPath).replace(/\\/g, "/"); + movie.subtitle_file = rel; + + const incomingLang = normalizeSubtitleLang(req.body?.subtitle_lang); + if (incomingLang !== undefined) movie.subtitle_lang = incomingLang || null; + + const incomingLabel = normalizeSubtitleLabel(req.body?.subtitle_label); + if (incomingLabel !== undefined) movie.subtitle_label = incomingLabel || null; + + const incomingDefault = parseSubtitleDefault(req.body?.subtitle_default); + if (incomingDefault !== undefined) movie.subtitle_default = incomingDefault; + + 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]; @@ -494,6 +659,9 @@ router.put("/:id", async (req, res) => { embed_src, video_file, video_file_low, + subtitle_lang, + subtitle_label, + subtitle_default, refreshOmdb, cam, } = req.body || {}; @@ -558,6 +726,21 @@ router.put("/:id", async (req, res) => { movie.video_file_low = v ? v : null; } + 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); } else if (year === null || year === "") { diff --git a/routes/subtitles.js b/routes/subtitles.js new file mode 100644 index 0000000..3413357 --- /dev/null +++ b/routes/subtitles.js @@ -0,0 +1,109 @@ +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("subtitle_file").lean(); + const 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", (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; -- 2.54.0 From 891eb67e2d2b96cf1e27eabc45685f8989e769d4 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sun, 25 Jan 2026 01:00:48 -0500 Subject: [PATCH 5/6] Add subtitle UI badge and VTT positioning Documents the disclaimer, shows subtitles in admin movie badges, and offsets VTT subtitles from the bottom. --- README.md | 2 ++ public/js/app_control.js | 2 ++ python_video_server/README.md | 3 +++ routes/movies.js | 10 +++++++++- 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 194cc52..8fd3a71 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ Little nodejs + mongodb app that I hack together with ChatGPT and Cursor to keep **Note:** The application interface is in French. +**Disclaimer:** This was a fun, experimental project built with “AI slop” (Cursor), so it may have security issues. For example, `python_video_server` uses the default token‑in‑URL approach to serve video files, which can leave user tokens in logs or browser history. There are ways to improve this, but at this point I’m blinder than Ray Charles. + ![Oscars Pool 2026](https://raw.githubusercontent.com/quoije/oscars-pool/refs/heads/prod/img/preview.png) ## Features diff --git a/public/js/app_control.js b/public/js/app_control.js index 0ba40d1..c846b30 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -2442,12 +2442,14 @@ window.onload = async function () { const hasLowFile = !!(m && m.video_file_low); const hasEmbed = !!(m && m.embed_src); const hasLegacy = !!(m && m.vod_link); + const hasSubtitle = !!(m && m.subtitle_file); 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' }); + if (hasSubtitle) badges.push({ text: 'Subtitles', cls: 'bg-warning text-dark' }); const modeRaw = String(m?.player_mode || 'auto').toLowerCase(); const mode = (modeRaw === 'video' || modeRaw === 'embed' || modeRaw === 'auto') ? modeRaw : 'auto'; 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/routes/movies.js b/routes/movies.js index f40a982..a0113bb 100644 --- a/routes/movies.js +++ b/routes/movies.js @@ -122,7 +122,15 @@ 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 vtt = `WEBVTT\n\n${body.trim()}\n`; + 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 (_) {} -- 2.54.0 From 406a4f0bb3a17e0e701f6d8bba1cb2eafe21f41e Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sun, 25 Jan 2026 02:01:08 -0500 Subject: [PATCH 6/6] Support multiple subtitle tracks Adds multi-language subtitle storage, upload/removal, and player/admin UI support, plus fixes subtitle metadata updates during upload. --- models/Movie.js | 9 +- public/control.html | 11 +- public/js/app_control.js | 110 ++++++++++++++++-- public/js/app_player.js | 29 ++++- routes/movies.js | 242 ++++++++++++++++++++++++++++++++++----- routes/subtitles.js | 34 +++++- 6 files changed, 390 insertions(+), 45 deletions(-) diff --git a/models/Movie.js b/models/Movie.js index 635275d..75f5c67 100644 --- a/models/Movie.js +++ b/models/Movie.js @@ -24,7 +24,14 @@ const movieSchema = new mongoose.Schema({ // 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 }, - // Optional subtitles (server-hosted .vtt, streamed via /api/subtitles/:movieId) + // Optional subtitles (server-hosted .vtt, streamed via /api/subtitles/:movieId/:subtitleId) + subtitles: [{ + file: { type: String, required: true }, + lang: { type: String, required: false }, + label: { type: String, required: false }, + default: { type: Boolean, required: false, default: false }, + }], + // Legacy single-subtitle fields (kept for backward compatibility) subtitle_file: { type: String, required: false }, subtitle_lang: { type: String, required: false }, subtitle_label: { type: String, required: false }, diff --git a/public/control.html b/public/control.html index 70036c9..705df9b 100644 --- a/public/control.html +++ b/public/control.html @@ -1418,7 +1418,16 @@
-
Fichier actuel: —
+
Sous-titres existants:
+
+
Ajoute un fichier par langue; répète l’upload pour plusieurs sous-titres.
+
+
+
+ + +
+
Efface les fichiers VTT/SRT et réinitialise les champs de langue/label.
diff --git a/public/js/app_control.js b/public/js/app_control.js index c846b30..11c1eb9 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -114,10 +114,11 @@ window.onload = async function () { const editVideoFileEl = document.getElementById('edit_video_file'); const editVideoFileLowEl = document.getElementById('edit_video_file_low'); const editSubtitleFileEl = document.getElementById('edit_subtitle_file'); + const editSubtitleRemoveEl = document.getElementById('edit_subtitle_remove'); const editSubtitleLangEl = document.getElementById('edit_subtitle_lang'); const editSubtitleLabelEl = document.getElementById('edit_subtitle_label'); const editSubtitleDefaultEl = document.getElementById('edit_subtitle_default'); - const editSubtitleCurrentEl = document.getElementById('edit_subtitle_current'); + const editSubtitleListEl = document.getElementById('edit_subtitle_list'); const editEmbedSrcEl = document.getElementById('edit_embed_src'); const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb'); const editTitleEl = document.getElementById('edit_title'); @@ -171,6 +172,20 @@ window.onload = async function () { return s.slice(0, maxLen); } + function getMovieSubtitles(movie) { + const subs = Array.isArray(movie?.subtitles) ? movie.subtitles.filter(Boolean) : []; + if (subs.length > 0) return subs; + const legacyFile = typeof movie?.subtitle_file === 'string' ? movie.subtitle_file.trim() : ''; + if (!legacyFile) return []; + return [{ + _id: 'legacy', + file: legacyFile, + lang: movie?.subtitle_lang || '', + label: movie?.subtitle_label || '', + default: !!movie?.subtitle_default, + }]; + } + async function uploadMovieSubtitle({ movieId, file, lang, label, isDefault }) { if (!movieId || !file) return null; const formData = new FormData(); @@ -195,6 +210,22 @@ window.onload = async function () { return data; } + async function removeMovieSubtitle({ movieId, subtitleId }) { + if (!movieId || !subtitleId) return null; + const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/subtitles/${encodeURIComponent(subtitleId)}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${token}`, + }, + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const msg = data.message || data.error || `Erreur (${res.status})`; + throw new Error(msg); + } + return data; + } + function normalizePlayerUi(value) { const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const showSource = v.showSource === undefined ? DEFAULT_PLAYER_UI.showSource : !!v.showSource; @@ -2255,6 +2286,59 @@ window.onload = async function () { selectAllBox.checked = allBoxes.length > 0 && checked === allBoxes.length; } + function renderSubtitleList(movie) { + if (!editSubtitleListEl) return; + const subs = getMovieSubtitles(movie); + if (!subs.length) { + editSubtitleListEl.innerHTML = '—'; + return; + } + + editSubtitleListEl.innerHTML = subs.map((s) => { + const id = escapeHtml(String(s?._id || '')); + const lang = escapeHtml(String(s?.lang || '')); + const label = escapeHtml(String(s?.label || '')); + const file = escapeHtml(String(s?.file || '')); + const title = label || lang || file || 'Sous-titres'; + const meta = [lang, label].filter(Boolean).join(' · '); + const isDefault = s?.default ? 'Par défaut' : ''; + + return ` +
+
+ ${title} + ${meta ? `${meta}` : ''} + ${isDefault} +
+ +
+ `; + }).join(''); + + editSubtitleListEl.querySelectorAll('[data-remove-subtitle-id]').forEach((btn) => { + btn.addEventListener('click', async () => { + const subtitleId = btn.getAttribute('data-remove-subtitle-id'); + const movieId = String(editMovieIdEl?.value || ''); + if (!movieId || !subtitleId) return; + if (!confirm('Supprimer ce sous-titre ?')) return; + try { + btn.disabled = true; + const updated = await removeMovieSubtitle({ movieId, subtitleId }); + moviesById.set(updated._id, updated); + renderSubtitleList(updated); + showResponse('success', 'Sous-titre supprimé.'); + await loadMoviesForManagement(); + } catch (err) { + showResponse('danger', err.message || 'Erreur réseau'); + } finally { + btn.disabled = false; + } + }); + }); + } + function openEditModal(movieId) { if (!editMovieModal) return; const movie = moviesById.get(movieId); @@ -2273,10 +2357,8 @@ window.onload = async function () { if (editSubtitleLangEl) editSubtitleLangEl.value = movie.subtitle_lang || ''; if (editSubtitleLabelEl) editSubtitleLabelEl.value = movie.subtitle_label || ''; if (editSubtitleDefaultEl) editSubtitleDefaultEl.checked = !!movie.subtitle_default; - if (editSubtitleCurrentEl) { - const current = movie.subtitle_file ? String(movie.subtitle_file) : ''; - editSubtitleCurrentEl.textContent = current || '—'; - } + renderSubtitleList(movie); + if (editSubtitleRemoveEl) editSubtitleRemoveEl.checked = false; if (editSubtitleFileEl) editSubtitleFileEl.value = ''; if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || ''; editRefreshOmdbEl.checked = false; @@ -2311,7 +2393,8 @@ window.onload = async function () { const subtitle_lang = normalizeSubtitleText(editSubtitleLangEl?.value, 24); const subtitle_label = normalizeSubtitleText(editSubtitleLabelEl?.value, 80); const subtitle_default = !!editSubtitleDefaultEl?.checked; - const subtitleFile = editSubtitleFileEl?.files?.[0] || null; + const subtitle_remove = !!editSubtitleRemoveEl?.checked; + const subtitleFile = subtitle_remove ? null : (editSubtitleFileEl?.files?.[0] || null); const refreshOmdb = !!editRefreshOmdbEl.checked; const cam = !!editCamFlagEl?.checked; @@ -2349,13 +2432,17 @@ window.onload = async function () { video_file, video_file_low, embed_src, - subtitle_lang, - subtitle_label, - subtitle_default, + subtitle_remove, refreshOmdb, cam }; + if (!subtitle_remove && !subtitleFile) { + body.subtitle_lang = subtitle_lang; + body.subtitle_label = subtitle_label; + body.subtitle_default = subtitle_default; + } + // Only send manual fields if we're not refreshing from OMDb if (!refreshOmdb) { body.title = title; @@ -2390,13 +2477,14 @@ window.onload = async function () { const updated = await res.json(); if (subtitleFile) { try { - await uploadMovieSubtitle({ + const uploaded = await uploadMovieSubtitle({ movieId, file: subtitleFile, lang: subtitle_lang, label: subtitle_label, isDefault: subtitle_default, }); + if (uploaded) moviesById.set(uploaded._id, uploaded); } catch (err) { showResponse('warning', `Film mis à jour, mais sous-titres non uploadés: ${err.message || 'Erreur'}`); } @@ -2442,7 +2530,7 @@ window.onload = async function () { const hasLowFile = !!(m && m.video_file_low); const hasEmbed = !!(m && m.embed_src); const hasLegacy = !!(m && m.vod_link); - const hasSubtitle = !!(m && m.subtitle_file); + const hasSubtitle = Array.isArray(m?.subtitles) ? m.subtitles.length > 0 : !!(m && m.subtitle_file); if (hasVideo) badges.push({ text: 'Video', cls: 'bg-primary' }); if (hasFile) badges.push({ text: 'Server file', cls: 'bg-dark' }); diff --git a/public/js/app_player.js b/public/js/app_player.js index 78b8523..b982b9c 100644 --- a/public/js/app_player.js +++ b/public/js/app_player.js @@ -804,9 +804,34 @@ function applySubtitleTrack({ videoEl, movie, token }) { if (!videoEl) return; clearVideoTracks(videoEl); const movieId = String(movie?._id || ''); - const subtitleFile = String(movie?.subtitle_file || '').trim(); - if (!movieId || !subtitleFile) return; + if (!movieId) return; + const subtitles = Array.isArray(movie?.subtitles) ? movie.subtitles : []; + if (subtitles.length > 0) { + const hasDefault = subtitles.some((s) => s?.default); + subtitles.forEach((s, idx) => { + const lang = String(s?.lang || 'en').trim() || 'en'; + const label = String(s?.label || 'Subtitles').trim() || 'Subtitles'; + const isDefault = s?.default === undefined ? (!hasDefault && idx === 0) : !!s.default; + const subId = String(s?._id || '').trim(); + const rawUrl = subId && subId !== 'legacy' + ? `/api/subtitles/${encodeURIComponent(movieId)}/${encodeURIComponent(subId)}` + : `/api/subtitles/${encodeURIComponent(movieId)}`; + const src = addTokenToApiSubtitleUrl(rawUrl, token); + + const track = document.createElement('track'); + track.kind = 'subtitles'; + track.label = label; + track.srclang = lang; + track.src = src; + track.default = isDefault; + videoEl.appendChild(track); + }); + return; + } + + const subtitleFile = String(movie?.subtitle_file || '').trim(); + if (!subtitleFile) return; const lang = String(movie?.subtitle_lang || 'en').trim() || 'en'; const label = String(movie?.subtitle_label || 'Subtitles').trim() || 'Subtitles'; const isDefault = movie?.subtitle_default === undefined ? true : !!movie.subtitle_default; diff --git a/routes/movies.js b/routes/movies.js index a0113bb..f60beea 100644 --- a/routes/movies.js +++ b/routes/movies.js @@ -85,6 +85,62 @@ function parseSubtitleDefault(v) { 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 }); } @@ -223,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 video_file_low subtitle_file subtitle_lang subtitle_label subtitle_default 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); @@ -237,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 }); } @@ -567,32 +624,112 @@ router.post("/:id/subtitles", (req, res) => { } } - 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 (_) {} - } + 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, "/"); - movie.subtitle_file = rel; - const incomingLang = normalizeSubtitleLang(req.body?.subtitle_lang); - if (incomingLang !== undefined) movie.subtitle_lang = incomingLang || null; - const incomingLabel = normalizeSubtitleLabel(req.body?.subtitle_label); - if (incomingLabel !== undefined) movie.subtitle_label = incomingLabel || null; - const incomingDefault = parseSubtitleDefault(req.body?.subtitle_default); - if (incomingDefault !== undefined) movie.subtitle_default = incomingDefault; + 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]; @@ -670,6 +807,7 @@ router.put("/:id", async (req, res) => { subtitle_lang, subtitle_label, subtitle_default, + subtitle_remove, refreshOmdb, cam, } = req.body || {}; @@ -734,19 +872,67 @@ router.put("/:id", async (req, res) => { movie.video_file_low = v ? v : null; } - if (subtitle_lang !== undefined) { - const v = normalizeSubtitleLang(subtitle_lang); - movie.subtitle_lang = 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 (_) {} + } + } - if (subtitle_label !== undefined) { - const v = normalizeSubtitleLabel(subtitle_label); - movie.subtitle_label = v ? v : null; - } + 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 (_) {} + } + } - if (subtitle_default !== undefined) { - const v = parseSubtitleDefault(subtitle_default); - if (v !== undefined) movie.subtitle_default = v; + 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 !== "") { @@ -972,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 index 3413357..5f9d6db 100644 --- a/routes/subtitles.js +++ b/routes/subtitles.js @@ -65,8 +65,26 @@ async function streamSubtitle(req, res) { return res.status(400).json({ message: "Invalid movie id" }); } - const movie = await Movie.findById(id).select("subtitle_file").lean(); - const subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : ""; + 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" }); @@ -94,6 +112,18 @@ async function streamSubtitle(req, res) { 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" }); -- 2.54.0