feat: Add server-hosted video streaming support
Introduces a new /api/video route for streaming video files stored on the server. This allows for direct playback of local video files with seeking capabilities. The Movie model and UI have been updated to support this new feature. Co-authored-by: mathieumoran1 <[email protected]>
This commit is contained in:
co-authored by
mathieumoran1
parent
f54b1d91c5
commit
2f767e15a6
@@ -354,6 +354,14 @@
|
||||
<div class="col-12 col-lg-8">
|
||||
<label for="video_src" class="form-label">Video URL (mp4/webm/m3u8)</label>
|
||||
<input type="text" class="form-control" id="video_src" placeholder="https://.../movie.mp4 or .../playlist.m3u8">
|
||||
<div class="form-text">If you use a server file (below) and leave this empty, it will auto-use <code>/api/video/<movieId></code>.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="video_file" class="form-label">Server video file (optional)</label>
|
||||
<input type="text" class="form-control font-monospace" id="video_file" placeholder="my_movie.mp4 or 2026/my_movie.mp4">
|
||||
<div class="form-text">
|
||||
Relative path under <code>VIDEO_FILES_DIR</code> (default: <code>public/video</code>). Supports seeking (Range).
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="embed_src" class="form-label">Embed URL or iframe code</label>
|
||||
@@ -700,6 +708,12 @@
|
||||
<div class="col-12">
|
||||
<label for="edit_video_src" class="form-label">Video URL (mp4/webm/m3u8)</label>
|
||||
<input type="text" class="form-control" id="edit_video_src" placeholder="https://.../movie.mp4 or .../playlist.m3u8">
|
||||
<div class="form-text">If you use a server file (below) and leave this empty, it will auto-use <code>/api/video/<movieId></code>.</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="edit_video_file" class="form-label">Server video file (optional)</label>
|
||||
<input type="text" class="form-control font-monospace" id="edit_video_file" placeholder="my_movie.mp4 or 2026/my_movie.mp4">
|
||||
<div class="form-text">Relative path under <code>VIDEO_FILES_DIR</code> (default: <code>public/video</code>).</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="edit_embed_src" class="form-label">Embed URL or iframe code</label>
|
||||
|
||||
@@ -104,6 +104,7 @@ window.onload = async function () {
|
||||
const editVodLinkEl = document.getElementById('edit_vod_link');
|
||||
const editPlayerModeEl = document.getElementById('edit_player_mode');
|
||||
const editVideoSrcEl = document.getElementById('edit_video_src');
|
||||
const editVideoFileEl = document.getElementById('edit_video_file');
|
||||
const editEmbedSrcEl = document.getElementById('edit_embed_src');
|
||||
const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb');
|
||||
const editTitleEl = document.getElementById('edit_title');
|
||||
@@ -1817,6 +1818,7 @@ window.onload = async function () {
|
||||
const vod_link = document.getElementById('vod_link').value.trim();
|
||||
const player_mode = (document.getElementById('player_mode')?.value || 'auto').trim();
|
||||
const video_src = document.getElementById('video_src')?.value?.trim() || '';
|
||||
const video_file = document.getElementById('video_file')?.value?.trim() || '';
|
||||
const embed_src = document.getElementById('embed_src')?.value?.trim() || '';
|
||||
|
||||
if (!year) {
|
||||
@@ -1829,8 +1831,8 @@ window.onload = async function () {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vod_link && !video_src && !embed_src) {
|
||||
showResponse('warning', 'Ajoute au moins une source (VOD / video_src / embed_src).');
|
||||
if (!vod_link && !video_src && !video_file && !embed_src) {
|
||||
showResponse('warning', 'Ajoute au moins une source (VOD / video_src / video_file / embed_src).');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1841,7 +1843,7 @@ window.onload = async function () {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ year, imdb_id, category, vod_link, player_mode, video_src, embed_src })
|
||||
body: JSON.stringify({ year, imdb_id, category, vod_link, player_mode, video_src, video_file, embed_src })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -2025,6 +2027,7 @@ window.onload = async function () {
|
||||
editVodLinkEl.value = movie.vod_link || '';
|
||||
if (editPlayerModeEl) editPlayerModeEl.value = movie.player_mode || 'auto';
|
||||
if (editVideoSrcEl) editVideoSrcEl.value = movie.video_src || '';
|
||||
if (editVideoFileEl) editVideoFileEl.value = movie.video_file || '';
|
||||
if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || '';
|
||||
editRefreshOmdbEl.checked = false;
|
||||
|
||||
@@ -2051,6 +2054,7 @@ window.onload = async function () {
|
||||
const vod_link = (editVodLinkEl.value || '').trim();
|
||||
const player_mode = (editPlayerModeEl?.value || 'auto').trim();
|
||||
const video_src = (editVideoSrcEl?.value || '').trim();
|
||||
const video_file = (editVideoFileEl?.value || '').trim();
|
||||
const embed_src = (editEmbedSrcEl?.value || '').trim();
|
||||
const refreshOmdb = !!editRefreshOmdbEl.checked;
|
||||
|
||||
@@ -2078,8 +2082,8 @@ window.onload = async function () {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!vod_link && !video_src && !embed_src) {
|
||||
showResponse('warning', 'Ajoute au moins une source (VOD / video_src / embed_src).');
|
||||
if (!vod_link && !video_src && !video_file && !embed_src) {
|
||||
showResponse('warning', 'Ajoute au moins une source (VOD / video_src / video_file / embed_src).');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2090,6 +2094,7 @@ window.onload = async function () {
|
||||
vod_link,
|
||||
player_mode,
|
||||
video_src,
|
||||
video_file,
|
||||
embed_src,
|
||||
refreshOmdb
|
||||
};
|
||||
@@ -2163,10 +2168,12 @@ window.onload = async function () {
|
||||
function buildPlayerBadges(m) {
|
||||
const badges = [];
|
||||
const hasVideo = !!(m && m.video_src);
|
||||
const hasFile = !!(m && m.video_file);
|
||||
const hasEmbed = !!(m && m.embed_src);
|
||||
const hasLegacy = !!(m && m.vod_link);
|
||||
|
||||
if (hasVideo) badges.push({ text: 'Video', cls: 'bg-primary' });
|
||||
if (hasFile) badges.push({ text: 'Server file', cls: 'bg-dark' });
|
||||
if (hasEmbed) badges.push({ text: 'Embed', cls: 'bg-info text-dark' });
|
||||
if (hasLegacy) badges.push({ text: 'Legacy', cls: 'bg-secondary' });
|
||||
|
||||
|
||||
+15
-2
@@ -428,13 +428,16 @@ function resolveMovieSource(movie) {
|
||||
const legacy = cleanUrlValue(movie?.vod_link);
|
||||
const video = cleanUrlValue(movie?.video_src);
|
||||
const embed = cleanUrlValue(movie?.embed_src);
|
||||
const serverFile = cleanUrlValue(movie?.video_file);
|
||||
|
||||
// Legacy should never embed in the player (open in new tab only).
|
||||
// So we only pick legacy if there is no other option, and mark it.
|
||||
let picked = null;
|
||||
if (mode === 'video') picked = video || null;
|
||||
const serverVideoUrl = serverFile ? `/api/video/${encodeURIComponent(String(movie?._id || ''))}` : null;
|
||||
|
||||
if (mode === 'video') picked = video || serverVideoUrl || null;
|
||||
else if (mode === 'embed') picked = embed || null;
|
||||
else picked = video || embed || null; // auto
|
||||
else picked = video || serverVideoUrl || embed || null; // auto
|
||||
|
||||
if (picked) return { src: picked, isLegacy: false };
|
||||
if (legacy) return { src: legacy, isLegacy: true };
|
||||
@@ -500,6 +503,16 @@ window.onload = async function () {
|
||||
}
|
||||
|
||||
setHeader(movie);
|
||||
|
||||
// Allow <video> to call /api/video/* without Authorization headers by using a cookie.
|
||||
// Best-effort: if it fails, external video_src URLs can still work.
|
||||
try {
|
||||
await fetch('/api/video/session', {
|
||||
method: 'POST',
|
||||
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
const resolved = resolveMovieSource(movie);
|
||||
if (!resolved?.src) {
|
||||
setOpenOriginal(null);
|
||||
|
||||
Reference in New Issue
Block a user