we subbing now bois #73
+8
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|
|||||||
|
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 || {};
|
||||||
@@ -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
@@ -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" });
|
||||||
|
|||||||
Reference in New Issue
Block a user
Duplicate identical functions for boolean parsing
Low Severity
The
parseSubtitleDefaultfunction (lines 78-86) andparseBooleanFlagfunction (lines 88-96) are completely identical implementations. Both parse boolean values from string inputs using the exact same logic (handling"true","1","yes","on"for true, etc.). One function should be removed and the other reused in both places where they're currently called.