Support multiple subtitle tracks
Adds multi-language subtitle storage, upload/removal, and player/admin UI support, plus fixes subtitle metadata updates during upload.
This commit is contained in:
+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;
|
||||||
|
|||||||
+203
-17
@@ -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,6 +872,52 @@ router.put("/:id", async (req, res) => {
|
|||||||
movie.video_file_low = v ? v : null;
|
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) {
|
if (subtitle_lang !== undefined) {
|
||||||
const v = normalizeSubtitleLang(subtitle_lang);
|
const v = normalizeSubtitleLang(subtitle_lang);
|
||||||
movie.subtitle_lang = v ? v : null;
|
movie.subtitle_lang = v ? v : null;
|
||||||
@@ -748,6 +932,8 @@ router.put("/:id", async (req, res) => {
|
|||||||
const v = parseSubtitleDefault(subtitle_default);
|
const v = parseSubtitleDefault(subtitle_default);
|
||||||
if (v !== undefined) movie.subtitle_default = v;
|
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);
|
||||||
@@ -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