Add subtitle upload and streaming support
Adds movie subtitle metadata and admin upload flow with SRT to VTT conversion, plus player/control UI and API route updates for subtitle delivery.
This commit is contained in:
@@ -695,6 +695,25 @@
|
||||
<input type="text" class="form-control font-monospace" id="video_file_low" placeholder="LOW/my_movie.LOW.mp4">
|
||||
<div class="form-text">Chemin relatif sous <code>VIDEO_FILES_DIR</code>. Ex: <code>LOW/123.LOW.mp4</code>.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="subtitle_file" class="form-label">Sous-titres (SRT, optionnel)</label>
|
||||
<input type="file" class="form-control" id="subtitle_file" accept=".srt,.vtt,text/plain,text/vtt">
|
||||
<div class="form-text">Upload d'un fichier <code>.srt</code> (converti en VTT serveur, interne uniquement).</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<label for="subtitle_lang" class="form-label">Langue (ex: fr, en)</label>
|
||||
<input type="text" class="form-control" id="subtitle_lang" placeholder="fr">
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<label for="subtitle_label" class="form-label">Label (affiché)</label>
|
||||
<input type="text" class="form-control" id="subtitle_label" placeholder="Français">
|
||||
</div>
|
||||
<div class="col-12 col-lg-4 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="subtitle_default">
|
||||
<label class="form-check-label" for="subtitle_default">Par défaut</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="embed_src" class="form-label">Embed URL ou iframe</label>
|
||||
<textarea class="form-control font-monospace" id="embed_src" rows="3" placeholder="https://... OR <iframe src='...'></iframe>"></textarea>
|
||||
@@ -1396,6 +1415,25 @@
|
||||
<input type="text" class="form-control font-monospace" id="edit_video_file_low" placeholder="LOW/my_movie.LOW.mp4">
|
||||
<div class="form-text">Relative path under <code>VIDEO_FILES_DIR</code>. Example: <code>LOW/123.LOW.mp4</code>.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="edit_subtitle_file" class="form-label">Sous-titres (SRT, optionnel)</label>
|
||||
<input type="file" class="form-control" id="edit_subtitle_file" accept=".srt,.vtt,text/plain,text/vtt">
|
||||
<div class="form-text">Fichier actuel: <span id="edit_subtitle_current">—</span></div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<label for="edit_subtitle_lang" class="form-label">Langue (ex: fr, en)</label>
|
||||
<input type="text" class="form-control" id="edit_subtitle_lang" placeholder="fr">
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<label for="edit_subtitle_label" class="form-label">Label (affiché)</label>
|
||||
<input type="text" class="form-control" id="edit_subtitle_label" placeholder="Français">
|
||||
</div>
|
||||
<div class="col-12 col-lg-4 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="edit_subtitle_default">
|
||||
<label class="form-check-label" for="edit_subtitle_default">Par défaut</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="edit_embed_src" class="form-label">Embed URL or iframe code</label>
|
||||
<textarea class="form-control font-monospace" id="edit_embed_src" rows="3" placeholder="https://... OR <iframe ...>"></textarea>
|
||||
|
||||
+103
-2
@@ -113,6 +113,11 @@ window.onload = async function () {
|
||||
const editVideoSrcEl = document.getElementById('edit_video_src');
|
||||
const editVideoFileEl = document.getElementById('edit_video_file');
|
||||
const editVideoFileLowEl = document.getElementById('edit_video_file_low');
|
||||
const editSubtitleFileEl = document.getElementById('edit_subtitle_file');
|
||||
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 editEmbedSrcEl = document.getElementById('edit_embed_src');
|
||||
const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb');
|
||||
const editTitleEl = document.getElementById('edit_title');
|
||||
@@ -159,6 +164,37 @@ window.onload = async function () {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function normalizeSubtitleText(v, maxLen = 80) {
|
||||
if (v === undefined || v === null) return '';
|
||||
const s = String(v).trim();
|
||||
if (!s) return '';
|
||||
return s.slice(0, maxLen);
|
||||
}
|
||||
|
||||
async function uploadMovieSubtitle({ movieId, file, lang, label, isDefault }) {
|
||||
if (!movieId || !file) return null;
|
||||
const formData = new FormData();
|
||||
formData.append('subtitle', file);
|
||||
if (lang) formData.append('subtitle_lang', lang);
|
||||
if (label) formData.append('subtitle_label', label);
|
||||
formData.append('subtitle_default', isDefault ? 'true' : 'false');
|
||||
|
||||
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/subtitles`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg = data.message || data.error || `Erreur (${res.status})`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function normalizePlayerUi(value) {
|
||||
const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
const showSource = v.showSource === undefined ? DEFAULT_PLAYER_UI.showSource : !!v.showSource;
|
||||
@@ -1930,6 +1966,11 @@ window.onload = async function () {
|
||||
const video_file = document.getElementById('video_file')?.value?.trim() || '';
|
||||
const video_file_low = document.getElementById('video_file_low')?.value?.trim() || '';
|
||||
const embed_src = document.getElementById('embed_src')?.value?.trim() || '';
|
||||
const subtitleFileInput = document.getElementById('subtitle_file');
|
||||
const subtitleFile = subtitleFileInput?.files?.[0] || null;
|
||||
const subtitle_lang = normalizeSubtitleText(document.getElementById('subtitle_lang')?.value, 24);
|
||||
const subtitle_label = normalizeSubtitleText(document.getElementById('subtitle_label')?.value, 80);
|
||||
const subtitle_default = !!document.getElementById('subtitle_default')?.checked;
|
||||
|
||||
if (!year) {
|
||||
showResponse('warning', 'Année invalide. Exemple attendu: 2026');
|
||||
@@ -1948,12 +1989,44 @@ window.onload = async function () {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ year, imdb_id, category, vod_link, player_mode, video_src, video_file, video_file_low, embed_src, cam })
|
||||
body: JSON.stringify({
|
||||
year,
|
||||
imdb_id,
|
||||
category,
|
||||
vod_link,
|
||||
player_mode,
|
||||
video_src,
|
||||
video_file,
|
||||
video_file_low,
|
||||
embed_src,
|
||||
subtitle_lang,
|
||||
subtitle_label,
|
||||
subtitle_default,
|
||||
cam,
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
showResponse('success', 'Film ajouté avec succès.');
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
const movieId = payload?.movie?._id;
|
||||
if (subtitleFile && movieId) {
|
||||
try {
|
||||
await uploadMovieSubtitle({
|
||||
movieId,
|
||||
file: subtitleFile,
|
||||
lang: subtitle_lang,
|
||||
label: subtitle_label,
|
||||
isDefault: subtitle_default,
|
||||
});
|
||||
showResponse('success', 'Film ajouté avec sous-titres.');
|
||||
} catch (err) {
|
||||
showResponse('warning', `Film ajouté, mais sous-titres non uploadés: ${err.message || 'Erreur'}`);
|
||||
}
|
||||
} else {
|
||||
showResponse('success', 'Film ajouté avec succès.');
|
||||
}
|
||||
form.reset();
|
||||
if (subtitleFileInput) subtitleFileInput.value = '';
|
||||
await refreshYears();
|
||||
await loadMoviesForManagement();
|
||||
return;
|
||||
@@ -2197,6 +2270,14 @@ window.onload = async function () {
|
||||
if (editVideoSrcEl) editVideoSrcEl.value = movie.video_src || '';
|
||||
if (editVideoFileEl) editVideoFileEl.value = movie.video_file || '';
|
||||
if (editVideoFileLowEl) editVideoFileLowEl.value = movie.video_file_low || '';
|
||||
if (editSubtitleLangEl) editSubtitleLangEl.value = movie.subtitle_lang || '';
|
||||
if (editSubtitleLabelEl) editSubtitleLabelEl.value = movie.subtitle_label || '';
|
||||
if (editSubtitleDefaultEl) editSubtitleDefaultEl.checked = !!movie.subtitle_default;
|
||||
if (editSubtitleCurrentEl) {
|
||||
const current = movie.subtitle_file ? String(movie.subtitle_file) : '';
|
||||
editSubtitleCurrentEl.textContent = current || '—';
|
||||
}
|
||||
if (editSubtitleFileEl) editSubtitleFileEl.value = '';
|
||||
if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || '';
|
||||
editRefreshOmdbEl.checked = false;
|
||||
|
||||
@@ -2227,6 +2308,10 @@ window.onload = async function () {
|
||||
const video_file = (editVideoFileEl?.value || '').trim();
|
||||
const video_file_low = (editVideoFileLowEl?.value || '').trim();
|
||||
const embed_src = (editEmbedSrcEl?.value || '').trim();
|
||||
const subtitle_lang = normalizeSubtitleText(editSubtitleLangEl?.value, 24);
|
||||
const subtitle_label = normalizeSubtitleText(editSubtitleLabelEl?.value, 80);
|
||||
const subtitle_default = !!editSubtitleDefaultEl?.checked;
|
||||
const subtitleFile = editSubtitleFileEl?.files?.[0] || null;
|
||||
const refreshOmdb = !!editRefreshOmdbEl.checked;
|
||||
const cam = !!editCamFlagEl?.checked;
|
||||
|
||||
@@ -2264,6 +2349,9 @@ window.onload = async function () {
|
||||
video_file,
|
||||
video_file_low,
|
||||
embed_src,
|
||||
subtitle_lang,
|
||||
subtitle_label,
|
||||
subtitle_default,
|
||||
refreshOmdb,
|
||||
cam
|
||||
};
|
||||
@@ -2300,6 +2388,19 @@ window.onload = async function () {
|
||||
}
|
||||
|
||||
const updated = await res.json();
|
||||
if (subtitleFile) {
|
||||
try {
|
||||
await uploadMovieSubtitle({
|
||||
movieId,
|
||||
file: subtitleFile,
|
||||
lang: subtitle_lang,
|
||||
label: subtitle_label,
|
||||
isDefault: subtitle_default,
|
||||
});
|
||||
} catch (err) {
|
||||
showResponse('warning', `Film mis à jour, mais sous-titres non uploadés: ${err.message || 'Erreur'}`);
|
||||
}
|
||||
}
|
||||
moviesById.set(updated._id, updated);
|
||||
showResponse('success', 'Film mis à jour avec succès.');
|
||||
editMovieModal.hide();
|
||||
|
||||
+72
-15
@@ -687,6 +687,7 @@ function toVideoSessionUrl(videoSrc) {
|
||||
// Best-effort cache to avoid spamming /api/video/session.
|
||||
// Keyed by sessionUrl + token (token is already in-memory, not persisted here).
|
||||
const _videoSessionCache = new Map(); // key -> expiresAt ms
|
||||
let _currentMovie = null;
|
||||
|
||||
async function ensureVideoSessionForSource(videoSrc, token) {
|
||||
// Only needed for our protected /api/video/* streams.
|
||||
@@ -777,6 +778,50 @@ async function ensurePlayableApiVideoUrl(rawUrl, token) {
|
||||
return addTokenToApiVideoUrl(rawUrl, token);
|
||||
}
|
||||
|
||||
function addTokenToApiSubtitleUrl(rawUrl, token) {
|
||||
if (!rawUrl || !token) return rawUrl;
|
||||
try {
|
||||
const isRelative = String(rawUrl).trim().startsWith('/');
|
||||
const u = new URL(String(rawUrl), window.location.origin);
|
||||
u.searchParams.set('token', token);
|
||||
return isRelative ? `${u.pathname}${u.search}${u.hash}` : u.toString();
|
||||
} catch (_) {
|
||||
const s = String(rawUrl);
|
||||
const join = s.includes('?') ? '&' : '?';
|
||||
return `${s}${join}token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function clearVideoTracks(videoEl) {
|
||||
if (!videoEl) return;
|
||||
const tracks = Array.from(videoEl.querySelectorAll('track'));
|
||||
tracks.forEach((t) => {
|
||||
try { t.remove(); } catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
function applySubtitleTrack({ videoEl, movie, token }) {
|
||||
if (!videoEl) return;
|
||||
clearVideoTracks(videoEl);
|
||||
const movieId = String(movie?._id || '');
|
||||
const subtitleFile = String(movie?.subtitle_file || '').trim();
|
||||
if (!movieId || !subtitleFile) return;
|
||||
|
||||
const lang = String(movie?.subtitle_lang || 'en').trim() || 'en';
|
||||
const label = String(movie?.subtitle_label || 'Subtitles').trim() || 'Subtitles';
|
||||
const isDefault = movie?.subtitle_default === undefined ? true : !!movie.subtitle_default;
|
||||
const rawUrl = `/api/subtitles/${encodeURIComponent(movieId)}`;
|
||||
const src = addTokenToApiSubtitleUrl(rawUrl, token);
|
||||
|
||||
const track = document.createElement('track');
|
||||
track.kind = 'subtitles';
|
||||
track.label = label;
|
||||
track.srclang = lang;
|
||||
track.src = src;
|
||||
track.default = isDefault;
|
||||
videoEl.appendChild(track);
|
||||
}
|
||||
|
||||
async function playVodLink(vodLink, { token } = {}) {
|
||||
const raw = normalizeVodLink(vodLink);
|
||||
if (!raw) {
|
||||
@@ -927,17 +972,22 @@ function addQualityParam(rawUrl, quality) {
|
||||
|
||||
function setQualityToggleUi({ visible, isLow }) {
|
||||
const wrap = document.getElementById('quality-toggle');
|
||||
const toggle = document.getElementById('quality-switch');
|
||||
const label = document.getElementById('quality-switch-label');
|
||||
if (!wrap || !toggle || !label) return;
|
||||
const btnHigh = document.getElementById('quality-btn-high');
|
||||
const btnLow = document.getElementById('quality-btn-low');
|
||||
if (!wrap || !btnHigh || !btnLow) return;
|
||||
if (visible) {
|
||||
wrap.classList.remove('d-none');
|
||||
toggle.checked = !!isLow;
|
||||
label.textContent = isLow ? 'Basse' : 'Haute';
|
||||
const useLow = !!isLow;
|
||||
btnHigh.classList.toggle('is-active', !useLow);
|
||||
btnLow.classList.toggle('is-active', useLow);
|
||||
btnHigh.setAttribute('aria-pressed', useLow ? 'false' : 'true');
|
||||
btnLow.setAttribute('aria-pressed', useLow ? 'true' : 'false');
|
||||
} else {
|
||||
wrap.classList.add('d-none');
|
||||
toggle.checked = false;
|
||||
label.textContent = 'Haute';
|
||||
btnHigh.classList.remove('is-active');
|
||||
btnLow.classList.remove('is-active');
|
||||
btnHigh.setAttribute('aria-pressed', 'false');
|
||||
btnLow.setAttribute('aria-pressed', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -961,14 +1011,16 @@ async function switchVideoSource({ src, token, preserveTime }) {
|
||||
videoEl.addEventListener('loadedmetadata', onLoaded, { once: true });
|
||||
|
||||
await playVodLink(src, { token });
|
||||
applySubtitleTrack({ videoEl, movie: _currentMovie, token });
|
||||
}
|
||||
|
||||
async function initQualityToggle({ movieId, token, fallbackSrc }) {
|
||||
async function initQualityToggle({ movieId, token, fallbackSrc, hasLowFile }) {
|
||||
const wrap = document.getElementById('quality-toggle');
|
||||
const toggle = document.getElementById('quality-switch');
|
||||
if (!wrap || !toggle) return { src: fallbackSrc };
|
||||
const btnHigh = document.getElementById('quality-btn-high');
|
||||
const btnLow = document.getElementById('quality-btn-low');
|
||||
if (!wrap || !btnHigh || !btnLow) return { src: fallbackSrc };
|
||||
|
||||
if (!isApiVideoUrl(fallbackSrc)) {
|
||||
if (!isApiVideoUrl(fallbackSrc) || !hasLowFile) {
|
||||
setQualityToggleUi({ visible: false, isLow: false });
|
||||
return { src: fallbackSrc };
|
||||
}
|
||||
@@ -987,8 +1039,7 @@ async function initQualityToggle({ movieId, token, fallbackSrc }) {
|
||||
|
||||
setQualityToggleUi({ visible: true, isLow: shouldUseLow });
|
||||
|
||||
toggle.addEventListener('change', async () => {
|
||||
const nextIsLow = !!toggle.checked;
|
||||
const onPickQuality = async (nextIsLow) => {
|
||||
setStoredQuality(movieId, nextIsLow ? 'low' : 'high');
|
||||
setQualityToggleUi({ visible: true, isLow: nextIsLow });
|
||||
await switchVideoSource({
|
||||
@@ -996,7 +1047,10 @@ async function initQualityToggle({ movieId, token, fallbackSrc }) {
|
||||
token,
|
||||
preserveTime: true,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
btnHigh.addEventListener('click', () => { void onPickQuality(false); });
|
||||
btnLow.addEventListener('click', () => { void onPickQuality(true); });
|
||||
|
||||
return { src: pickedSrc };
|
||||
}
|
||||
@@ -1069,6 +1123,7 @@ window.onload = async function () {
|
||||
pageLoader.fail();
|
||||
return;
|
||||
}
|
||||
_currentMovie = movie;
|
||||
|
||||
const [activeYear, playerUi] = await Promise.all([activeYearPromise, playerUiPromise]);
|
||||
setHeader(movie, { activeYear });
|
||||
@@ -1105,7 +1160,8 @@ window.onload = async function () {
|
||||
}
|
||||
|
||||
pageLoader.setProgress(70);
|
||||
const qualityChoice = await initQualityToggle({ movieId: id, token, fallbackSrc: resolved.src });
|
||||
const hasLowFile = !!(movie?.video_file_low);
|
||||
const qualityChoice = await initQualityToggle({ movieId: id, token, fallbackSrc: resolved.src, hasLowFile });
|
||||
const selectedSrc = qualityChoice?.src || resolved.src;
|
||||
await playVodLink(selectedSrc, { token });
|
||||
pageLoader.setProgress(82);
|
||||
@@ -1113,6 +1169,7 @@ window.onload = async function () {
|
||||
// Only the <video> player supports reliable progress tracking (mp4/hls).
|
||||
const videoEl = document.getElementById('video');
|
||||
if (videoEl && !videoEl.classList.contains('d-none')) {
|
||||
applySubtitleTrack({ videoEl, movie, token });
|
||||
setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' });
|
||||
const userId = normalizeUserId(decoded);
|
||||
if (userId) {
|
||||
|
||||
+48
-5
@@ -63,6 +63,49 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quality-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: .6rem;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 999px;
|
||||
padding: .25rem .4rem .25rem .6rem;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
.quality-label {
|
||||
font-size: .8rem;
|
||||
color: rgba(0,0,0,0.6);
|
||||
font-weight: 600;
|
||||
}
|
||||
.quality-pill {
|
||||
display: inline-flex;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(0,0,0,0.08);
|
||||
padding: 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
.quality-btn {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(0,0,0,0.65);
|
||||
font-size: .78rem;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
padding: .25rem .6rem;
|
||||
line-height: 1.1;
|
||||
transition: background .2s ease, color .2s ease;
|
||||
}
|
||||
.quality-btn.is-active {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
}
|
||||
.quality-btn:focus-visible {
|
||||
outline: 2px solid rgba(13,110,253,.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Progress indicator: icon-first, minimal text */
|
||||
.progress-indicator {
|
||||
display: inline-flex;
|
||||
@@ -153,11 +196,11 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-column gap-2 align-items-start align-items-lg-end">
|
||||
<div id="quality-toggle" class="d-none d-flex align-items-center gap-2">
|
||||
<span class="text-muted small">Qualité</span>
|
||||
<div class="form-check form-switch m-0">
|
||||
<input class="form-check-input" type="checkbox" id="quality-switch">
|
||||
<label class="form-check-label small" for="quality-switch" id="quality-switch-label">Haute</label>
|
||||
<div id="quality-toggle" class="d-none quality-toggle">
|
||||
<span class="quality-label">Qualité</span>
|
||||
<div class="quality-pill" role="group" aria-label="Qualité">
|
||||
<button type="button" class="quality-btn" data-quality="high" id="quality-btn-high" aria-pressed="true">Haute</button>
|
||||
<button type="button" class="quality-btn" data-quality="low" id="quality-btn-low" aria-pressed="false">Basse</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user