From 406a4f0bb3a17e0e701f6d8bba1cb2eafe21f41e Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sun, 25 Jan 2026 02:01:08 -0500 Subject: [PATCH] Support multiple subtitle tracks Adds multi-language subtitle storage, upload/removal, and player/admin UI support, plus fixes subtitle metadata updates during upload. --- models/Movie.js | 9 +- public/control.html | 11 +- public/js/app_control.js | 110 ++++++++++++++++-- public/js/app_player.js | 29 ++++- routes/movies.js | 242 ++++++++++++++++++++++++++++++++++----- routes/subtitles.js | 34 +++++- 6 files changed, 390 insertions(+), 45 deletions(-) diff --git a/models/Movie.js b/models/Movie.js index 635275d..75f5c67 100644 --- a/models/Movie.js +++ b/models/Movie.js @@ -24,7 +24,14 @@ const movieSchema = new mongoose.Schema({ // Optional low-quality server file (relative path under VIDEO_FILES_DIR, default public/video) // Served when the player requests ?quality=low on /api/video/:movieId. video_file_low: { type: String, required: false }, - // Optional subtitles (server-hosted .vtt, streamed via /api/subtitles/:movieId) + // Optional subtitles (server-hosted .vtt, streamed via /api/subtitles/:movieId/:subtitleId) + subtitles: [{ + file: { type: String, required: true }, + lang: { type: String, required: false }, + label: { type: String, required: false }, + default: { type: Boolean, required: false, default: false }, + }], + // Legacy single-subtitle fields (kept for backward compatibility) subtitle_file: { type: String, required: false }, subtitle_lang: { type: String, required: false }, subtitle_label: { type: String, required: false }, diff --git a/public/control.html b/public/control.html index 70036c9..705df9b 100644 --- a/public/control.html +++ b/public/control.html @@ -1418,7 +1418,16 @@
-
Fichier actuel: —
+
Sous-titres existants:
+
+
Ajoute un fichier par langue; répète l’upload pour plusieurs sous-titres.
+
+
+
+ + +
+
Efface les fichiers VTT/SRT et réinitialise les champs de langue/label.
diff --git a/public/js/app_control.js b/public/js/app_control.js index c846b30..11c1eb9 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -114,10 +114,11 @@ window.onload = async function () { const editVideoFileEl = document.getElementById('edit_video_file'); const editVideoFileLowEl = document.getElementById('edit_video_file_low'); const editSubtitleFileEl = document.getElementById('edit_subtitle_file'); + const editSubtitleRemoveEl = document.getElementById('edit_subtitle_remove'); const editSubtitleLangEl = document.getElementById('edit_subtitle_lang'); const editSubtitleLabelEl = document.getElementById('edit_subtitle_label'); const editSubtitleDefaultEl = document.getElementById('edit_subtitle_default'); - const editSubtitleCurrentEl = document.getElementById('edit_subtitle_current'); + const editSubtitleListEl = document.getElementById('edit_subtitle_list'); const editEmbedSrcEl = document.getElementById('edit_embed_src'); const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb'); const editTitleEl = document.getElementById('edit_title'); @@ -171,6 +172,20 @@ window.onload = async function () { return s.slice(0, maxLen); } + function getMovieSubtitles(movie) { + const subs = Array.isArray(movie?.subtitles) ? movie.subtitles.filter(Boolean) : []; + if (subs.length > 0) return subs; + const legacyFile = typeof movie?.subtitle_file === 'string' ? movie.subtitle_file.trim() : ''; + if (!legacyFile) return []; + return [{ + _id: 'legacy', + file: legacyFile, + lang: movie?.subtitle_lang || '', + label: movie?.subtitle_label || '', + default: !!movie?.subtitle_default, + }]; + } + async function uploadMovieSubtitle({ movieId, file, lang, label, isDefault }) { if (!movieId || !file) return null; const formData = new FormData(); @@ -195,6 +210,22 @@ window.onload = async function () { 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) { const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; 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; } + function renderSubtitleList(movie) { + if (!editSubtitleListEl) return; + const subs = getMovieSubtitles(movie); + if (!subs.length) { + editSubtitleListEl.innerHTML = '—'; + 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 ? 'Par défaut' : ''; + + return ` +
+
+ ${title} + ${meta ? `${meta}` : ''} + ${isDefault} +
+ +
+ `; + }).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) { if (!editMovieModal) return; const movie = moviesById.get(movieId); @@ -2273,10 +2357,8 @@ window.onload = async function () { if (editSubtitleLangEl) editSubtitleLangEl.value = movie.subtitle_lang || ''; if (editSubtitleLabelEl) editSubtitleLabelEl.value = movie.subtitle_label || ''; if (editSubtitleDefaultEl) editSubtitleDefaultEl.checked = !!movie.subtitle_default; - if (editSubtitleCurrentEl) { - const current = movie.subtitle_file ? String(movie.subtitle_file) : ''; - editSubtitleCurrentEl.textContent = current || '—'; - } + renderSubtitleList(movie); + if (editSubtitleRemoveEl) editSubtitleRemoveEl.checked = false; if (editSubtitleFileEl) editSubtitleFileEl.value = ''; if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || ''; editRefreshOmdbEl.checked = false; @@ -2311,7 +2393,8 @@ window.onload = async function () { const subtitle_lang = normalizeSubtitleText(editSubtitleLangEl?.value, 24); const subtitle_label = normalizeSubtitleText(editSubtitleLabelEl?.value, 80); const subtitle_default = !!editSubtitleDefaultEl?.checked; - const subtitleFile = editSubtitleFileEl?.files?.[0] || null; + const subtitle_remove = !!editSubtitleRemoveEl?.checked; + const subtitleFile = subtitle_remove ? null : (editSubtitleFileEl?.files?.[0] || null); const refreshOmdb = !!editRefreshOmdbEl.checked; const cam = !!editCamFlagEl?.checked; @@ -2349,13 +2432,17 @@ window.onload = async function () { video_file, video_file_low, embed_src, - subtitle_lang, - subtitle_label, - subtitle_default, + subtitle_remove, refreshOmdb, 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 if (!refreshOmdb) { body.title = title; @@ -2390,13 +2477,14 @@ window.onload = async function () { const updated = await res.json(); if (subtitleFile) { try { - await uploadMovieSubtitle({ + const uploaded = await uploadMovieSubtitle({ movieId, file: subtitleFile, lang: subtitle_lang, label: subtitle_label, isDefault: subtitle_default, }); + if (uploaded) moviesById.set(uploaded._id, uploaded); } catch (err) { showResponse('warning', `Film mis à jour, mais sous-titres non uploadés: ${err.message || 'Erreur'}`); } @@ -2442,7 +2530,7 @@ window.onload = async function () { const hasLowFile = !!(m && m.video_file_low); const hasEmbed = !!(m && m.embed_src); 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 (hasFile) badges.push({ text: 'Server file', cls: 'bg-dark' }); diff --git a/public/js/app_player.js b/public/js/app_player.js index 78b8523..b982b9c 100644 --- a/public/js/app_player.js +++ b/public/js/app_player.js @@ -804,9 +804,34 @@ function applySubtitleTrack({ videoEl, movie, token }) { if (!videoEl) return; clearVideoTracks(videoEl); const movieId = String(movie?._id || ''); - const subtitleFile = String(movie?.subtitle_file || '').trim(); - if (!movieId || !subtitleFile) return; + if (!movieId) return; + const subtitles = Array.isArray(movie?.subtitles) ? movie.subtitles : []; + if (subtitles.length > 0) { + const hasDefault = subtitles.some((s) => s?.default); + subtitles.forEach((s, idx) => { + const lang = String(s?.lang || 'en').trim() || 'en'; + const label = String(s?.label || 'Subtitles').trim() || 'Subtitles'; + const isDefault = s?.default === undefined ? (!hasDefault && idx === 0) : !!s.default; + const subId = String(s?._id || '').trim(); + const rawUrl = subId && subId !== 'legacy' + ? `/api/subtitles/${encodeURIComponent(movieId)}/${encodeURIComponent(subId)}` + : `/api/subtitles/${encodeURIComponent(movieId)}`; + const src = addTokenToApiSubtitleUrl(rawUrl, token); + + const track = document.createElement('track'); + track.kind = 'subtitles'; + track.label = label; + track.srclang = lang; + track.src = src; + track.default = isDefault; + videoEl.appendChild(track); + }); + return; + } + + const subtitleFile = String(movie?.subtitle_file || '').trim(); + if (!subtitleFile) return; const lang = String(movie?.subtitle_lang || 'en').trim() || 'en'; const label = String(movie?.subtitle_label || 'Subtitles').trim() || 'Subtitles'; const isDefault = movie?.subtitle_default === undefined ? true : !!movie.subtitle_default; diff --git a/routes/movies.js b/routes/movies.js index a0113bb..f60beea 100644 --- a/routes/movies.js +++ b/routes/movies.js @@ -85,6 +85,62 @@ function parseSubtitleDefault(v) { return undefined; } +function parseBooleanFlag(v) { + if (v === undefined) return undefined; + if (typeof v === "boolean") return v; + const s = String(v).trim().toLowerCase(); + if (!s) return undefined; + if (["true", "1", "yes", "on"].includes(s)) return true; + if (["false", "0", "no", "off"].includes(s)) return false; + return undefined; +} + +function normalizeSubtitleEntry({ file, lang, label, isDefault } = {}) { + const safeFile = typeof file === "string" ? file.trim() : ""; + if (!safeFile) return null; + return { + file: safeFile, + lang: normalizeSubtitleLang(lang) || null, + label: normalizeSubtitleLabel(label) || null, + default: !!isDefault, + }; +} + +function getLegacySubtitle(movie) { + const file = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : ""; + if (!file) return null; + return { + _id: "legacy", + file, + lang: typeof movie?.subtitle_lang === "string" ? movie.subtitle_lang : null, + label: typeof movie?.subtitle_label === "string" ? movie.subtitle_label : null, + default: !!movie?.subtitle_default, + }; +} + +function getSubtitlesForResponse(movie) { + const subs = Array.isArray(movie?.subtitles) ? movie.subtitles.filter(Boolean) : []; + if (subs.length > 0) return subs; + const legacy = getLegacySubtitle(movie); + return legacy ? [legacy] : []; +} + +function syncLegacyFromSubtitles(movie) { + const subs = Array.isArray(movie?.subtitles) ? movie.subtitles : []; + if (!subs.length) { + movie.subtitle_file = null; + movie.subtitle_lang = null; + movie.subtitle_label = null; + movie.subtitle_default = false; + return; + } + const preferred = subs.find((s) => s?.default) || subs[0]; + movie.subtitle_file = preferred?.file || null; + movie.subtitle_lang = preferred?.lang || null; + movie.subtitle_label = preferred?.label || null; + movie.subtitle_default = !!preferred?.default; +} + async function ensureSubtitleDir() { await fs.promises.mkdir(SUBTITLE_FILES_DIR, { recursive: true }); } @@ -223,7 +279,7 @@ router.get("/", async (req, res) => { const projection = isChecklist ? "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 cached = cacheGet(cacheKey); @@ -237,9 +293,10 @@ router.get("/", async (req, res) => { .sort({ title: 1 }) .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.status(200).json(movies); + res.status(200).json(payload); } catch (err) { 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 (existing) { - const oldPath = path.resolve(SUBTITLE_FILES_DIR, existing); - if (oldPath.startsWith(SUBTITLE_FILES_DIR + path.sep)) { - try { await fs.promises.unlink(oldPath); } catch (_) {} - } + if (!Array.isArray(movie.subtitles)) movie.subtitles = []; + if (movie.subtitles.length === 0) { + const legacy = normalizeSubtitleEntry({ + file: movie.subtitle_file, + lang: movie.subtitle_lang, + label: movie.subtitle_label, + isDefault: movie.subtitle_default, + }); + if (legacy) movie.subtitles.push(legacy); } const rel = path.relative(SUBTITLE_FILES_DIR, storedPath).replace(/\\/g, "/"); - movie.subtitle_file = rel; - const incomingLang = normalizeSubtitleLang(req.body?.subtitle_lang); - if (incomingLang !== undefined) movie.subtitle_lang = incomingLang || null; - const incomingLabel = normalizeSubtitleLabel(req.body?.subtitle_label); - if (incomingLabel !== undefined) movie.subtitle_label = incomingLabel || null; - const incomingDefault = parseSubtitleDefault(req.body?.subtitle_default); - if (incomingDefault !== undefined) movie.subtitle_default = incomingDefault; + const shouldDefault = incomingDefault !== undefined + ? incomingDefault + : movie.subtitles.length === 0; + + if (shouldDefault) { + movie.subtitles.forEach((s) => { s.default = false; }); + } + + const entry = normalizeSubtitleEntry({ + file: rel, + lang: incomingLang, + label: incomingLabel, + isDefault: shouldDefault, + }); + if (entry) movie.subtitles.push(entry); + + syncLegacyFromSubtitles(movie); await movie.save(); cacheClear(); return res.status(200).json(movie); }); }); +// Admin: delete one subtitle track from a movie +router.delete("/:id/subtitles/:subtitleId", async (req, res) => { + const token = req.headers.authorization?.split(" ")[1]; + if (!token) return res.status(401).json({ message: "Authentication token is required" }); + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + if (!decoded.admin) { + return res.status(403).json({ message: "You do not have admin privileges" }); + } + } catch (err) { + if (err instanceof jwt.JsonWebTokenError) { + return res.status(401).json({ message: "Invalid or expired token" }); + } + return res.status(500).json({ error: err.message }); + } + + const { id, subtitleId } = req.params; + if (!mongoose.Types.ObjectId.isValid(id)) { + return res.status(400).json({ message: "Invalid movie id" }); + } + + const movie = await Movie.findById(id); + if (!movie) return res.status(404).json({ message: "Movie not found" }); + + if (subtitleId === "legacy") { + const existing = typeof movie.subtitle_file === "string" ? movie.subtitle_file.trim() : ""; + if (!existing) return res.status(404).json({ message: "Subtitle not found" }); + const oldPath = path.resolve(SUBTITLE_FILES_DIR, existing); + if (oldPath.startsWith(SUBTITLE_FILES_DIR + path.sep)) { + try { await fs.promises.unlink(oldPath); } catch (_) {} + } + movie.subtitle_file = null; + movie.subtitle_lang = null; + movie.subtitle_label = null; + movie.subtitle_default = false; + await movie.save(); + cacheClear(); + return res.status(200).json(movie); + } + + const subs = Array.isArray(movie.subtitles) ? movie.subtitles : []; + const idx = subs.findIndex((s) => String(s?._id || "") === String(subtitleId)); + if (idx < 0) { + return res.status(404).json({ message: "Subtitle not found" }); + } + + const removed = subs[idx]; + if (removed?.file) { + const full = path.resolve(SUBTITLE_FILES_DIR, String(removed.file)); + if (full.startsWith(SUBTITLE_FILES_DIR + path.sep)) { + try { await fs.promises.unlink(full); } catch (_) {} + } + } + + subs.splice(idx, 1); + if (subs.length > 0 && !subs.some((s) => s?.default)) { + subs[0].default = true; + } + movie.subtitles = subs; + syncLegacyFromSubtitles(movie); + + await movie.save(); + cacheClear(); + return res.status(200).json(movie); +}); + // Admin: delete one or more movies (by Mongo _id or imdb_id) router.delete("/delete", async (req, res) => { const token = req.headers.authorization?.split(" ")[1]; @@ -670,6 +807,7 @@ router.put("/:id", async (req, res) => { subtitle_lang, subtitle_label, subtitle_default, + subtitle_remove, refreshOmdb, cam, } = req.body || {}; @@ -734,19 +872,67 @@ router.put("/:id", async (req, res) => { movie.video_file_low = v ? v : null; } - if (subtitle_lang !== undefined) { - const v = normalizeSubtitleLang(subtitle_lang); - movie.subtitle_lang = 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 (_) {} + } + } - if (subtitle_label !== undefined) { - const v = normalizeSubtitleLabel(subtitle_label); - movie.subtitle_label = v ? v : null; - } + 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 (_) {} + } + } - if (subtitle_default !== undefined) { - const v = parseSubtitleDefault(subtitle_default); - if (v !== undefined) movie.subtitle_default = v; + movie.subtitles = []; + movie.subtitle_file = null; + movie.subtitle_lang = null; + movie.subtitle_label = null; + movie.subtitle_default = false; + } else { + const subs = Array.isArray(movie.subtitles) ? movie.subtitles : []; + if (subs.length > 0) { + const target = subs.find((s) => s?.default) || subs[0]; + if (subtitle_lang !== undefined) { + const v = normalizeSubtitleLang(subtitle_lang); + target.lang = v ? v : null; + } + if (subtitle_label !== undefined) { + const v = normalizeSubtitleLabel(subtitle_label); + target.label = v ? v : null; + } + if (subtitle_default !== undefined) { + const v = parseSubtitleDefault(subtitle_default); + if (v !== undefined) { + subs.forEach((s) => { s.default = false; }); + target.default = !!v; + } + } + syncLegacyFromSubtitles(movie); + } else { + if (subtitle_lang !== undefined) { + const v = normalizeSubtitleLang(subtitle_lang); + movie.subtitle_lang = v ? v : null; + } + + if (subtitle_label !== undefined) { + const v = normalizeSubtitleLabel(subtitle_label); + movie.subtitle_label = v ? v : null; + } + + if (subtitle_default !== undefined) { + const v = parseSubtitleDefault(subtitle_default); + if (v !== undefined) movie.subtitle_default = v; + } + } } if (year !== undefined && year !== null && year !== "") { @@ -972,7 +1158,7 @@ router.get("/:id", async (req, res) => { const movie = await Movie.findById(id).lean(); 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) { return res.status(500).json({ error: err.message }); } diff --git a/routes/subtitles.js b/routes/subtitles.js index 3413357..5f9d6db 100644 --- a/routes/subtitles.js +++ b/routes/subtitles.js @@ -65,8 +65,26 @@ async function streamSubtitle(req, res) { return res.status(400).json({ message: "Invalid movie id" }); } - const movie = await Movie.findById(id).select("subtitle_file").lean(); - const subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : ""; + const movie = await Movie.findById(id).select("subtitles subtitle_file subtitle_lang subtitle_label subtitle_default").lean(); + if (!movie) return res.status(404).json({ message: "Movie not found" }); + let subtitleFile = ""; + const subtitleId = req.params?.subtitleId; + const subs = Array.isArray(movie?.subtitles) ? movie.subtitles : []; + + if (subtitleId) { + if (subtitleId === "legacy") { + subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : ""; + } else { + const match = subs.find((s) => String(s?._id || "") === String(subtitleId)); + subtitleFile = typeof match?.file === "string" ? match.file.trim() : ""; + } + } else if (subs.length > 0) { + const preferred = subs.find((s) => s?.default) || subs[0]; + subtitleFile = typeof preferred?.file === "string" ? preferred.file.trim() : ""; + } else { + subtitleFile = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : ""; + } + if (!subtitleFile) return res.status(404).json({ message: "No subtitles configured for this movie" }); if (!isSafeRelativeFile(subtitleFile)) return res.status(400).json({ message: "Invalid subtitle_file path" }); @@ -94,6 +112,18 @@ async function streamSubtitle(req, 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) => { streamSubtitle(req, res).catch((err) => { return res.status(500).json({ error: err?.message || "Subtitle streaming error" });