From 2f767e15a64a2cfff14ad2f5b99480140b1dd20f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 22 Dec 2025 05:12:06 +0000 Subject: [PATCH] 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 --- index.js | 2 + models/Movie.js | 3 + public/control.html | 14 +++ public/js/app_control.js | 17 +++- public/js/app_player.js | 17 +++- routes/movies.js | 63 ++++++++++-- routes/video.js | 206 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 306 insertions(+), 16 deletions(-) create mode 100644 routes/video.js diff --git a/index.js b/index.js index 2ed5a7b..877665a 100644 --- a/index.js +++ b/index.js @@ -24,6 +24,7 @@ const movieRoutes = require("./routes/movies"); const versionRoutes = require("./routes/version"); const settingsRoutes = require("./routes/settings"); const adminDbRoutes = require("./routes/adminDb"); +const videoRoutes = require("./routes/video"); app.use(express.static('public')); app.use("/api/users", userRoutes); @@ -31,6 +32,7 @@ app.use("/api/movies", movieRoutes); app.use("/api/version", versionRoutes); app.use("/api/settings", settingsRoutes); app.use("/api/admin", adminDbRoutes); +app.use("/api/video", videoRoutes); // Start Server const PORT = process.env.PORT || 5000; diff --git a/models/Movie.js b/models/Movie.js index a854260..5083547 100644 --- a/models/Movie.js +++ b/models/Movie.js @@ -17,6 +17,9 @@ const movieSchema = new mongoose.Schema({ player_mode: { type: String, required: false, enum: ["auto", "video", "embed"], default: "auto" }, video_src: { type: String, required: false }, // direct video file URL or HLS (.m3u8) embed_src: { type: String, required: false }, // URL or iframe code + // Server-hosted file (relative path under VIDEO_FILES_DIR, default public/video) + // If set, the backend can stream it via /api/video/:movieId (Range/seek supported). + video_file: { type: String, required: false }, watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }] }, { timestamps: true }); diff --git a/public/control.html b/public/control.html index 7b26bab..f18a600 100644 --- a/public/control.html +++ b/public/control.html @@ -354,6 +354,14 @@
+
If you use a server file (below) and leave this empty, it will auto-use /api/video/<movieId>.
+
+
+ + +
+ Relative path under VIDEO_FILES_DIR (default: public/video). Supports seeking (Range). +
@@ -700,6 +708,12 @@
+
If you use a server file (below) and leave this empty, it will auto-use /api/video/<movieId>.
+
+
+ + +
Relative path under VIDEO_FILES_DIR (default: public/video).
diff --git a/public/js/app_control.js b/public/js/app_control.js index 0dd0cab..947f26e 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -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' }); diff --git a/public/js/app_player.js b/public/js/app_player.js index 1f28919..d89d1dc 100644 --- a/public/js/app_player.js +++ b/public/js/app_player.js @@ -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