From 2a74ed38de3138bdb7ed529b3537a97611ba4667 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Dec 2025 00:20:37 +0000 Subject: [PATCH 1/4] Add movie player link and API endpoint Co-authored-by: mathieumoran1 --- public/js/app_movies.js | 7 +++++-- routes/movies.js | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/public/js/app_movies.js b/public/js/app_movies.js index 8dfa399..c93c623 100644 --- a/public/js/app_movies.js +++ b/public/js/app_movies.js @@ -117,6 +117,7 @@ window.onload = async function () { const isChecked = watchedMoviesInYear.some(watchedMovie => watchedMovie.imdb_id === movie.imdb_id); const watchedMovie = watchedMoviesInYear.find(wm => wm.imdb_id === movie.imdb_id); const watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : ''; + const playerUrl = movie && movie._id ? `/player.html?id=${encodeURIComponent(movie._id)}` : (movie.vod_link || '#'); const movieDiv = document.createElement('div'); movieDiv.classList.add('col-md-4', 'mb-4', 'movie-card'); @@ -124,10 +125,12 @@ window.onload = async function () { movieDiv.innerHTML = `
${isChecked ? '
VISIONNÉ
' : ''} - ${movie.title} + + ${movie.title} +
- ${movie.title} + ${movie.title} ⭐ ${movie.rating}

${movie.category}

diff --git a/routes/movies.js b/routes/movies.js index 6099dce..f14dd50 100644 --- a/routes/movies.js +++ b/routes/movies.js @@ -3,6 +3,7 @@ const Movie = require("../models/Movie"); const User = require("../models/User"); const axios = require("axios"); const jwt = require("jsonwebtoken"); +const mongoose = require("mongoose"); const router = express.Router(); @@ -321,4 +322,20 @@ router.put("/:id", async (req, res) => { } }); +// Get one movie by Mongo _id (used by the custom player page) +router.get("/:id", async (req, res) => { + try { + const { id } = 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" }); + return res.status(200).json(movie); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + module.exports = router; -- 2.54.0 From ae1f065000702463aba98330ee93db628e5cbc1d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Dec 2025 00:24:01 +0000 Subject: [PATCH 2/4] feat: Add movie player page and logic Implement the movie player page and associated JavaScript logic for handling various video sources and embed types. Co-authored-by: mathieumoran1 --- public/js/app_player.js | 277 ++++++++++++++++++++++++++++++++++++++++ public/player.html | 91 +++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 public/js/app_player.js create mode 100644 public/player.html diff --git a/public/js/app_player.js b/public/js/app_player.js new file mode 100644 index 0000000..a62517a --- /dev/null +++ b/public/js/app_player.js @@ -0,0 +1,277 @@ +function decodeJwt(token) { + return JSON.parse(atob(token.split('.')[1])); +} + +function getQueryParam(name) { + const url = new URL(window.location.href); + return url.searchParams.get(name); +} + +function showAlert(message) { + const el = document.getElementById('alert'); + if (!el) return; + el.textContent = message; + el.classList.remove('d-none'); +} + +function setSourceLabel(text) { + const el = document.getElementById('source-label'); + if (!el) return; + el.textContent = text || '—'; + el.title = text || ''; +} + +function setHeader(movie) { + const titleEl = document.getElementById('movie-title'); + const categoryEl = document.getElementById('movie-category'); + const ratingEl = document.getElementById('movie-rating'); + + if (titleEl) titleEl.textContent = movie?.title || 'Player'; + + if (categoryEl) { + const cat = movie?.category ? String(movie.category) : ''; + categoryEl.textContent = cat ? `${cat} · ` : ''; + } + + if (ratingEl) { + const r = movie?.rating ? String(movie.rating) : ''; + ratingEl.textContent = r ? `⭐ ${r}` : ''; + } + + document.title = movie?.title ? `Player - ${movie.title}` : 'Player'; +} + +function normalizeVodLink(raw) { + const v = String(raw || '').trim(); + if (!v) return null; + + // Support pasted iframe embed code (extract src) + if (v.toLowerCase().includes(' { + // If the browser can't play it (or CORS blocks), fall back to iframe. + setSourceLabel('Embed (fallback)'); + showEmbedPlayer(raw); + }, { once: true }); + try { videoEl.load(); } catch (_) {} + return; + } + + // HLS (.m3u8) + if (looksLikeHls(raw)) { + setSourceLabel('HLS stream (.m3u8)'); + const videoEl = showVideoPlayer(); + if (!videoEl) return; + + // Native HLS (Safari) first + if (videoEl.canPlayType('application/vnd.apple.mpegurl')) { + videoEl.src = raw; + try { videoEl.load(); } catch (_) {} + return; + } + + // hls.js fallback (if supported) + if (window.Hls && window.Hls.isSupported()) { + const hls = new window.Hls({ + enableWorker: true, + }); + hls.loadSource(raw); + hls.attachMedia(videoEl); + hls.on(window.Hls.Events.ERROR, function (_evt, data) { + if (data?.fatal) { + try { hls.destroy(); } catch (_) {} + setSourceLabel('Embed (fallback)'); + showEmbedPlayer(raw); + } + }); + return; + } + + // Last resort + setSourceLabel('Embed (fallback)'); + showEmbedPlayer(raw); + return; + } + + // Default: treat as embeddable URL + setSourceLabel('Embed'); + showEmbedPlayer(raw); +} + +window.onload = async function () { + // Basic auth UX: require a valid local token (like the rest of the app). + const token = localStorage.getItem('auth_token'); + if (!token) { + window.location.href = '/'; + return; + } + + try { + const decoded = decodeJwt(token); + const currentTime = Math.floor(Date.now() / 1000); + if (decoded.exp && decoded.exp < currentTime) { + localStorage.removeItem('auth_token'); + window.location.href = '/'; + return; + } + if (decoded.mustChangePassword) { + window.location.href = '/change-password.html'; + return; + } + const nameEl = document.getElementById('player-user-name'); + if (nameEl) nameEl.textContent = decoded.name || ''; + } catch (_) { + localStorage.removeItem('auth_token'); + window.location.href = '/'; + return; + } + + const id = getQueryParam('id'); + if (!id) { + showAlert('Missing movie id.'); + return; + } + + try { + const res = await fetch(`/api/movies/${encodeURIComponent(id)}`, { method: 'GET' }); + const movie = await res.json().catch(() => ({})); + if (!res.ok) { + showAlert(movie?.message || movie?.error || `Failed to load movie (${res.status}).`); + return; + } + + setHeader(movie); + await playVodLink(movie?.vod_link); + } catch (err) { + showAlert(err.message || 'Network error.'); + } +}; + diff --git a/public/player.html b/public/player.html new file mode 100644 index 0000000..2c0dbf3 --- /dev/null +++ b/public/player.html @@ -0,0 +1,91 @@ + + + + + + Player + + + + + + + +
+
+

Loading…

+
+ + +
+
+ + + +
+ + +
+ +
+ Source + — +
+
+ + + + + + + -- 2.54.0 From 43925c3b60bdf5357c16162e566d4259b04791c6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Dec 2025 00:26:34 +0000 Subject: [PATCH 3/4] Refactor: Add flexible video source options to Movie model Co-authored-by: mathieumoran1 --- models/Movie.js | 9 ++++- public/control.html | 53 ++++++++++++++++++++++++--- public/js/app_control.js | 26 ++++++++++++-- public/js/app_player.js | 15 +++++++- routes/movies.js | 77 +++++++++++++++++++++++++++++++++++++--- 5 files changed, 167 insertions(+), 13 deletions(-) diff --git a/models/Movie.js b/models/Movie.js index 8e8d3ef..8f7cddf 100644 --- a/models/Movie.js +++ b/models/Movie.js @@ -9,7 +9,14 @@ const movieSchema = new mongoose.Schema({ // Oscar pool year / edition (used to segment movies + stats) year: { type: Number, required: false, index: true }, category: { type: String, required: true }, - vod_link: { type: String, required: true }, + // Legacy "one field" player source. + // Kept for backwards-compat. New UI prefers video_src / embed_src. + vod_link: { type: String, required: false }, + + // Player configuration (optional; used by player.html) + 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 watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }] }, { timestamps: true }); diff --git a/public/control.html b/public/control.html index c11baa6..825389d 100644 --- a/public/control.html +++ b/public/control.html @@ -251,8 +251,31 @@
- - + +
+
+ + +
Auto = video_src → embed_src → vod_link.
+
+
+ + +
+
+ + +
+
+ + +
Backward-compat. Prefer video_src / embed_src above.
+
+
@@ -342,8 +365,30 @@
- - + +
+
+ + +
Auto = video_src → embed_src → vod_link.
+
+
+ + +
+
+ + +
+
+ + +
+
diff --git a/public/js/app_control.js b/public/js/app_control.js index ca9c142..0bffd55 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -72,6 +72,9 @@ window.onload = async function () { const editYearEl = document.getElementById('edit_year'); const editCategoryEl = document.getElementById('edit_category'); const editVodLinkEl = document.getElementById('edit_vod_link'); + const editPlayerModeEl = document.getElementById('edit_player_mode'); + const editVideoSrcEl = document.getElementById('edit_video_src'); + const editEmbedSrcEl = document.getElementById('edit_embed_src'); const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb'); const editTitleEl = document.getElementById('edit_title'); const editRatingEl = document.getElementById('edit_rating'); @@ -485,6 +488,9 @@ window.onload = async function () { const imdb_id = document.getElementById('imdb_id').value.trim(); const category = document.getElementById('category').value.trim(); 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 embed_src = document.getElementById('embed_src')?.value?.trim() || ''; if (!year) { showResponse('warning', 'Année invalide. Exemple attendu: 2026'); @@ -496,6 +502,11 @@ window.onload = async function () { return; } + if (!vod_link && !video_src && !embed_src) { + showResponse('warning', 'Ajoute au moins une source (VOD / video_src / embed_src).'); + return; + } + try { const res = await fetch('/api/movies/add', { method: 'POST', @@ -503,7 +514,7 @@ window.onload = async function () { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, - body: JSON.stringify({ year, imdb_id, category, vod_link }) + body: JSON.stringify({ year, imdb_id, category, vod_link, player_mode, video_src, embed_src }) }); if (res.ok) { @@ -596,6 +607,9 @@ window.onload = async function () { editYearEl.value = movie.year ? String(movie.year) : ''; editCategoryEl.value = movie.category || ''; editVodLinkEl.value = movie.vod_link || ''; + if (editPlayerModeEl) editPlayerModeEl.value = movie.player_mode || 'auto'; + if (editVideoSrcEl) editVideoSrcEl.value = movie.video_src || ''; + if (editEmbedSrcEl) editEmbedSrcEl.value = movie.embed_src || ''; editRefreshOmdbEl.checked = false; editTitleEl.value = movie.title || ''; @@ -619,6 +633,9 @@ window.onload = async function () { const yearRaw = (editYearEl.value || '').trim(); const category = (editCategoryEl.value || '').trim(); const vod_link = (editVodLinkEl.value || '').trim(); + const player_mode = (editPlayerModeEl?.value || 'auto').trim(); + const video_src = (editVideoSrcEl?.value || '').trim(); + const embed_src = (editEmbedSrcEl?.value || '').trim(); const refreshOmdb = !!editRefreshOmdbEl.checked; const title = editTitleEl.value; @@ -645,8 +662,8 @@ window.onload = async function () { return; } - if (!vod_link) { - showResponse('warning', 'Lien VOD invalide.'); + if (!vod_link && !video_src && !embed_src) { + showResponse('warning', 'Ajoute au moins une source (VOD / video_src / embed_src).'); return; } @@ -655,6 +672,9 @@ window.onload = async function () { year: yearRaw === '' ? null : year, category, vod_link, + player_mode, + video_src, + embed_src, refreshOmdb }; diff --git a/public/js/app_player.js b/public/js/app_player.js index a62517a..543df82 100644 --- a/public/js/app_player.js +++ b/public/js/app_player.js @@ -226,6 +226,18 @@ async function playVodLink(vodLink) { showEmbedPlayer(raw); } +function resolveMovieSource(movie) { + const mode = String(movie?.player_mode || 'auto').toLowerCase(); + const legacy = movie?.vod_link; + const video = movie?.video_src; + const embed = movie?.embed_src; + + if (mode === 'video') return video || legacy || embed || null; + if (mode === 'embed') return embed || legacy || video || null; + // auto + return video || embed || legacy || null; +} + window.onload = async function () { // Basic auth UX: require a valid local token (like the rest of the app). const token = localStorage.getItem('auth_token'); @@ -269,7 +281,8 @@ window.onload = async function () { } setHeader(movie); - await playVodLink(movie?.vod_link); + const src = resolveMovieSource(movie); + await playVodLink(src); } catch (err) { showAlert(err.message || 'Network error.'); } diff --git a/routes/movies.js b/routes/movies.js index f14dd50..f754618 100644 --- a/routes/movies.js +++ b/routes/movies.js @@ -19,6 +19,28 @@ function isValidImdbId(value) { return typeof value === "string" && /^tt\d{5,}$/.test(value.trim()); } +function normalizeOptionalString(v, maxLen = 2048) { + if (v === undefined) return undefined; + if (v === null) return null; + const s = String(v).trim(); + if (!s) return ""; + return s.slice(0, maxLen); +} + +function normalizePlayerMode(v) { + const s = String(v || "").trim().toLowerCase(); + if (!s) return "auto"; + if (s === "auto" || s === "video" || s === "embed") return s; + return null; +} + +function hasAnySource({ vod_link, video_src, embed_src }) { + const a = typeof vod_link === "string" ? vod_link.trim() : ""; + const b = typeof video_src === "string" ? video_src.trim() : ""; + const c = typeof embed_src === "string" ? embed_src.trim() : ""; + return !!(a || b || c); +} + // Fetch movie details from OMDb API async function fetchMovieDetailsFromOmdb(imdb_id) { const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key @@ -145,7 +167,7 @@ router.patch("/users/updateWatchedMovies", async (req, res) => { // Add movie router.post("/add", async (req, res) => { - const { imdb_id, category, vod_link, year } = req.body; + const { imdb_id, category, vod_link, year, player_mode, video_src, embed_src } = req.body; try { // Get the token from the Authorization header @@ -168,6 +190,17 @@ router.post("/add", async (req, res) => { return res.status(400).json({ message: "Année invalide (ex: 2026)" }); } + const normalizedVod = normalizeOptionalString(vod_link, 4096); + const normalizedVideo = normalizeOptionalString(video_src, 4096); + const normalizedEmbed = normalizeOptionalString(embed_src, 20000); + const normalizedMode = normalizePlayerMode(player_mode); + if (normalizedMode === null) { + return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" }); + } + if (!hasAnySource({ vod_link: normalizedVod || "", video_src: normalizedVideo || "", embed_src: normalizedEmbed || "" })) { + return res.status(400).json({ message: "Ajoute au moins une source (VOD / video_src / embed_src)." }); + } + // Fetch movie details from OMDb API const movieDetails = await fetchMovieDetailsFromOmdb(imdb_id); @@ -180,7 +213,10 @@ router.post("/add", async (req, res) => { poster: movieDetails.poster, year: parsedYear, category, - vod_link + vod_link: normalizedVod || undefined, + player_mode: normalizedMode, + video_src: normalizedVideo || undefined, + embed_src: normalizedEmbed || undefined, }); await movie.save(); @@ -262,6 +298,9 @@ router.put("/:id", async (req, res) => { year, category, vod_link, + player_mode, + video_src, + embed_src, refreshOmdb, } = req.body || {}; @@ -281,7 +320,14 @@ router.put("/:id", async (req, res) => { } if (typeof vod_link === "string" && vod_link.trim() === "") { - return res.status(400).json({ message: "Lien VOD invalide." }); + // Allow clearing vod_link; enforce "at least one source" after applying changes. + } + + if (player_mode !== undefined) { + const normalizedMode = normalizePlayerMode(player_mode); + if (normalizedMode === null) { + return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" }); + } } const movie = await Movie.findById(id); @@ -289,7 +335,22 @@ router.put("/:id", async (req, res) => { if (imdb_id !== undefined) movie.imdb_id = String(imdb_id).trim(); if (category !== undefined) movie.category = String(category).trim(); - if (vod_link !== undefined) movie.vod_link = String(vod_link).trim(); + if (vod_link !== undefined) { + const v = normalizeOptionalString(vod_link, 4096); + movie.vod_link = v ? v : null; + } + if (player_mode !== undefined) { + const normalizedMode = normalizePlayerMode(player_mode); + movie.player_mode = normalizedMode === null ? movie.player_mode : normalizedMode; + } + if (video_src !== undefined) { + const v = normalizeOptionalString(video_src, 4096); + movie.video_src = v ? v : null; + } + if (embed_src !== undefined) { + const v = normalizeOptionalString(embed_src, 20000); + movie.embed_src = v ? v : null; + } if (year !== undefined && year !== null && year !== "") { movie.year = parseOscarYear(year); @@ -312,6 +373,14 @@ router.put("/:id", async (req, res) => { if (poster !== undefined) movie.poster = poster; } + if (!hasAnySource({ + vod_link: movie.vod_link || "", + video_src: movie.video_src || "", + embed_src: movie.embed_src || "", + })) { + return res.status(400).json({ message: "Ajoute au moins une source (VOD / video_src / embed_src)." }); + } + await movie.save(); return res.status(200).json(movie); } catch (err) { -- 2.54.0 From 5cfa35c79c55ea2a5e03e446c69668924a9b1f8d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Dec 2025 00:27:58 +0000 Subject: [PATCH 4/4] Add player column and badges to movie management table Co-authored-by: mathieumoran1 --- public/control.html | 3 ++- public/js/app_control.js | 35 +++++++++++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/public/control.html b/public/control.html index 825389d..96fb469 100644 --- a/public/control.html +++ b/public/control.html @@ -322,13 +322,14 @@ Sel. Titre + Player Catégorie Année Actions - Charge la liste… + Charge la liste…
diff --git a/public/js/app_control.js b/public/js/app_control.js index 0bffd55..71a7b26 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -726,12 +726,12 @@ window.onload = async function () { async function loadMoviesForManagement() { const year = manageYearSelect.value; const url = year ? `/api/movies?year=${encodeURIComponent(year)}` : '/api/movies'; - adminMoviesBody.innerHTML = `Chargement…`; + adminMoviesBody.innerHTML = `Chargement…`; try { const res = await fetch(url, { method: 'GET' }); if (!res.ok) { - adminMoviesBody.innerHTML = `Erreur lors du chargement (${res.status})`; + adminMoviesBody.innerHTML = `Erreur lors du chargement (${res.status})`; return; } const movies = await res.json(); @@ -739,13 +739,39 @@ window.onload = async function () { moviesById = new Map(movies.map((m) => [m._id, m])); if (!movies.length) { - adminMoviesBody.innerHTML = `Aucun film.`; + adminMoviesBody.innerHTML = `Aucun film.`; updateSelectionUI(); return; } + function buildPlayerBadges(m) { + const badges = []; + const hasVideo = !!(m && m.video_src); + const hasEmbed = !!(m && m.embed_src); + const hasLegacy = !!(m && m.vod_link); + + if (hasVideo) badges.push({ text: 'Video', cls: 'bg-primary' }); + if (hasEmbed) badges.push({ text: 'Embed', cls: 'bg-info text-dark' }); + if (hasLegacy) badges.push({ text: 'Legacy', cls: 'bg-secondary' }); + + const modeRaw = String(m?.player_mode || 'auto').toLowerCase(); + const mode = (modeRaw === 'video' || modeRaw === 'embed' || modeRaw === 'auto') ? modeRaw : 'auto'; + const modeText = mode === 'auto' ? 'Auto' : mode === 'video' ? 'Mode: Video' : 'Mode: Embed'; + badges.push({ text: modeText, cls: 'bg-light text-dark border' }); + + if (!hasVideo && !hasEmbed && !hasLegacy) { + return [{ text: '—', cls: 'bg-light text-dark border' }]; + } + return badges; + } + adminMoviesBody.innerHTML = ''; movies.forEach((m) => { + const badges = buildPlayerBadges(m); + const badgesHtml = badges + .map((b) => `${b.text}`) + .join(''); + const tr = document.createElement('tr'); tr.innerHTML = ` @@ -755,6 +781,7 @@ window.onload = async function () {
${m.title || '(sans titre)'}
${m.imdb_id || ''}
+ ${badgesHtml} ${m.category || ''} ${m.year || ''} @@ -776,7 +803,7 @@ window.onload = async function () { updateSelectionUI(); } catch (err) { - adminMoviesBody.innerHTML = `${err.message || 'Erreur réseau'}`; + adminMoviesBody.innerHTML = `${err.message || 'Erreur réseau'}`; } } -- 2.54.0