From 59eb70dea96b329325e10292dfc5a4fb19c7d499 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 22 Dec 2025 16:17:31 -0500 Subject: [PATCH 1/3] Player text visibility option (#55) * feat: Add player admin UI settings Co-authored-by: mathieumoran1 * Refactor player UI settings to combine source and progress Co-authored-by: mathieumoran1 * Refactor admin settings to use accordion layout Co-authored-by: mathieumoran1 --------- Co-authored-by: Cursor Agent --- public/control.html | 129 ++++++++++++++++++++++++++------------- public/js/app_control.js | 106 ++++++++++++++++++++++++++++++++ public/js/app_player.js | 31 ++++++++-- routes/settings.js | 53 ++++++++++++++++ 4 files changed, 273 insertions(+), 46 deletions(-) diff --git a/public/control.html b/public/control.html index b38825a..a72c8b5 100644 --- a/public/control.html +++ b/public/control.html @@ -102,51 +102,96 @@
-
-
-

Année active

-

- Cette année pilote tout le site (films affichés, checklist et statistiques). -

-
- - -
La liste est basée sur les années présentes dans les films.
-
-
- -
-
-
- -
-
-

Date des Oscars (par année)

-

- Configure la date exacte de la cérémonie pour le compte à rebours sur Films & Checklist. - Si non défini, la date par défaut reste 15 mars (comportement actuel). -

- -
-
- - -
Basé sur les années présentes dans les films.
-
-
- - -
Format: YYYY-MM-DD.
-
-
- - +
+
+ +
+

+ +

+
+
+
+ Pilote tout le site (films, checklist, statistiques). +
+
+
+ + +
Basé sur les années présentes dans les films.
+
+
+ +
+
+
-
-
Dates configurées
-
—
+ +
+

+ +

+
+
+
+
+ + +
+
+ + +
+
+
+ Contrôle l’affichage du bloc debug dans player.html (admins uniquement). +
+
+
+
+ + +
+

+ +

+
+
+
+ Configure la date de cérémonie pour le compte à rebours (Films & Checklist). Défaut: 15 mars. +
+ +
+
+ + +
Basé sur les années présentes dans les films.
+
+
+ + +
Format: YYYY-MM-DD.
+
+
+ + +
+
+ +
+
Dates configurées
+
—
+
+
+
diff --git a/public/js/app_control.js b/public/js/app_control.js index 11c1ffe..b5ade3a 100644 --- a/public/js/app_control.js +++ b/public/js/app_control.js @@ -41,6 +41,11 @@ window.onload = async function () { const activeYearInput = document.getElementById('active_oscar_year'); const saveActiveYearBtn = document.getElementById('save-active-year'); + // Player UI (settings tab): show/hide admin status blocks in player.html + const playerUiShowStatusEl = document.getElementById('player_ui_show_status'); + const playerUiReloadBtn = document.getElementById('player_ui_reload'); + const playerUiSaveBtn = document.getElementById('player_ui_save'); + // Version control tab elements const adminVersionTabBtn = document.getElementById('admin-version-tab'); const appVersionAlertEl = document.getElementById('app_version_alert'); @@ -132,6 +137,11 @@ window.onload = async function () { bodyHtml: '', }); + const DEFAULT_PLAYER_UI = Object.freeze({ + showSource: true, + showProgress: true, + }); + function showResponse(kind, message) { responseEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning'); responseEl.classList.add(kind === 'success' ? 'alert-success' : kind === 'warning' ? 'alert-warning' : 'alert-danger'); @@ -147,6 +157,67 @@ window.onload = async function () { .replace(/'/g, '''); } + function normalizePlayerUi(value) { + const v = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; + const showSource = v.showSource === undefined ? DEFAULT_PLAYER_UI.showSource : !!v.showSource; + const showProgress = v.showProgress === undefined ? DEFAULT_PLAYER_UI.showProgress : !!v.showProgress; + return { showSource, showProgress }; + } + + async function fetchPlayerUi() { + const res = await fetch('/api/settings/player-admin-status-ui', { method: 'GET' }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const msg = data.message || data.error || `Erreur (${res.status})`; + throw new Error(msg); + } + return normalizePlayerUi(data); + } + + async function savePlayerUi(payload) { + const res = await fetch('/api/settings/player-admin-status-ui', { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + const msg = data.message || data.error || `Erreur (${res.status})`; + throw new Error(msg); + } + return normalizePlayerUi(data); + } + + function setPlayerUiForm(value) { + if (!playerUiShowStatusEl) return; + // Single checkbox controls both flags. + playerUiShowStatusEl.checked = !!value.showSource && !!value.showProgress; + } + + function getPlayerUiFormValue() { + const enabled = !!playerUiShowStatusEl?.checked; + return normalizePlayerUi({ + showSource: enabled, + showProgress: enabled, + }); + } + + async function loadPlayerUiIntoUi() { + // If the section isn't present (older HTML), skip gracefully. + if (!playerUiShowStatusEl) return; + try { + const value = await fetchPlayerUi(); + setPlayerUiForm(value); + } catch (err) { + // Keep defaults so admin can still set something. + setPlayerUiForm(DEFAULT_PLAYER_UI); + showResponse('warning', err.message || 'Impossible de charger les réglages du lecteur.'); + } + } + async function copyToClipboard(text) { const t = String(text || '').trim(); if (!t) return false; @@ -2337,6 +2408,41 @@ window.onload = async function () { hookModal100Inputs(); await loadCompletionModalIntoUi(); + await loadPlayerUiIntoUi(); + + if (playerUiReloadBtn) { + playerUiReloadBtn.addEventListener('click', async () => { + playerUiReloadBtn.disabled = true; + const old = playerUiReloadBtn.textContent; + playerUiReloadBtn.textContent = 'Chargement...'; + try { + await loadPlayerUiIntoUi(); + showResponse('success', 'Réglages du lecteur rechargés.'); + } finally { + playerUiReloadBtn.disabled = false; + playerUiReloadBtn.textContent = old; + } + }); + } + + if (playerUiSaveBtn) { + playerUiSaveBtn.addEventListener('click', async () => { + playerUiSaveBtn.disabled = true; + const old = playerUiSaveBtn.textContent; + playerUiSaveBtn.textContent = 'Enregistrement...'; + try { + const payload = getPlayerUiFormValue(); + const saved = await savePlayerUi(payload); + setPlayerUiForm(saved); + showResponse('success', 'Réglages du lecteur enregistrés.'); + } catch (err) { + showResponse('danger', err.message || 'Erreur réseau'); + } finally { + playerUiSaveBtn.disabled = false; + playerUiSaveBtn.textContent = old; + } + }); + } if (saveActiveYearBtn && activeYearInput) { saveActiveYearBtn.addEventListener('click', async () => { diff --git a/public/js/app_player.js b/public/js/app_player.js index de77bbc..99d9381 100644 --- a/public/js/app_player.js +++ b/public/js/app_player.js @@ -139,6 +139,20 @@ async function fetchActiveOscarYear() { } } +async function fetchPlayerAdminStatusUi() { + try { + const res = await fetch('/api/settings/player-admin-status-ui', { method: 'GET' }); + if (!res.ok) return { showSource: true, showProgress: true }; + const data = await res.json().catch(() => ({})); + return { + showSource: data?.showSource === undefined ? true : !!data.showSource, + showProgress: data?.showProgress === undefined ? true : !!data.showProgress, + }; + } catch (_) { + return { showSource: true, showProgress: true }; + } +} + async function fetchPlaybackProgress(movieId, token) { try { const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, { @@ -868,8 +882,9 @@ window.onload = async function () { } pageLoader.setProgress(14); - // Load the active Oscar year in parallel with the movie fetch. + // Load the active Oscar year (and player UI flags) in parallel with the movie fetch. const activeYearPromise = fetchActiveOscarYear(); + const playerUiPromise = fetchPlayerAdminStatusUi(); let decoded = null; try { @@ -889,8 +904,6 @@ window.onload = async function () { if (decoded.admin) { const adminLink = document.getElementById('admin-control-link'); if (adminLink) adminLink.classList.remove('d-none'); - const adminStatus = document.getElementById('player-admin-status'); - if (adminStatus) adminStatus.classList.remove('d-none'); } const logoffBtn = document.getElementById('log-off'); @@ -927,10 +940,20 @@ window.onload = async function () { return; } - const activeYear = await activeYearPromise; + const [activeYear, playerUi] = await Promise.all([activeYearPromise, playerUiPromise]); setHeader(movie, { activeYear }); pageLoader.setProgress(58); + // Admin-only status block (Source + Progress) can be globally disabled via settings. + if (decoded?.admin) { + const enabled = !!playerUi?.showSource && !!playerUi?.showProgress; + const adminStatus = document.getElementById('player-admin-status'); + if (adminStatus) { + if (enabled) adminStatus.classList.remove('d-none'); + else adminStatus.classList.add('d-none'); + } + } + const resolved = resolveMovieSource(movie); if (!resolved?.src) { setSourceLabel('—'); diff --git a/routes/settings.js b/routes/settings.js index 4d39c99..386e1ad 100644 --- a/routes/settings.js +++ b/routes/settings.js @@ -13,6 +13,7 @@ const COMPLETION_MODAL_KEY = "completion_modal_content"; const WINNERS_BY_YEAR_KEY = "winners_by_year"; const OSCAR_DATES_BY_YEAR_KEY = "oscar_date_by_year"; const APP_VERSION_KEY = "app_version_control"; +const PLAYER_ADMIN_STATUS_UI_KEY = "player_admin_status_ui"; const crypto = require("crypto"); @@ -23,6 +24,12 @@ const DEFAULT_COMPLETION_MODAL = Object.freeze({ bodyHtml: "", }); +const DEFAULT_PLAYER_ADMIN_STATUS_UI = Object.freeze({ + // Controls the admin-only debug/status block in player.html + showSource: true, + showProgress: true, +}); + function parseOscarYear(raw) { if (raw === undefined || raw === null || raw === "") return null; const n = Number(raw); @@ -146,6 +153,13 @@ function normalizeCompletionModal(rawValue) { }; } +function normalizePlayerAdminStatusUi(rawValue) { + const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {}; + const showSource = v.showSource === undefined ? DEFAULT_PLAYER_ADMIN_STATUS_UI.showSource : !!v.showSource; + const showProgress = v.showProgress === undefined ? DEFAULT_PLAYER_ADMIN_STATUS_UI.showProgress : !!v.showProgress; + return { showSource, showProgress }; +} + function createId() { try { // Node 16+ / 18+: randomUUID @@ -431,6 +445,45 @@ router.get("/app-version", async (req, res) => { } }); +// Public: get player admin status UI flags (source/progress visibility) +router.get("/player-admin-status-ui", async (req, res) => { + try { + const existing = await Setting.findOne({ key: PLAYER_ADMIN_STATUS_UI_KEY }).select("value"); + const normalized = normalizePlayerAdminStatusUi(existing?.value); + return res.status(200).json(normalized); + } catch (_) { + return res.status(200).json(DEFAULT_PLAYER_ADMIN_STATUS_UI); + } +}); + +// Admin-only: update player admin status UI flags +// Body: { showSource?: boolean, showProgress?: boolean } +router.put("/player-admin-status-ui", 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" }); + } + + const normalized = normalizePlayerAdminStatusUi(req.body); + const updated = await Setting.findOneAndUpdate( + { key: PLAYER_ADMIN_STATUS_UI_KEY }, + { $set: { value: normalized } }, + { upsert: true, new: true } + ).select("value"); + + return res.status(200).json(normalizePlayerAdminStatusUi(updated?.value)); + } 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 }); + } +}); + // Admin-only: create a new version entry (optionally activates if none or if activate=true) // Body: { version: "1.2.3", message?: "..." } router.post("/app-version", async (req, res) => { From eeaf2592ea3c0572c03e0b0d136b4888199a314b Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 22 Dec 2025 16:23:28 -0500 Subject: [PATCH 2/3] Docs: Add more .env options and python server details (#56) Co-authored-by: Cursor Agent --- README.md | 19 +++++++++++++++++++ python_video_server/README.md | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/README.md b/README.md index bf10323..151b7a3 100644 --- a/README.md +++ b/README.md @@ -53,3 +53,22 @@ This app uses **MongoDB** via Mongoose. Provide a connection string in `MONGO_UR - Atlas: `mongodb+srv://USER:PASS@cluster0.XXXX.mongodb.net/?retryWrites=true&w=majority` - Local: `mongodb://127.0.0.1:27017/` - Collections are created/managed automatically from the models in `models/` (e.g. `User`, `Movie`, `Setting`, `PlaybackProgress`). + +## More `.env` options +The app also reads these environment variables (all optional): + +```bash +PORT=5000 # server port (default: 5000) +JWT_EXPIRES_IN=8h # JWT expiry (default: 8h) +VIDEO_FILES_DIR=/abs/path/to/video # where server video files live (default: ./public/video) +VIDEO_SESSION_MAX_AGE_SECONDS=28800 # video_auth cookie max-age (default: 8h) +MOVIES_CACHE_TTL_MS=30000 # movies route cache TTL (default: 30000ms) +``` + +## Python video server (optional) +There is also a standalone Python server in `python_video_server/` that mirrors `/api/video/:id` (Range streaming + JWT + Mongo). +See `python_video_server/README.md` for full instructions. In addition to `MONGO_URI` and `JWT_SECRET`, you may need: + +```bash +export MONGO_DB_NAME="oscars-pool" # if your MONGO_URI doesn't include a / path +``` diff --git a/python_video_server/README.md b/python_video_server/README.md index 54d0eba..cf6151f 100644 --- a/python_video_server/README.md +++ b/python_video_server/README.md @@ -11,12 +11,31 @@ This is a small standalone server that mirrors the app’s `/api/video/:id` beha ```bash python -m pip install -r python_video_server/requirements.txt export MONGO_URI="..." +export MONGO_DB_NAME="..." # optional; required if MONGO_URI doesn't include / export JWT_SECRET="..." export VIDEO_FILES_DIR="/absolute/path/to/public/video" # optional (default: ./public/video) export VIDEO_SESSION_MAX_AGE_SECONDS="28800" # optional (default: 8h) uvicorn python_video_server.server:app --host 0.0.0.0 --port 8000 ``` +## Extra environment variables (optional) + +```bash +# CORS (needed when the main app is on a different origin) +export CORS_ALLOW_ORIGINS="https://your-app.example.com" # default: "*" + +# Cookie behavior for /api/video/session +export VIDEO_COOKIE_SAMESITE="Lax" # Lax|Strict|None (default: Lax) + +# Mongo TLS / connectivity troubleshooting (Atlas, weird hosts, etc.) +export MONGO_TLS_CA_FILE="/path/to/ca.pem" # prefer a valid CA bundle +export MONGO_TLS_FORCE_TLS12="1" # force TLS 1.2 if TLS 1.3 breaks +export MONGO_TLS_INSECURE="1" # NOT recommended; debug only +export MONGO_SERVER_SELECTION_TIMEOUT_MS="8000" +export MONGO_CONNECT_TIMEOUT_MS="8000" +export MONGO_SOCKET_TIMEOUT_MS="20000" +``` + ## HTTPS (Let’s Encrypt, Python-only) This server can be served over HTTPS by running `uvicorn` with `--ssl-certfile` and `--ssl-keyfile`. From 647a0b6a4776e38a1a4b84919f58207846e295a2 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 22 Dec 2025 16:32:17 -0500 Subject: [PATCH 3/3] Readme file updates (#57) * Refactor READMEs and clarify optional features Co-authored-by: mathieumoran1 * Add video auth details and Let's Encrypt helper Co-authored-by: mathieumoran1 --------- Co-authored-by: Cursor Agent --- README.md | 12 ++- python_video_server/README.md | 143 +++++++++++++++++++++++----------- 2 files changed, 105 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 151b7a3..821ff58 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ Little nodejs + mongodb app that I hack together with ChatGPT and Cursor to keep ## Features - User accounts: register, login, JWT auth -- Simple registration verification via `DOG_NAMES` +- Optional registration "verification" via `DOG_NAMES` (if unset, verification is skipped) - Mark nominees as watched (per user), with watched date - Browse movies by Oscar year + API for available years - User stats: watched count/ratio and watched titles -- Movie details pulled from OMDb (title/plot/rating/poster) via IMDb ID +- Movie details pulled from OMDb (title/plot/rating/poster) via IMDb ID (optional) - Admin movie management: add, edit (optionally refresh from OMDb), delete -- Built-in player page with multiple source options (VOD link, direct video file, embed) +- Built-in player page with multiple source options (VOD link, direct video URL, embed, or server file) - Resume playback: per-user playback progress saving (time/duration) - Global settings: active Oscar year, “completion” modal content for checklist - Password flows: admin temp password reset + forced change on next login @@ -35,7 +35,7 @@ MONGO_URI=mongodb://127.0.0.1:27017/oscars-pool # MongoDB connection string JWT_SECRET=replace-with-a-random-string # JWT signing secret (auth) # optional -DOG_NAMES=woof,WOOF,Woof # simple registration "verification" +DOG_NAMES=woof,WOOF,Woof # registration verification answers (comma-separated) OMDB_API=your_omdb_key # OMDb API key (movie info) ``` @@ -65,6 +65,10 @@ VIDEO_SESSION_MAX_AGE_SECONDS=28800 # video_auth cookie max-age (default: 8h) MOVIES_CACHE_TTL_MS=30000 # movies route cache TTL (default: 30000ms) ``` +Notes: +- The server serves the UI from `public/` (open `http://localhost:5000` by default). +- If you use `video_file` in a movie record, it must be a **relative path** under `VIDEO_FILES_DIR` (the backend blocks traversal/absolute paths). + ## Python video server (optional) There is also a standalone Python server in `python_video_server/` that mirrors `/api/video/:id` (Range streaming + JWT + Mongo). See `python_video_server/README.md` for full instructions. In addition to `MONGO_URI` and `JWT_SECRET`, you may need: diff --git a/python_video_server/README.md b/python_video_server/README.md index cf6151f..c188ba7 100644 --- a/python_video_server/README.md +++ b/python_video_server/README.md @@ -27,7 +27,7 @@ export CORS_ALLOW_ORIGINS="https://your-app.example.com" # default: "*" # Cookie behavior for /api/video/session export VIDEO_COOKIE_SAMESITE="Lax" # Lax|Strict|None (default: Lax) -# Mongo TLS / connectivity troubleshooting (Atlas, weird hosts, etc.) +# Mongo TLS / connectivity troubleshooting (Atlas, odd hosts, etc.) export MONGO_TLS_CA_FILE="/path/to/ca.pem" # prefer a valid CA bundle export MONGO_TLS_FORCE_TLS12="1" # force TLS 1.2 if TLS 1.3 breaks export MONGO_TLS_INSECURE="1" # NOT recommended; debug only @@ -36,63 +36,104 @@ export MONGO_CONNECT_TIMEOUT_MS="8000" export MONGO_SOCKET_TIMEOUT_MS="20000" ``` -## HTTPS (Let’s Encrypt, Python-only) +## Video auth / usage -This server can be served over HTTPS by running `uvicorn` with `--ssl-certfile` and `--ssl-keyfile`. -To obtain/renew a trusted cert from Let’s Encrypt **without installing system certbot**, this repo includes a small wrapper that uses the **`certbot` Python package**. +`GET /api/video/{movieId}` accepts auth in three ways (in priority order): -### 1) Issue a certificate (HTTP-01 standalone) +1) `video_auth` cookie (best for browser `