we subbing now bois #73
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+103
-2
@@ -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, ''');
|
.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) {
|
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) {
|
||||||
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();
|
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
@@ -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
@@ -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
@@ -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 === "") {
|
||||||
|
|||||||
@@ -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.