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;