Merge pull request #73 from quoije/2026

we subbing now bois
This commit was merged in pull request #73.
This commit is contained in:
Mathieu Moran
2026-01-25 02:02:20 -05:00
committed by GitHub
13 changed files with 1103 additions and 30 deletions
+3
View File
@@ -147,6 +147,9 @@ backups/
# Video files in public/video/ # Video files in public/video/
public/video/* public/video/*
# Subtitles
subtitles/*
# Uploads directory (temporary files) # Uploads directory (temporary files)
uploads/ uploads/
+2
View File
@@ -3,6 +3,8 @@ Little nodejs + mongodb app that I hack together with ChatGPT and Cursor to keep
**Note:** The application interface is in French. **Note:** The application interface is in French.
**Disclaimer:** This was a fun, experimental project built with “AI slop” (Cursor), so it may have security issues. For example, `python_video_server` uses the default token‑in‑URL approach to serve video files, which can leave user tokens in logs or browser history. There are ways to improve this, but at this point I’m blinder than Ray Charles.
![Oscars Pool 2026](https://raw.githubusercontent.com/quoije/oscars-pool/refs/heads/prod/img/preview.png) ![Oscars Pool 2026](https://raw.githubusercontent.com/quoije/oscars-pool/refs/heads/prod/img/preview.png)
## Features ## Features
+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);
+15
View File
@@ -21,6 +21,21 @@ const movieSchema = new mongoose.Schema({
// Server-hosted file (relative path under VIDEO_FILES_DIR, default public/video) // Server-hosted file (relative path under VIDEO_FILES_DIR, default public/video)
// If set, the backend can stream it via /api/video/:movieId (Range/seek supported). // If set, the backend can stream it via /api/video/:movieId (Range/seek supported).
video_file: { type: String, required: false }, video_file: { type: String, required: false },
// Optional low-quality server file (relative path under VIDEO_FILES_DIR, default public/video)
// Served when the player requests ?quality=low on /api/video/:movieId.
video_file_low: { type: String, required: false },
// Optional subtitles (server-hosted .vtt, streamed via /api/subtitles/:movieId/:subtitleId)
subtitles: [{
file: { type: String, required: true },
lang: { type: String, required: false },
label: { type: String, required: false },
default: { type: Boolean, required: false, default: false },
}],
// Legacy single-subtitle fields (kept for backward compatibility)
subtitle_file: { type: String, required: false },
subtitle_lang: { type: String, required: false },
subtitle_label: { type: String, required: false },
subtitle_default: { type: Boolean, required: false, default: false },
watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }] watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]
}, { timestamps: true }); }, { timestamps: true });
+58
View File
@@ -690,6 +690,30 @@
<input type="text" class="form-control font-monospace" id="video_file" placeholder="my_movie.mp4 or 2026/my_movie.mp4"> <input type="text" class="form-control font-monospace" id="video_file" placeholder="my_movie.mp4 or 2026/my_movie.mp4">
<div class="form-text">Chemin relatif sous <code>VIDEO_FILES_DIR</code> (défaut: <code>public/video</code>).</div> <div class="form-text">Chemin relatif sous <code>VIDEO_FILES_DIR</code> (défaut: <code>public/video</code>).</div>
</div> </div>
<div class="col-12">
<label for="video_file_low" class="form-label">Server video file (basse qualité, optionnel)</label>
<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"> <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>
@@ -723,6 +747,7 @@
<ul class="mb-0"> <ul class="mb-0">
<li><strong>Auto</strong>: utilise <code>video_src</code> → <code>embed_src</code> → <code>vod_link</code>.</li> <li><strong>Auto</strong>: utilise <code>video_src</code> → <code>embed_src</code> → <code>vod_link</code>.</li>
<li><strong>Server file</strong>: mets <code>video_file</code> (chemin relatif sous <code>public/video</code>).</li> <li><strong>Server file</strong>: mets <code>video_file</code> (chemin relatif sous <code>public/video</code>).</li>
<li><strong>Low quality</strong>: mets <code>video_file_low</code> (ex: <code>LOW/mon_film.LOW.mp4</code>).</li>
<li><strong>Édition</strong>: le bouton “Éditer” ouvre une modale avec des onglets Info / Player.</li> <li><strong>Édition</strong>: le bouton “Éditer” ouvre une modale avec des onglets Info / Player.</li>
</ul> </ul>
</div> </div>
@@ -1385,6 +1410,39 @@
<input type="text" class="form-control font-monospace" id="edit_video_file" placeholder="my_movie.mp4 or 2026/my_movie.mp4"> <input type="text" class="form-control font-monospace" id="edit_video_file" placeholder="my_movie.mp4 or 2026/my_movie.mp4">
<div class="form-text">Relative path under <code>VIDEO_FILES_DIR</code> (default: <code>public/video</code>).</div> <div class="form-text">Relative path under <code>VIDEO_FILES_DIR</code> (default: <code>public/video</code>).</div>
</div> </div>
<div class="col-12">
<label for="edit_video_file_low" class="form-label">Server video file (low quality, optional)</label>
<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">Sous-titres existants:</div>
<div id="edit_subtitle_list" class="d-flex flex-column gap-2 mt-1"></div>
<div class="form-text">Ajoute un fichier par langue; répète l’upload pour plusieurs sous-titres.</div>
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit_subtitle_remove">
<label class="form-check-label" for="edit_subtitle_remove">Supprimer tous les sous-titres</label>
</div>
<div class="form-text">Efface les fichiers VTT/SRT et réinitialise les champs de langue/label.</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>
+200 -2
View File
@@ -112,6 +112,13 @@ window.onload = async function () {
const editPlayerModeEl = document.getElementById('edit_player_mode'); const editPlayerModeEl = document.getElementById('edit_player_mode');
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 editSubtitleFileEl = document.getElementById('edit_subtitle_file');
const editSubtitleRemoveEl = document.getElementById('edit_subtitle_remove');
const editSubtitleLangEl = document.getElementById('edit_subtitle_lang');
const editSubtitleLabelEl = document.getElementById('edit_subtitle_label');
const editSubtitleDefaultEl = document.getElementById('edit_subtitle_default');
const editSubtitleListEl = document.getElementById('edit_subtitle_list');
const editEmbedSrcEl = document.getElementById('edit_embed_src'); const 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');
@@ -158,6 +165,67 @@ 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);
}
function getMovieSubtitles(movie) {
const subs = Array.isArray(movie?.subtitles) ? movie.subtitles.filter(Boolean) : [];
if (subs.length > 0) return subs;
const legacyFile = typeof movie?.subtitle_file === 'string' ? movie.subtitle_file.trim() : '';
if (!legacyFile) return [];
return [{
_id: 'legacy',
file: legacyFile,
lang: movie?.subtitle_lang || '',
label: movie?.subtitle_label || '',
default: !!movie?.subtitle_default,
}];
}
async function uploadMovieSubtitle({ movieId, file, lang, label, isDefault }) {
if (!movieId || !file) return null;
const formData = new FormData();
formData.append('subtitle', file);
if (lang) formData.append('subtitle_lang', lang);
if (label) formData.append('subtitle_label', label);
formData.append('subtitle_default', isDefault ? 'true' : 'false');
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/subtitles`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
},
body: formData,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return data;
}
async function removeMovieSubtitle({ movieId, subtitleId }) {
if (!movieId || !subtitleId) return null;
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/subtitles/${encodeURIComponent(subtitleId)}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const msg = data.message || data.error || `Erreur (${res.status})`;
throw new Error(msg);
}
return data;
}
function normalizePlayerUi(value) { 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;
@@ -1927,7 +1995,13 @@ window.onload = async function () {
const player_mode = (document.getElementById('player_mode')?.value || 'auto').trim(); const player_mode = (document.getElementById('player_mode')?.value || 'auto').trim();
const video_src = document.getElementById('video_src')?.value?.trim() || ''; const video_src = document.getElementById('video_src')?.value?.trim() || '';
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 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');
@@ -1946,12 +2020,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, 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;
@@ -2180,6 +2286,59 @@ window.onload = async function () {
selectAllBox.checked = allBoxes.length > 0 && checked === allBoxes.length; selectAllBox.checked = allBoxes.length > 0 && checked === allBoxes.length;
} }
function renderSubtitleList(movie) {
if (!editSubtitleListEl) return;
const subs = getMovieSubtitles(movie);
if (!subs.length) {
editSubtitleListEl.innerHTML = '<span class="text-muted">—</span>';
return;
}
editSubtitleListEl.innerHTML = subs.map((s) => {
const id = escapeHtml(String(s?._id || ''));
const lang = escapeHtml(String(s?.lang || ''));
const label = escapeHtml(String(s?.label || ''));
const file = escapeHtml(String(s?.file || ''));
const title = label || lang || file || 'Sous-titres';
const meta = [lang, label].filter(Boolean).join(' · ');
const isDefault = s?.default ? '<span class="badge bg-success ms-2">Par défaut</span>' : '';
return `
<div class="d-flex align-items-center justify-content-between gap-2">
<div class="small">
<span class="fw-semibold">${title}</span>
${meta ? `<span class="text-muted ms-2">${meta}</span>` : ''}
${isDefault}
</div>
<button type="button" class="btn btn-sm btn-outline-danger" data-remove-subtitle-id="${id}">
Supprimer
</button>
</div>
`;
}).join('');
editSubtitleListEl.querySelectorAll('[data-remove-subtitle-id]').forEach((btn) => {
btn.addEventListener('click', async () => {
const subtitleId = btn.getAttribute('data-remove-subtitle-id');
const movieId = String(editMovieIdEl?.value || '');
if (!movieId || !subtitleId) return;
if (!confirm('Supprimer ce sous-titre ?')) return;
try {
btn.disabled = true;
const updated = await removeMovieSubtitle({ movieId, subtitleId });
moviesById.set(updated._id, updated);
renderSubtitleList(updated);
showResponse('success', 'Sous-titre supprimé.');
await loadMoviesForManagement();
} catch (err) {
showResponse('danger', err.message || 'Erreur réseau');
} finally {
btn.disabled = false;
}
});
});
}
function openEditModal(movieId) { function openEditModal(movieId) {
if (!editMovieModal) return; if (!editMovieModal) return;
const movie = moviesById.get(movieId); const movie = moviesById.get(movieId);
@@ -2194,6 +2353,13 @@ window.onload = async function () {
if (editPlayerModeEl) editPlayerModeEl.value = movie.player_mode || 'auto'; if (editPlayerModeEl) editPlayerModeEl.value = movie.player_mode || 'auto';
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 (editSubtitleLangEl) editSubtitleLangEl.value = movie.subtitle_lang || '';
if (editSubtitleLabelEl) editSubtitleLabelEl.value = movie.subtitle_label || '';
if (editSubtitleDefaultEl) editSubtitleDefaultEl.checked = !!movie.subtitle_default;
renderSubtitleList(movie);
if (editSubtitleRemoveEl) editSubtitleRemoveEl.checked = false;
if (editSubtitleFileEl) editSubtitleFileEl.value = '';
if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || ''; if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || '';
editRefreshOmdbEl.checked = false; editRefreshOmdbEl.checked = false;
@@ -2222,7 +2388,13 @@ window.onload = async function () {
const player_mode = (editPlayerModeEl?.value || 'auto').trim(); const player_mode = (editPlayerModeEl?.value || 'auto').trim();
const video_src = (editVideoSrcEl?.value || '').trim(); const video_src = (editVideoSrcEl?.value || '').trim();
const video_file = (editVideoFileEl?.value || '').trim(); const video_file = (editVideoFileEl?.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 subtitle_remove = !!editSubtitleRemoveEl?.checked;
const subtitleFile = subtitle_remove ? null : (editSubtitleFileEl?.files?.[0] || null);
const refreshOmdb = !!editRefreshOmdbEl.checked; const refreshOmdb = !!editRefreshOmdbEl.checked;
const cam = !!editCamFlagEl?.checked; const cam = !!editCamFlagEl?.checked;
@@ -2258,11 +2430,19 @@ window.onload = async function () {
player_mode, player_mode,
video_src, video_src,
video_file, video_file,
video_file_low,
embed_src, embed_src,
subtitle_remove,
refreshOmdb, refreshOmdb,
cam cam
}; };
if (!subtitle_remove && !subtitleFile) {
body.subtitle_lang = subtitle_lang;
body.subtitle_label = subtitle_label;
body.subtitle_default = subtitle_default;
}
// Only send manual fields if we're not refreshing from OMDb // Only send manual fields if we're not refreshing from OMDb
if (!refreshOmdb) { if (!refreshOmdb) {
body.title = title; body.title = title;
@@ -2295,6 +2475,20 @@ window.onload = async function () {
} }
const updated = await res.json(); const updated = await res.json();
if (subtitleFile) {
try {
const uploaded = await uploadMovieSubtitle({
movieId,
file: subtitleFile,
lang: subtitle_lang,
label: subtitle_label,
isDefault: subtitle_default,
});
if (uploaded) moviesById.set(uploaded._id, uploaded);
} catch (err) {
showResponse('warning', `Film mis à jour, mais sous-titres non uploadés: ${err.message || 'Erreur'}`);
}
}
moviesById.set(updated._id, updated); 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();
@@ -2333,13 +2527,17 @@ window.onload = async function () {
const badges = []; const badges = [];
const hasVideo = !!(m && m.video_src); const hasVideo = !!(m && m.video_src);
const hasFile = !!(m && m.video_file); const hasFile = !!(m && m.video_file);
const hasLowFile = !!(m && m.video_file_low);
const hasEmbed = !!(m && m.embed_src); const hasEmbed = !!(m && m.embed_src);
const hasLegacy = !!(m && m.vod_link); const hasLegacy = !!(m && m.vod_link);
const hasSubtitle = Array.isArray(m?.subtitles) ? m.subtitles.length > 0 : !!(m && m.subtitle_file);
if (hasVideo) badges.push({ text: 'Video', cls: 'bg-primary' }); if (hasVideo) badges.push({ text: 'Video', cls: 'bg-primary' });
if (hasFile) badges.push({ text: 'Server file', cls: 'bg-dark' }); if (hasFile) badges.push({ text: 'Server file', cls: 'bg-dark' });
if (hasLowFile) badges.push({ text: 'Low file', cls: 'bg-secondary' });
if (hasEmbed) badges.push({ text: 'Embed', cls: 'bg-info text-dark' }); if (hasEmbed) badges.push({ text: 'Embed', cls: 'bg-info text-dark' });
if (hasLegacy) badges.push({ text: 'Legacy', cls: 'bg-secondary' }); if (hasLegacy) badges.push({ text: 'Legacy', cls: 'bg-secondary' });
if (hasSubtitle) badges.push({ text: 'Subtitles', cls: 'bg-warning text-dark' });
const modeRaw = String(m?.player_mode || 'auto').toLowerCase(); const modeRaw = String(m?.player_mode || 'auto').toLowerCase();
const mode = (modeRaw === 'video' || modeRaw === 'embed' || modeRaw === 'auto') ? modeRaw : 'auto'; const mode = (modeRaw === 'video' || modeRaw === 'embed' || modeRaw === 'auto') ? modeRaw : 'auto';
+203 -1
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.
@@ -753,6 +754,21 @@ async function probeApiVideoReadable(rawUrl) {
} }
} }
async function probeApiVideoExists(rawUrl) {
try {
const res = await fetch(rawUrl, {
method: 'GET',
headers: { Range: 'bytes=0-0' },
credentials: 'include',
cache: 'no-store',
});
if (res.status === 401 || res.status === 403 || res.status === 404) return false;
return res.ok;
} catch (_) {
return false;
}
}
async function ensurePlayableApiVideoUrl(rawUrl, token) { async function ensurePlayableApiVideoUrl(rawUrl, token) {
if (!isApiVideoUrl(rawUrl) || !token) return rawUrl; if (!isApiVideoUrl(rawUrl) || !token) return rawUrl;
// IMPORTANT: Prefer query-token auth for media elements. // IMPORTANT: Prefer query-token auth for media elements.
@@ -762,6 +778,75 @@ 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 || '');
if (!movieId) return;
const subtitles = Array.isArray(movie?.subtitles) ? movie.subtitles : [];
if (subtitles.length > 0) {
const hasDefault = subtitles.some((s) => s?.default);
subtitles.forEach((s, idx) => {
const lang = String(s?.lang || 'en').trim() || 'en';
const label = String(s?.label || 'Subtitles').trim() || 'Subtitles';
const isDefault = s?.default === undefined ? (!hasDefault && idx === 0) : !!s.default;
const subId = String(s?._id || '').trim();
const rawUrl = subId && subId !== 'legacy'
? `/api/subtitles/${encodeURIComponent(movieId)}/${encodeURIComponent(subId)}`
: `/api/subtitles/${encodeURIComponent(movieId)}`;
const src = addTokenToApiSubtitleUrl(rawUrl, token);
const track = document.createElement('track');
track.kind = 'subtitles';
track.label = label;
track.srclang = lang;
track.src = src;
track.default = isDefault;
videoEl.appendChild(track);
});
return;
}
const subtitleFile = String(movie?.subtitle_file || '').trim();
if (!subtitleFile) return;
const lang = String(movie?.subtitle_lang || 'en').trim() || 'en';
const label = String(movie?.subtitle_label || 'Subtitles').trim() || 'Subtitles';
const isDefault = movie?.subtitle_default === undefined ? true : !!movie.subtitle_default;
const rawUrl = `/api/subtitles/${encodeURIComponent(movieId)}`;
const src = addTokenToApiSubtitleUrl(rawUrl, token);
const track = document.createElement('track');
track.kind = 'subtitles';
track.label = label;
track.srclang = lang;
track.src = src;
track.default = isDefault;
videoEl.appendChild(track);
}
async function playVodLink(vodLink, { token } = {}) { async function playVodLink(vodLink, { token } = {}) {
const raw = normalizeVodLink(vodLink); const raw = normalizeVodLink(vodLink);
if (!raw) { if (!raw) {
@@ -883,6 +968,118 @@ function resolveMovieSource(movie) {
return { src: null, isLegacy: false }; return { src: null, isLegacy: false };
} }
function qualityStorageKey(movieId) {
return `player_quality:${String(movieId || '')}`;
}
function getStoredQuality(movieId) {
const raw = localStorage.getItem(qualityStorageKey(movieId));
return raw === 'low' ? 'low' : 'high';
}
function setStoredQuality(movieId, quality) {
const value = quality === 'low' ? 'low' : 'high';
try { localStorage.setItem(qualityStorageKey(movieId), value); } catch (_) {}
}
function addQualityParam(rawUrl, quality) {
if (!rawUrl) return rawUrl;
try {
const isRelative = String(rawUrl).trim().startsWith('/');
const u = new URL(String(rawUrl), window.location.origin);
if (quality) u.searchParams.set('quality', quality);
else u.searchParams.delete('quality');
return isRelative ? `${u.pathname}${u.search}${u.hash}` : u.toString();
} catch (_) {
return rawUrl;
}
}
function setQualityToggleUi({ visible, isLow }) {
const wrap = document.getElementById('quality-toggle');
const btnHigh = document.getElementById('quality-btn-high');
const btnLow = document.getElementById('quality-btn-low');
if (!wrap || !btnHigh || !btnLow) return;
if (visible) {
wrap.classList.remove('d-none');
const useLow = !!isLow;
btnHigh.classList.toggle('is-active', !useLow);
btnLow.classList.toggle('is-active', useLow);
btnHigh.setAttribute('aria-pressed', useLow ? 'false' : 'true');
btnLow.setAttribute('aria-pressed', useLow ? 'true' : 'false');
} else {
wrap.classList.add('d-none');
btnHigh.classList.remove('is-active');
btnLow.classList.remove('is-active');
btnHigh.setAttribute('aria-pressed', 'false');
btnLow.setAttribute('aria-pressed', 'false');
}
}
async function switchVideoSource({ src, token, preserveTime }) {
const videoEl = document.getElementById('video');
if (!videoEl || !src) return;
const shouldPreserve = !!preserveTime && Number.isFinite(videoEl.currentTime) && videoEl.currentTime > 0;
const previousTime = shouldPreserve ? Number(videoEl.currentTime) : 0;
const wasPlaying = !videoEl.paused && !videoEl.ended;
const onLoaded = () => {
if (previousTime > 1) {
try { videoEl.currentTime = previousTime; } catch (_) {}
}
if (wasPlaying) {
const playPromise = videoEl.play();
if (playPromise && typeof playPromise.catch === 'function') playPromise.catch(() => {});
}
};
videoEl.addEventListener('loadedmetadata', onLoaded, { once: true });
await playVodLink(src, { token });
applySubtitleTrack({ videoEl, movie: _currentMovie, token });
}
async function initQualityToggle({ movieId, token, fallbackSrc, hasLowFile }) {
const wrap = document.getElementById('quality-toggle');
const btnHigh = document.getElementById('quality-btn-high');
const btnLow = document.getElementById('quality-btn-low');
if (!wrap || !btnHigh || !btnLow) return { src: fallbackSrc };
if (!isApiVideoUrl(fallbackSrc) || !hasLowFile) {
setQualityToggleUi({ visible: false, isLow: false });
return { src: fallbackSrc };
}
const lowSrcRaw = addQualityParam(fallbackSrc, 'low');
const playableLow = await ensurePlayableApiVideoUrl(lowSrcRaw, token);
const hasLow = await probeApiVideoExists(playableLow);
if (!hasLow) {
setQualityToggleUi({ visible: false, isLow: false });
return { src: fallbackSrc };
}
const preferred = getStoredQuality(movieId);
const shouldUseLow = preferred === 'low';
const pickedSrc = shouldUseLow ? lowSrcRaw : fallbackSrc;
setQualityToggleUi({ visible: true, isLow: shouldUseLow });
const onPickQuality = async (nextIsLow) => {
setStoredQuality(movieId, nextIsLow ? 'low' : 'high');
setQualityToggleUi({ visible: true, isLow: nextIsLow });
await switchVideoSource({
src: nextIsLow ? lowSrcRaw : fallbackSrc,
token,
preserveTime: true,
});
};
btnHigh.addEventListener('click', () => { void onPickQuality(false); });
btnLow.addEventListener('click', () => { void onPickQuality(true); });
return { src: pickedSrc };
}
window.onload = async function () { window.onload = async function () {
const pageLoader = createPageLoader({ title: 'Chargement du lecteur' }); const pageLoader = createPageLoader({ title: 'Chargement du lecteur' });
@@ -951,6 +1148,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 });
@@ -987,12 +1185,16 @@ window.onload = async function () {
} }
pageLoader.setProgress(70); pageLoader.setProgress(70);
await playVodLink(resolved.src, { token }); const hasLowFile = !!(movie?.video_file_low);
const qualityChoice = await initQualityToggle({ movieId: id, token, fallbackSrc: resolved.src, hasLowFile });
const selectedSrc = qualityChoice?.src || resolved.src;
await playVodLink(selectedSrc, { token });
pageLoader.setProgress(82); pageLoader.setProgress(82);
// 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) {
+64 -11
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;
@@ -152,19 +195,29 @@
</div> </div>
</div> </div>
<!-- Admin-only debug/status (Source + Progress) --> <div class="d-flex flex-column gap-2 align-items-start align-items-lg-end">
<div id="player-admin-status" class="d-none d-flex flex-column gap-2"> <div id="quality-toggle" class="d-none quality-toggle">
<div class="d-flex flex-wrap gap-2 align-items-center"> <span class="quality-label">Qualité</span>
<span class="badge bg-secondary">Source</span> <div class="quality-pill" role="group" aria-label="Qualité">
<span id="source-label" class="badge bg-light text-dark badge-src">—</span> <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> </div>
<div class="d-flex flex-wrap gap-2 align-items-center"> <!-- Admin-only debug/status (Source + Progress) -->
<span class="badge bg-secondary">Progress</span> <div id="player-admin-status" class="d-none d-flex flex-column gap-2">
<span id="progress-status" class="text-muted small progress-indicator"> <div class="d-flex flex-wrap gap-2 align-items-center">
<span id="progress-status-icon" class="progress-icon" aria-hidden="true"></span> <span class="badge bg-secondary">Source</span>
<span id="progress-status-text" class="progress-text">—</span> <span id="source-label" class="badge bg-light text-dark badge-src">—</span>
</span> </div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="badge bg-secondary">Progress</span>
<span id="progress-status" class="text-muted small progress-indicator">
<span id="progress-status-icon" class="progress-icon" aria-hidden="true"></span>
<span id="progress-status-text" class="progress-text">—</span>
</span>
</div>
</div> </div>
</div> </div>
</div> </div>
+3
View File
@@ -15,7 +15,10 @@ export MONGO_DB_NAME="..." # optional; required if MO
export JWT_SECRET="..." export JWT_SECRET="..."
export VIDEO_FILES_DIR="/absolute/path/to/public/video" # optional (default: ./public/video) export VIDEO_FILES_DIR="/absolute/path/to/public/video" # optional (default: ./public/video)
export VIDEO_SESSION_MAX_AGE_SECONDS="28800" # optional (default: 8h) export VIDEO_SESSION_MAX_AGE_SECONDS="28800" # optional (default: 8h)
uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000 uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000
or
python3 -m uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000 --ssl-certfile python_video_server/certs/config/live/[site]/fullchain.pem --ssl-keyfile python_video_server/certs/config/live/[site]/privkey.pem
``` ```
## Extra environment variables (optional) ## Extra environment variables (optional)
+13 -5
View File
@@ -274,7 +274,7 @@ def stream_video(
try: try:
client = _mongo_client_cached() client = _mongo_client_cached()
db = client[db_name] db = client[db_name]
movie = db["movies"].find_one({"_id": oid}, {"video_file": 1}) movie = db["movies"].find_one({"_id": oid}, {"video_file": 1, "video_file_low": 1})
except PyMongoError as e: except PyMongoError as e:
raise HTTPException( raise HTTPException(
status_code=502, status_code=502,
@@ -285,15 +285,23 @@ def stream_video(
), ),
) )
quality = (request.query_params.get("quality") or "").strip().lower()
wants_low = quality == "low"
video_file = (movie or {}).get("video_file") or "" video_file = (movie or {}).get("video_file") or ""
if not isinstance(video_file, str) or not video_file.strip(): video_file_low = (movie or {}).get("video_file_low") or ""
chosen = video_file_low if wants_low else video_file
if not isinstance(chosen, str) or not chosen.strip():
if wants_low:
raise HTTPException(status_code=404, detail="No low-quality server video file configured for this movie")
raise HTTPException(status_code=404, detail="No server video file configured for this movie") raise HTTPException(status_code=404, detail="No server video file configured for this movie")
video_file = video_file.strip() chosen = chosen.strip()
if not _is_safe_relative_file(video_file): if not _is_safe_relative_file(chosen):
raise HTTPException(status_code=400, detail="Invalid video_file path") raise HTTPException(status_code=400, detail="Invalid video_file path")
video_dir = _video_files_dir() video_dir = _video_files_dir()
full_path = (video_dir / video_file).resolve() full_path = (video_dir / chosen).resolve()
if not str(full_path).startswith(str(video_dir) + os.sep) and full_path != video_dir: if not str(full_path).startswith(str(video_dir) + os.sep) and full_path != video_dir:
raise HTTPException(status_code=400, detail="Invalid video_file path") raise HTTPException(status_code=400, detail="Invalid video_file path")
if not full_path.exists() or not full_path.is_file(): if not full_path.exists() or not full_path.is_file():
+390 -6
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,132 @@ 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;
}
function parseBooleanFlag(v) {
if (v === undefined) return undefined;
if (typeof v === "boolean") return v;
const s = String(v).trim().toLowerCase();
if (!s) return undefined;
if (["true", "1", "yes", "on"].includes(s)) return true;
if (["false", "0", "no", "off"].includes(s)) return false;
return undefined;
}
function normalizeSubtitleEntry({ file, lang, label, isDefault } = {}) {
const safeFile = typeof file === "string" ? file.trim() : "";
if (!safeFile) return null;
return {
file: safeFile,
lang: normalizeSubtitleLang(lang) || null,
label: normalizeSubtitleLabel(label) || null,
default: !!isDefault,
};
}
function getLegacySubtitle(movie) {
const file = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
if (!file) return null;
return {
_id: "legacy",
file,
lang: typeof movie?.subtitle_lang === "string" ? movie.subtitle_lang : null,
label: typeof movie?.subtitle_label === "string" ? movie.subtitle_label : null,
default: !!movie?.subtitle_default,
};
}
function getSubtitlesForResponse(movie) {
const subs = Array.isArray(movie?.subtitles) ? movie.subtitles.filter(Boolean) : [];
if (subs.length > 0) return subs;
const legacy = getLegacySubtitle(movie);
return legacy ? [legacy] : [];
}
function syncLegacyFromSubtitles(movie) {
const subs = Array.isArray(movie?.subtitles) ? movie.subtitles : [];
if (!subs.length) {
movie.subtitle_file = null;
movie.subtitle_lang = null;
movie.subtitle_label = null;
movie.subtitle_default = false;
return;
}
const preferred = subs.find((s) => s?.default) || subs[0];
movie.subtitle_file = preferred?.file || null;
movie.subtitle_lang = preferred?.lang || null;
movie.subtitle_label = preferred?.label || null;
movie.subtitle_default = !!preferred?.default;
}
async function ensureSubtitleDir() {
await fs.promises.mkdir(SUBTITLE_FILES_DIR, { recursive: true });
}
function safeFilenameBase(raw) {
return String(raw || "").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 32) || "movie";
}
const subtitleUpload = multer({
storage: multer.diskStorage({
destination: async (_req, _file, cb) => {
try {
await ensureSubtitleDir();
cb(null, SUBTITLE_FILES_DIR);
} catch (err) {
cb(err);
}
},
filename: (req, file, cb) => {
const safeId = safeFilenameBase(req.params?.id);
const ext = path.extname(file.originalname || "").toLowerCase() || ".vtt";
const name = `${safeId}-${Date.now()}-${Math.random().toString(16).slice(2)}${ext}`;
cb(null, name);
},
}),
limits: { fileSize: MAX_SUBTITLE_UPLOAD_BYTES },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname || "").toLowerCase();
if (ext !== ".vtt" && ext !== ".srt") return cb(new Error("Only .srt or .vtt subtitle files are allowed"));
return cb(null, true);
},
});
async function convertSrtToVtt(filePath) {
const content = await fs.promises.readFile(filePath, "utf8");
const normalized = content.replace(/\r\n/g, "\n");
const body = normalized.replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, "$1.$2");
const positioned = body.replace(
/^(\d{2}:\d{2}:\d{2}\.\d{3}\s+-->\s+\d{2}:\d{2}:\d{2}\.\d{3})(.*)$/gm,
(_match, timing, settings) => {
const tail = String(settings || "");
if (/\bline:/i.test(tail)) return `${timing}${tail}`;
return `${timing}${tail} line:90%`;
},
);
const vtt = `WEBVTT\n\n${positioned.trim()}\n`;
const vttPath = filePath.replace(/\.srt$/i, ".vtt");
await fs.promises.writeFile(vttPath, vtt, "utf8");
try { await fs.promises.unlink(filePath); } catch (_) {}
return vttPath;
}
function normalizePlayerMode(v) { 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 +279,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 updatedAt createdAt"; : "imdb_id title description rating poster cam year category vod_link player_mode video_src embed_src video_file video_file_low subtitles subtitle_file subtitle_lang subtitle_label subtitle_default updatedAt createdAt";
const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`; const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`;
const cached = cacheGet(cacheKey); const cached = cacheGet(cacheKey);
@@ -162,9 +293,10 @@ router.get("/", async (req, res) => {
.sort({ title: 1 }) .sort({ title: 1 })
.lean(); .lean();
cacheSet(cacheKey, movies); const payload = movies.map((m) => ({ ...m, subtitles: getSubtitlesForResponse(m) }));
cacheSet(cacheKey, payload);
res.set("Cache-Control", "public, max-age=30"); res.set("Cache-Control", "public, max-age=30");
res.status(200).json(movies); res.status(200).json(payload);
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
@@ -333,7 +465,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, 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
@@ -360,10 +506,14 @@ router.post("/add", async (req, res) => {
const normalizedVideo = normalizeOptionalString(video_src, 4096); const normalizedVideo = normalizeOptionalString(video_src, 4096);
const normalizedEmbed = normalizeOptionalString(embed_src, 20000); const normalizedEmbed = normalizeOptionalString(embed_src, 20000);
const normalizedFile = normalizeVideoFile(video_file); const normalizedFile = normalizeVideoFile(video_file);
const normalizedLowFile = normalizeVideoFile(video_file_low);
const normalizedMode = normalizePlayerMode(player_mode); const normalizedMode = normalizePlayerMode(player_mode);
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;
@@ -398,6 +548,10 @@ router.post("/add", async (req, res) => {
video_src: normalizedVideo || undefined, video_src: normalizedVideo || undefined,
embed_src: normalizedEmbed || undefined, embed_src: normalizedEmbed || undefined,
video_file: normalizedFile || undefined, video_file: normalizedFile || undefined,
video_file_low: normalizedLowFile || undefined,
subtitle_lang: normalizedSubtitleLang || undefined,
subtitle_label: normalizedSubtitleLabel || undefined,
subtitle_default: normalizedSubtitleDefault,
}); });
await movie.save(); await movie.save();
@@ -409,7 +563,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" });
@@ -418,6 +572,164 @@ router.post("/add", async (req, res) => {
} }
}); });
// Admin: upload subtitles for a movie (VTT)
router.post("/:id/subtitles", (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
subtitleUpload.single("subtitle")(req, res, async (err) => {
if (err) {
const isSize = err instanceof multer.MulterError && err.code === "LIMIT_FILE_SIZE";
return res.status(isSize ? 413 : 400).json({ message: err.message || "Upload failed" });
}
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id)) {
if (req.file?.path) {
try { await fs.promises.unlink(req.file.path); } catch (_) {}
}
return res.status(400).json({ message: "Invalid movie id" });
}
const movie = await Movie.findById(id);
if (!movie) {
if (req.file?.path) {
try { await fs.promises.unlink(req.file.path); } catch (_) {}
}
return res.status(404).json({ message: "Movie not found" });
}
if (!req.file?.path) {
return res.status(400).json({ message: "No subtitle file uploaded" });
}
let storedPath = req.file.path;
if (storedPath.toLowerCase().endsWith(".srt")) {
try {
storedPath = await convertSrtToVtt(storedPath);
} catch (e) {
return res.status(400).json({ message: "Failed to convert .srt to .vtt" });
}
}
if (!Array.isArray(movie.subtitles)) movie.subtitles = [];
if (movie.subtitles.length === 0) {
const legacy = normalizeSubtitleEntry({
file: movie.subtitle_file,
lang: movie.subtitle_lang,
label: movie.subtitle_label,
isDefault: movie.subtitle_default,
});
if (legacy) movie.subtitles.push(legacy);
}
const rel = path.relative(SUBTITLE_FILES_DIR, storedPath).replace(/\\/g, "/");
const incomingLang = normalizeSubtitleLang(req.body?.subtitle_lang);
const incomingLabel = normalizeSubtitleLabel(req.body?.subtitle_label);
const incomingDefault = parseSubtitleDefault(req.body?.subtitle_default);
const shouldDefault = incomingDefault !== undefined
? incomingDefault
: movie.subtitles.length === 0;
if (shouldDefault) {
movie.subtitles.forEach((s) => { s.default = false; });
}
const entry = normalizeSubtitleEntry({
file: rel,
lang: incomingLang,
label: incomingLabel,
isDefault: shouldDefault,
});
if (entry) movie.subtitles.push(entry);
syncLegacyFromSubtitles(movie);
await movie.save();
cacheClear();
return res.status(200).json(movie);
});
});
// Admin: delete one subtitle track from a movie
router.delete("/:id/subtitles/:subtitleId", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Authentication token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded.admin) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: err.message });
}
const { id, subtitleId } = req.params;
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(400).json({ message: "Invalid movie id" });
}
const movie = await Movie.findById(id);
if (!movie) return res.status(404).json({ message: "Movie not found" });
if (subtitleId === "legacy") {
const existing = typeof movie.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
if (!existing) return res.status(404).json({ message: "Subtitle not found" });
const oldPath = path.resolve(SUBTITLE_FILES_DIR, existing);
if (oldPath.startsWith(SUBTITLE_FILES_DIR + path.sep)) {
try { await fs.promises.unlink(oldPath); } catch (_) {}
}
movie.subtitle_file = null;
movie.subtitle_lang = null;
movie.subtitle_label = null;
movie.subtitle_default = false;
await movie.save();
cacheClear();
return res.status(200).json(movie);
}
const subs = Array.isArray(movie.subtitles) ? movie.subtitles : [];
const idx = subs.findIndex((s) => String(s?._id || "") === String(subtitleId));
if (idx < 0) {
return res.status(404).json({ message: "Subtitle not found" });
}
const removed = subs[idx];
if (removed?.file) {
const full = path.resolve(SUBTITLE_FILES_DIR, String(removed.file));
if (full.startsWith(SUBTITLE_FILES_DIR + path.sep)) {
try { await fs.promises.unlink(full); } catch (_) {}
}
}
subs.splice(idx, 1);
if (subs.length > 0 && !subs.some((s) => s?.default)) {
subs[0].default = true;
}
movie.subtitles = subs;
syncLegacyFromSubtitles(movie);
await movie.save();
cacheClear();
return res.status(200).json(movie);
});
// Admin: delete one or more movies (by Mongo _id or imdb_id) // 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];
@@ -491,6 +803,11 @@ router.put("/:id", async (req, res) => {
video_src, video_src,
embed_src, embed_src,
video_file, video_file,
video_file_low,
subtitle_lang,
subtitle_label,
subtitle_default,
subtitle_remove,
refreshOmdb, refreshOmdb,
cam, cam,
} = req.body || {}; } = req.body || {};
@@ -550,6 +867,73 @@ router.put("/:id", async (req, res) => {
movie.video_src = `/api/video/${movie._id}`; movie.video_src = `/api/video/${movie._id}`;
} }
} }
if (video_file_low !== undefined) {
const v = normalizeVideoFile(video_file_low);
movie.video_file_low = v ? v : null;
}
const removeSubtitles = parseBooleanFlag(subtitle_remove);
if (removeSubtitles) {
const entries = Array.isArray(movie.subtitles) ? movie.subtitles : [];
for (const entry of entries) {
const rel = typeof entry?.file === "string" ? entry.file.trim() : "";
if (!rel) continue;
const full = path.resolve(SUBTITLE_FILES_DIR, rel);
if (full.startsWith(SUBTITLE_FILES_DIR + path.sep)) {
try { await fs.promises.unlink(full); } catch (_) {}
}
}
const existing = typeof movie.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
if (existing) {
const oldPath = path.resolve(SUBTITLE_FILES_DIR, existing);
if (oldPath.startsWith(SUBTITLE_FILES_DIR + path.sep)) {
try { await fs.promises.unlink(oldPath); } catch (_) {}
}
}
movie.subtitles = [];
movie.subtitle_file = null;
movie.subtitle_lang = null;
movie.subtitle_label = null;
movie.subtitle_default = false;
} else {
const subs = Array.isArray(movie.subtitles) ? movie.subtitles : [];
if (subs.length > 0) {
const target = subs.find((s) => s?.default) || subs[0];
if (subtitle_lang !== undefined) {
const v = normalizeSubtitleLang(subtitle_lang);
target.lang = v ? v : null;
}
if (subtitle_label !== undefined) {
const v = normalizeSubtitleLabel(subtitle_label);
target.label = v ? v : null;
}
if (subtitle_default !== undefined) {
const v = parseSubtitleDefault(subtitle_default);
if (v !== undefined) {
subs.forEach((s) => { s.default = false; });
target.default = !!v;
}
}
syncLegacyFromSubtitles(movie);
} else {
if (subtitle_lang !== undefined) {
const v = normalizeSubtitleLang(subtitle_lang);
movie.subtitle_lang = v ? v : null;
}
if (subtitle_label !== undefined) {
const v = normalizeSubtitleLabel(subtitle_label);
movie.subtitle_label = v ? v : null;
}
if (subtitle_default !== undefined) {
const v = parseSubtitleDefault(subtitle_default);
if (v !== undefined) movie.subtitle_default = v;
}
}
}
if (year !== undefined && year !== null && year !== "") { if (year !== undefined && year !== null && year !== "") {
movie.year = parseOscarYear(year); movie.year = parseOscarYear(year);
@@ -774,7 +1158,7 @@ router.get("/:id", async (req, res) => {
const movie = await Movie.findById(id).lean(); const movie = await Movie.findById(id).lean();
if (!movie) return res.status(404).json({ message: "Movie not found" }); if (!movie) return res.status(404).json({ message: "Movie not found" });
return res.status(200).json(movie); return res.status(200).json({ ...movie, subtitles: getSubtitlesForResponse(movie) });
} catch (err) { } catch (err) {
return res.status(500).json({ error: err.message }); return res.status(500).json({ error: err.message });
} }
+139
View File
@@ -0,0 +1,139 @@
const express = require("express");
const fs = require("fs");
const path = require("path");
const jwt = require("jsonwebtoken");
const mongoose = require("mongoose");
const Movie = require("../models/Movie");
const router = express.Router();
const SUBTITLE_FILES_DIR = path.resolve(process.env.SUBTITLE_FILES_DIR || path.join(process.cwd(), "subtitles"));
function parseCookies(header) {
const cookies = {};
const raw = String(header || "");
if (!raw) return cookies;
raw.split(";").forEach((part) => {
const idx = part.indexOf("=");
if (idx <= 0) return;
const k = part.slice(0, idx).trim();
const v = part.slice(idx + 1).trim();
if (!k) return;
cookies[k] = decodeURIComponent(v);
});
return cookies;
}
function getAuthToken(req) {
const cookies = parseCookies(req.headers.cookie);
const cookieToken = cookies.video_auth ? String(cookies.video_auth) : "";
if (cookieToken) return cookieToken;
const authHeader = String(req.headers.authorization || "");
const headerToken = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : "";
if (headerToken) return headerToken;
const qs = typeof req.query?.token === "string" ? req.query.token.trim() : "";
return qs || "";
}
function requireAuth(req, res) {
const token = getAuthToken(req);
if (!token) return { ok: false, error: res.status(401).json({ message: "Missing subtitle auth token" }) };
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
return { ok: true, decoded };
} catch (_) {
return { ok: false, error: res.status(401).json({ message: "Invalid or expired subtitle auth token" }) };
}
}
function isSafeRelativeFile(p) {
if (typeof p !== "string") return false;
const s = p.trim();
if (!s) return false;
if (s.includes("\0")) return false;
if (path.isAbsolute(s)) return false;
const norm = path.posix.normalize(s.replaceAll("\\", "/"));
if (norm.startsWith("../") || norm === "..") return false;
return true;
}
async function streamSubtitle(req, res) {
const auth = requireAuth(req, res);
if (!auth.ok) return;
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(400).json({ message: "Invalid movie id" });
}
const movie = await Movie.findById(id).select("subtitles subtitle_file subtitle_lang subtitle_label subtitle_default").lean();
if (!movie) return res.status(404).json({ message: "Movie not found" });
let subtitleFile = "";
const subtitleId = req.params?.subtitleId;
const subs = Array.isArray(movie?.subtitles) ? movie.subtitles : [];
if (subtitleId) {
if (subtitleId === "legacy") {
subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
} else {
const match = subs.find((s) => String(s?._id || "") === String(subtitleId));
subtitleFile = typeof match?.file === "string" ? match.file.trim() : "";
}
} else if (subs.length > 0) {
const preferred = subs.find((s) => s?.default) || subs[0];
subtitleFile = typeof preferred?.file === "string" ? preferred.file.trim() : "";
} else {
subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
}
if (!subtitleFile) return res.status(404).json({ message: "No subtitles configured for this movie" });
if (!isSafeRelativeFile(subtitleFile)) return res.status(400).json({ message: "Invalid subtitle_file path" });
const fullPath = path.resolve(SUBTITLE_FILES_DIR, subtitleFile);
if (!fullPath.startsWith(SUBTITLE_FILES_DIR + path.sep) && fullPath !== SUBTITLE_FILES_DIR) {
return res.status(400).json({ message: "Invalid subtitle_file path" });
}
let stat;
try {
stat = await fs.promises.stat(fullPath);
} catch (_) {
return res.status(404).json({ message: "Subtitle file not found on server" });
}
if (!stat.isFile()) return res.status(404).json({ message: "Subtitle file not found on server" });
res.setHeader("Content-Type", "text/vtt; charset=utf-8");
res.setHeader("Content-Length", String(stat.size));
res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
if (req.method === "HEAD") {
return res.status(200).end();
}
return fs.createReadStream(fullPath).pipe(res);
}
router.get("/:id/:subtitleId", (req, res) => {
streamSubtitle(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
});
});
router.head("/:id/:subtitleId", (req, res) => {
streamSubtitle(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
});
});
router.get("/:id", (req, res) => {
streamSubtitle(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
});
});
router.head("/:id", (req, res) => {
streamSubtitle(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Subtitle streaming error" });
});
});
module.exports = router;
+11 -5
View File
@@ -119,17 +119,23 @@ async function streamMovieFile(req, res) {
return res.status(400).json({ message: "Invalid movie id" }); return res.status(400).json({ message: "Invalid movie id" });
} }
const movie = await Movie.findById(id).select("video_file").lean(); const qualityRaw = typeof req.query?.quality === "string" ? req.query.quality.trim().toLowerCase() : "";
const wantsLow = qualityRaw === "low";
const movie = await Movie.findById(id).select("video_file video_file_low").lean();
const videoFile = typeof movie?.video_file === "string" ? movie.video_file.trim() : ""; const videoFile = typeof movie?.video_file === "string" ? movie.video_file.trim() : "";
if (!videoFile) { const videoFileLow = typeof movie?.video_file_low === "string" ? movie.video_file_low.trim() : "";
return res.status(404).json({ message: "No server video file configured for this movie" });
const chosen = wantsLow ? videoFileLow : videoFile;
if (!chosen) {
return res.status(404).json({ message: wantsLow ? "No low-quality server video file configured for this movie" : "No server video file configured for this movie" });
} }
if (!isSafeRelativeFile(videoFile)) { if (!isSafeRelativeFile(chosen)) {
return res.status(400).json({ message: "Invalid video_file path" }); return res.status(400).json({ message: "Invalid video_file path" });
} }
const baseDir = getVideoBaseDir(); const baseDir = getVideoBaseDir();
const fullPath = path.resolve(baseDir, videoFile); const fullPath = path.resolve(baseDir, chosen);
if (!fullPath.startsWith(baseDir + path.sep) && fullPath !== baseDir) { if (!fullPath.startsWith(baseDir + path.sep) && fullPath !== baseDir) {
return res.status(400).json({ message: "Invalid video_file path" }); return res.status(400).json({ message: "Invalid video_file path" });
} }