we subbing now bois #73
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -695,6 +695,25 @@
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
@@ -1396,6 +1415,25 @@
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
+103
-2
@@ -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();
|
||||
|
||||
+72
-15
@@ -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 <video> player supports reliable progress tracking (mp4/hls).
|
||||
const videoEl = document.getElementById('video');
|
||||
if (videoEl && !videoEl.classList.contains('d-none')) {
|
||||
applySubtitleTrack({ videoEl, movie, token });
|
||||
setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' });
|
||||
const userId = normalizeUserId(decoded);
|
||||
if (userId) {
|
||||
|
||||
+48
-5
@@ -63,6 +63,49 @@
|
||||
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 {
|
||||
display: inline-flex;
|
||||
@@ -153,11 +196,11 @@
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<span class="text-muted small">Qualité</span>
|
||||
<div class="form-check form-switch m-0">
|
||||
<input class="form-check-input" type="checkbox" id="quality-switch">
|
||||
<label class="form-check-label small" for="quality-switch" id="quality-switch-label">Haute</label>
|
||||
<div id="quality-toggle" class="d-none quality-toggle">
|
||||
<span class="quality-label">Qualité</span>
|
||||
<div class="quality-pill" role="group" aria-label="Qualité">
|
||||
<button type="button" class="quality-btn" data-quality="high" id="quality-btn-high" aria-pressed="true">Haute</button>
|
||||
<button type="button" class="quality-btn" data-quality="low" id="quality-btn-low" aria-pressed="false">Basse</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+186
-3
@@ -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 === "") {
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user
Duplicate identical functions for boolean parsing
Low Severity
The
parseSubtitleDefaultfunction (lines 78-86) andparseBooleanFlagfunction (lines 88-96) are completely identical implementations. Both parse boolean values from string inputs using the exact same logic (handling"true","1","yes","on"for true, etc.). One function should be removed and the other reused in both places where they're currently called.