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.
This commit is contained in:
Mathieu Moran
2026-01-25 00:29:04 -05:00
parent 9076461c7e
commit a9592ece7a
8 changed files with 563 additions and 25 deletions
+2
View File
@@ -25,6 +25,7 @@ const versionRoutes = require("./routes/version");
const settingsRoutes = require("./routes/settings"); const settingsRoutes = require("./routes/settings");
const adminDbRoutes = require("./routes/adminDb"); const adminDbRoutes = require("./routes/adminDb");
const videoRoutes = require("./routes/video"); const videoRoutes = require("./routes/video");
const subtitlesRoutes = require("./routes/subtitles");
const picksRoutes = require("./routes/picks"); const picksRoutes = require("./routes/picks");
const categoriesRoutes = require("./routes/categories"); const categoriesRoutes = require("./routes/categories");
@@ -35,6 +36,7 @@ app.use("/api/version", versionRoutes);
app.use("/api/settings", settingsRoutes); app.use("/api/settings", settingsRoutes);
app.use("/api/admin", adminDbRoutes); app.use("/api/admin", adminDbRoutes);
app.use("/api/video", videoRoutes); app.use("/api/video", videoRoutes);
app.use("/api/subtitles", subtitlesRoutes);
app.use("/api/picks", picksRoutes); app.use("/api/picks", picksRoutes);
app.use("/api/categories", categoriesRoutes); app.use("/api/categories", categoriesRoutes);
+5
View File
@@ -24,6 +24,11 @@ const movieSchema = new mongoose.Schema({
// Optional low-quality server file (relative path under VIDEO_FILES_DIR, default public/video) // 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. // Served when the player requests ?quality=low on /api/video/:movieId.
video_file_low: { type: String, required: false }, 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' }] watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]
}, { timestamps: true }); }, { timestamps: true });
+38
View File
@@ -695,6 +695,25 @@
<input type="text" class="form-control font-monospace" id="video_file_low" placeholder="LOW/my_movie.LOW.mp4"> <input type="text" class="form-control font-monospace" id="video_file_low" placeholder="LOW/my_movie.LOW.mp4">
<div class="form-text">Chemin relatif sous <code>VIDEO_FILES_DIR</code>. Ex: <code>LOW/123.LOW.mp4</code>.</div> <div class="form-text">Chemin relatif sous <code>VIDEO_FILES_DIR</code>. Ex: <code>LOW/123.LOW.mp4</code>.</div>
</div> </div>
<div class="col-12">
<label for="subtitle_file" class="form-label">Sous-titres (SRT, optionnel)</label>
<input type="file" class="form-control" id="subtitle_file" accept=".srt,.vtt,text/plain,text/vtt">
<div class="form-text">Upload d'un fichier <code>.srt</code> (converti en VTT serveur, interne uniquement).</div>
</div>
<div class="col-12 col-lg-4">
<label for="subtitle_lang" class="form-label">Langue (ex: fr, en)</label>
<input type="text" class="form-control" id="subtitle_lang" placeholder="fr">
</div>
<div class="col-12 col-lg-4">
<label for="subtitle_label" class="form-label">Label (affiché)</label>
<input type="text" class="form-control" id="subtitle_label" placeholder="Français">
</div>
<div class="col-12 col-lg-4 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="subtitle_default">
<label class="form-check-label" for="subtitle_default">Par défaut</label>
</div>
</div>
<div class="col-12"> <div class="col-12">
<label for="embed_src" class="form-label">Embed URL ou iframe</label> <label for="embed_src" class="form-label">Embed URL ou iframe</label>
<textarea class="form-control font-monospace" id="embed_src" rows="3" placeholder="https://... OR <iframe src='...'></iframe>"></textarea> <textarea class="form-control font-monospace" id="embed_src" rows="3" placeholder="https://... OR <iframe src='...'></iframe>"></textarea>
@@ -1396,6 +1415,25 @@
<input type="text" class="form-control font-monospace" id="edit_video_file_low" placeholder="LOW/my_movie.LOW.mp4"> <input type="text" class="form-control font-monospace" id="edit_video_file_low" placeholder="LOW/my_movie.LOW.mp4">
<div class="form-text">Relative path under <code>VIDEO_FILES_DIR</code>. Example: <code>LOW/123.LOW.mp4</code>.</div> <div class="form-text">Relative path under <code>VIDEO_FILES_DIR</code>. Example: <code>LOW/123.LOW.mp4</code>.</div>
</div> </div>
<div class="col-12">
<label for="edit_subtitle_file" class="form-label">Sous-titres (SRT, optionnel)</label>
<input type="file" class="form-control" id="edit_subtitle_file" accept=".srt,.vtt,text/plain,text/vtt">
<div class="form-text">Fichier actuel: <span id="edit_subtitle_current">—</span></div>
</div>
<div class="col-12 col-lg-4">
<label for="edit_subtitle_lang" class="form-label">Langue (ex: fr, en)</label>
<input type="text" class="form-control" id="edit_subtitle_lang" placeholder="fr">
</div>
<div class="col-12 col-lg-4">
<label for="edit_subtitle_label" class="form-label">Label (affiché)</label>
<input type="text" class="form-control" id="edit_subtitle_label" placeholder="Français">
</div>
<div class="col-12 col-lg-4 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit_subtitle_default">
<label class="form-check-label" for="edit_subtitle_default">Par défaut</label>
</div>
</div>
<div class="col-12"> <div class="col-12">
<label for="edit_embed_src" class="form-label">Embed URL or iframe code</label> <label for="edit_embed_src" class="form-label">Embed URL or iframe code</label>
<textarea class="form-control font-monospace" id="edit_embed_src" rows="3" placeholder="https://... OR <iframe ...>"></textarea> <textarea class="form-control font-monospace" id="edit_embed_src" rows="3" placeholder="https://... OR <iframe ...>"></textarea>
+102 -1
View File
@@ -113,6 +113,11 @@ window.onload = async function () {
const editVideoSrcEl = document.getElementById('edit_video_src'); const editVideoSrcEl = document.getElementById('edit_video_src');
const editVideoFileEl = document.getElementById('edit_video_file'); const editVideoFileEl = document.getElementById('edit_video_file');
const editVideoFileLowEl = document.getElementById('edit_video_file_low'); 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 editEmbedSrcEl = document.getElementById('edit_embed_src');
const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb'); const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb');
const editTitleEl = document.getElementById('edit_title'); const editTitleEl = document.getElementById('edit_title');
@@ -159,6 +164,37 @@ window.onload = async function () {
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
} }
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) { function normalizePlayerUi(value) {
const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const showSource = v.showSource === undefined ? DEFAULT_PLAYER_UI.showSource : !!v.showSource; 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 = document.getElementById('video_file')?.value?.trim() || '';
const video_file_low = document.getElementById('video_file_low')?.value?.trim() || ''; const video_file_low = document.getElementById('video_file_low')?.value?.trim() || '';
const embed_src = document.getElementById('embed_src')?.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) { if (!year) {
showResponse('warning', 'Année invalide. Exemple attendu: 2026'); showResponse('warning', 'Année invalide. Exemple attendu: 2026');
@@ -1948,12 +1989,44 @@ window.onload = async function () {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${token}` '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) { if (res.ok) {
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.'); showResponse('success', 'Film ajouté avec succès.');
}
form.reset(); form.reset();
if (subtitleFileInput) subtitleFileInput.value = '';
await refreshYears(); await refreshYears();
await loadMoviesForManagement(); await loadMoviesForManagement();
return; return;
@@ -2197,6 +2270,14 @@ window.onload = async function () {
if (editVideoSrcEl) editVideoSrcEl.value = movie.video_src || ''; if (editVideoSrcEl) editVideoSrcEl.value = movie.video_src || '';
if (editVideoFileEl) editVideoFileEl.value = movie.video_file || ''; if (editVideoFileEl) editVideoFileEl.value = movie.video_file || '';
if (editVideoFileLowEl) editVideoFileLowEl.value = movie.video_file_low || ''; 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 || ''; if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || '';
editRefreshOmdbEl.checked = false; editRefreshOmdbEl.checked = false;
@@ -2227,6 +2308,10 @@ window.onload = async function () {
const video_file = (editVideoFileEl?.value || '').trim(); const video_file = (editVideoFileEl?.value || '').trim();
const video_file_low = (editVideoFileLowEl?.value || '').trim(); const video_file_low = (editVideoFileLowEl?.value || '').trim();
const embed_src = (editEmbedSrcEl?.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 refreshOmdb = !!editRefreshOmdbEl.checked;
const cam = !!editCamFlagEl?.checked; const cam = !!editCamFlagEl?.checked;
@@ -2264,6 +2349,9 @@ window.onload = async function () {
video_file, video_file,
video_file_low, video_file_low,
embed_src, embed_src,
subtitle_lang,
subtitle_label,
subtitle_default,
refreshOmdb, refreshOmdb,
cam cam
}; };
@@ -2300,6 +2388,19 @@ window.onload = async function () {
} }
const updated = await res.json(); 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); moviesById.set(updated._id, updated);
showResponse('success', 'Film mis à jour avec succès.'); showResponse('success', 'Film mis à jour avec succès.');
editMovieModal.hide(); editMovieModal.hide();
+72 -15
View File
@@ -687,6 +687,7 @@ function toVideoSessionUrl(videoSrc) {
// Best-effort cache to avoid spamming /api/video/session. // Best-effort cache to avoid spamming /api/video/session.
// Keyed by sessionUrl + token (token is already in-memory, not persisted here). // Keyed by sessionUrl + token (token is already in-memory, not persisted here).
const _videoSessionCache = new Map(); // key -> expiresAt ms const _videoSessionCache = new Map(); // key -> expiresAt ms
let _currentMovie = null;
async function ensureVideoSessionForSource(videoSrc, token) { async function ensureVideoSessionForSource(videoSrc, token) {
// Only needed for our protected /api/video/* streams. // Only needed for our protected /api/video/* streams.
@@ -777,6 +778,50 @@ async function ensurePlayableApiVideoUrl(rawUrl, token) {
return addTokenToApiVideoUrl(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 } = {}) { async function playVodLink(vodLink, { token } = {}) {
const raw = normalizeVodLink(vodLink); const raw = normalizeVodLink(vodLink);
if (!raw) { if (!raw) {
@@ -927,17 +972,22 @@ function addQualityParam(rawUrl, quality) {
function setQualityToggleUi({ visible, isLow }) { function setQualityToggleUi({ visible, isLow }) {
const wrap = document.getElementById('quality-toggle'); const wrap = document.getElementById('quality-toggle');
const toggle = document.getElementById('quality-switch'); const btnHigh = document.getElementById('quality-btn-high');
const label = document.getElementById('quality-switch-label'); const btnLow = document.getElementById('quality-btn-low');
if (!wrap || !toggle || !label) return; if (!wrap || !btnHigh || !btnLow) return;
if (visible) { if (visible) {
wrap.classList.remove('d-none'); wrap.classList.remove('d-none');
toggle.checked = !!isLow; const useLow = !!isLow;
label.textContent = isLow ? 'Basse' : 'Haute'; 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 { } else {
wrap.classList.add('d-none'); wrap.classList.add('d-none');
toggle.checked = false; btnHigh.classList.remove('is-active');
label.textContent = 'Haute'; 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 }); videoEl.addEventListener('loadedmetadata', onLoaded, { once: true });
await playVodLink(src, { token }); 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 wrap = document.getElementById('quality-toggle');
const toggle = document.getElementById('quality-switch'); const btnHigh = document.getElementById('quality-btn-high');
if (!wrap || !toggle) return { src: fallbackSrc }; 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 }); setQualityToggleUi({ visible: false, isLow: false });
return { src: fallbackSrc }; return { src: fallbackSrc };
} }
@@ -987,8 +1039,7 @@ async function initQualityToggle({ movieId, token, fallbackSrc }) {
setQualityToggleUi({ visible: true, isLow: shouldUseLow }); setQualityToggleUi({ visible: true, isLow: shouldUseLow });
toggle.addEventListener('change', async () => { const onPickQuality = async (nextIsLow) => {
const nextIsLow = !!toggle.checked;
setStoredQuality(movieId, nextIsLow ? 'low' : 'high'); setStoredQuality(movieId, nextIsLow ? 'low' : 'high');
setQualityToggleUi({ visible: true, isLow: nextIsLow }); setQualityToggleUi({ visible: true, isLow: nextIsLow });
await switchVideoSource({ await switchVideoSource({
@@ -996,7 +1047,10 @@ async function initQualityToggle({ movieId, token, fallbackSrc }) {
token, token,
preserveTime: true, preserveTime: true,
}); });
}); };
btnHigh.addEventListener('click', () => { void onPickQuality(false); });
btnLow.addEventListener('click', () => { void onPickQuality(true); });
return { src: pickedSrc }; return { src: pickedSrc };
} }
@@ -1069,6 +1123,7 @@ window.onload = async function () {
pageLoader.fail(); pageLoader.fail();
return; return;
} }
_currentMovie = movie;
const [activeYear, playerUi] = await Promise.all([activeYearPromise, playerUiPromise]); const [activeYear, playerUi] = await Promise.all([activeYearPromise, playerUiPromise]);
setHeader(movie, { activeYear }); setHeader(movie, { activeYear });
@@ -1105,7 +1160,8 @@ window.onload = async function () {
} }
pageLoader.setProgress(70); 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; const selectedSrc = qualityChoice?.src || resolved.src;
await playVodLink(selectedSrc, { token }); await playVodLink(selectedSrc, { token });
pageLoader.setProgress(82); pageLoader.setProgress(82);
@@ -1113,6 +1169,7 @@ window.onload = async function () {
// Only the <video> player supports reliable progress tracking (mp4/hls). // Only the <video> player supports reliable progress tracking (mp4/hls).
const videoEl = document.getElementById('video'); const videoEl = document.getElementById('video');
if (videoEl && !videoEl.classList.contains('d-none')) { if (videoEl && !videoEl.classList.contains('d-none')) {
applySubtitleTrack({ videoEl, movie, token });
setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' }); setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' });
const userId = normalizeUserId(decoded); const userId = normalizeUserId(decoded);
if (userId) { if (userId) {
+48 -5
View File
@@ -63,6 +63,49 @@
white-space: nowrap; white-space: nowrap;
} }
.quality-toggle {
display: inline-flex;
align-items: center;
gap: .6rem;
background: #f8f9fa;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 999px;
padding: .25rem .4rem .25rem .6rem;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.quality-label {
font-size: .8rem;
color: rgba(0,0,0,0.6);
font-weight: 600;
}
.quality-pill {
display: inline-flex;
background: #fff;
border-radius: 999px;
border: 1px solid rgba(0,0,0,0.08);
padding: 2px;
gap: 2px;
}
.quality-btn {
border: 0;
background: transparent;
color: rgba(0,0,0,0.65);
font-size: .78rem;
font-weight: 600;
border-radius: 999px;
padding: .25rem .6rem;
line-height: 1.1;
transition: background .2s ease, color .2s ease;
}
.quality-btn.is-active {
background: #111;
color: #fff;
}
.quality-btn:focus-visible {
outline: 2px solid rgba(13,110,253,.35);
outline-offset: 2px;
}
/* Progress indicator: icon-first, minimal text */ /* Progress indicator: icon-first, minimal text */
.progress-indicator { .progress-indicator {
display: inline-flex; display: inline-flex;
@@ -153,11 +196,11 @@
</div> </div>
<div class="d-flex flex-column gap-2 align-items-start align-items-lg-end"> <div class="d-flex flex-column gap-2 align-items-start align-items-lg-end">
<div id="quality-toggle" class="d-none d-flex align-items-center gap-2"> <div id="quality-toggle" class="d-none quality-toggle">
<span class="text-muted small">Qualité</span> <span class="quality-label">Qualité</span>
<div class="form-check form-switch m-0"> <div class="quality-pill" role="group" aria-label="Qualité">
<input class="form-check-input" type="checkbox" id="quality-switch"> <button type="button" class="quality-btn" data-quality="high" id="quality-btn-high" aria-pressed="true">Haute</button>
<label class="form-check-label small" for="quality-switch" id="quality-switch-label">Haute</label> <button type="button" class="quality-btn" data-quality="low" id="quality-btn-low" aria-pressed="false">Basse</button>
</div> </div>
</div> </div>
+186 -3
View File
@@ -1,4 +1,7 @@
const express = require("express"); const express = require("express");
const fs = require("fs");
const path = require("path");
const multer = require("multer");
const Movie = require("../models/Movie"); const Movie = require("../models/Movie");
const User = require("../models/User"); const User = require("../models/User");
const PlaybackProgress = require("../models/PlaybackProgress"); 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. // 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 MOVIES_CACHE_TTL_MS = Number(process.env.MOVIES_CACHE_TTL_MS || "30000"); // 30s
const _cache = new Map(); // key -> { expiresAt:number, value:any } 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) { function cacheGet(key) {
const hit = _cache.get(key); const hit = _cache.get(key);
@@ -62,6 +67,68 @@ function normalizeVideoFile(v) {
return s.slice(0, 1024); 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) { function normalizePlayerMode(v) {
const s = String(v || "").trim().toLowerCase(); const s = String(v || "").trim().toLowerCase();
if (!s) return "auto"; if (!s) return "auto";
@@ -148,7 +215,7 @@ router.get("/", async (req, res) => {
const projection = isChecklist const projection = isChecklist
? "imdb_id title" ? "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 cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`;
const cached = cacheGet(cacheKey); const cached = cacheGet(cacheKey);
@@ -333,7 +400,21 @@ router.patch("/users/updateWatchedMovies", async (req, res) => {
// Add movie // Add movie
router.post("/add", async (req, res) => { 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 { try {
// Get the token from the Authorization header // Get the token from the Authorization header
@@ -365,6 +446,9 @@ router.post("/add", async (req, res) => {
if (normalizedMode === null) { if (normalizedMode === null) {
return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" }); 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 // Fetch movie details from OMDb API
let movieDetails; let movieDetails;
@@ -400,6 +484,9 @@ router.post("/add", async (req, res) => {
embed_src: normalizedEmbed || undefined, embed_src: normalizedEmbed || undefined,
video_file: normalizedFile || undefined, video_file: normalizedFile || undefined,
video_file_low: normalizedLowFile || undefined, video_file_low: normalizedLowFile || undefined,
subtitle_lang: normalizedSubtitleLang || undefined,
subtitle_label: normalizedSubtitleLabel || undefined,
subtitle_default: normalizedSubtitleDefault,
}); });
await movie.save(); await movie.save();
@@ -411,7 +498,7 @@ router.post("/add", async (req, res) => {
} }
cacheClear(); cacheClear();
res.status(201).json({ message: "Movie added successfully!" }); res.status(201).json({ message: "Movie added successfully!", movie });
} catch (err) { } catch (err) {
if (err instanceof jwt.JsonWebTokenError) { if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" }); 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) // Admin: delete one or more movies (by Mongo _id or imdb_id)
router.delete("/delete", async (req, res) => { router.delete("/delete", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1]; const token = req.headers.authorization?.split(" ")[1];
@@ -494,6 +659,9 @@ router.put("/:id", async (req, res) => {
embed_src, embed_src,
video_file, video_file,
video_file_low, video_file_low,
subtitle_lang,
subtitle_label,
subtitle_default,
refreshOmdb, refreshOmdb,
cam, cam,
} = req.body || {}; } = req.body || {};
@@ -558,6 +726,21 @@ router.put("/:id", async (req, res) => {
movie.video_file_low = v ? v : null; 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 !== "") { if (year !== undefined && year !== null && year !== "") {
movie.year = parseOscarYear(year); movie.year = parseOscarYear(year);
} else if (year === null || year === "") { } else if (year === null || year === "") {
+109
View File
@@ -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;