we subbing now bois #73

Merged
quoije merged 5 commits from 2026 into prod 2026-01-25 02:02:20 -05:00
6 changed files with 390 additions and 45 deletions
Showing only changes of commit 406a4f0bb3 - Show all commits
+8 -1
View File
@@ -24,7 +24,14 @@ 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) // 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_file: { type: String, required: false },
subtitle_lang: { type: String, required: false }, subtitle_lang: { type: String, required: false },
subtitle_label: { type: String, required: false }, subtitle_label: { type: String, required: false },
+10 -1
View File
@@ -1418,7 +1418,16 @@
<div class="col-12"> <div class="col-12">
<label for="edit_subtitle_file" class="form-label">Sous-titres (SRT, optionnel)</label> <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"> <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 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>
<div class="col-12 col-lg-4"> <div class="col-12 col-lg-4">
<label for="edit_subtitle_lang" class="form-label">Langue (ex: fr, en)</label> <label for="edit_subtitle_lang" class="form-label">Langue (ex: fr, en)</label>
+99 -11
View File
@@ -114,10 +114,11 @@ window.onload = async function () {
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 editSubtitleFileEl = document.getElementById('edit_subtitle_file');
const editSubtitleRemoveEl = document.getElementById('edit_subtitle_remove');
const editSubtitleLangEl = document.getElementById('edit_subtitle_lang'); const editSubtitleLangEl = document.getElementById('edit_subtitle_lang');
const editSubtitleLabelEl = document.getElementById('edit_subtitle_label'); const editSubtitleLabelEl = document.getElementById('edit_subtitle_label');
const editSubtitleDefaultEl = document.getElementById('edit_subtitle_default'); const editSubtitleDefaultEl = document.getElementById('edit_subtitle_default');
const editSubtitleCurrentEl = document.getElementById('edit_subtitle_current'); 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');
@@ -171,6 +172,20 @@ window.onload = async function () {
return s.slice(0, maxLen); 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 }) { async function uploadMovieSubtitle({ movieId, file, lang, label, isDefault }) {
if (!movieId || !file) return null; if (!movieId || !file) return null;
const formData = new FormData(); const formData = new FormData();
@@ -195,6 +210,22 @@ window.onload = async function () {
return data; 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;
@@ -2255,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);
@@ -2273,10 +2357,8 @@ window.onload = async function () {
if (editSubtitleLangEl) editSubtitleLangEl.value = movie.subtitle_lang || ''; if (editSubtitleLangEl) editSubtitleLangEl.value = movie.subtitle_lang || '';
if (editSubtitleLabelEl) editSubtitleLabelEl.value = movie.subtitle_label || ''; if (editSubtitleLabelEl) editSubtitleLabelEl.value = movie.subtitle_label || '';
if (editSubtitleDefaultEl) editSubtitleDefaultEl.checked = !!movie.subtitle_default; if (editSubtitleDefaultEl) editSubtitleDefaultEl.checked = !!movie.subtitle_default;
if (editSubtitleCurrentEl) { renderSubtitleList(movie);
const current = movie.subtitle_file ? String(movie.subtitle_file) : ''; if (editSubtitleRemoveEl) editSubtitleRemoveEl.checked = false;
editSubtitleCurrentEl.textContent = current || '—';
}
if (editSubtitleFileEl) editSubtitleFileEl.value = ''; if (editSubtitleFileEl) editSubtitleFileEl.value = '';
if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || ''; if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || '';
editRefreshOmdbEl.checked = false; editRefreshOmdbEl.checked = false;
@@ -2311,7 +2393,8 @@ window.onload = async function () {
const subtitle_lang = normalizeSubtitleText(editSubtitleLangEl?.value, 24); const subtitle_lang = normalizeSubtitleText(editSubtitleLangEl?.value, 24);
const subtitle_label = normalizeSubtitleText(editSubtitleLabelEl?.value, 80); const subtitle_label = normalizeSubtitleText(editSubtitleLabelEl?.value, 80);
const subtitle_default = !!editSubtitleDefaultEl?.checked; const subtitle_default = !!editSubtitleDefaultEl?.checked;
const subtitleFile = editSubtitleFileEl?.files?.[0] || null; 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;
@@ -2349,13 +2432,17 @@ window.onload = async function () {
video_file, video_file,
video_file_low, video_file_low,
embed_src, embed_src,
subtitle_lang, subtitle_remove,
subtitle_label,
subtitle_default,
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;
@@ -2390,13 +2477,14 @@ window.onload = async function () {
const updated = await res.json(); const updated = await res.json();
if (subtitleFile) { if (subtitleFile) {
try { try {
await uploadMovieSubtitle({ const uploaded = await uploadMovieSubtitle({
movieId, movieId,
file: subtitleFile, file: subtitleFile,
lang: subtitle_lang, lang: subtitle_lang,
label: subtitle_label, label: subtitle_label,
isDefault: subtitle_default, isDefault: subtitle_default,
}); });
if (uploaded) moviesById.set(uploaded._id, uploaded);
} catch (err) { } catch (err) {
showResponse('warning', `Film mis à jour, mais sous-titres non uploadés: ${err.message || 'Erreur'}`); showResponse('warning', `Film mis à jour, mais sous-titres non uploadés: ${err.message || 'Erreur'}`);
} }
@@ -2442,7 +2530,7 @@ window.onload = async function () {
const hasLowFile = !!(m && m.video_file_low); 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 = !!(m && m.subtitle_file); 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' });
+27 -2
View File
@@ -804,9 +804,34 @@ function applySubtitleTrack({ videoEl, movie, token }) {
if (!videoEl) return; if (!videoEl) return;
clearVideoTracks(videoEl); clearVideoTracks(videoEl);
const movieId = String(movie?._id || ''); const movieId = String(movie?._id || '');
const subtitleFile = String(movie?.subtitle_file || '').trim(); if (!movieId) return;
if (!movieId || !subtitleFile) 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 lang = String(movie?.subtitle_lang || 'en').trim() || 'en';
const label = String(movie?.subtitle_label || 'Subtitles').trim() || 'Subtitles'; const label = String(movie?.subtitle_label || 'Subtitles').trim() || 'Subtitles';
const isDefault = movie?.subtitle_default === undefined ? true : !!movie.subtitle_default; const isDefault = movie?.subtitle_default === undefined ? true : !!movie.subtitle_default;
+214 -28
View File
@@ -85,6 +85,62 @@ function parseSubtitleDefault(v) {
return undefined; 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",
cursor[bot] commented 2026-01-25 02:10:04 -05:00 (Migrated from github.com)
Review

Duplicate identical functions for boolean parsing

Low Severity

The parseSubtitleDefault function (lines 78-86) and parseBooleanFlag function (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.

Fix in Cursor Fix in Web

### Duplicate identical functions for boolean parsing **Low Severity** <!-- DESCRIPTION START --> The `parseSubtitleDefault` function (lines 78-86) and `parseBooleanFlag` function (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. <!-- DESCRIPTION END --> <!-- BUGBOT_BUG_ID: e8232935-88e0-46aa-a11d-f87144aa4467 --> <!-- LOCATIONS START routes/movies.js#L77-L96 LOCATIONS END --> <a href="https://cursor.com/open?data=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJ1Z2JvdC12MiJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9DVVJTT1IiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjZkNTQ2MzE1LTRlMjEtNDc2OS05NTQ1LTc5YTNlMWM3MTY4NiIsImVuY3J5cHRpb25LZXkiOiJScDBtWV83TVA0cGhHaExqTk1qcTMxMFRMNjRkT1ptTXA1STlJbzJhYTU0IiwiYnJhbmNoIjoiMjAyNiJ9LCJpYXQiOjE3NjkzMjUwMDMsImV4cCI6MTc3MTkxNzAwM30.Tj54I4d2hItocuhS5JBzM2-O2aW-J410ubBLfiKqPZr-0bxe47_Bio6DKJWXXjSPSHRgrUNInPX1YpqOspKmGX1vfLjblvVJZnQsMP1wr8pOW6r-nFnb9yWbOyHFdQDC9xxYqbbL_mW78DA38rof9b7dzleH0sRPWVtuS7zL7_Lx6-yLpa7aW9EMcm8w9L_-tnNVzCCZp2-dUpBmmvliS52uNPkvY-w7UoUkTb95WbKSTNCcTuHi9pcrOH1DdIIdr4yVaqdFqOwlndFp2GeWzvOrGbz5EqFjxL3E_OOUt0gxQPYHRIug8KSoofBYehEkrm3lSTOeIfUyLBX3eFp_Mw" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/fix-in-cursor-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/fix-in-cursor-light.svg"><img alt="Fix in Cursor" src="https://cursor.com/fix-in-cursor.svg"></picture></a>&nbsp;<a href="https://cursor.com/agents?data=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJ1Z2JvdC12MiJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9XRUIiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjZkNTQ2MzE1LTRlMjEtNDc2OS05NTQ1LTc5YTNlMWM3MTY4NiIsImVuY3J5cHRpb25LZXkiOiJScDBtWV83TVA0cGhHaExqTk1qcTMxMFRMNjRkT1ptTXA1STlJbzJhYTU0IiwiYnJhbmNoIjoiMjAyNiIsInJlcG9Pd25lciI6InF1b2lqZSIsInJlcG9OYW1lIjoib3NjYXJzLXBvb2wiLCJwck51bWJlciI6NzMsImNvbW1pdFNoYSI6IjQwNmE0ZjBiYjNhMTdlMGU3MDFmNmQ4YmJhMWNiMmVhZmUyMWY0MWUiLCJwcm92aWRlciI6ImdpdGh1YiJ9LCJpYXQiOjE3NjkzMjUwMDMsImV4cCI6MTc3MTkxNzAwM30.bwaj7A2IDeyIJhlMwBvMDTlSe6utkA_s6CP2TFd-oI260RkzQ4swy6QFu0PN-XWzyUE2JiLOtbvH92C3hdxuzxgDkNUSPeu0qvLASkd4gzY4w0GKxVm7Ss0_b_UiCf52SOMOWoqImNunYo8hhdfETCGTaq-S9jmNvLHRWKjX8AAmRAct8p6aI1wC0Hc8zituGKge5fCBr2lLs46wKIu6-Xpfsei5lXsqL4d3MG0E5_Aaquogm760B-TWsOIy8lE5raelJmto4HULL5Cu0AxrmML_Gkq4KIZWI2QHpYWr9V5KShkUEE5jHMI8ijb5C0Qhi37WWsOP8Ot4czcHrUWm3A" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/fix-in-web-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/fix-in-web-light.svg"><img alt="Fix in Web" src="https://cursor.com/fix-in-web.svg"></picture></a>
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() { async function ensureSubtitleDir() {
await fs.promises.mkdir(SUBTITLE_FILES_DIR, { recursive: true }); await fs.promises.mkdir(SUBTITLE_FILES_DIR, { recursive: true });
} }
@@ -223,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 video_file_low subtitle_file subtitle_lang subtitle_label subtitle_default 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);
@@ -237,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 });
} }
@@ -567,32 +624,112 @@ router.post("/:id/subtitles", (req, res) => {
} }
} }
const existing = typeof movie.subtitle_file === "string" ? movie.subtitle_file.trim() : ""; if (!Array.isArray(movie.subtitles)) movie.subtitles = [];
if (existing) { if (movie.subtitles.length === 0) {
const oldPath = path.resolve(SUBTITLE_FILES_DIR, existing); const legacy = normalizeSubtitleEntry({
if (oldPath.startsWith(SUBTITLE_FILES_DIR + path.sep)) { file: movie.subtitle_file,
try { await fs.promises.unlink(oldPath); } catch (_) {} 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 rel = path.relative(SUBTITLE_FILES_DIR, storedPath).replace(/\\/g, "/");
movie.subtitle_file = rel;
const incomingLang = normalizeSubtitleLang(req.body?.subtitle_lang); const incomingLang = normalizeSubtitleLang(req.body?.subtitle_lang);
if (incomingLang !== undefined) movie.subtitle_lang = incomingLang || null;
const incomingLabel = normalizeSubtitleLabel(req.body?.subtitle_label); const incomingLabel = normalizeSubtitleLabel(req.body?.subtitle_label);
if (incomingLabel !== undefined) movie.subtitle_label = incomingLabel || null;
const incomingDefault = parseSubtitleDefault(req.body?.subtitle_default); const incomingDefault = parseSubtitleDefault(req.body?.subtitle_default);
if (incomingDefault !== undefined) movie.subtitle_default = incomingDefault;
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(); await movie.save();
cacheClear(); cacheClear();
return res.status(200).json(movie); 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];
@@ -670,6 +807,7 @@ router.put("/:id", async (req, res) => {
subtitle_lang, subtitle_lang,
subtitle_label, subtitle_label,
subtitle_default, subtitle_default,
subtitle_remove,
refreshOmdb, refreshOmdb,
cam, cam,
} = req.body || {}; } = req.body || {};
1
@@ -734,19 +872,67 @@ 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 removeSubtitles = parseBooleanFlag(subtitle_remove);
const v = normalizeSubtitleLang(subtitle_lang); if (removeSubtitles) {
movie.subtitle_lang = v ? v : null; 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 (_) {}
}
}
if (subtitle_label !== undefined) { const existing = typeof movie.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
const v = normalizeSubtitleLabel(subtitle_label); if (existing) {
movie.subtitle_label = v ? v : null; const oldPath = path.resolve(SUBTITLE_FILES_DIR, existing);
} if (oldPath.startsWith(SUBTITLE_FILES_DIR + path.sep)) {
try { await fs.promises.unlink(oldPath); } catch (_) {}
}
}
if (subtitle_default !== undefined) { movie.subtitles = [];
const v = parseSubtitleDefault(subtitle_default); movie.subtitle_file = null;
if (v !== undefined) movie.subtitle_default = v; 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 !== "") {
@@ -972,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 });
} }
+32 -2
View File
@@ -65,8 +65,26 @@ async function streamSubtitle(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("subtitle_file").lean(); const movie = await Movie.findById(id).select("subtitles subtitle_file subtitle_lang subtitle_label subtitle_default").lean();
const subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : ""; 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 (!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" }); if (!isSafeRelativeFile(subtitleFile)) return res.status(400).json({ message: "Invalid subtitle_file path" });
@@ -94,6 +112,18 @@ async function streamSubtitle(req, res) {
return fs.createReadStream(fullPath).pipe(res); 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) => { router.get("/:id", (req, res) => {
streamSubtitle(req, res).catch((err) => { streamSubtitle(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Subtitle streaming error" }); return res.status(500).json({ error: err?.message || "Subtitle streaming error" });