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/ 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/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 407ed1a..75f5c67 100644 --- a/models/Movie.js +++ b/models/Movie.js @@ -21,6 +21,21 @@ 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 }, + // 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 }, + 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 c83ed79..705df9b 100644 --- a/public/control.html +++ b/public/control.html @@ -690,6 +690,30 @@
Chemin relatif sous VIDEO_FILES_DIR (défaut: public/video).
+
+ + +
Chemin relatif sous VIDEO_FILES_DIR. Ex: LOW/123.LOW.mp4.
+
+
+ + +
Upload d'un fichier .srt (converti en VTT serveur, interne uniquement).
+
+
+ + +
+
+ + +
+
+
+ + +
+
@@ -723,6 +747,7 @@
@@ -1385,6 +1410,39 @@
Relative path under VIDEO_FILES_DIR (default: public/video).
+
+ + +
Relative path under VIDEO_FILES_DIR. Example: LOW/123.LOW.mp4.
+
+
+ + +
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 4b61ff8..11c1eb9 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -112,6 +112,13 @@ 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 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 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'); @@ -158,6 +165,67 @@ 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); + } + + 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(); + 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; + } + + 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; @@ -1927,7 +1995,13 @@ 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() || ''; + 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'); @@ -1946,12 +2020,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, 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; @@ -2180,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); @@ -2194,6 +2353,13 @@ 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 (editSubtitleLangEl) editSubtitleLangEl.value = movie.subtitle_lang || ''; + if (editSubtitleLabelEl) editSubtitleLabelEl.value = movie.subtitle_label || ''; + if (editSubtitleDefaultEl) editSubtitleDefaultEl.checked = !!movie.subtitle_default; + renderSubtitleList(movie); + if (editSubtitleRemoveEl) editSubtitleRemoveEl.checked = false; + if (editSubtitleFileEl) editSubtitleFileEl.value = ''; if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || ''; editRefreshOmdbEl.checked = false; @@ -2222,7 +2388,13 @@ 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 subtitle_lang = normalizeSubtitleText(editSubtitleLangEl?.value, 24); + const subtitle_label = normalizeSubtitleText(editSubtitleLabelEl?.value, 80); + const subtitle_default = !!editSubtitleDefaultEl?.checked; + const subtitle_remove = !!editSubtitleRemoveEl?.checked; + const subtitleFile = subtitle_remove ? null : (editSubtitleFileEl?.files?.[0] || null); const refreshOmdb = !!editRefreshOmdbEl.checked; const cam = !!editCamFlagEl?.checked; @@ -2258,11 +2430,19 @@ window.onload = async function () { player_mode, video_src, video_file, + video_file_low, embed_src, + 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; @@ -2295,6 +2475,20 @@ window.onload = async function () { } const updated = await res.json(); + if (subtitleFile) { + try { + 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'}`); + } + } moviesById.set(updated._id, updated); showResponse('success', 'Film mis à jour avec succès.'); editMovieModal.hide(); @@ -2333,13 +2527,17 @@ 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); + 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' }); + 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/public/js/app_player.js b/public/js/app_player.js index 3c359b8..b982b9c 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. @@ -753,6 +754,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. @@ -762,6 +778,75 @@ 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 || ''); + 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; + 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) { @@ -883,6 +968,118 @@ 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 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'); + 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'); + btnHigh.classList.remove('is-active'); + btnLow.classList.remove('is-active'); + btnHigh.setAttribute('aria-pressed', 'false'); + btnLow.setAttribute('aria-pressed', 'false'); + } +} + +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 }); + applySubtitleTrack({ videoEl, movie: _currentMovie, token }); +} + +async function initQualityToggle({ movieId, token, fallbackSrc, hasLowFile }) { + const wrap = document.getElementById('quality-toggle'); + const btnHigh = document.getElementById('quality-btn-high'); + const btnLow = document.getElementById('quality-btn-low'); + if (!wrap || !btnHigh || !btnLow) return { src: fallbackSrc }; + + if (!isApiVideoUrl(fallbackSrc) || !hasLowFile) { + 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 }); + + const onPickQuality = async (nextIsLow) => { + setStoredQuality(movieId, nextIsLow ? 'low' : 'high'); + setQualityToggleUi({ visible: true, isLow: nextIsLow }); + await switchVideoSource({ + src: nextIsLow ? lowSrcRaw : fallbackSrc, + token, + preserveTime: true, + }); + }; + + btnHigh.addEventListener('click', () => { void onPickQuality(false); }); + btnLow.addEventListener('click', () => { void onPickQuality(true); }); + + return { src: pickedSrc }; +} + window.onload = async function () { const pageLoader = createPageLoader({ title: 'Chargement du lecteur' }); @@ -951,6 +1148,7 @@ window.onload = async function () { pageLoader.fail(); return; } + _currentMovie = movie; const [activeYear, playerUi] = await Promise.all([activeYearPromise, playerUiPromise]); setHeader(movie, { activeYear }); @@ -987,12 +1185,16 @@ window.onload = async function () { } pageLoader.setProgress(70); - await playVodLink(resolved.src, { token }); + 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); // Only the
- -
-
- Source - — +
+
+ Qualité +
+ + +
-
- Progress - - - — - + +
+
+ Source + — +
+ +
+ Progress + + + — + +
diff --git a/python_video_server/README.md b/python_video_server/README.md index 8091ee3..e0ba9a7 100644 --- a/python_video_server/README.md +++ b/python_video_server/README.md @@ -15,7 +15,10 @@ export MONGO_DB_NAME="..." # optional; required if MO export JWT_SECRET="..." export VIDEO_FILES_DIR="/absolute/path/to/public/video" # optional (default: ./public/video) export VIDEO_SESSION_MAX_AGE_SECONDS="28800" # optional (default: 8h) + uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000 +or +python3 -m uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000 --ssl-certfile python_video_server/certs/config/live/[site]/fullchain.pem --ssl-keyfile python_video_server/certs/config/live/[site]/privkey.pem ``` ## Extra environment variables (optional) diff --git a/python_video_server/server.py b/python_video_server/server.py index fee364f..ff2a48c 100644 --- a/python_video_server/server.py +++ b/python_video_server/server.py @@ -274,7 +274,7 @@ def stream_video( try: client = _mongo_client_cached() db = client[db_name] - movie = db["movies"].find_one({"_id": oid}, {"video_file": 1}) + movie = db["movies"].find_one({"_id": oid}, {"video_file": 1, "video_file_low": 1}) except PyMongoError as e: raise HTTPException( status_code=502, @@ -285,15 +285,23 @@ def stream_video( ), ) + quality = (request.query_params.get("quality") or "").strip().lower() + wants_low = quality == "low" + video_file = (movie or {}).get("video_file") or "" - if not isinstance(video_file, str) or not video_file.strip(): + video_file_low = (movie or {}).get("video_file_low") or "" + chosen = video_file_low if wants_low else video_file + + if not isinstance(chosen, str) or not chosen.strip(): + if wants_low: + raise HTTPException(status_code=404, detail="No low-quality server video file configured for this movie") raise HTTPException(status_code=404, detail="No server video file configured for this movie") - video_file = video_file.strip() - if not _is_safe_relative_file(video_file): + chosen = chosen.strip() + if not _is_safe_relative_file(chosen): raise HTTPException(status_code=400, detail="Invalid video_file path") video_dir = _video_files_dir() - full_path = (video_dir / video_file).resolve() + full_path = (video_dir / chosen).resolve() if not str(full_path).startswith(str(video_dir) + os.sep) and full_path != video_dir: raise HTTPException(status_code=400, detail="Invalid video_file path") if not full_path.exists() or not full_path.is_file(): diff --git a/routes/movies.js b/routes/movies.js index 43094e8..f60beea 100644 --- a/routes/movies.js +++ b/routes/movies.js @@ -1,4 +1,7 @@ const express = require("express"); +const fs = require("fs"); +const path = require("path"); +const multer = require("multer"); const Movie = require("../models/Movie"); const User = require("../models/User"); const PlaybackProgress = require("../models/PlaybackProgress"); @@ -13,6 +16,8 @@ const router = express.Router(); // This is safe because movie lists are identical for all users and change rarely. const MOVIES_CACHE_TTL_MS = Number(process.env.MOVIES_CACHE_TTL_MS || "30000"); // 30s const _cache = new Map(); // key -> { expiresAt:number, value:any } +const SUBTITLE_FILES_DIR = path.resolve(process.env.SUBTITLE_FILES_DIR || path.join(process.cwd(), "subtitles")); +const MAX_SUBTITLE_UPLOAD_BYTES = 10 * 1024 * 1024; // 10MB function cacheGet(key) { const hit = _cache.get(key); @@ -62,6 +67,132 @@ function normalizeVideoFile(v) { return s.slice(0, 1024); } +function normalizeSubtitleLang(v) { + return normalizeOptionalString(v, 24); +} + +function normalizeSubtitleLabel(v) { + return normalizeOptionalString(v, 80); +} + +function parseSubtitleDefault(v) { + if (v === undefined) return undefined; + if (typeof v === "boolean") return v; + const s = String(v).trim().toLowerCase(); + if (!s) return undefined; + if (["true", "1", "yes", "on"].includes(s)) return true; + if (["false", "0", "no", "off"].includes(s)) return false; + return undefined; +} + +function parseBooleanFlag(v) { + if (v === undefined) return undefined; + if (typeof v === "boolean") return v; + const s = String(v).trim().toLowerCase(); + if (!s) return undefined; + if (["true", "1", "yes", "on"].includes(s)) return true; + if (["false", "0", "no", "off"].includes(s)) return false; + return undefined; +} + +function normalizeSubtitleEntry({ file, lang, label, isDefault } = {}) { + const safeFile = typeof file === "string" ? file.trim() : ""; + if (!safeFile) return null; + return { + file: safeFile, + lang: normalizeSubtitleLang(lang) || null, + label: normalizeSubtitleLabel(label) || null, + default: !!isDefault, + }; +} + +function getLegacySubtitle(movie) { + const file = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : ""; + if (!file) return null; + return { + _id: "legacy", + file, + lang: typeof movie?.subtitle_lang === "string" ? movie.subtitle_lang : null, + label: typeof movie?.subtitle_label === "string" ? movie.subtitle_label : null, + default: !!movie?.subtitle_default, + }; +} + +function getSubtitlesForResponse(movie) { + const subs = Array.isArray(movie?.subtitles) ? movie.subtitles.filter(Boolean) : []; + if (subs.length > 0) return subs; + const legacy = getLegacySubtitle(movie); + return legacy ? [legacy] : []; +} + +function syncLegacyFromSubtitles(movie) { + const subs = Array.isArray(movie?.subtitles) ? movie.subtitles : []; + if (!subs.length) { + movie.subtitle_file = null; + movie.subtitle_lang = null; + movie.subtitle_label = null; + movie.subtitle_default = false; + return; + } + const preferred = subs.find((s) => s?.default) || subs[0]; + movie.subtitle_file = preferred?.file || null; + movie.subtitle_lang = preferred?.lang || null; + movie.subtitle_label = preferred?.label || null; + movie.subtitle_default = !!preferred?.default; +} + +async function ensureSubtitleDir() { + await fs.promises.mkdir(SUBTITLE_FILES_DIR, { recursive: true }); +} + +function safeFilenameBase(raw) { + return String(raw || "").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 32) || "movie"; +} + +const subtitleUpload = multer({ + storage: multer.diskStorage({ + destination: async (_req, _file, cb) => { + try { + await ensureSubtitleDir(); + cb(null, SUBTITLE_FILES_DIR); + } catch (err) { + cb(err); + } + }, + filename: (req, file, cb) => { + const safeId = safeFilenameBase(req.params?.id); + const ext = path.extname(file.originalname || "").toLowerCase() || ".vtt"; + const name = `${safeId}-${Date.now()}-${Math.random().toString(16).slice(2)}${ext}`; + cb(null, name); + }, + }), + limits: { fileSize: MAX_SUBTITLE_UPLOAD_BYTES }, + fileFilter: (_req, file, cb) => { + const ext = path.extname(file.originalname || "").toLowerCase(); + if (ext !== ".vtt" && ext !== ".srt") return cb(new Error("Only .srt or .vtt subtitle files are allowed")); + return cb(null, true); + }, +}); + +async function convertSrtToVtt(filePath) { + const content = await fs.promises.readFile(filePath, "utf8"); + const normalized = content.replace(/\r\n/g, "\n"); + const body = normalized.replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, "$1.$2"); + const positioned = body.replace( + /^(\d{2}:\d{2}:\d{2}\.\d{3}\s+-->\s+\d{2}:\d{2}:\d{2}\.\d{3})(.*)$/gm, + (_match, timing, settings) => { + const tail = String(settings || ""); + if (/\bline:/i.test(tail)) return `${timing}${tail}`; + return `${timing}${tail} line:90%`; + }, + ); + const vtt = `WEBVTT\n\n${positioned.trim()}\n`; + const vttPath = filePath.replace(/\.srt$/i, ".vtt"); + await fs.promises.writeFile(vttPath, vtt, "utf8"); + try { await fs.promises.unlink(filePath); } catch (_) {} + return vttPath; +} + function normalizePlayerMode(v) { const s = String(v || "").trim().toLowerCase(); if (!s) return "auto"; @@ -148,7 +279,7 @@ router.get("/", async (req, res) => { const projection = isChecklist ? "imdb_id title" - : "imdb_id title description rating poster cam year category vod_link player_mode video_src embed_src video_file updatedAt createdAt"; + : "imdb_id title description rating poster cam year category vod_link player_mode video_src embed_src video_file video_file_low subtitles subtitle_file subtitle_lang subtitle_label subtitle_default updatedAt createdAt"; const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`; const cached = cacheGet(cacheKey); @@ -162,9 +293,10 @@ router.get("/", async (req, res) => { .sort({ title: 1 }) .lean(); - cacheSet(cacheKey, movies); + const payload = movies.map((m) => ({ ...m, subtitles: getSubtitlesForResponse(m) })); + cacheSet(cacheKey, payload); res.set("Cache-Control", "public, max-age=30"); - res.status(200).json(movies); + res.status(200).json(payload); } catch (err) { res.status(500).json({ error: err.message }); } @@ -333,7 +465,21 @@ router.patch("/users/updateWatchedMovies", async (req, res) => { // Add movie router.post("/add", async (req, res) => { - const { imdb_id, category, vod_link, year, player_mode, video_src, embed_src, video_file, cam } = req.body; + const { + imdb_id, + category, + vod_link, + year, + player_mode, + video_src, + embed_src, + video_file, + video_file_low, + subtitle_lang, + subtitle_label, + subtitle_default, + cam, + } = req.body; try { // Get the token from the Authorization header @@ -360,10 +506,14 @@ router.post("/add", async (req, res) => { const normalizedVideo = normalizeOptionalString(video_src, 4096); const normalizedEmbed = normalizeOptionalString(embed_src, 20000); const normalizedFile = normalizeVideoFile(video_file); + const normalizedLowFile = normalizeVideoFile(video_file_low); const normalizedMode = normalizePlayerMode(player_mode); if (normalizedMode === null) { return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" }); } + const normalizedSubtitleLang = normalizeSubtitleLang(subtitle_lang); + const normalizedSubtitleLabel = normalizeSubtitleLabel(subtitle_label); + const normalizedSubtitleDefault = parseSubtitleDefault(subtitle_default) ?? false; // Fetch movie details from OMDb API let movieDetails; @@ -398,6 +548,10 @@ router.post("/add", async (req, res) => { video_src: normalizedVideo || undefined, embed_src: normalizedEmbed || undefined, video_file: normalizedFile || undefined, + video_file_low: normalizedLowFile || undefined, + subtitle_lang: normalizedSubtitleLang || undefined, + subtitle_label: normalizedSubtitleLabel || undefined, + subtitle_default: normalizedSubtitleDefault, }); await movie.save(); @@ -409,7 +563,7 @@ router.post("/add", async (req, res) => { } cacheClear(); - res.status(201).json({ message: "Movie added successfully!" }); + res.status(201).json({ message: "Movie added successfully!", movie }); } catch (err) { if (err instanceof jwt.JsonWebTokenError) { return res.status(401).json({ message: "Invalid or expired token" }); @@ -418,6 +572,164 @@ router.post("/add", async (req, res) => { } }); +// Admin: upload subtitles for a movie (VTT) +router.post("/:id/subtitles", (req, res) => { + const token = req.headers.authorization?.split(" ")[1]; + if (!token) return res.status(401).json({ message: "Authentication token is required" }); + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + if (!decoded.admin) { + return res.status(403).json({ message: "You do not have admin privileges" }); + } + } catch (err) { + if (err instanceof jwt.JsonWebTokenError) { + return res.status(401).json({ message: "Invalid or expired token" }); + } + return res.status(500).json({ error: err.message }); + } + + subtitleUpload.single("subtitle")(req, res, async (err) => { + if (err) { + const isSize = err instanceof multer.MulterError && err.code === "LIMIT_FILE_SIZE"; + return res.status(isSize ? 413 : 400).json({ message: err.message || "Upload failed" }); + } + + const { id } = req.params; + if (!mongoose.Types.ObjectId.isValid(id)) { + if (req.file?.path) { + try { await fs.promises.unlink(req.file.path); } catch (_) {} + } + return res.status(400).json({ message: "Invalid movie id" }); + } + + const movie = await Movie.findById(id); + if (!movie) { + if (req.file?.path) { + try { await fs.promises.unlink(req.file.path); } catch (_) {} + } + return res.status(404).json({ message: "Movie not found" }); + } + + if (!req.file?.path) { + return res.status(400).json({ message: "No subtitle file uploaded" }); + } + + let storedPath = req.file.path; + if (storedPath.toLowerCase().endsWith(".srt")) { + try { + storedPath = await convertSrtToVtt(storedPath); + } catch (e) { + return res.status(400).json({ message: "Failed to convert .srt to .vtt" }); + } + } + + if (!Array.isArray(movie.subtitles)) movie.subtitles = []; + if (movie.subtitles.length === 0) { + const legacy = normalizeSubtitleEntry({ + file: movie.subtitle_file, + lang: movie.subtitle_lang, + label: movie.subtitle_label, + isDefault: movie.subtitle_default, + }); + if (legacy) movie.subtitles.push(legacy); + } + + const rel = path.relative(SUBTITLE_FILES_DIR, storedPath).replace(/\\/g, "/"); + const incomingLang = normalizeSubtitleLang(req.body?.subtitle_lang); + const incomingLabel = normalizeSubtitleLabel(req.body?.subtitle_label); + const incomingDefault = parseSubtitleDefault(req.body?.subtitle_default); + + const shouldDefault = incomingDefault !== undefined + ? incomingDefault + : movie.subtitles.length === 0; + + if (shouldDefault) { + movie.subtitles.forEach((s) => { s.default = false; }); + } + + const entry = normalizeSubtitleEntry({ + file: rel, + lang: incomingLang, + label: incomingLabel, + isDefault: shouldDefault, + }); + if (entry) movie.subtitles.push(entry); + + syncLegacyFromSubtitles(movie); + await movie.save(); + cacheClear(); + return res.status(200).json(movie); + }); +}); + +// Admin: delete one subtitle track from a movie +router.delete("/:id/subtitles/:subtitleId", async (req, res) => { + const token = req.headers.authorization?.split(" ")[1]; + if (!token) return res.status(401).json({ message: "Authentication token is required" }); + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + if (!decoded.admin) { + return res.status(403).json({ message: "You do not have admin privileges" }); + } + } catch (err) { + if (err instanceof jwt.JsonWebTokenError) { + return res.status(401).json({ message: "Invalid or expired token" }); + } + return res.status(500).json({ error: err.message }); + } + + const { id, subtitleId } = req.params; + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid movie id" }); + } + + const movie = await Movie.findById(id); + if (!movie) return res.status(404).json({ message: "Movie not found" }); + + if (subtitleId === "legacy") { + const existing = typeof movie.subtitle_file === "string" ? movie.subtitle_file.trim() : ""; + if (!existing) return res.status(404).json({ message: "Subtitle not found" }); + const oldPath = path.resolve(SUBTITLE_FILES_DIR, existing); + if (oldPath.startsWith(SUBTITLE_FILES_DIR + path.sep)) { + try { await fs.promises.unlink(oldPath); } catch (_) {} + } + movie.subtitle_file = null; + movie.subtitle_lang = null; + movie.subtitle_label = null; + movie.subtitle_default = false; + await movie.save(); + cacheClear(); + return res.status(200).json(movie); + } + + const subs = Array.isArray(movie.subtitles) ? movie.subtitles : []; + const idx = subs.findIndex((s) => String(s?._id || "") === String(subtitleId)); + if (idx < 0) { + return res.status(404).json({ message: "Subtitle not found" }); + } + + const removed = subs[idx]; + if (removed?.file) { + const full = path.resolve(SUBTITLE_FILES_DIR, String(removed.file)); + if (full.startsWith(SUBTITLE_FILES_DIR + path.sep)) { + try { await fs.promises.unlink(full); } catch (_) {} + } + } + + subs.splice(idx, 1); + if (subs.length > 0 && !subs.some((s) => s?.default)) { + subs[0].default = true; + } + movie.subtitles = subs; + syncLegacyFromSubtitles(movie); + + await movie.save(); + cacheClear(); + return res.status(200).json(movie); +}); + // Admin: delete one or more movies (by Mongo _id or imdb_id) router.delete("/delete", async (req, res) => { const token = req.headers.authorization?.split(" ")[1]; @@ -491,6 +803,11 @@ router.put("/:id", async (req, res) => { video_src, embed_src, video_file, + video_file_low, + subtitle_lang, + subtitle_label, + subtitle_default, + subtitle_remove, refreshOmdb, cam, } = req.body || {}; @@ -550,6 +867,73 @@ router.put("/:id", async (req, res) => { movie.video_src = `/api/video/${movie._id}`; } } + if (video_file_low !== undefined) { + const v = normalizeVideoFile(video_file_low); + movie.video_file_low = v ? v : null; + } + + const removeSubtitles = parseBooleanFlag(subtitle_remove); + if (removeSubtitles) { + const entries = Array.isArray(movie.subtitles) ? movie.subtitles : []; + for (const entry of entries) { + const rel = typeof entry?.file === "string" ? entry.file.trim() : ""; + if (!rel) continue; + const full = path.resolve(SUBTITLE_FILES_DIR, rel); + if (full.startsWith(SUBTITLE_FILES_DIR + path.sep)) { + try { await fs.promises.unlink(full); } catch (_) {} + } + } + + const existing = typeof movie.subtitle_file === "string" ? movie.subtitle_file.trim() : ""; + if (existing) { + const oldPath = path.resolve(SUBTITLE_FILES_DIR, existing); + if (oldPath.startsWith(SUBTITLE_FILES_DIR + path.sep)) { + try { await fs.promises.unlink(oldPath); } catch (_) {} + } + } + + movie.subtitles = []; + movie.subtitle_file = null; + movie.subtitle_lang = null; + movie.subtitle_label = null; + movie.subtitle_default = false; + } else { + const subs = Array.isArray(movie.subtitles) ? movie.subtitles : []; + if (subs.length > 0) { + const target = subs.find((s) => s?.default) || subs[0]; + if (subtitle_lang !== undefined) { + const v = normalizeSubtitleLang(subtitle_lang); + target.lang = v ? v : null; + } + if (subtitle_label !== undefined) { + const v = normalizeSubtitleLabel(subtitle_label); + target.label = v ? v : null; + } + if (subtitle_default !== undefined) { + const v = parseSubtitleDefault(subtitle_default); + if (v !== undefined) { + subs.forEach((s) => { s.default = false; }); + target.default = !!v; + } + } + syncLegacyFromSubtitles(movie); + } else { + if (subtitle_lang !== undefined) { + const v = normalizeSubtitleLang(subtitle_lang); + movie.subtitle_lang = v ? v : null; + } + + if (subtitle_label !== undefined) { + const v = normalizeSubtitleLabel(subtitle_label); + movie.subtitle_label = v ? v : null; + } + + if (subtitle_default !== undefined) { + const v = parseSubtitleDefault(subtitle_default); + if (v !== undefined) movie.subtitle_default = v; + } + } + } if (year !== undefined && year !== null && year !== "") { movie.year = parseOscarYear(year); @@ -774,7 +1158,7 @@ router.get("/:id", async (req, res) => { const movie = await Movie.findById(id).lean(); if (!movie) return res.status(404).json({ message: "Movie not found" }); - return res.status(200).json(movie); + return res.status(200).json({ ...movie, subtitles: getSubtitlesForResponse(movie) }); } catch (err) { return res.status(500).json({ error: err.message }); } diff --git a/routes/subtitles.js b/routes/subtitles.js new file mode 100644 index 0000000..5f9d6db --- /dev/null +++ b/routes/subtitles.js @@ -0,0 +1,139 @@ +const express = require("express"); +const fs = require("fs"); +const path = require("path"); +const jwt = require("jsonwebtoken"); +const mongoose = require("mongoose"); +const Movie = require("../models/Movie"); + +const router = express.Router(); +const SUBTITLE_FILES_DIR = path.resolve(process.env.SUBTITLE_FILES_DIR || path.join(process.cwd(), "subtitles")); + +function parseCookies(header) { + const cookies = {}; + const raw = String(header || ""); + if (!raw) return cookies; + raw.split(";").forEach((part) => { + const idx = part.indexOf("="); + if (idx <= 0) return; + const k = part.slice(0, idx).trim(); + const v = part.slice(idx + 1).trim(); + if (!k) return; + cookies[k] = decodeURIComponent(v); + }); + return cookies; +} + +function getAuthToken(req) { + const cookies = parseCookies(req.headers.cookie); + const cookieToken = cookies.video_auth ? String(cookies.video_auth) : ""; + if (cookieToken) return cookieToken; + const authHeader = String(req.headers.authorization || ""); + const headerToken = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : ""; + if (headerToken) return headerToken; + const qs = typeof req.query?.token === "string" ? req.query.token.trim() : ""; + return qs || ""; +} + +function requireAuth(req, res) { + const token = getAuthToken(req); + if (!token) return { ok: false, error: res.status(401).json({ message: "Missing subtitle auth token" }) }; + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + return { ok: true, decoded }; + } catch (_) { + return { ok: false, error: res.status(401).json({ message: "Invalid or expired subtitle auth token" }) }; + } +} + +function isSafeRelativeFile(p) { + if (typeof p !== "string") return false; + const s = p.trim(); + if (!s) return false; + if (s.includes("\0")) return false; + if (path.isAbsolute(s)) return false; + const norm = path.posix.normalize(s.replaceAll("\\", "/")); + if (norm.startsWith("../") || norm === "..") return false; + return true; +} + +async function streamSubtitle(req, res) { + const auth = requireAuth(req, res); + if (!auth.ok) return; + + const { id } = req.params; + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid movie id" }); + } + + const movie = await Movie.findById(id).select("subtitles subtitle_file subtitle_lang subtitle_label subtitle_default").lean(); + if (!movie) return res.status(404).json({ message: "Movie not found" }); + let subtitleFile = ""; + const subtitleId = req.params?.subtitleId; + const subs = Array.isArray(movie?.subtitles) ? movie.subtitles : []; + + if (subtitleId) { + if (subtitleId === "legacy") { + subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : ""; + } else { + const match = subs.find((s) => String(s?._id || "") === String(subtitleId)); + subtitleFile = typeof match?.file === "string" ? match.file.trim() : ""; + } + } else if (subs.length > 0) { + const preferred = subs.find((s) => s?.default) || subs[0]; + subtitleFile = typeof preferred?.file === "string" ? preferred.file.trim() : ""; + } else { + subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : ""; + } + + if (!subtitleFile) return res.status(404).json({ message: "No subtitles configured for this movie" }); + if (!isSafeRelativeFile(subtitleFile)) return res.status(400).json({ message: "Invalid subtitle_file path" }); + + const fullPath = path.resolve(SUBTITLE_FILES_DIR, subtitleFile); + if (!fullPath.startsWith(SUBTITLE_FILES_DIR + path.sep) && fullPath !== SUBTITLE_FILES_DIR) { + return res.status(400).json({ message: "Invalid subtitle_file path" }); + } + + let stat; + try { + stat = await fs.promises.stat(fullPath); + } catch (_) { + return res.status(404).json({ message: "Subtitle file not found on server" }); + } + if (!stat.isFile()) return res.status(404).json({ message: "Subtitle file not found on server" }); + + res.setHeader("Content-Type", "text/vtt; charset=utf-8"); + res.setHeader("Content-Length", String(stat.size)); + res.setHeader("Cache-Control", "private, max-age=0, must-revalidate"); + + if (req.method === "HEAD") { + return res.status(200).end(); + } + + return fs.createReadStream(fullPath).pipe(res); +} + +router.get("/:id/:subtitleId", (req, res) => { + streamSubtitle(req, res).catch((err) => { + return res.status(500).json({ error: err?.message || "Subtitle streaming error" }); + }); +}); + +router.head("/:id/:subtitleId", (req, res) => { + streamSubtitle(req, res).catch((err) => { + return res.status(500).json({ error: err?.message || "Subtitle streaming error" }); + }); +}); + +router.get("/:id", (req, res) => { + streamSubtitle(req, res).catch((err) => { + return res.status(500).json({ error: err?.message || "Subtitle streaming error" }); + }); +}); + +router.head("/:id", (req, res) => { + streamSubtitle(req, res).catch((err) => { + return res.status(500).json({ error: err?.message || "Subtitle streaming error" }); + }); +}); + +module.exports = router; diff --git a/routes/video.js b/routes/video.js index ef10361..8b77be9 100644 --- a/routes/video.js +++ b/routes/video.js @@ -119,17 +119,23 @@ async function streamMovieFile(req, res) { return res.status(400).json({ message: "Invalid movie id" }); } - const movie = await Movie.findById(id).select("video_file").lean(); + const qualityRaw = typeof req.query?.quality === "string" ? req.query.quality.trim().toLowerCase() : ""; + const wantsLow = qualityRaw === "low"; + + const movie = await Movie.findById(id).select("video_file video_file_low").lean(); const videoFile = typeof movie?.video_file === "string" ? movie.video_file.trim() : ""; - if (!videoFile) { - return res.status(404).json({ message: "No server video file configured for this movie" }); + const videoFileLow = typeof movie?.video_file_low === "string" ? movie.video_file_low.trim() : ""; + + const chosen = wantsLow ? videoFileLow : videoFile; + if (!chosen) { + return res.status(404).json({ message: wantsLow ? "No low-quality server video file configured for this movie" : "No server video file configured for this movie" }); } - if (!isSafeRelativeFile(videoFile)) { + if (!isSafeRelativeFile(chosen)) { return res.status(400).json({ message: "Invalid video_file path" }); } const baseDir = getVideoBaseDir(); - const fullPath = path.resolve(baseDir, videoFile); + const fullPath = path.resolve(baseDir, chosen); if (!fullPath.startsWith(baseDir + path.sep) && fullPath !== baseDir) { return res.status(400).json({ message: "Invalid video_file path" }); }