Merge pull request #54 from quoije/2026-video.backend

add video backend server, fix styling and more
This commit was merged in pull request #54.
This commit is contained in:
Mathieu Moran
2025-12-22 05:42:04 -05:00
committed by GitHub
43 changed files with 9946 additions and 375 deletions
+4 -1
View File
@@ -136,4 +136,7 @@ dist
.pnp.*
# ayylmao
ayylmao.js
ayylmao.js
# Python video server - generated TLS material (certbot state)
python_video_server/certs/
+49 -12
View File
@@ -1,18 +1,55 @@
## Oscars Pool 2025
Little nodejs + mongodb app that I hack together with ChatGPT to keep track of watched movies for an incoming Oscars Pool with some friends. App is not secure, using old html standard and is not the most responsive but does the job.
## Oscars Pool 2026
Little nodejs + mongodb app that I hack together with ChatGPT and Cursor to keep track of watched movies for an incoming Oscars Pool with some friends.
Would probably need to revamp everything, but didn't want to spend more time on it.
![Oscars Pool 2026](https://github.com/quoije/oscars-pool/blob/2026-video.backend/img/preview.png)
![Oscars Pool 2025](https://github.com/quoije/oscar-pool/blob/prod/img/preview.png)
## Features
- User accounts: register, login, JWT auth
- Simple registration verification via `DOG_NAMES`
- 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
- Admin movie management: add, edit (optionally refresh from OMDb), delete
- Built-in player page with multiple source options (VOD link, direct video file, embed)
- 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
- App version display (footer) managed from Admin panel (no GitHub dependency)
## Setup
Config those in your .env file:
Create a `.env` file at the project root (same folder as `package.json`).
**DOG_NAMES=**`woof, WOOF, Woof` &nbsp;&nbsp;&nbsp;&nbsp;(for "verification")<br>
**GITHUB_OWNER=**`quoije` &nbsp;&nbsp;&nbsp;&nbsp;(for version history)<br>
**GITHUB_REPO=**`oscars-pool` &nbsp;&nbsp;&nbsp;&nbsp;(for version history)<br>
**JWT_SECRET=**`RANDOM STRING` &nbsp;&nbsp;&nbsp;&nbsp;(for authentication token)<br>
**MONGO_URI=**`mongodb+srv://USER:[email protected]/?retryWrites=true&w=majority&appName=Cluster69`<br>
**OMDB_API=**`1234567` &nbsp;&nbsp;&nbsp;&nbsp;(for movie info)
Minimal steps:
run with **node .\index.js**
```bash
npm install
node index.js
```
Add this to your `.env` (**required**: `MONGO_URI`, `JWT_SECRET`):
```bash
# required
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"
OMDB_API=your_omdb_key # OMDb API key (movie info)
```
Generate a good `JWT_SECRET`:
```bash
openssl rand -hex 32
```
## Database (MongoDB)
This app uses **MongoDB** via Mongoose. Provide a connection string in `MONGO_URI`.
- Use **MongoDB Atlas** (recommended) or a local MongoDB instance.
- `MONGO_URI` examples:
- Atlas: `mongodb+srv://USER:[email protected]/<dbName>?retryWrites=true&w=majority`
- Local: `mongodb://127.0.0.1:27017/<dbName>`
- Collections are created/managed automatically from the models in `models/` (e.g. `User`, `Movie`, `Setting`, `PlaybackProgress`).
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 868 KiB

After

Width:  |  Height:  |  Size: 689 KiB

+8
View File
@@ -2,12 +2,14 @@ const express = require("express");
const mongoose = require("mongoose");
const dotenv = require("dotenv");
const cors = require("cors");
const compression = require("compression");
dotenv.config();
const app = express();
// Middleware
app.use(cors());
app.use(compression());
app.use(express.json());
// Connect to MongoDB
@@ -20,11 +22,17 @@ mongoose
const userRoutes = require("./routes/users");
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);
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;
+21 -2
View File
@@ -6,9 +6,28 @@ const movieSchema = new mongoose.Schema({
description: { type: String, required: false },
rating: { type: String, required: false },
poster: { type: String, required: false },
// 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
// 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 });
// Common read patterns:
// - list movies for a year, sorted by title
// - check "last update" per year (sort by updatedAt)
movieSchema.index({ year: 1, title: 1 });
movieSchema.index({ year: 1, updatedAt: -1 });
movieSchema.index({ year: 1, imdb_id: 1 });
module.exports = mongoose.model("Movie", movieSchema);
+20
View File
@@ -0,0 +1,20 @@
const mongoose = require("mongoose");
const playbackProgressSchema = new mongoose.Schema(
{
userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true },
movieId: { type: mongoose.Schema.Types.ObjectId, ref: "Movie", required: true, index: true },
// seconds
time: { type: Number, default: 0 },
// seconds (optional, best-effort from the client)
duration: { type: Number, default: null },
updatedAt: { type: Date, default: Date.now },
},
{ versionKey: false }
);
// One progress row per user + movie
playbackProgressSchema.index({ userId: 1, movieId: 1 }, { unique: true });
module.exports = mongoose.model("PlaybackProgress", playbackProgressSchema);
+14
View File
@@ -0,0 +1,14 @@
const mongoose = require("mongoose");
// Simple key/value store for global site settings.
// We currently use it for the "active Oscar year" that drives filtering.
const settingSchema = new mongoose.Schema(
{
key: { type: String, required: true, unique: true, index: true },
value: { type: mongoose.Schema.Types.Mixed, required: true },
},
{ timestamps: true }
);
module.exports = mongoose.model("Setting", settingSchema);
+4
View File
@@ -5,6 +5,10 @@ const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
password: { type: String, required: true },
role: { type: Number, default: 0 },
// If true, user must change password on next login (e.g. after admin reset)
mustChangePassword: { type: Boolean, default: false },
tempPasswordIssuedAt: { type: Date, default: null },
tempPasswordExpiresAt: { type: Date, default: null },
watchedMovies: [{
imdb_id: { type: String, required: false },
watchedDate: { type: Date, default: Date.now } // Store the watched date
+262 -36
View File
@@ -12,12 +12,14 @@
"axios": "^1.7.9",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.3",
"compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.22.1",
"jsonwebtoken": "^9.0.2",
"moment-timezone": "^0.5.47",
"mongoose": "^8.9.5"
"mongoose": "^8.9.5",
"multer": "^2.0.2"
},
"devDependencies": {
"nodemon": "^3.1.9"
@@ -74,6 +76,12 @@
"node": ">= 8"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -87,13 +95,13 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.7.9",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
@@ -148,9 +156,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -186,6 +194,23 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -261,6 +286,45 @@
"node": ">= 0.8"
}
},
"node_modules/compressible": {
"version": "2.0.18",
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
"license": "MIT",
"dependencies": {
"mime-db": ">= 1.43.0 < 2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/compression": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"compressible": "~2.0.18",
"debug": "2.6.9",
"negotiator": "~0.6.4",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1",
"vary": "~1.1.2"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/compression/node_modules/negotiator": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -268,6 +332,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -434,6 +513,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@@ -450,39 +544,39 @@
}
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "1.20.3",
"content-disposition": "0.5.4",
"body-parser": "~1.20.3",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "0.7.1",
"cookie-signature": "1.0.6",
"cookie": "~0.7.1",
"cookie-signature": "~1.0.6",
"debug": "2.6.9",
"depd": "2.0.0",
"encodeurl": "~2.0.0",
"escape-html": "~1.0.3",
"etag": "~1.8.1",
"finalhandler": "1.3.1",
"fresh": "0.5.2",
"http-errors": "2.0.0",
"finalhandler": "~1.3.1",
"fresh": "~0.5.2",
"http-errors": "~2.0.0",
"merge-descriptors": "1.0.3",
"methods": "~1.1.2",
"on-finished": "2.4.1",
"on-finished": "~2.4.1",
"parseurl": "~1.3.3",
"path-to-regexp": "0.1.12",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "6.13.0",
"qs": "~6.14.0",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "0.19.0",
"serve-static": "1.16.2",
"send": "~0.19.0",
"serve-static": "~1.16.2",
"setprototypeof": "1.2.0",
"statuses": "2.0.1",
"statuses": "~2.0.1",
"type-is": "~1.6.18",
"utils-merge": "1.0.1",
"vary": "~1.1.2"
@@ -495,6 +589,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -547,13 +656,15 @@
}
},
"node_modules/form-data": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz",
"integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
@@ -686,6 +797,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -823,23 +949,23 @@
"license": "MIT"
},
"node_modules/jwa": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
"license": "MIT",
"dependencies": {
"jwa": "^1.4.1",
"jwa": "^1.4.2",
"safe-buffer": "^5.0.1"
}
},
@@ -982,6 +1108,27 @@
"node": "*"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
@@ -1137,6 +1284,24 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
},
"engines": {
"node": ">= 10.16.0"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1243,6 +1408,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1345,6 +1519,20 @@
"node": ">= 0.8"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@@ -1565,6 +1753,23 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -1635,6 +1840,12 @@
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -1651,6 +1862,12 @@
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -1690,6 +1907,15 @@
"engines": {
"node": ">=18"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
}
}
}
+4 -2
View File
@@ -13,12 +13,14 @@
"axios": "^1.7.9",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.3",
"compression": "^1.8.1",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.22.1",
"jsonwebtoken": "^9.0.2",
"moment-timezone": "^0.5.47",
"mongoose": "^8.9.5"
"mongoose": "^8.9.5",
"multer": "^2.0.2"
},
"devDependencies": {
"nodemon": "^3.1.9"
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars - Changer le mot de passe</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
</head>
<body class="bg-light">
<div class="container d-flex justify-content-center align-items-center vh-100">
<div class="card p-4" style="max-width: 420px; width: 100%;">
<div class="text-center mb-3">
<img src="img/logo.png" style="width: 80%;">
</div>
<div class="alert alert-warning" role="alert">
Votre compte utilise un <strong>mot de passe temporaire</strong>. Veuillez définir un nouveau mot de passe pour continuer.
</div>
<div id="change-password-error" class="alert alert-danger d-none" role="alert"></div>
<form id="change-password-form">
<div class="mb-3">
<label for="new-password" class="form-label">Nouveau mot de passe</label>
<input type="password" class="form-control" id="new-password" minlength="8" required>
<div class="form-text">Minimum 8 caractères.</div>
</div>
<div class="mb-3">
<label for="confirm-password" class="form-label">Confirmer le mot de passe</label>
<input type="password" class="form-control" id="confirm-password" minlength="8" required>
</div>
<button type="submit" class="btn btn-primary w-100">Enregistrer</button>
</form>
<button type="button" id="log-off" class="btn btn-outline-danger w-100 mt-3">Déconnexion</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script src="./js/app_change-password.js"></script>
</body>
</html>
+55 -27
View File
@@ -3,9 +3,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars 2025 - Checklist</title>
<title>Pool Oscars - Checklist</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
background-color: black;
@@ -42,20 +44,25 @@
.navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
}
/* New styles for table */
.table-hover tbody tr:hover {
background-color: #f1f1f1;
cursor: pointer;
}
</style>
</head>
<body class="bg-light">
<body class="bg-light page-loading">
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">Chargement…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
</div>
</div>
<!-- Black banner with buttons and user info -->
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container">
<a class="navbar-brand" style="margin-left: -7px;">
<img src="img/logo_w.png" style="max-width: 220px; height: 70px; object-fit: contain;">
<img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
@@ -65,6 +72,7 @@
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
</div>
<!-- Move user info inside collapsible navbar and align it properly -->
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
@@ -77,25 +85,42 @@
<div class="container mt-5">
<h2>Checklist</h2>
<div id="watched-ratio" class="ratio"></div>
<h3 class="fs-6" style="margin-bottom: -1px;">Temps avant les Oscars (2 mars 2025) - <span id="time-left"></span></h3>
<h3 class="fs-6"><span id="movies-per-day"></span></h3>
<div class="progress my-3">
<div id="progress-bar" class="progress-bar" role="progressbar" style="width: 0%; background-color: red;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"></div>
<!-- Summary card (match Stats page spacing/structure) -->
<div class="card shadow-sm mt-3">
<div class="card-body">
<div class="d-flex flex-column gap-1">
<div id="watched-ratio" class="fw-semibold">—</div>
<div class="text-muted small">
Temps avant les Oscars (<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) — <span id="time-left">—</span>
</div>
<div class="text-muted small"><span id="movies-per-day"></span></div>
</div>
<div class="progress mt-3" style="height: 10px;">
<div id="progress-bar" class="progress-bar" role="progressbar" style="width: 0%; background-color: red;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" aria-busy="true"></div>
</div>
</div>
</div>
<!-- Table for movies -->
<table class="table table-hover movie-table">
<thead>
<tr>
<th scope="col">Vu</th>
<th scope="col">Titre</th>
<th scope="col">Date</th>
</tr>
</thead>
<tbody id="movie-table-body">
<!-- Movie rows will be inserted here -->
</tbody>
</table>
<div class="table-responsive mt-3">
<table class="table table-striped table-hover align-middle movie-table mb-0">
<thead>
<tr>
<th scope="col">Vu</th>
<th scope="col">Titre</th>
<th scope="col">Date</th>
</tr>
</thead>
<tbody id="movie-table-body">
<tr>
<td colspan="3" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
</tr>
</tbody>
</table>
</div>
<br><br>
</div>
@@ -111,8 +136,11 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<video id="rewardVideo" width="75%" controls>
<source src="video/reward.mp4" type="video/mp4">
<div id="completion-modal-custom" class="text-start d-none"></div>
<div id="completion-modal-text" class="text-start mb-3" style="white-space: pre-wrap;"></div>
<video id="rewardVideo" width="75%" controls class="d-none">
<source id="rewardVideoSource" src="video/reward.mp4" type="video/mp4">
Votre navigateur ne supporte pas la vidéo.
</video>
</div>
+836
View File
@@ -0,0 +1,836 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars - Admin</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
background-color: black;
color: white;
}
.navbar-custom .navbar-brand,
.navbar-custom .nav-link {
color: white;
}
.navbar-custom .nav-link:hover {
color: #ddd;
}
.navbar-toggler {
border-color: white;
}
.navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
}
</style>
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container">
<a class="navbar-brand" style="margin-left: -7px;">
<img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav">
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a href="control.html" class="nav-link btn btn-warning me-2">Admin</a>
</div>
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
<span id="user-name" class="navbar-text me-3"></span>
<button id="log-off" class="btn btn-danger">Déconnexion</button>
</div>
</div>
</div>
</nav>
<div class="container mt-5">
<div class="mb-3">
<h2 class="mb-0">Admin</h2>
<div class="text-muted">Paramètres, utilisateurs et gestion des films.</div>
</div>
<div id="response" class="alert d-none" role="alert"></div>
<ul class="nav nav-tabs" id="adminTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="admin-settings-tab" data-bs-toggle="tab" data-bs-target="#admin-settings" type="button" role="tab" aria-controls="admin-settings" aria-selected="true">
Réglages
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-movies-tab" data-bs-toggle="tab" data-bs-target="#admin-movies" type="button" role="tab" aria-controls="admin-movies" aria-selected="false">
Films
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-users-tab" data-bs-toggle="tab" data-bs-target="#admin-users" type="button" role="tab" aria-controls="admin-users" aria-selected="false">
Utilisateurs
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-modal-100-tab" data-bs-toggle="tab" data-bs-target="#admin-modal-100" type="button" role="tab" aria-controls="admin-modal-100" aria-selected="false">
Modal 100%
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-winners-tab" data-bs-toggle="tab" data-bs-target="#admin-winners" type="button" role="tab" aria-controls="admin-winners" aria-selected="false">
Gagnants
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-backup-tab" data-bs-toggle="tab" data-bs-target="#admin-backup" type="button" role="tab" aria-controls="admin-backup" aria-selected="false">
Backup / Restore
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="admin-version-tab" data-bs-toggle="tab" data-bs-target="#admin-version" type="button" role="tab" aria-controls="admin-version" aria-selected="false">
Version
</button>
</li>
</ul>
<div class="tab-content pt-3">
<div class="tab-pane fade show active" id="admin-settings" role="tabpanel" aria-labelledby="admin-settings-tab">
<div class="row g-3">
<div class="col-12 col-xl-8">
<div class="card p-3">
<h4 class="mb-1">Année active</h4>
<p class="text-muted mb-3">
Cette année pilote tout le site (films affichés, checklist et statistiques).
</p>
<div class="mb-3">
<label for="active_oscar_year" class="form-label">Année active</label>
<select class="form-select" id="active_oscar_year"></select>
<div class="form-text">La liste est basée sur les années présentes dans les films.</div>
</div>
<div class="d-flex justify-content-end">
<button type="button" id="save-active-year" class="btn btn-warning">Appliquer l’année</button>
</div>
</div>
</div>
<div class="col-12 col-xl-8">
<div class="card p-3">
<h4 class="mb-1">Date des Oscars (par année)</h4>
<p class="text-muted mb-3">
Configure la date exacte de la cérémonie pour le <strong>compte à rebours</strong> sur Films &amp; Checklist.
Si non défini, la date par défaut reste <code>15 mars</code> (comportement actuel).
</p>
<div class="row g-3">
<div class="col-md-4">
<label for="oscar_date_year" class="form-label">Année</label>
<select class="form-select" id="oscar_date_year"></select>
<div class="form-text">Basé sur les années présentes dans les films.</div>
</div>
<div class="col-md-4">
<label for="oscar_date_value" class="form-label">Date</label>
<input type="date" class="form-control" id="oscar_date_value">
<div class="form-text">Format: YYYY-MM-DD.</div>
</div>
<div class="col-md-4 d-flex align-items-end justify-content-end gap-2">
<button type="button" id="clear-oscar-date" class="btn btn-outline-danger">Effacer</button>
<button type="button" id="save-oscar-date" class="btn btn-warning">Enregistrer</button>
</div>
</div>
<div class="mt-3">
<div class="text-muted small mb-1">Dates configurées</div>
<div id="oscar-date-current" class="border rounded p-2 bg-light">—</div>
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="admin-version" role="tabpanel" aria-labelledby="admin-version-tab">
<div class="row g-3">
<div class="col-12 col-xxl-6">
<div class="card p-3">
<h4 class="mb-1">Version active (footer)</h4>
<div class="text-muted mb-3">
Remplace complètement le checker GitHub. La version affichée dans le footer vient de la DB et peut être changée ici.
</div>
<div id="app_version_alert" class="alert d-none" role="alert"></div>
<div class="mb-3">
<label for="app_version_active_select" class="form-label">Choisir la version active</label>
<select id="app_version_active_select" class="form-select"></select>
<div class="form-text">Cette sélection impacte ce que les utilisateurs voient dans le footer.</div>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2">
<button type="button" id="app_version_reload" class="btn btn-outline-primary">Recharger</button>
<button type="button" id="app_version_set_active" class="btn btn-warning">Définir active</button>
</div>
<div class="mt-3">
<div class="text-muted small mb-1">Aperçu (format footer)</div>
<div id="app_version_preview" class="border rounded p-2 bg-light">—</div>
</div>
</div>
</div>
<div class="col-12 col-xxl-6">
<div class="card p-3">
<h4 class="mb-1">Créer une nouvelle version</h4>
<div class="text-muted mb-3">
Ajoute une entrée dans l’historique, puis tu peux la rendre active.
</div>
<div class="row g-3">
<div class="col-md-4">
<label for="app_version_new_version" class="form-label">Version</label>
<input id="app_version_new_version" type="text" class="form-control" placeholder="ex: 1.0.1" maxlength="80">
</div>
<div class="col-md-8">
<label for="app_version_new_message" class="form-label">Message (optionnel)</label>
<input id="app_version_new_message" type="text" class="form-control" placeholder="ex: Hotfix login" maxlength="500">
</div>
<div class="col-12 d-flex flex-wrap justify-content-end gap-2">
<button type="button" id="app_version_create" class="btn btn-primary">Créer</button>
<button type="button" id="app_version_create_and_activate" class="btn btn-warning">Créer &amp; activer</button>
</div>
</div>
<div class="mt-3">
<div class="text-muted small mb-2">Historique</div>
<div id="app_version_list" class="border rounded bg-white app-version-list">—</div>
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="admin-modal-100" role="tabpanel" aria-labelledby="admin-modal-100-tab">
<div class="row g-3">
<div class="col-12 col-xxl-6">
<div class="card p-3">
<h4 class="mb-1">Contenu “Modal 100%”</h4>
<p class="text-muted mb-3">
Contenu affiché quand un utilisateur a vu 100% des films dans la checklist.
</p>
<div class="mb-3">
<label for="completion_modal_title" class="form-label">Titre</label>
<input type="text" class="form-control" id="completion_modal_title" maxlength="200" placeholder="Félicitations! ...">
</div>
<div class="mb-3">
<label for="completion_modal_body_text" class="form-label">Texte (optionnel)</label>
<textarea class="form-control" id="completion_modal_body_text" rows="4" placeholder="Texte affiché dans le modal (retours à la ligne conservés)"></textarea>
<div class="form-text">Affiché en texte brut (safe). Laisse vide si tu n’en veux pas.</div>
</div>
<div class="mb-3">
<label for="completion_modal_body_html" class="form-label">HTML (optionnel)</label>
<textarea class="form-control font-monospace" id="completion_modal_body_html" rows="6" placeholder="<p>…</p>"></textarea>
<div class="form-text">
Affiché comme HTML (le serveur retire les <code>&lt;script&gt;</code> et les handlers <code>on*</code>).
</div>
</div>
<div class="mb-3">
<label for="completion_modal_video_src" class="form-label">Video src (optionnel)</label>
<input type="text" class="form-control" id="completion_modal_video_src" maxlength="2048" placeholder="video/reward.mp4">
<div class="form-text">Chemin relatif (ex: <code>video/reward.mp4</code>) ou URL complète. Vide = pas de vidéo.</div>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2">
<button type="button" id="completion_modal_reload" class="btn btn-outline-secondary">Recharger</button>
<button type="button" id="completion_modal_save" class="btn btn-warning">Enregistrer</button>
</div>
</div>
</div>
<div class="col-12 col-xxl-6">
<div class="card p-3">
<h4 class="mb-1">Aperçu</h4>
<div class="text-muted mb-3">Aperçu live (non sauvegardé tant que tu n’enregistres pas).</div>
<div class="border rounded p-3 bg-white">
<div class="d-flex align-items-center justify-content-between gap-2 mb-2">
<div class="fw-semibold" id="completion_modal_preview_title">—</div>
<span class="badge bg-secondary">Preview</span>
</div>
<div id="completion_modal_preview_custom" class="text-start d-none"></div>
<div id="completion_modal_preview_text" class="text-start" style="white-space: pre-wrap;"></div>
<div class="mt-3">
<video id="completion_modal_preview_video" width="75%" controls class="d-none">
<source id="completion_modal_preview_video_source" src="" type="video/mp4">
Votre navigateur ne supporte pas la vidéo.
</video>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="admin-users" role="tabpanel" aria-labelledby="admin-users-tab">
<div class="row g-3">
<div class="col-12">
<div class="card p-3">
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-2">
<div>
<h4 class="mb-1">Liste des utilisateurs</h4>
<div class="text-muted">
<span id="admin-users-count">—</span> utilisateur(s)
</div>
</div>
<div class="d-flex gap-2">
<button type="button" id="open-add-user-modal" class="btn btn-warning btn-sm">Ajouter un utilisateur</button>
<button type="button" id="refresh-users" class="btn btn-outline-primary btn-sm">Rafraîchir</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Nom</th>
<th>Email</th>
<th style="width: 1%;">Statut</th>
<th style="width: 1%;">Actions</th>
</tr>
</thead>
<tbody id="admin-users-body">
<tr><td colspan="4" class="text-muted">Charge la liste…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="admin-movies" role="tabpanel" aria-labelledby="admin-movies-tab">
<div class="row g-3">
<div class="col-12 col-xxl-4">
<div class="card p-3">
<h4 class="mb-1">Ajouter un film</h4>
<p class="text-muted mb-3">
Ajoute un film via l’API (<code>/api/movies/add</code>). Le serveur récupère automatiquement le titre/plot/affiche depuis OMDb avec l’<code>imdb_id</code>.
</p>
<form id="add-movie-form">
<div class="mb-3">
<label for="oscar_year" class="form-label">Oscar year</label>
<input type="number" class="form-control" id="oscar_year" placeholder="2026" min="1900" max="3000" required>
<div class="form-text">Permet de séparer les films et les stats par année.</div>
</div>
<div class="mb-3">
<label for="imdb_id" class="form-label">IMDB ID (ex: tt1234567)</label>
<input type="text" class="form-control" id="imdb_id" placeholder="tt..." required>
</div>
<div class="mb-3">
<label for="category" class="form-label">Catégorie</label>
<input type="text" class="form-control" id="category" placeholder="Best Picture, etc." required>
</div>
<div class="mb-3">
<label class="form-label">Lecteur (source)</label>
<div class="row g-2">
<div class="col-12 col-lg-4">
<label for="player_mode" class="form-label">Mode</label>
<select id="player_mode" class="form-select">
<option value="auto" selected>Auto</option>
<option value="video">Video</option>
<option value="embed">Embed</option>
</select>
<div class="form-text">Auto = video_src → embed_src → vod_link.</div>
</div>
<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/&lt;movieId&gt;</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>
<textarea class="form-control font-monospace" id="embed_src" rows="3" placeholder="https://www.youtube.com/watch?v=... OR <iframe src='...'></iframe>"></textarea>
</div>
<div class="col-12">
<label for="vod_link" class="form-label">Legacy VOD link (optional)</label>
<input type="text" class="form-control" id="vod_link" placeholder="https://...">
<div class="form-text">Backward-compat. Prefer video_src / embed_src above.</div>
</div>
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Ajouter</button>
<button type="button" id="reset-form" class="btn btn-outline-secondary">Reset</button>
</div>
</form>
</div>
</div>
<div class="col-12 col-xxl-8">
<div class="card p-3">
<div class="d-flex flex-wrap align-items-end justify-content-between gap-2 mb-2">
<div>
<h4 class="mb-1">Gérer les films</h4>
<div class="text-muted">Sélectionne une année, puis coche les films à supprimer.</div>
</div>
</div>
<div class="row g-2 align-items-end">
<div class="col-md-6 col-xl-4">
<label for="manage_year" class="form-label">Oscar year</label>
<select id="manage_year" class="form-select">
<option value="">Toutes</option>
</select>
</div>
<div class="col-md-6 col-xl-4 d-flex gap-2">
<button type="button" id="refresh-movies" class="btn btn-outline-primary w-100">Rafraîchir</button>
<button type="button" id="delete-selected" class="btn btn-danger w-100" disabled>Supprimer</button>
</div>
<div class="col-12 col-xl-4">
<div class="d-flex justify-content-between align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="select-all-movies">
<label class="form-check-label" for="select-all-movies">Tout sélectionner</label>
</div>
<small class="text-muted"><span id="selected-count">0</span> sélectionné(s)</small>
</div>
</div>
</div>
<div class="table-responsive mt-2">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th style="width: 1%;">Sel.</th>
<th style="width: 1%;">ID</th>
<th>Titre</th>
<th style="width: 12%;">Player</th>
<th style="width: 20%;">Catégorie</th>
<th style="width: 12%;">Année</th>
<th style="width: 1%;">Actions</th>
</tr>
</thead>
<tbody id="admin-movies-body">
<tr><td colspan="7" class="text-muted">Charge la liste…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="admin-backup" role="tabpanel" aria-labelledby="admin-backup-tab">
<div class="row g-3">
<div class="col-12 col-xxl-6">
<div class="card p-3">
<h4 class="mb-1">Backup</h4>
<p class="text-muted mb-3">
Crée une sauvegarde de la base MongoDB (format <code>.ndjson.gz</code>) et conserve-la sur le serveur.
</p>
<div class="d-flex flex-wrap gap-2">
<button type="button" id="db-backup-create" class="btn btn-warning">Créer une sauvegarde</button>
<button type="button" id="db-backup-refresh" class="btn btn-outline-primary">Rafraîchir la liste</button>
</div>
<div class="table-responsive mt-3">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Fichier</th>
<th class="text-nowrap" style="width: 1%;">Taille</th>
<th class="text-nowrap" style="width: 1%;">Modifié</th>
<th class="text-nowrap text-end" style="width: 1%;">Actions</th>
</tr>
</thead>
<tbody id="db-backup-list">
<tr><td colspan="4" class="text-muted">Ouvre l’onglet pour charger la liste…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-12 col-xxl-6">
<div class="card p-3">
<h4 class="mb-1 text-danger">Restore</h4>
<p class="text-muted mb-3">
Restaure une sauvegarde (fichier <code>.ndjson.gz</code>). Option recommandée: <strong>drop</strong> pour supprimer les collections avant import.
</p>
<div class="alert alert-warning">
La restauration peut <strong>écraser</strong> les données. Fais un backup juste avant si besoin.
</div>
<div class="mb-3">
<label for="db-restore-file" class="form-label">Fichier de backup</label>
<input class="form-control" type="file" id="db-restore-file" accept=".gz,.ndjson.gz,application/gzip">
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="db-restore-drop" checked>
<label class="form-check-label" for="db-restore-drop">
Drop les collections avant restore (recommandé)
</label>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2">
<button type="button" id="db-restore-run" class="btn btn-danger">Restaurer</button>
</div>
<div id="db-restore-result" class="small text-muted mt-3"></div>
</div>
</div>
</div>
</div>
<div class="tab-pane fade" id="admin-winners" role="tabpanel" aria-labelledby="admin-winners-tab">
<div class="row g-3">
<div class="col-12 col-xxl-8">
<div class="card p-3">
<h4 class="mb-1">Définir les gagnants par année (égalité)</h4>
<div class="text-muted mb-3">
Tu peux ajouter plusieurs gagnants pour la même année (ex: égalité au même nombre de points).
Choisis un utilisateur + des points (optionnel), puis clique <strong>Ajouter</strong>.
</div>
<div class="row g-3">
<div class="col-md-4">
<label for="winner_year" class="form-label">Année</label>
<select id="winner_year" class="form-select"></select>
<div class="form-text">Basé sur les années présentes dans les films.</div>
</div>
<div class="col-md-5">
<label for="winner_user" class="form-label">Utilisateur</label>
<select id="winner_user" class="form-select">
<option value="">—</option>
</select>
<div class="form-text">Pour retirer un gagnant, utilise la liste “Gagnants actuels” ci-dessous.</div>
</div>
<div class="col-md-3">
<label for="winner_points" class="form-label">Points (optionnel)</label>
<input id="winner_points" type="number" class="form-control" placeholder="ex: 42" min="0" step="1">
</div>
</div>
<div class="d-flex flex-wrap justify-content-end gap-2 mt-3">
<button type="button" id="winner_reload" class="btn btn-outline-primary">Recharger</button>
<button type="button" id="winner_clear" class="btn btn-outline-danger">Tout supprimer</button>
<button type="button" id="winner_save" class="btn btn-warning">Ajouter</button>
</div>
<div class="mt-3">
<div class="text-muted small mb-1">Gagnants actuels</div>
<div id="winner_current" class="border rounded p-2 bg-light">—</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Reset user password modal -->
<div class="modal fade" id="resetUserPasswordModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Reset mot de passe</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="text-muted mb-2">
Génère un mot de passe temporaire. L'utilisateur devra le changer à sa prochaine connexion.
</div>
<div class="mb-3">
<div class="fw-semibold" id="reset_user_label">—</div>
<div class="text-muted small" id="reset_user_email_label">—</div>
</div>
<div id="reset-user-modal-response" class="alert d-none" role="alert"></div>
<div id="reset-user-modal-result" class="border rounded p-2 bg-light d-none">
<div class="small text-muted mb-1">Mot de passe temporaire (affiché une seule fois)</div>
<div class="d-flex gap-2 align-items-center">
<code class="flex-grow-1" id="reset-user-temp-password">—</code>
<button type="button" class="btn btn-outline-secondary btn-sm" id="copy-temp-password">Copier</button>
</div>
<div class="small text-muted mt-2" id="reset-user-expires-at"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Fermer</button>
<button type="button" class="btn btn-danger" id="confirm-reset-user-password">Générer</button>
</div>
</div>
</div>
</div>
<!-- Delete user confirmation modal -->
<div class="modal fade" id="deleteUserModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title text-danger">Supprimer un utilisateur</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
Tu es sur le point de supprimer:
<div class="fw-semibold" id="delete_user_label">—</div>
<div class="text-muted small" id="delete_user_email_label">—</div>
</div>
<div class="alert alert-warning">
Cette action est <strong>irréversible</strong>.
</div>
<div class="mb-2">
Pour confirmer, tape l'email exact:
</div>
<input type="text" class="form-control" id="delete_user_verify_input" placeholder="[email protected]">
<div class="form-text">
Le bouton “Supprimer” s'activera seulement si l'email correspond.
</div>
<div id="delete-user-modal-response" class="alert d-none mt-3" role="alert"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="button" class="btn btn-danger" id="confirm-delete-user" disabled>Supprimer</button>
</div>
</div>
</div>
</div>
<!-- Add user modal -->
<div class="modal fade" id="addUserModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Ajouter un utilisateur</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="text-muted mb-2">
Crée un compte avec un <strong>mot de passe temporaire</strong> (affiché une seule fois). L’utilisateur devra le changer à sa prochaine connexion.
</div>
<div id="add-user-response" class="alert d-none" role="alert"></div>
<form id="add-user-form">
<div class="mb-3">
<label for="add_user_name" class="form-label">Nom</label>
<input type="text" class="form-control" id="add_user_name" placeholder="Prénom / pseudo" required>
</div>
<div class="mb-3">
<label for="add_user_email" class="form-label">Email</label>
<input type="email" class="form-control" id="add_user_email" placeholder="[email protected]" required>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="add_user_admin">
<label class="form-check-label" for="add_user_admin">Admin</label>
</div>
<div id="add-user-result" class="border rounded p-2 bg-light d-none">
<div class="small text-muted mb-1">Mot de passe temporaire (affiché une seule fois)</div>
<div class="d-flex gap-2 align-items-center">
<code class="flex-grow-1" id="add-user-temp-password">—</code>
<button type="button" class="btn btn-outline-secondary btn-sm" id="copy-add-user-temp-password">Copier</button>
</div>
<div class="small text-muted mt-2" id="add-user-expires-at"></div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Fermer</button>
<button type="button" id="add-user-reset" class="btn btn-outline-secondary">Reset</button>
<button type="submit" form="add-user-form" class="btn btn-warning" id="add-user-submit">Créer</button>
</div>
</div>
</div>
</div>
<!-- Edit movie modal -->
<div class="modal fade" id="editMovieModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editMovieModalTitle">Modifier le film</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="edit_movie_id">
<div class="mb-3">
<div class="text-muted small mb-1">Movie ID</div>
<div class="d-flex gap-2 align-items-center">
<code class="flex-grow-1" id="edit_movie_id_label">—</code>
<button type="button" class="btn btn-outline-secondary btn-sm" id="copy-movie-id">Copier</button>
</div>
</div>
<ul class="nav nav-tabs" id="editMovieTabs" role="tablist">
<li class="nav-item" role="presentation">
<button
class="nav-link active"
id="edit-movie-tab-info"
data-bs-toggle="tab"
data-bs-target="#edit-movie-pane-info"
type="button"
role="tab"
aria-controls="edit-movie-pane-info"
aria-selected="true"
>
Info
</button>
</li>
<li class="nav-item" role="presentation">
<button
class="nav-link"
id="edit-movie-tab-video"
data-bs-toggle="tab"
data-bs-target="#edit-movie-pane-video"
type="button"
role="tab"
aria-controls="edit-movie-pane-video"
aria-selected="false"
>
Player / Vidéo
</button>
</li>
</ul>
<div class="tab-content pt-3" id="editMovieTabsContent">
<!-- Info tab -->
<div
class="tab-pane fade show active"
id="edit-movie-pane-info"
role="tabpanel"
aria-labelledby="edit-movie-tab-info"
tabindex="0"
>
<div class="row g-3">
<div class="col-md-6">
<label for="edit_imdb_id" class="form-label">IMDB ID (ex: tt1234567)</label>
<input type="text" class="form-control" id="edit_imdb_id" placeholder="tt..." required>
</div>
<div class="col-md-6">
<label for="edit_year" class="form-label">Oscar year</label>
<input type="number" class="form-control" id="edit_year" placeholder="2026" min="1900" max="3000">
</div>
<div class="col-md-6">
<label for="edit_category" class="form-label">Catégorie</label>
<input type="text" class="form-control" id="edit_category" placeholder="Best Picture, etc." required>
</div>
<div class="col-md-6">
<label for="edit_rating" class="form-label">Rating (imdbRating)</label>
<input type="text" class="form-control" id="edit_rating" placeholder="7.9">
</div>
<div class="col-12">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit_refresh_omdb">
<label class="form-check-label" for="edit_refresh_omdb">
Recharger depuis OMDb (écrase titre / plot / rating / poster)
</label>
</div>
<div class="form-text">Si coché, le serveur refait un appel OMDb via l’IMDB ID au moment de l’enregistrement.</div>
</div>
<div class="col-12">
<label for="edit_title" class="form-label">Titre</label>
<input type="text" class="form-control" id="edit_title" placeholder="Title">
</div>
<div class="col-12">
<label for="edit_poster" class="form-label">Poster URL</label>
<input type="url" class="form-control" id="edit_poster" placeholder="https://...">
</div>
<div class="col-12">
<label for="edit_description" class="form-label">Description (Plot)</label>
<textarea class="form-control" id="edit_description" rows="4" placeholder="Plot..."></textarea>
</div>
</div>
</div>
<!-- Video tab -->
<div
class="tab-pane fade"
id="edit-movie-pane-video"
role="tabpanel"
aria-labelledby="edit-movie-tab-video"
tabindex="0"
>
<div class="row g-3">
<div class="col-12">
<label class="form-label">Lecteur (source)</label>
<div class="row g-2">
<div class="col-12 col-lg-4">
<label for="edit_player_mode" class="form-label">Mode</label>
<select id="edit_player_mode" class="form-select">
<option value="auto">Auto</option>
<option value="video">Video</option>
<option value="embed">Embed</option>
</select>
<div class="form-text">Auto = video_src → embed_src → vod_link.</div>
</div>
<div class="col-12 col-lg-8">
<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/&lt;movieId&gt;</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>
<textarea class="form-control font-monospace" id="edit_embed_src" rows="3" placeholder="https://... OR <iframe ...>"></textarea>
</div>
<div class="col-12">
<label for="edit_vod_link" class="form-label">Legacy VOD link (optional)</label>
<input type="text" class="form-control" id="edit_vod_link" placeholder="https://...">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuler</button>
<button type="button" class="btn btn-primary" id="save-movie-changes">Enregistrer</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<script src="./js/app_control.js"></script>
</body>
</html>
+163
View File
@@ -20,4 +20,167 @@ footer {
#copy {
text-decoration: none;
}
/* Simple, clean loading indicator (no spinner spam) */
.loading-indicator {
display: flex;
justify-content: center;
align-items: center;
gap: 0.35rem;
padding: 2rem 0;
}
.loading-dots {
display: inline-flex;
gap: 0.15rem;
}
.loading-dots span {
display: inline-block;
opacity: 0.2;
transform: translateY(-1px);
animation: loading-dot 1.1s infinite ease-in-out;
}
.loading-dots span:nth-child(2) { animation-delay: 0.15s; }
.loading-dots span:nth-child(3) { animation-delay: 0.30s; }
@keyframes loading-dot {
0%, 80%, 100% { opacity: 0.2; }
40% { opacity: 1; }
}
/* Full-page loader overlay (blur + progress bar) */
body.page-loading {
overflow: hidden;
}
.page-loader-overlay {
position: fixed;
top: var(--page-loader-top, 0px);
left: 0;
right: 0;
bottom: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
transition: opacity 200ms ease;
opacity: 1;
}
/* Avoid initial navbar "blur flash" before JS computes the exact height. */
body.page-loading .page-loader-overlay {
top: var(--page-loader-top, 72px);
}
/* Reserve space for username so it doesn't "pop" the layout. */
#user-name {
display: inline-block;
min-width: 8ch;
white-space: nowrap;
}
/*
Reserve space for the Admin button even when hidden.
This prevents a visible re-layout when we discover admin status from the token.
*/
@media (min-width: 992px) {
nav.navbar #admin-control-link.d-none {
display: inline-block !important;
visibility: hidden !important;
pointer-events: none;
}
}
.page-loader-overlay.page-loader-hide {
opacity: 0;
pointer-events: none;
}
.page-loader-card {
width: min(520px, 92vw);
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 16px;
padding: 18px 18px 14px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
}
.page-loader-card.page-loader-card--baronly {
width: min(420px, 90vw);
padding: 14px;
}
.page-loader-title {
margin: 0 0 10px 0;
font-weight: 700;
font-size: 1rem;
color: #111;
}
.page-loader-subtitle {
margin: 0 0 12px 0;
font-size: 0.9rem;
color: rgba(0, 0, 0, 0.7);
}
.page-loader-progress {
height: 10px;
border-radius: 999px;
overflow: hidden;
background: rgba(0, 0, 0, 0.08);
}
.page-loader-bar {
height: 100%;
width: 0%;
border-radius: 999px;
background: #111;
transition: width 220ms ease;
}
.page-loader-actions {
margin-top: 12px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* Admin → Version: keep history rows clean and readable */
.app-version-list .list-group-item {
border-left: 0;
border-right: 0;
}
.app-version-list .list-group-item:first-child {
border-top: 0;
}
.app-version-list .list-group-item:last-child {
border-bottom: 0;
}
/* Movies page helpers (Bootstrap-first, minimal custom) */
.movie-poster {
width: 100%;
max-height: 360px;
object-fit: contain;
}
.imdb-icon {
width: 26px;
height: 26px;
vertical-align: middle;
margin-left: 6px;
display: inline-block;
}
.watched-badge {
z-index: 2;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 12 KiB

+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars 2025</title>
<title>Pool Oscars</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<style>
@@ -46,7 +46,7 @@
<!-- Footer Section -->
<footer>
<p id="footer-copy"><a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a> - deux mille vingt-cinq</p>
<p id="footer-copy"><a id="copy" href="https://github.com/quoije/oscars-pool">quoije</a></p>
<p id="footer-text"></p>
</footer>
+123
View File
@@ -0,0 +1,123 @@
function decodeJwt(token) {
return JSON.parse(atob(token.split('.')[1]));
}
function isTokenExpired(decoded) {
const currentTime = Math.floor(Date.now() / 1000);
return !!decoded.exp && decoded.exp < currentTime;
}
async function applyActiveOscarYearToTitle() {
const base = 'Pool Oscars';
const current = String(document.title || base);
const sep = ' - ';
const idx = current.indexOf(sep);
const suffix = idx >= 0 ? current.slice(idx) : '';
try {
const res = await fetch('/api/settings/year', { method: 'GET' });
const data = res.ok ? await res.json().catch(() => null) : null;
const year = Number(data?.year);
document.title = Number.isInteger(year) ? `${base} (${year})${suffix}` : `${base}${suffix}`;
} catch (_) {
document.title = `${base}${suffix}`;
}
}
window.onload = function () {
// Best-effort: update page title with active year.
applyActiveOscarYearToTitle();
const token = localStorage.getItem('auth_token');
if (!token) {
window.location.href = '/';
return;
}
let decoded;
try {
decoded = decodeJwt(token);
} catch (e) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
if (isTokenExpired(decoded)) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
// Only show this page if user must change password.
if (!decoded.mustChangePassword) {
window.location.href = '/movies.html';
return;
}
const errorEl = document.getElementById('change-password-error');
const form = document.getElementById('change-password-form');
function showError(message) {
errorEl.classList.remove('d-none');
errorEl.textContent = message;
}
function clearError() {
errorEl.classList.add('d-none');
errorEl.textContent = '';
}
document.getElementById('log-off').addEventListener('click', function () {
localStorage.removeItem('auth_token');
window.location.href = '/';
});
form.addEventListener('submit', async function (e) {
e.preventDefault();
clearError();
const newPassword = document.getElementById('new-password').value;
const confirmPassword = document.getElementById('confirm-password').value;
if (newPassword.length < 8) {
showError('Le mot de passe doit contenir au moins 8 caractères.');
return;
}
if (newPassword !== confirmPassword) {
showError('Les mots de passe ne correspondent pas.');
return;
}
try {
const res = await fetch('/api/users/change-password', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ newPassword }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
showError(data.message || data.error || `Erreur (${res.status})`);
return;
}
if (data.token) {
localStorage.setItem('auth_token', data.token);
} else {
// Fallback: force re-login if no token is returned.
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
window.location.href = '/movies.html';
} catch (err) {
showError(err.message || 'Erreur réseau');
}
});
};
+395 -50
View File
@@ -1,17 +1,309 @@
window.onload = async function () {
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || 'Préparation de la page…');
let progress = 0;
let removed = false;
let navResizeObserver = null;
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
const preloadedOverlay = document.getElementById('page-loader-overlay');
const overlay = preloadedOverlay || document.createElement('div');
if (!preloadedOverlay) {
overlay.className = 'page-loader-overlay';
overlay.setAttribute('role', 'status');
overlay.setAttribute('aria-live', 'polite');
overlay.setAttribute('aria-busy', 'true');
overlay.innerHTML = `
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">${title}</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar"></div>
</div>
</div>
`;
} else {
// Keep screen reader label up to date.
const label = overlay.querySelector('.visually-hidden');
if (label) label.textContent = title;
}
const barEl = () => overlay.querySelector('#page-loader-bar');
function clampPct(p) {
const n = Number(p);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
}
function setProgress(p) {
progress = clampPct(p);
const bar = barEl();
if (bar) bar.style.width = `${progress}%`;
}
function setSubtitle(_) {
// Intentionally no-op (we only show the bar now).
}
function getNavHeightPx() {
const nav = document.querySelector('nav.navbar');
if (!nav) return 0;
const rect = nav.getBoundingClientRect();
const h = Number(rect?.height) || 0;
return h > 0 ? Math.round(h) : 0;
}
function updateOverlayTopOffset() {
// Scope the offset to this overlay instance (no global CSS var).
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
}
function ensureMounted() {
if (removed) return;
// IMPORTANT: even if the overlay is preloaded in HTML (already connected),
// we still need to compute the navbar offset. Otherwise it defaults to 0px
// and the blur covers the header.
updateOverlayTopOffset();
document.body.classList.add('page-loading');
if (!overlay.isConnected) document.body.appendChild(overlay);
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.)
if (!navResizeObserver) {
const nav = document.querySelector('nav.navbar');
if (nav && typeof ResizeObserver !== 'undefined') {
try {
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
navResizeObserver.observe(nav);
} catch (_) {
navResizeObserver = null;
}
}
}
}
function hideAndRemoveSoon() {
if (removed) return;
overlay.classList.add('page-loader-hide');
overlay.setAttribute('aria-busy', 'false');
document.body.classList.remove('page-loading');
window.setTimeout(() => {
removed = true;
if (navResizeObserver) {
try { navResizeObserver.disconnect(); } catch (_) {}
navResizeObserver = null;
}
try { overlay.remove(); } catch (_) {}
}, 220);
}
function done() {
setProgress(100);
hideAndRemoveSoon();
}
function fail(_) {
// Don't block the UI on errors: let the page render its own error message.
setProgress(Math.max(progress, 95));
window.setTimeout(() => hideAndRemoveSoon(), 200);
}
ensureMounted();
setProgress(8);
return { setProgress, setSubtitle, done, fail };
}
window.addEventListener('DOMContentLoaded', async function () {
const pageLoader = createPageLoader({
title: 'Chargement de la checklist',
subtitle: 'Récupération des données…'
});
const token = localStorage.getItem('auth_token');
const movieTableBody = document.getElementById('movie-table-body');
const watchedRatioEl = document.getElementById('watched-ratio');
function showTableError(message) {
if (!movieTableBody) return;
movieTableBody.innerHTML = `
<tr>
<td colspan="3">
<div class="alert alert-danger my-2" role="alert">
${message || 'Erreur lors du chargement.'}
</div>
</td>
</tr>
`;
}
const DEFAULT_COMPLETION_MODAL = Object.freeze({
title: 'Félicitations! very nice 🎉🎉🎉',
bodyText: '',
videoSrc: 'video/reward.mp4',
bodyHtml: ''
});
function sanitizeHtml(raw) {
const html = String(raw || '');
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, '');
const withoutOnAttrs = withoutScripts
.replace(/\son\w+="[^"]*"/gi, '')
.replace(/\son\w+='[^']*'/gi, '')
.replace(/\son\w+=\S+/gi, '');
return withoutOnAttrs.trim();
}
async function fetchCompletionModalContent() {
try {
const res = await fetch('/api/settings/completion-modal', { method: 'GET' });
if (!res.ok) throw new Error('Failed to fetch completion modal');
const data = await res.json();
return {
title: typeof data?.title === 'string' ? data.title : DEFAULT_COMPLETION_MODAL.title,
bodyText: typeof data?.bodyText === 'string' ? data.bodyText : DEFAULT_COMPLETION_MODAL.bodyText,
videoSrc: typeof data?.videoSrc === 'string' ? data.videoSrc : DEFAULT_COMPLETION_MODAL.videoSrc,
bodyHtml: typeof data?.bodyHtml === 'string' ? data.bodyHtml : DEFAULT_COMPLETION_MODAL.bodyHtml,
};
} catch (_) {
return DEFAULT_COMPLETION_MODAL;
}
}
function applyCompletionModalContent(content) {
const titleEl = document.getElementById('videoModalLabel');
const bodyTextEl = document.getElementById('completion-modal-text');
const bodyCustomEl = document.getElementById('completion-modal-custom');
const videoEl = document.getElementById('rewardVideo');
const videoSourceEl = document.getElementById('rewardVideoSource');
const title = String(content?.title || '').trim() || DEFAULT_COMPLETION_MODAL.title;
const bodyText = String(content?.bodyText || '');
const bodyHtml = sanitizeHtml(String(content?.bodyHtml || ''));
const videoSrc = String(content?.videoSrc || '').trim();
if (titleEl) titleEl.textContent = title;
if (bodyCustomEl) {
if (bodyHtml) {
bodyCustomEl.innerHTML = bodyHtml;
bodyCustomEl.classList.remove('d-none');
} else {
bodyCustomEl.innerHTML = '';
bodyCustomEl.classList.add('d-none');
}
}
if (bodyTextEl) {
bodyTextEl.textContent = bodyText || '';
bodyTextEl.classList.toggle('d-none', !bodyText);
}
if (videoEl && videoSourceEl) {
if (videoSrc) {
videoSourceEl.setAttribute('src', videoSrc);
// reload source (so updated src is used)
try { videoEl.load(); } catch (_) {}
videoEl.classList.remove('d-none');
} else {
try { videoEl.pause(); } catch (_) {}
videoEl.classList.add('d-none');
}
}
}
async function fetchActiveYear() {
try {
const res = await fetch('/api/settings/year', { method: 'GET' });
if (!res.ok) throw new Error('Failed to fetch active year');
const data = await res.json();
const year = Number(data?.year);
return Number.isInteger(year) ? year : new Date().getFullYear();
} catch (_) {
return new Date().getFullYear();
}
}
async function fetchOscarEffectiveDate(year) {
try {
const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET' });
if (!res.ok) throw new Error('Failed to fetch oscar date');
const data = await res.json();
const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null;
return effectiveDate && /^\d{4}-\d{2}-\d{2}$/.test(effectiveDate) ? effectiveDate : `${year}-03-15`;
} catch (_) {
return `${year}-03-15`;
}
}
function parseLocalNoonFromIsoDate(dateStr) {
const d = new Date(`${dateStr}T12:00:00`);
return Number.isNaN(d.getTime()) ? null : d;
}
async function fetchMoviesSummary(year) {
const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to fetch movies summary');
return await res.json();
}
const activeYear = await fetchActiveYear();
document.title = `Pool Oscars (${activeYear}) - Checklist`;
const oscarYearEl = document.getElementById('oscar-year');
if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
// Render countdown immediately with a deterministic fallback (then refine from settings).
const oscarDayMonthEl = document.getElementById('oscar-date-day-month');
const timeLeftEl = document.getElementById('time-left');
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
} catch (_) {
oscarDayMonthEl.textContent = '15 mars';
}
}
const targetDate = new Date('March 2, 2025');
const currentDate = new Date();
const timeDifference = targetDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
document.getElementById('time-left').textContent = `${daysLeft} jours`;
let timeDifference = fallbackDate - currentDate;
let daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`;
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
const targetDate =
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
fallbackDate;
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
} catch (_) {}
}
const now = new Date();
timeDifference = targetDate - now;
daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
if (timeLeftEl) timeLeftEl.textContent = `${daysLeft} jours`;
}).catch(() => {});
if (token) {
try {
const decoded = JSON.parse(atob(token.split('.')[1]));
if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
return;
}
const userName = decoded.name;
document.getElementById('user-name').textContent = userName;
if (decoded.admin) {
const adminLink = document.getElementById('admin-control-link');
if (adminLink) adminLink.classList.remove('d-none');
}
const currentTime = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < currentTime) {
localStorage.removeItem('auth_token');
@@ -25,35 +317,71 @@ window.onload = async function () {
window.location.href = '/';
}
const res = await fetch('/api/movies', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
if (watchedRatioEl) watchedRatioEl.textContent = '—';
const progressBarEl = document.getElementById('progress-bar');
pageLoader.setProgress(18);
if (!res.ok) {
localStorage.removeItem('auth_token');
window.location.href = '/';
let movies = [];
let watchedMovies = [];
let watchedMoviesInYear = [];
let movieImdbIds = new Set();
try {
// Start summary fetch immediately so the header can update without waiting for the full list.
const summaryPromise = fetchMoviesSummary(activeYear)
.then((summary) => {
const totalCount = Number(summary?.totalMoviesCount) || 0;
const watchedCount = Number(summary?.watchedMoviesCount) || 0;
const ratioPct = totalCount > 0 ? ((watchedCount / totalCount) * 100).toFixed(1) : '0.0';
if (watchedRatioEl) watchedRatioEl.innerText = `Vu: ${watchedCount} / ${totalCount} (${ratioPct}%)`;
updateProgressBar(watchedCount, totalCount, false);
pageLoader.setProgress(32);
return summary;
})
.catch(() => null);
// Fetch remaining data in parallel.
pageLoader.setSubtitle('Chargement de la liste des films…');
pageLoader.setProgress(26);
const [completionModalContent, moviesRes] = await Promise.all([
fetchCompletionModalContent(),
fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}&view=checklist`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
})
]);
applyCompletionModalContent(completionModalContent);
pageLoader.setProgress(55);
if (!moviesRes.ok) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
movies = await moviesRes.json();
pageLoader.setProgress(62);
// Sort movies alphabetically by title
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
const summary = await summaryPromise;
watchedMovies = Array.isArray(summary?.watchedMovies) ? summary.watchedMovies : [];
if (progressBarEl) progressBarEl.removeAttribute('aria-busy');
} catch (e) {
showTableError('Impossible de charger la checklist. Réessaie dans quelques instants.');
pageLoader.fail('Impossible de charger la checklist. Vérifie ta connexion puis réessaie.');
return;
}
const movies = await res.json();
// Sort movies alphabetically by title
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
const watchedRes = await fetch('/api/movies/watchedMovies', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
let watchedMovies = [];
if (watchedRes.ok) {
watchedMovies = await watchedRes.json();
}
movieImdbIds = new Set(movies.map((m) => m.imdb_id).filter(Boolean));
watchedMoviesInYear = watchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
const totalMoviesCount = movies.length;
const watchedMoviesCount = watchedMovies.length;
document.getElementById('watched-ratio').innerText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1)}%)`;
const watchedMoviesCount = watchedMoviesInYear.length;
const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
document.getElementById('watched-ratio').innerText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
function launchConfetti() {
const duration = 3 * 1000; // 3 seconds
@@ -80,10 +408,12 @@ window.onload = async function () {
frame();
}
function updateProgressBar(watchedCount, totalCount) {
function updateProgressBar(watchedCount, totalCount, celebrate = true) {
const progressBar = document.getElementById('progress-bar');
const percentage = (watchedCount / totalCount) * 100;
console.log(percentage);
if (!progressBar) return;
const safeTotal = Number(totalCount);
const safeWatched = Number(watchedCount);
const percentage = safeTotal > 0 && Number.isFinite(safeWatched) ? (safeWatched / safeTotal) * 100 : 0;
progressBar.style.width = `${percentage}%`;
if (percentage <= 50) {
@@ -94,20 +424,25 @@ window.onload = async function () {
progressBar.setAttribute('aria-valuenow', Math.round(percentage));
const videoModal = new bootstrap.Modal(document.getElementById("videoModal"));
if (percentage === 100) {
videoModal.show();
launchConfetti();
document.getElementById("rewardVideo").play();
if (celebrate) {
const videoModal = new bootstrap.Modal(document.getElementById("videoModal"));
if (percentage === 100) {
videoModal.show();
launchConfetti();
const rewardVideo = document.getElementById("rewardVideo");
// Only attempt autoplay if we actually have a visible <video>.
if (rewardVideo && !rewardVideo.classList.contains('d-none')) {
rewardVideo.play().catch(() => {});
}
}
}
}
updateProgressBar(watchedMoviesCount, totalMoviesCount);
const movieTableBody = document.getElementById('movie-table-body');
if (movieTableBody) movieTableBody.innerHTML = '';
movies.forEach(movie => {
const watchedMovie = watchedMovies.find(wm => wm.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 movieRow = document.createElement('tr');
movieRow.innerHTML = `
@@ -118,12 +453,9 @@ window.onload = async function () {
<td id="watched-date-${movie.imdb_id}">${watchedDate}</td>
`;
// Set initial row color based on watched state
if (watchedMovie) {
movieRow.style.backgroundColor = 'rgba(144, 238, 144, 0.6)'; // Lighter light green with transparency
} else {
movieRow.style.backgroundColor = 'rgba(255, 99, 71, 0.6)'; // Lighter light red with transparency
}
// Use Bootstrap table state classes (matches Stats page styling better than custom backgrounds)
movieRow.classList.toggle('table-success', Boolean(watchedMovie));
movieRow.classList.toggle('table-danger', !watchedMovie);
movieTableBody.appendChild(movieRow);
@@ -143,10 +475,16 @@ window.onload = async function () {
if (isChecked) {
watchedDateCell.textContent = new Date().toLocaleString();
row.style.backgroundColor = 'rgba(144, 238, 144, 0.6)'; // Apply light green with transparency
if (row) {
row.classList.add('table-success');
row.classList.remove('table-danger');
}
} else {
watchedDateCell.textContent = '';
row.style.backgroundColor = 'rgba(255, 99, 71, 0.6)'; // Apply light red with transparency
if (row) {
row.classList.add('table-danger');
row.classList.remove('table-success');
}
}
await fetch('/api/movies/users/updateWatchedMovies', {
@@ -165,8 +503,10 @@ window.onload = async function () {
if (updatedWatchedMoviesRes.ok) {
const updatedWatchedMovies = await updatedWatchedMoviesRes.json();
const updatedWatchedCount = updatedWatchedMovies.length;
document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1)}%)`;
watchedMoviesInYear = updatedWatchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
const updatedWatchedCount = watchedMoviesInYear.length;
const updatedPct = totalMoviesCount > 0 ? ((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${updatedPct}%)`;
updateProgressBar(updatedWatchedCount, totalMoviesCount);
// Recalculate and update movies per day
@@ -181,6 +521,10 @@ window.onload = async function () {
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`;
pageLoader.setSubtitle('Finalisation de l’affichage…');
pageLoader.setProgress(92);
pageLoader.done();
document.getElementById('log-off').addEventListener('click', () => {
localStorage.removeItem('auth_token');
window.location.href = '/';
@@ -190,7 +534,8 @@ window.onload = async function () {
const videoModal = document.getElementById("videoModal");
const rewardVideo = document.getElementById("rewardVideo");
videoModal.addEventListener("hidden.bs.modal", function () {
if (!rewardVideo) return;
rewardVideo.pause();
rewardVideo.currentTime = 0;
});
};
});
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
// Runs early (defer) to populate the navbar before page-specific JS.
// Goal: avoid "late" header updates that look like flicker.
(function () {
function base64UrlToJson(b64url) {
const s = String(b64url || '')
.replace(/-/g, '+')
.replace(/_/g, '/');
const padded = s + '='.repeat((4 - (s.length % 4)) % 4);
return JSON.parse(atob(padded));
}
function decodeJwtPayload(token) {
const parts = String(token || '').split('.');
if (parts.length < 2) return null;
try { return base64UrlToJson(parts[1]); } catch (_) { return null; }
}
function isExpired(decoded) {
const exp = Number(decoded?.exp);
if (!Number.isFinite(exp) || exp <= 0) return false;
const now = Math.floor(Date.now() / 1000);
return exp < now;
}
const token = localStorage.getItem('auth_token');
if (!token) return;
const decoded = decodeJwtPayload(token);
if (!decoded) return;
// Fast-path redirect checks (match page scripts behavior).
if (decoded.mustChangePassword && !/\/change-password\.html(\?|#|$)/.test(window.location.pathname)) {
window.location.href = '/change-password.html';
return;
}
if (isExpired(decoded)) {
try { localStorage.removeItem('auth_token'); } catch (_) {}
if (!/\/index\.html$/.test(window.location.pathname) && window.location.pathname !== '/') {
window.location.href = '/';
}
return;
}
const nameEl = document.getElementById('user-name');
if (nameEl && !nameEl.textContent) {
nameEl.textContent = decoded.name || '';
}
const adminLink = document.getElementById('admin-control-link');
if (adminLink && decoded.admin) {
adminLink.classList.remove('d-none');
}
})();
+46 -5
View File
@@ -1,8 +1,44 @@
// Check if the user is already logged in by looking for auth_token in localStorage
if (localStorage.getItem('auth_token')) {
// If the token is found, redirect to /movies.html
window.location.href = '/movies.html';
function decodeJwt(token) {
return JSON.parse(atob(token.split('.')[1]));
}
async function applyActiveOscarYearToTitle() {
const base = 'Pool Oscars';
const current = String(document.title || base);
const sep = ' - ';
const idx = current.indexOf(sep);
const suffix = idx >= 0 ? current.slice(idx) : '';
try {
const res = await fetch('/api/settings/year', { method: 'GET' });
const data = res.ok ? await res.json().catch(() => null) : null;
const year = Number(data?.year);
document.title = Number.isInteger(year) ? `${base} (${year})${suffix}` : `${base}${suffix}`;
} catch (_) {
document.title = `${base}${suffix}`;
}
}
// Fire and forget.
applyActiveOscarYearToTitle();
// Check if the user is already logged in by looking for auth_token in localStorage
const existingToken = localStorage.getItem('auth_token');
if (existingToken) {
try {
const decoded = decodeJwt(existingToken);
const currentTime = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < currentTime) {
localStorage.removeItem('auth_token');
} else if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
} else {
window.location.href = '/movies.html';
}
} catch (_) {
localStorage.removeItem('auth_token');
}
}
// Handle login form submission
document.getElementById('login-form').addEventListener('submit', async function (e) {
@@ -21,7 +57,12 @@ if (localStorage.getItem('auth_token')) {
const data = await res.json();
// Save the token to localStorage
localStorage.setItem('auth_token', data.token);
window.location.href = '/movies.html'; // Redirect to movies page on success
if (data.mustChangePassword) {
window.location.href = '/change-password.html';
} else {
window.location.href = '/movies.html'; // Redirect to movies page on success
}
} else {
const error = await res.json();
alert(`${error.message}`);
+381 -61
View File
@@ -1,16 +1,283 @@
window.onload = async function () {
const targetDate = new Date('March 2, 2025');
const currentDate = new Date();
const timeDifference = targetDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
document.getElementById('time-left').textContent = `${daysLeft} jours`;
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || 'Préparation de la page…');
let progress = 0;
let removed = false;
let navResizeObserver = null;
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
const preloadedOverlay = document.getElementById('page-loader-overlay');
const overlay = preloadedOverlay || document.createElement('div');
if (!preloadedOverlay) {
overlay.className = 'page-loader-overlay';
overlay.setAttribute('role', 'status');
overlay.setAttribute('aria-live', 'polite');
overlay.setAttribute('aria-busy', 'true');
overlay.innerHTML = `
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">${title}</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar"></div>
</div>
</div>
`;
} else {
// Keep screen reader label up to date.
const label = overlay.querySelector('.visually-hidden');
if (label) label.textContent = title;
}
const barEl = () => overlay.querySelector('#page-loader-bar');
function clampPct(p) {
const n = Number(p);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
}
function setProgress(p) {
progress = clampPct(p);
const bar = barEl();
if (bar) bar.style.width = `${progress}%`;
}
function setSubtitle(_) {
// Intentionally no-op (we only show the bar now).
}
function getNavHeightPx() {
const nav = document.querySelector('nav.navbar');
if (!nav) return 0;
const rect = nav.getBoundingClientRect();
const h = Number(rect?.height) || 0;
return h > 0 ? Math.round(h) : 0;
}
function updateOverlayTopOffset() {
// Scope the offset to this overlay instance (no global CSS var).
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
}
function ensureMounted() {
if (removed) return;
// IMPORTANT: even if the overlay is preloaded in HTML (already connected),
// we still need to compute the navbar offset. Otherwise it defaults to 0px
// and the blur covers the header.
updateOverlayTopOffset();
document.body.classList.add('page-loading');
if (!overlay.isConnected) document.body.appendChild(overlay);
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.)
if (!navResizeObserver) {
const nav = document.querySelector('nav.navbar');
if (nav && typeof ResizeObserver !== 'undefined') {
try {
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
navResizeObserver.observe(nav);
} catch (_) {
navResizeObserver = null;
}
}
}
}
function hideAndRemoveSoon() {
if (removed) return;
overlay.classList.add('page-loader-hide');
overlay.setAttribute('aria-busy', 'false');
document.body.classList.remove('page-loading');
window.setTimeout(() => {
removed = true;
if (navResizeObserver) {
try { navResizeObserver.disconnect(); } catch (_) {}
navResizeObserver = null;
}
try { overlay.remove(); } catch (_) {}
}, 220);
}
function done() {
setProgress(100);
hideAndRemoveSoon();
}
function fail(_) {
// Don't block the UI on errors: let the page render its own error message.
setProgress(Math.max(progress, 95));
window.setTimeout(() => hideAndRemoveSoon(), 200);
}
ensureMounted();
setProgress(8);
return { setProgress, setSubtitle, done, fail };
}
async function waitForImagesIn(containerEl, onProgress) {
if (!containerEl) return;
const imgs = Array.from(containerEl.querySelectorAll('img'));
if (imgs.length === 0) return;
let resolved = 0;
const total = imgs.length;
const notify = () => {
if (typeof onProgress === 'function') onProgress(resolved, total);
};
await Promise.race([
Promise.all(
imgs.map((img) => new Promise((resolve) => {
const finish = () => {
resolved += 1;
notify();
resolve();
};
if (img.complete) return finish();
img.addEventListener('load', finish, { once: true });
img.addEventListener('error', finish, { once: true });
}))
),
new Promise((resolve) => window.setTimeout(resolve, 7000)), // safety timeout
]);
}
window.addEventListener('DOMContentLoaded', async function () {
const pageLoader = createPageLoader({
title: 'Chargement des films',
subtitle: 'Récupération des données…'
});
function setLoadError(el, message) {
if (!el) return;
el.innerHTML = `
<div class="alert alert-danger my-3" role="alert">
${message || 'Erreur lors du chargement.'}
</div>
`;
}
async function fetchActiveYear() {
try {
const res = await fetch('/api/settings/year', { method: 'GET' });
if (!res.ok) throw new Error('Failed to fetch active year');
const data = await res.json();
const year = Number(data?.year);
return Number.isInteger(year) ? year : new Date().getFullYear();
} catch (_) {
return new Date().getFullYear();
}
}
async function fetchOscarEffectiveDate(year) {
try {
const res = await fetch(`/api/settings/oscar-date?year=${encodeURIComponent(String(year))}`, { method: 'GET' });
if (!res.ok) throw new Error('Failed to fetch oscar date');
const data = await res.json();
const effectiveDate = typeof data?.effectiveDate === 'string' ? data.effectiveDate : null;
return effectiveDate && /^\d{4}-\d{2}-\d{2}$/.test(effectiveDate) ? effectiveDate : `${year}-03-15`;
} catch (_) {
return `${year}-03-15`;
}
}
function parseLocalNoonFromIsoDate(dateStr) {
// Use local noon to avoid DST/midnight edge cases for "days left" math.
const d = new Date(`${dateStr}T12:00:00`);
return Number.isNaN(d.getTime()) ? null : d;
}
async function fetchMoviesSummary(year, token) {
const res = await fetch(`/api/movies/summary?year=${encodeURIComponent(String(year))}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) throw new Error('Failed to fetch movies summary');
return await res.json();
}
function applySummaryToHeader(summary) {
const lastUpdatedEl = document.getElementById('movies-last-updated');
const ratioEl = document.getElementById('watched-ratio');
const lastUpdatedIso = summary?.lastUpdated;
if (lastUpdatedEl) {
if (lastUpdatedIso) {
const d = new Date(lastUpdatedIso);
if (!Number.isNaN(d.getTime())) {
lastUpdatedEl.textContent = d.toLocaleString('fr-FR', { dateStyle: 'medium', timeStyle: 'short' });
} else {
lastUpdatedEl.textContent = '—';
}
} else {
lastUpdatedEl.textContent = '—';
}
}
const total = Number(summary?.totalMoviesCount) || 0;
const watched = Number(summary?.watchedMoviesCount) || 0;
const ratioPct = total > 0 ? ((watched / total) * 100).toFixed(1) : '0.0';
if (ratioEl) ratioEl.innerText = `Vu: ${watched} / ${total} (${ratioPct}%)`;
}
const activeYear = await fetchActiveYear();
document.title = `Pool Oscars (${activeYear}) - Films`;
const oscarYearEl = document.getElementById('oscar-year');
if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
// Render countdown immediately with a deterministic fallback (then refine from settings).
const oscarDayMonthEl = document.getElementById('oscar-date-day-month');
const timeLeftEl = document.getElementById('time-left');
const fallbackDate = parseLocalNoonFromIsoDate(`${activeYear}-03-15`) || new Date(`March 15, ${activeYear} 12:00:00`);
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = fallbackDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
} catch (_) {
oscarDayMonthEl.textContent = '15 mars';
}
}
if (timeLeftEl) {
const currentDate = new Date();
const timeDifference = fallbackDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
timeLeftEl.textContent = `${daysLeft} jours`;
}
fetchOscarEffectiveDate(activeYear).then((oscarEffectiveDate) => {
const targetDate =
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
fallbackDate;
if (oscarDayMonthEl) {
try {
oscarDayMonthEl.textContent = targetDate.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' });
} catch (_) {}
}
if (timeLeftEl) {
const currentDate = new Date();
const timeDifference = targetDate - currentDate;
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
timeLeftEl.textContent = `${daysLeft} jours`;
}
}).catch(() => {});
const token = localStorage.getItem('auth_token');
if (token) {
try {
const decoded = JSON.parse(atob(token.split('.')[1]));
if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
return;
}
const userName = decoded.name;
document.getElementById('user-name').textContent = userName;
if (decoded.admin) {
const adminLink = document.getElementById('admin-control-link');
if (adminLink) adminLink.classList.remove('d-none');
}
const currentTime = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < currentTime) {
@@ -25,62 +292,115 @@ window.onload = async function () {
window.location.href = '/';
}
const res = await fetch('/api/movies', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
const movies = await res.json();
// Sort movies alphabetically by title
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
const watchedRes = await fetch('/api/movies/watchedMovies', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
});
let watchedMovies = [];
if (watchedRes.ok) {
watchedMovies = await watchedRes.json();
}
const moviesList = document.getElementById('movies-list');
const totalMoviesCount = movies.length;
const watchedMoviesCount = watchedMovies.length;
const ratioText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1)}%)`;
document.getElementById('watched-ratio').innerText = ratioText;
pageLoader.setProgress(18);
movies.forEach(movie => {
const isChecked = watchedMovies.some(watchedMovie => watchedMovie.imdb_id === movie.imdb_id);
const watchedMovie = watchedMovies.find(wm => wm.imdb_id === movie.imdb_id);
const watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : '';
let watchedMoviesInYear = [];
const movieDiv = document.createElement('div');
movieDiv.classList.add('col-md-4', 'mb-4', 'movie-card');
movieDiv.setAttribute('data-imdb-id', movie.imdb_id);
movieDiv.innerHTML = `
<div class="card">
${isChecked ? '<div class="watched-banner">VISIONNÉ</div>' : ''}
<a href="${movie.vod_link}"><img src="${movie.poster}" class="card-img-top" alt="${movie.title}" style="width: 75%; display: block; margin: 0 auto; padding: 10px;"></a>
<div class="card-body">
<h5 class="card-title d-flex justify-content-between align-items-center">
${movie.title}
<span>⭐ ${movie.rating} <a href="https://www.imdb.com/title/${movie.imdb_id}/"><img src="/img/imdb.png"></a></span>
</h5>
<p class="fw-bold fst-italic card-text" style="font-size: 0.75rem;">${movie.category}</p>
<p class="card-text">${movie.description}</p>
${isChecked ? `<p class="card-text"><strong>Regardé le:</strong> ${watchedDate}</p>` : ''}
</div>
</div>`;
moviesList.appendChild(movieDiv);
});
try {
// Start summary fetch immediately so header can update ASAP.
const summaryPromise = fetchMoviesSummary(activeYear, token)
.then((data) => {
watchedMoviesInYear = Array.isArray(data?.watchedMovies) ? data.watchedMovies : [];
applySummaryToHeader(data);
pageLoader.setProgress(32);
return data;
})
.catch(() => {
// Leave the placeholders (—) if summary fails; movies list can still load.
const lastUpdatedEl = document.getElementById('movies-last-updated');
if (lastUpdatedEl) lastUpdatedEl.textContent = '—';
const ratioEl = document.getElementById('watched-ratio');
if (ratioEl) ratioEl.textContent = '—';
return null;
});
pageLoader.setProgress(28);
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` },
});
if (!res.ok) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
const movies = await res.json();
pageLoader.setProgress(55);
// If summary is still in flight, wait for it before rendering watched banners.
await summaryPromise;
pageLoader.setProgress(62);
// Sort movies alphabetically by title (server also sorts, but keep client as a fallback)
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
const totalMoviesCount = movies.length;
const watchedMoviesCount = watchedMoviesInYear.length;
const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
const ratioText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
document.getElementById('watched-ratio').innerText = ratioText;
if (moviesList) moviesList.innerHTML = '';
movies.forEach(movie => {
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 hasVideoSrc = !!(movie && typeof movie.video_src === 'string' && movie.video_src.trim());
const hasEmbedSrc = !!(movie && typeof movie.embed_src === 'string' && movie.embed_src.trim());
const hasNewPlayerSource = hasVideoSrc || hasEmbedSrc;
const hasLegacy = !!(movie && typeof movie.vod_link === 'string' && movie.vod_link.trim());
// If the movie only has legacy `vod_link`, skip the player entirely and open the original URL in a new tab.
const playerUrl = (movie && movie._id && hasNewPlayerSource)
? `/player.html?id=${encodeURIComponent(movie._id)}`
: (hasLegacy ? movie.vod_link : (movie && movie._id ? `/player.html?id=${encodeURIComponent(movie._id)}` : '#'));
const movieDiv = document.createElement('div');
movieDiv.classList.add('col-12', 'col-sm-6', 'col-lg-4', 'movie-card');
movieDiv.setAttribute('data-imdb-id', movie.imdb_id);
movieDiv.innerHTML = `
<div class="card h-100 shadow-sm">
${isChecked ? '<span class="badge bg-success position-absolute top-0 start-0 m-2 watched-badge">VISIONNÉ</span>' : ''}
<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none">
<div class="text-center pt-3 px-3">
<img src="${movie.poster}" class="img-fluid rounded movie-poster" alt="${movie.title}">
</div>
</a>
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start gap-2">
<h5 class="card-title mb-1 flex-grow-1">
<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none text-dark">${movie.title}</a>
</h5>
<div class="text-nowrap small text-muted">
⭐ ${movie.rating}
<a href="https://www.imdb.com/title/${movie.imdb_id}/" target="_blank" rel="noopener noreferrer" aria-label="Voir sur IMDb">
<img src="/img/imdb.png" class="imdb-icon" alt="IMDb">
</a>
</div>
</div>
<div class="text-muted small fw-semibold fst-italic mb-2">${movie.category}</div>
<p class="card-text small flex-grow-1 mb-2">${movie.description}</p>
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">Regardé le:</span> ${watchedDate}</div>` : ''}
</div>
</div>`;
moviesList.appendChild(movieDiv);
});
pageLoader.setSubtitle('Finalisation de l’affichage…');
pageLoader.setProgress(80);
await waitForImagesIn(moviesList, (loaded, total) => {
// Move 80% -> 98% as posters resolve
const pct = 80 + (loaded / Math.max(1, total)) * 18;
pageLoader.setProgress(pct);
});
pageLoader.done();
} catch (e) {
setLoadError(moviesList, 'Impossible de charger la liste des films. Réessaie dans quelques instants.');
pageLoader.fail('Impossible de charger les films. Vérifie ta connexion puis réessaie.');
}
document.getElementById('log-off').addEventListener('click', function () {
localStorage.removeItem('auth_token');
@@ -104,7 +424,7 @@ window.onload = async function () {
movieCards.forEach(card => {
const imdbId = card.getAttribute('data-imdb-id');
const isWatched = watchedMovies.some(watchedMovie => watchedMovie.imdb_id === imdbId);
const isWatched = watchedMoviesInYear.some(watchedMovie => watchedMovie.imdb_id === imdbId);
if (showUnwatchedOnly && isWatched) {
card.style.display = 'none';
@@ -115,4 +435,4 @@ window.onload = async function () {
}
filterMovies();
};
});
+979
View File
@@ -0,0 +1,979 @@
function decodeJwt(token) {
const parts = String(token || '').split('.');
if (parts.length < 2) throw new Error('Invalid token');
// JWT uses base64url (not base64)
const b64url = String(parts[1] || '').replace(/-/g, '+').replace(/_/g, '/');
const padded = b64url + '='.repeat((4 - (b64url.length % 4)) % 4);
return JSON.parse(atob(padded));
}
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
let progress = 0;
let removed = false;
let navResizeObserver = null;
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
const preloadedOverlay = document.getElementById('page-loader-overlay');
const overlay = preloadedOverlay || document.createElement('div');
if (!preloadedOverlay) {
overlay.className = 'page-loader-overlay';
overlay.setAttribute('role', 'status');
overlay.setAttribute('aria-live', 'polite');
overlay.setAttribute('aria-busy', 'true');
overlay.innerHTML = `
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">${title}</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar"></div>
</div>
</div>
`;
} else {
const label = overlay.querySelector('.visually-hidden');
if (label) label.textContent = title;
}
const barEl = () => overlay.querySelector('#page-loader-bar');
function clampPct(p) {
const n = Number(p);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
}
function setProgress(p) {
progress = clampPct(p);
const bar = barEl();
if (bar) bar.style.width = `${progress}%`;
}
function getNavHeightPx() {
const nav = document.querySelector('nav.navbar');
if (!nav) return 0;
const rect = nav.getBoundingClientRect();
const h = Number(rect?.height) || 0;
return h > 0 ? Math.round(h) : 0;
}
function updateOverlayTopOffset() {
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
}
function ensureMounted() {
if (removed) return;
updateOverlayTopOffset();
document.body.classList.add('page-loading');
if (!overlay.isConnected) document.body.appendChild(overlay);
if (!navResizeObserver) {
const nav = document.querySelector('nav.navbar');
if (nav && typeof ResizeObserver !== 'undefined') {
try {
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
navResizeObserver.observe(nav);
} catch (_) {
navResizeObserver = null;
}
}
}
}
function hideAndRemoveSoon() {
if (removed) return;
overlay.classList.add('page-loader-hide');
overlay.setAttribute('aria-busy', 'false');
document.body.classList.remove('page-loading');
window.setTimeout(() => {
removed = true;
if (navResizeObserver) {
try { navResizeObserver.disconnect(); } catch (_) {}
navResizeObserver = null;
}
try { overlay.remove(); } catch (_) {}
}, 220);
}
function done() {
setProgress(100);
hideAndRemoveSoon();
}
function fail() {
setProgress(Math.max(progress, 95));
window.setTimeout(() => hideAndRemoveSoon(), 200);
}
ensureMounted();
setProgress(8);
return { setProgress, done, fail };
}
function safeJsonParse(v) {
try { return JSON.parse(v); } catch (_) { return null; }
}
function progressStorageKey(userId, movieId) {
return `playback_progress:${String(userId || '')}:${String(movieId || '')}`;
}
function normalizeUserId(raw) {
// Expect a Mongo ObjectId string in the token payload.
const v = raw?.id;
if (typeof v === 'string' && /^[a-f0-9]{24}$/i.test(v)) return v;
const s = v && typeof v === 'object' && typeof v.toString === 'function' ? v.toString() : String(v || '');
return /^[a-f0-9]{24}$/i.test(s) ? s : null;
}
async function fetchActiveOscarYear() {
try {
const res = await fetch('/api/settings/year', { method: 'GET' });
if (!res.ok) return null;
const data = await res.json().catch(() => null);
const year = Number(data?.year);
return Number.isInteger(year) ? year : null;
} catch (_) {
return null;
}
}
async function fetchPlaybackProgress(movieId, token) {
try {
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, {
method: 'GET',
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
});
if (!res.ok) return null;
const data = await res.json().catch(() => null);
if (!data) return null;
const time = Number(data.time);
const duration = data.duration === null || data.duration === undefined ? null : Number(data.duration);
return {
time: Number.isFinite(time) && time >= 0 ? time : 0,
duration: duration !== null && Number.isFinite(duration) && duration > 0 ? duration : null,
};
} catch (_) {
return null;
}
}
async function savePlaybackProgress(movieId, token, { time, duration, imdbId, keepalive } = {}) {
try {
// Retry once on rare upsert races (409).
for (let attempt = 0; attempt < 2; attempt += 1) {
const payload = { time };
// Don't send duration when unknown; avoids wiping server-side duration.
if (typeof duration === 'number' && Number.isFinite(duration) && duration > 0) {
payload.duration = duration;
}
const safeImdb = typeof imdbId === 'string' ? imdbId.trim() : '';
if (safeImdb) payload.imdb_id = safeImdb;
const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/progress`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
},
body: JSON.stringify(payload),
keepalive: !!keepalive,
});
if (res.ok) return true;
if (res.status === 409) continue;
if (res.status === 401) return false;
return true; // best-effort; don't break playback on other errors
}
return true;
} catch (_) {
// best-effort
return true;
}
}
function setupVideoProgress({ videoEl, movieId, token, userId, imdbId }) {
if (!videoEl || !movieId || !token || !userId) return () => {};
const key = progressStorageKey(userId, movieId);
let restored = false;
let restoreInProgress = false;
let pendingRestoreTime = null; // number | null
let restoreServerPromise = null;
let intervalId = null;
let lastSavedAt = 0;
let lastSavedTime = -1;
let lastSaveStatus = '—';
let seekSaveTimer = null;
let savedUiTimer = null;
function getSnapshot() {
const t = Number(videoEl.currentTime);
const d = Number(videoEl.duration);
return {
// Use whole seconds to keep saved values stable.
time: Number.isFinite(t) && t >= 0 ? Math.floor(t) : 0,
duration: Number.isFinite(d) && d > 0 ? Math.floor(d) : null,
};
}
async function tryRestoreProgress() {
if (restored) return true;
const dur = Number(videoEl.duration);
// Don't mark restored until we have a usable duration: some sources report NaN/Infinity first.
if (!Number.isFinite(dur) || dur <= 0 || dur === Infinity) return false;
// Prefer server progress, fall back to localStorage if offline.
if (!restoreServerPromise) restoreServerPromise = fetchPlaybackProgress(movieId, token);
const server = await restoreServerPromise;
let targetTime = server?.time ?? 0;
if (!Number.isFinite(targetTime) || targetTime < 0) targetTime = 0;
if (targetTime <= 1) {
const local = safeJsonParse(localStorage.getItem(key));
const lt = Number(local?.time);
if (Number.isFinite(lt) && lt > 1) targetTime = lt;
}
// Clamp to avoid seeking beyond the end.
const clamped = Math.min(Math.max(targetTime, 0), Math.max(0, dur - 3));
restored = true;
if (clamped > 1) {
// Block any progress saves until the seek completes; otherwise we can overwrite
// server progress with an early "0s" save during reload/startup.
pendingRestoreTime = clamped;
restoreInProgress = true;
try { videoEl.currentTime = clamped; } catch (_) {}
lastSaveStatus = `Reprise · ${formatClock(clamped)}`;
setProgressUi({ state: 'info', text: lastSaveStatus, showText: true, ariaText: `Reprise à ${formatClock(clamped)}` });
} else {
pendingRestoreTime = null;
restoreInProgress = false;
lastSaveStatus = 'Début';
setProgressUi({ state: 'info', text: 'Début', showText: true, ariaText: 'Lecture depuis le début' });
}
return true;
}
function scheduleRestore() {
if (!restored) setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' });
void tryRestoreProgress();
}
async function persist({ keepalive, force } = {}) {
// Never persist until the resume logic finished; otherwise the first "0s" timeupdate
// on reload can overwrite the user's saved progress on the server.
if (!restored) {
void tryRestoreProgress();
return;
}
if (restoreInProgress) return;
// Throttle to avoid spamming the API
const now = Date.now();
if (!force && !keepalive && now - lastSavedAt < 1500) return;
const snap = getSnapshot();
if (!Number.isFinite(snap.time)) return;
if (pendingRestoreTime !== null) {
// Some browsers may not fire seeked reliably. Only start saving once we
// observe that currentTime actually reached the restored position.
if (snap.time >= Math.max(0, Math.floor(pendingRestoreTime) - 1)) {
pendingRestoreTime = null;
} else {
return;
}
}
// Only save if it meaningfully changed (>= 1s)
if (!force && lastSavedTime >= 0 && Math.abs(snap.time - lastSavedTime) < 1) return;
lastSavedAt = now;
lastSavedTime = snap.time;
try {
localStorage.setItem(key, JSON.stringify({ time: snap.time, duration: snap.duration, at: now }));
} catch (_) {}
// Icon-only saving indicator (avoid noisy text updates).
setProgressUi({ state: 'saving', text: '', showText: false, ariaText: 'Sauvegarde…' });
const ok = await savePlaybackProgress(movieId, token, { ...snap, imdbId, keepalive: !!keepalive });
if (ok === false) {
// Token expired mid-playback; stop spamming requests and prompt user.
stopInterval();
try { localStorage.removeItem('auth_token'); } catch (_) {}
lastSaveStatus = 'Connexion requise pour sauvegarder.';
setProgressUi({ state: 'warning', text: 'Connexion', showText: true, ariaText: lastSaveStatus });
showAlertVariant('Session expirée. Reconnecte-toi pour continuer à sauvegarder la progression.', 'warning');
return;
}
lastSaveStatus = `Sauvegardé à ${formatClock(snap.time)}.`;
setProgressUi({ state: 'saved', text: '', showText: false, ariaText: lastSaveStatus });
if (savedUiTimer) window.clearTimeout(savedUiTimer);
savedUiTimer = window.setTimeout(() => {
// Settle back to a quiet state (no spam), keep detail in aria-label.
setProgressUi({ state: 'info', text: '—', showText: false, ariaText: lastSaveStatus });
}, 900);
}
function startInterval() {
if (intervalId) return;
// Safety-net; primary saving happens via timeupdate throttling.
intervalId = window.setInterval(() => {
if (videoEl.paused || videoEl.seeking) return;
void persist();
}, 8000);
}
function stopInterval() {
if (!intervalId) return;
window.clearInterval(intervalId);
intervalId = null;
}
const onPlay = () => startInterval();
const onPause = () => { stopInterval(); void persist({ force: true }); };
const onSeeking = () => {
// While scrubbing, browsers fire many events; debounce a forced save so it lands quickly.
if (seekSaveTimer) window.clearTimeout(seekSaveTimer);
seekSaveTimer = window.setTimeout(() => { void persist({ force: true }); }, 400);
};
const onSeeked = () => {
if (restoreInProgress) {
restoreInProgress = false;
pendingRestoreTime = null;
}
void persist({ force: true });
};
const onEnded = () => { stopInterval(); void persist(); };
const onError = () => { stopInterval(); };
const onTimeUpdate = () => {
if (videoEl.paused || videoEl.seeking) return;
void persist();
};
const onPageHide = () => { void persist({ keepalive: true, force: true }); };
const onVisibility = () => {
if (document.visibilityState === 'hidden') void persist({ keepalive: true, force: true });
};
// Restore may require multiple tries (duration may be unknown at first).
videoEl.addEventListener('loadedmetadata', scheduleRestore);
videoEl.addEventListener('durationchange', scheduleRestore);
videoEl.addEventListener('loadeddata', scheduleRestore);
videoEl.addEventListener('canplay', scheduleRestore);
videoEl.addEventListener('play', onPlay);
videoEl.addEventListener('pause', onPause);
videoEl.addEventListener('seeking', onSeeking);
videoEl.addEventListener('seeked', onSeeked);
videoEl.addEventListener('ended', onEnded);
videoEl.addEventListener('error', onError);
videoEl.addEventListener('timeupdate', onTimeUpdate);
window.addEventListener('pagehide', onPageHide);
document.addEventListener('visibilitychange', onVisibility);
// If metadata is already available, try immediately.
if (videoEl.readyState >= 1) scheduleRestore();
return () => {
stopInterval();
if (seekSaveTimer) window.clearTimeout(seekSaveTimer);
if (savedUiTimer) window.clearTimeout(savedUiTimer);
videoEl.removeEventListener('play', onPlay);
videoEl.removeEventListener('pause', onPause);
videoEl.removeEventListener('seeking', onSeeking);
videoEl.removeEventListener('seeked', onSeeked);
videoEl.removeEventListener('ended', onEnded);
videoEl.removeEventListener('error', onError);
videoEl.removeEventListener('timeupdate', onTimeUpdate);
videoEl.removeEventListener('loadedmetadata', scheduleRestore);
videoEl.removeEventListener('durationchange', scheduleRestore);
videoEl.removeEventListener('loadeddata', scheduleRestore);
videoEl.removeEventListener('canplay', scheduleRestore);
window.removeEventListener('pagehide', onPageHide);
document.removeEventListener('visibilitychange', onVisibility);
if (lastSaveStatus) setProgressStatus(lastSaveStatus);
};
}
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;
// Ensure error styling (danger) when using the simple API.
el.classList.remove('alert-primary', 'alert-secondary', 'alert-success', 'alert-danger', 'alert-warning', 'alert-info', 'alert-light', 'alert-dark');
el.classList.add('alert-danger');
el.classList.remove('d-none');
}
function showAlertVariant(message, variant) {
const el = document.getElementById('alert');
if (!el) return;
el.textContent = message;
el.classList.remove('d-none');
// Reset any prior bootstrap alert-* variants
el.classList.remove('alert-primary', 'alert-secondary', 'alert-success', 'alert-danger', 'alert-warning', 'alert-info', 'alert-light', 'alert-dark');
const v = String(variant || 'danger').trim().toLowerCase();
el.classList.add(`alert-${v || 'danger'}`);
}
function setSourceLabel(text) {
const el = document.getElementById('source-label');
if (!el) return;
el.textContent = text || '—';
el.title = text || '';
}
function setProgressStatus(text) {
// Back-compat: older calls pass a string. We render minimal UI.
setProgressUi({ state: 'info', text: text || '—', showText: true });
}
function setProgressUi({ state, text, showText, ariaText } = {}) {
const iconEl = document.getElementById('progress-status-icon');
const textEl = document.getElementById('progress-status-text');
const wrapEl = document.getElementById('progress-status');
if (!iconEl || !textEl) return;
const s = String(state || '').trim().toLowerCase();
const t = typeof text === 'string' ? text : (text === null || text === undefined ? '' : String(text));
const shouldShowText = showText === undefined ? true : !!showText;
const a11y = typeof ariaText === 'string' ? ariaText : t;
// Reset icon
iconEl.classList.remove('progress-icon--spinner');
iconEl.innerHTML = '';
// Text
textEl.textContent = t || '—';
textEl.classList.toggle('is-hidden', !shouldShowText);
// A11y label lives on the wrapper span.
if (wrapEl) {
if (a11y) wrapEl.setAttribute('aria-label', a11y);
else wrapEl.removeAttribute('aria-label');
}
// Icon by state (no external deps)
if (s === 'saving' || s === 'preparing') {
iconEl.classList.add('progress-icon--spinner');
return;
}
if (s === 'saved') {
iconEl.innerHTML = `
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M13.485 1.929a.75.75 0 0 1 .086 1.057l-6.5 8a.75.75 0 0 1-1.1.06l-3.5-3.5a.75.75 0 1 1 1.06-1.06l2.91 2.91 5.98-7.36a.75.75 0 0 1 1.064-.107z"/>
</svg>
`;
return;
}
if (s === 'warning' || s === 'error') {
iconEl.innerHTML = `
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.964 0L.165 13.233c-.457.778.091 1.767.982 1.767h13.706c.89 0 1.438-.99.982-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 7a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
</svg>
`;
return;
}
}
function formatClock(seconds) {
const s = Math.max(0, Math.floor(Number(seconds) || 0));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(sec).padStart(2, '0')}`;
return `${m}:${String(sec).padStart(2, '0')}`;
}
function setHeader(movie, { activeYear } = {}) {
const titleEl = document.getElementById('movie-title');
const categoryEl = document.getElementById('movie-category');
const ratingEl = document.getElementById('movie-rating');
const year = Number.isInteger(activeYear) ? activeYear : null;
const titlePrefix = year ? `Pool Oscars (${year})` : 'Pool Oscars';
if (titleEl) titleEl.textContent = movie?.title || titlePrefix;
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 ? `${titlePrefix} - ${movie.title}` : titlePrefix;
}
function normalizeVodLink(raw) {
const v = String(raw || '').trim();
if (!v) return null;
// Support pasted iframe embed code (extract src)
if (v.toLowerCase().includes('<iframe')) {
try {
const doc = new DOMParser().parseFromString(v, 'text/html');
const iframe = doc.querySelector('iframe');
const src = iframe?.getAttribute('src');
return src ? String(src).trim() : null;
} catch (_) {
return null;
}
}
return v;
}
function looksLikeHls(url) {
try {
const u = new URL(url, window.location.origin);
return u.pathname.toLowerCase().endsWith('.m3u8');
} catch (_) {
return String(url).toLowerCase().includes('.m3u8');
}
}
function looksLikeVideoFile(url) {
const s = String(url || '').toLowerCase();
// common direct file extensions
return /\.(mp4|webm|ogg|ogv|mov|m4v)(\?|#|$)/.test(s);
}
function toYoutubeEmbed(url) {
try {
const u = new URL(url);
const host = u.hostname.replace(/^www\./, '');
if (host === 'youtu.be') {
const id = u.pathname.split('/').filter(Boolean)[0];
return id ? `https://www.youtube.com/embed/${id}` : null;
}
if (host.endsWith('youtube.com')) {
if (u.pathname.startsWith('/embed/')) return url;
const id = u.searchParams.get('v');
return id ? `https://www.youtube.com/embed/${id}` : null;
}
return null;
} catch (_) {
return null;
}
}
function toVimeoEmbed(url) {
try {
const u = new URL(url);
const host = u.hostname.replace(/^www\./, '');
if (host === 'player.vimeo.com' && u.pathname.startsWith('/video/')) return url;
if (host === 'vimeo.com') {
const id = u.pathname.split('/').filter(Boolean)[0];
return id ? `https://player.vimeo.com/video/${id}` : null;
}
return null;
} catch (_) {
return null;
}
}
function showVideoPlayer() {
const videoEl = document.getElementById('video');
const iframeEl = document.getElementById('embed');
if (iframeEl) {
iframeEl.classList.add('d-none');
iframeEl.removeAttribute('src');
}
if (videoEl) videoEl.classList.remove('d-none');
return videoEl;
}
function showEmbedPlayer(embedUrl) {
const videoEl = document.getElementById('video');
const iframeEl = document.getElementById('embed');
if (videoEl) {
try { videoEl.pause(); } catch (_) {}
videoEl.classList.add('d-none');
// best-effort: clear any previous src
try { videoEl.removeAttribute('src'); videoEl.load(); } catch (_) {}
}
if (iframeEl) {
iframeEl.classList.remove('d-none');
iframeEl.setAttribute('src', embedUrl);
}
}
function hideAllPlayers() {
const videoEl = document.getElementById('video');
const iframeEl = document.getElementById('embed');
if (videoEl) {
try { videoEl.pause(); } catch (_) {}
videoEl.classList.add('d-none');
try { videoEl.removeAttribute('src'); videoEl.load(); } catch (_) {}
}
if (iframeEl) {
iframeEl.classList.add('d-none');
iframeEl.removeAttribute('src');
}
}
function isApiVideoUrl(raw) {
const s = String(raw || '').trim();
if (!s) return false;
if (s.startsWith('/api/video/')) return true;
try {
const u = new URL(s);
return u.pathname.startsWith('/api/video/');
} catch (_) {
return false;
}
}
function toVideoSessionUrl(videoSrc) {
const s = String(videoSrc || '').trim();
if (!s) return null;
if (s.startsWith('/')) return '/api/video/session';
try {
const u = new URL(s);
return new URL('/api/video/session', u.origin).toString();
} catch (_) {
return null;
}
}
// 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
async function ensureVideoSessionForSource(videoSrc, token) {
// Only needed for our protected /api/video/* streams.
if (!token) return;
if (!isApiVideoUrl(videoSrc)) return;
const sessionUrl = toVideoSessionUrl(videoSrc);
if (!sessionUrl) return;
// If sessionUrl is cross-origin, we need credentials so the cookie is stored for that domain.
const isCrossOrigin = (() => {
try {
const u = new URL(sessionUrl, window.location.origin);
return u.origin !== window.location.origin;
} catch (_) {
return false;
}
})();
const cacheKey = `${sessionUrl}::${token}`;
const cachedUntil = _videoSessionCache.get(cacheKey) || 0;
if (Date.now() < cachedUntil) return;
try {
await fetch(sessionUrl, {
method: 'POST',
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
// Always include credentials so the cookie is stored even if the browser
// treats this as cross-origin unexpectedly (hostname differences, etc).
credentials: 'include',
cache: 'no-store',
});
_videoSessionCache.set(cacheKey, Date.now() + 60_000); // 60s
} catch (_) {
// best-effort
}
}
function addTokenToApiVideoUrl(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 (_) {
// Fallback: naive append
const s = String(rawUrl);
const join = s.includes('?') ? '&' : '?';
return `${s}${join}token=${encodeURIComponent(token)}`;
}
}
async function probeApiVideoReadable(rawUrl) {
try {
const res = await fetch(rawUrl, {
method: 'GET',
headers: { Range: 'bytes=0-0' },
credentials: 'include',
cache: 'no-store',
});
return res.status !== 401;
} catch (_) {
return true; // don't block playback if probe fails (CORS/network/etc)
}
}
async function ensurePlayableApiVideoUrl(rawUrl, token) {
if (!isApiVideoUrl(rawUrl) || !token) return rawUrl;
// IMPORTANT: Prefer query-token auth for media elements.
// Cookie-based auth is unreliable cross-origin (and even across ports in some setups)
// and causes intermittent 401s + preflight noise. Both Node + Python servers already
// support ?token=... so we use it consistently.
return addTokenToApiVideoUrl(rawUrl, token);
}
async function playVodLink(vodLink, { token } = {}) {
const raw = normalizeVodLink(vodLink);
if (!raw) {
showAlert('No VOD link configured for this movie.');
setSourceLabel('—');
return;
}
// Prefer known embed providers
const yt = toYoutubeEmbed(raw);
if (yt) {
setSourceLabel('YouTube (embed)');
showEmbedPlayer(yt);
return;
}
const vimeo = toVimeoEmbed(raw);
if (vimeo) {
setSourceLabel('Vimeo (embed)');
showEmbedPlayer(vimeo);
return;
}
// App/Python protected video endpoint (no file extension, but it's still a direct <video> stream).
if (isApiVideoUrl(raw)) {
setSourceLabel('Server video stream (/api/video)');
const videoEl = showVideoPlayer();
if (!videoEl) return;
const playable = await ensurePlayableApiVideoUrl(raw, token);
videoEl.src = playable;
videoEl.addEventListener('error', () => {
// If the browser can't play it (or CORS blocks), fall back to iframe.
setSourceLabel('Embed (fallback)');
showEmbedPlayer(playable);
}, { once: true });
try { videoEl.load(); } catch (_) {}
return;
}
// Direct video file
if (looksLikeVideoFile(raw)) {
setSourceLabel('Direct video file');
const videoEl = showVideoPlayer();
if (!videoEl) return;
videoEl.src = raw;
videoEl.addEventListener('error', () => {
// 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);
}
function cleanUrlValue(v) {
const s = typeof v === 'string' ? v.trim() : '';
return s ? s : null;
}
function resolveMovieSource(movie) {
const mode = String(movie?.player_mode || 'auto').toLowerCase();
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;
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 || serverVideoUrl || embed || null; // auto
if (picked) return { src: picked, isLegacy: false };
if (legacy) return { src: legacy, isLegacy: true };
return { src: null, isLegacy: false };
}
window.onload = async function () {
const pageLoader = createPageLoader({ title: 'Chargement du lecteur' });
// 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;
}
pageLoader.setProgress(14);
// Load the active Oscar year in parallel with the movie fetch.
const activeYearPromise = fetchActiveOscarYear();
let decoded = null;
try {
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('user-name');
if (nameEl) nameEl.textContent = decoded.name || '';
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');
if (logoffBtn) {
logoffBtn.addEventListener('click', function () {
localStorage.removeItem('auth_token');
window.location.href = '/';
});
}
} catch (_) {
localStorage.removeItem('auth_token');
window.location.href = '/';
return;
}
pageLoader.setProgress(26);
const id = getQueryParam('id');
if (!id) {
showAlert('Missing movie id.');
pageLoader.fail();
return;
}
try {
pageLoader.setProgress(36);
const res = await fetch(`/api/movies/${encodeURIComponent(id)}`, {
method: 'GET',
headers: token ? { 'Authorization': `Bearer ${token}` } : undefined,
});
const movie = await res.json().catch(() => ({}));
if (!res.ok) {
showAlert(movie?.message || movie?.error || `Failed to load movie (${res.status}).`);
pageLoader.fail();
return;
}
const activeYear = await activeYearPromise;
setHeader(movie, { activeYear });
pageLoader.setProgress(58);
const resolved = resolveMovieSource(movie);
if (!resolved?.src) {
setSourceLabel('—');
hideAllPlayers();
showAlert('No playable source configured for this movie.');
pageLoader.fail();
return;
}
// We use ?token=... for /api/video/* playback (more reliable than cookies cross-origin).
if (resolved.isLegacy) {
// Legacy source: never embed it in the player.
setSourceLabel('Legacy link');
hideAllPlayers();
showAlertVariant('Legacy source: this link is not playable in the in-app player.', 'warning');
pageLoader.done();
return;
}
pageLoader.setProgress(70);
await playVodLink(resolved.src, { token });
pageLoader.setProgress(82);
// Only the <video> player supports reliable progress tracking (mp4/hls).
const videoEl = document.getElementById('video');
if (videoEl && !videoEl.classList.contains('d-none')) {
setProgressUi({ state: 'preparing', text: 'Reprise', showText: true, ariaText: 'Prépare la reprise…' });
const userId = normalizeUserId(decoded);
if (userId) {
setupVideoProgress({ videoEl, movieId: id, token, userId, imdbId: movie?.imdb_id });
} else {
// Avoid a "stuck" progress state if the token payload is unexpected.
setProgressUi({ state: 'warning', text: '—', showText: true, ariaText: "Impossible d'identifier l'utilisateur pour sauvegarder la progression." });
}
} else {
setProgressUi({ state: 'info', text: '—', showText: true });
}
pageLoader.setProgress(92);
pageLoader.done();
} catch (err) {
showAlert(err.message || 'Network error.');
pageLoader.fail();
}
};
+40 -4
View File
@@ -1,8 +1,44 @@
// Check if the user is already logged in by looking for auth_token in localStorage
if (localStorage.getItem('auth_token')) {
// If the token is found, redirect to /movies.html
window.location.href = '/movies.html';
function decodeJwt(token) {
return JSON.parse(atob(token.split('.')[1]));
}
async function applyActiveOscarYearToTitle() {
const base = 'Pool Oscars';
const current = String(document.title || base);
const sep = ' - ';
const idx = current.indexOf(sep);
const suffix = idx >= 0 ? current.slice(idx) : '';
try {
const res = await fetch('/api/settings/year', { method: 'GET' });
const data = res.ok ? await res.json().catch(() => null) : null;
const year = Number(data?.year);
document.title = Number.isInteger(year) ? `${base} (${year})${suffix}` : `${base}${suffix}`;
} catch (_) {
document.title = `${base}${suffix}`;
}
}
// Fire and forget.
applyActiveOscarYearToTitle();
// Check if the user is already logged in by looking for auth_token in localStorage
const existingToken = localStorage.getItem('auth_token');
if (existingToken) {
try {
const decoded = decodeJwt(existingToken);
const currentTime = Math.floor(Date.now() / 1000);
if (decoded.exp && decoded.exp < currentTime) {
localStorage.removeItem('auth_token');
} else if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
} else {
window.location.href = '/movies.html';
}
} catch (_) {
localStorage.removeItem('auth_token');
}
}
// Handle registration form submission
document.getElementById('register-form').addEventListener('submit', async function (e) {
+387 -13
View File
@@ -1,11 +1,341 @@
window.onload = async function () {
function createPageLoader(options = {}) {
const title = String(options.title || 'Chargement…');
// Subtitle kept for backwards-compat with callers, but we no longer render text in the UI.
const subtitle = String(options.subtitle || 'Préparation de la page…');
let progress = 0;
let removed = false;
let navResizeObserver = null;
// Reuse a preloaded overlay if present (prevents initial "clear" flash).
const preloadedOverlay = document.getElementById('page-loader-overlay');
const overlay = preloadedOverlay || document.createElement('div');
if (!preloadedOverlay) {
overlay.className = 'page-loader-overlay';
overlay.setAttribute('role', 'status');
overlay.setAttribute('aria-live', 'polite');
overlay.setAttribute('aria-busy', 'true');
overlay.innerHTML = `
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">${title}</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar"></div>
</div>
</div>
`;
} else {
// Keep screen reader label up to date.
const label = overlay.querySelector('.visually-hidden');
if (label) label.textContent = title;
}
const barEl = () => overlay.querySelector('#page-loader-bar');
function clampPct(p) {
const n = Number(p);
if (!Number.isFinite(n)) return 0;
return Math.max(0, Math.min(100, n));
}
function setProgress(p) {
progress = clampPct(p);
const bar = barEl();
if (bar) bar.style.width = `${progress}%`;
}
function setSubtitle(_) {
// Intentionally no-op (we only show the bar now).
}
function getNavHeightPx() {
const nav = document.querySelector('nav.navbar');
if (!nav) return 0;
const rect = nav.getBoundingClientRect();
const h = Number(rect?.height) || 0;
return h > 0 ? Math.round(h) : 0;
}
function updateOverlayTopOffset() {
// Scope the offset to this overlay instance (no global CSS var).
overlay.style.setProperty('--page-loader-top', `${getNavHeightPx()}px`);
}
function ensureMounted() {
if (removed) return;
// IMPORTANT: even if the overlay is preloaded in HTML (already connected),
// we still need to compute the navbar offset. Otherwise it defaults to 0px
// and the blur covers the header.
updateOverlayTopOffset();
document.body.classList.add('page-loading');
if (!overlay.isConnected) document.body.appendChild(overlay);
// Keep the overlay aligned if the navbar height changes (mobile collapse, resize, etc.)
if (!navResizeObserver) {
const nav = document.querySelector('nav.navbar');
if (nav && typeof ResizeObserver !== 'undefined') {
try {
navResizeObserver = new ResizeObserver(() => updateOverlayTopOffset());
navResizeObserver.observe(nav);
} catch (_) {
navResizeObserver = null;
}
}
}
}
function hideAndRemoveSoon() {
if (removed) return;
overlay.classList.add('page-loader-hide');
overlay.setAttribute('aria-busy', 'false');
document.body.classList.remove('page-loading');
window.setTimeout(() => {
removed = true;
if (navResizeObserver) {
try { navResizeObserver.disconnect(); } catch (_) {}
navResizeObserver = null;
}
try { overlay.remove(); } catch (_) {}
}, 220);
}
function done() {
setProgress(100);
hideAndRemoveSoon();
}
function fail(_) {
// Don't block the UI on errors: let the page render its own error message.
setProgress(Math.max(progress, 95));
window.setTimeout(() => hideAndRemoveSoon(), 200);
}
ensureMounted();
setProgress(8);
return { setProgress, setSubtitle, done, fail };
}
window.addEventListener('DOMContentLoaded', async function () {
const pageLoader = createPageLoader({
title: 'Chargement des statistiques',
subtitle: 'Récupération des données…'
});
const token = localStorage.getItem('auth_token');
let winnersCache = null;
let completionsCache = null;
async function fetchActiveYear() {
try {
const res = await fetch('/api/settings/year', { method: 'GET' });
if (!res.ok) throw new Error('Failed to fetch active year');
const data = await res.json();
const year = Number(data?.year);
return Number.isInteger(year) ? year : null;
} catch (_) {
return null;
}
}
async function fetchWinners() {
try {
const res = await fetch('/api/settings/winners', { method: 'GET' });
if (!res.ok) throw new Error('Failed to fetch winners');
const data = await res.json().catch(() => ({}));
return Array.isArray(data?.winners) ? data.winners : [];
} catch (_) {
return [];
}
}
async function fetchCompletions() {
try {
const res = await fetch('/api/users/completions', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
if (!res.ok) throw new Error('Failed to fetch completions');
const data = await res.json().catch(() => ({}));
return data && typeof data === 'object' ? data : null;
} catch (_) {
return null;
}
}
function renderWinners(winners) {
const tbody = document.getElementById('winners-table-body');
if (!tbody) return;
const list = Array.isArray(winners) ? winners : [];
if (!list.length) {
tbody.innerHTML = `
<tr>
<td colspan="2" class="text-muted">Aucun gagnant défini.</td>
</tr>
`;
return;
}
// Group by year (supports ties)
const byYear = new Map();
list.forEach((w) => {
const y = Number(w?.year || 0);
if (!Number.isInteger(y) || y < 1900 || y > 3000) return;
const arr = byYear.get(String(y)) || [];
arr.push({
year: y,
name: w?.name || '(utilisateur supprimé)',
points: w?.points === null || w?.points === undefined || w?.points === '' ? null : Number(w.points),
});
byYear.set(String(y), arr);
});
const years = Array.from(byYear.keys()).map(Number).sort((a, b) => b - a);
tbody.innerHTML = '';
years.forEach((year) => {
const winnersForYear = byYear.get(String(year)) || [];
const sorted = winnersForYear.slice().sort((a, b) => {
const ap = a.points === null || Number.isNaN(a.points) ? null : a.points;
const bp = b.points === null || Number.isNaN(b.points) ? null : b.points;
if (ap === null && bp !== null) return 1;
if (ap !== null && bp === null) return -1;
if (ap !== null && bp !== null && ap !== bp) return bp - ap;
return String(a.name || '').localeCompare(String(b.name || ''), 'fr', { sensitivity: 'base' });
});
const tr = document.createElement('tr');
const tdYear = document.createElement('td');
tdYear.className = 'fw-semibold';
tdYear.textContent = String(year);
if (sorted.length > 1) {
const small = document.createElement('div');
small.className = 'text-muted small';
small.textContent = 'égalité';
tdYear.appendChild(document.createElement('br'));
tdYear.appendChild(small);
}
const tdWinners = document.createElement('td');
sorted.forEach((w, idx) => {
const row = document.createElement('div');
if (idx > 0) row.classList.add('mt-1');
const name = document.createElement('span');
name.className = 'fw-semibold';
name.textContent = w?.name || '(utilisateur supprimé)';
row.appendChild(name);
const pts = w.points === null || Number.isNaN(w.points) ? null : w.points;
if (pts !== null) {
const ptsSpan = document.createElement('span');
ptsSpan.className = 'text-muted';
ptsSpan.textContent = ` — ${pts} pts`;
row.appendChild(ptsSpan);
}
tdWinners.appendChild(row);
});
tr.appendChild(tdYear);
tr.appendChild(tdWinners);
tbody.appendChild(tr);
});
}
function renderCompletions(completions) {
const tbody = document.getElementById('completers-table-body');
if (!tbody) return;
if (!completions || typeof completions !== 'object') {
tbody.innerHTML = `
<tr>
<td colspan="4" class="text-muted">Impossible de charger les finisseurs 100%.</td>
</tr>
`;
return;
}
const years = Array.isArray(completions?.years) ? completions.years : [];
const totals = completions?.totals && typeof completions.totals === 'object' ? completions.totals : {};
const byYear = completions?.completersByYear && typeof completions.completersByYear === 'object' ? completions.completersByYear : {};
if (!years.length) {
tbody.innerHTML = `
<tr>
<td colspan="4" class="text-muted">Aucune année trouvée.</td>
</tr>
`;
return;
}
const sortedYears = years
.map((y) => Number(y))
.filter((y) => Number.isInteger(y))
.sort((a, b) => b - a);
tbody.innerHTML = '';
sortedYears.forEach((y) => {
const yearStr = String(y);
const total = Number(totals?.[yearStr] ?? 0);
const completers = Array.isArray(byYear?.[yearStr]) ? byYear[yearStr] : [];
const count = completers.length;
const tr = document.createElement('tr');
const tdYear = document.createElement('td');
tdYear.className = 'fw-semibold';
tdYear.textContent = yearStr;
const tdCount = document.createElement('td');
const badge = document.createElement('span');
badge.className = 'badge bg-secondary';
badge.textContent = String(count);
tdCount.appendChild(badge);
const tdTotal = document.createElement('td');
tdTotal.textContent = String(Number.isFinite(total) ? total : 0);
const tdNames = document.createElement('td');
if (!count) {
tdNames.className = 'text-muted';
tdNames.textContent = '—';
} else {
const wrap = document.createElement('div');
wrap.className = 'd-flex flex-wrap gap-1';
completers.forEach((u) => {
const name = document.createElement('span');
name.className = 'badge bg-light text-dark border';
name.textContent = u?.name || '(sans nom)';
wrap.appendChild(name);
});
tdNames.appendChild(wrap);
}
tr.appendChild(tdYear);
tr.appendChild(tdCount);
tr.appendChild(tdTotal);
tr.appendChild(tdNames);
tbody.appendChild(tr);
});
}
if (token) {
try {
const decoded = JSON.parse(atob(token.split('.')[1])); // Manually decoding JWT
if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
return;
}
const userName = decoded.name;
document.getElementById('user-name').textContent = userName;
if (decoded.admin) {
const adminLink = document.getElementById('admin-control-link');
if (adminLink) adminLink.classList.remove('d-none');
}
// Verify JWT expiration
const currentTime = Math.floor(Date.now() / 1000);
@@ -24,21 +354,45 @@ window.onload = async function () {
}
try {
const statsRes = await fetch('/api/users/stats', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`
}
});
pageLoader.setProgress(12);
// Show loading states immediately (avoid "blank" panels)
const table = document.querySelector('.user-table');
const statsError = document.getElementById('stats-error');
if (table) table.classList.add('d-none');
if (statsError) statsError.classList.add('d-none');
pageLoader.setProgress(20);
const activeYear = await fetchActiveYear();
if (activeYear) {
document.title = `Pool Oscars (${activeYear}) - Statistiques des utilisateurs`;
const h2 = document.querySelector('h2');
if (h2) h2.textContent = `Statistiques des utilisateurs (${activeYear})`;
}
const statsUrl = activeYear ? `/api/users/stats?year=${encodeURIComponent(String(activeYear))}` : '/api/users/stats';
pageLoader.setProgress(32);
const [statsRes, winners, completions] = await Promise.all([
fetch(statsUrl, {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
}),
fetchWinners(),
fetchCompletions(),
]);
winnersCache = winners;
completionsCache = completions;
renderWinners(winnersCache);
renderCompletions(completionsCache);
if (!statsRes.ok) {
throw new Error('Failed to fetch user stats');
}
pageLoader.setProgress(70);
const stats = await statsRes.json();
const userTableBody = document.getElementById('user-table-body');
const table = document.querySelector('.user-table');
const spinner = document.getElementById('loading-spinner');
// Ensure watchedRatio is treated as a number and sort in descending order
stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio));
@@ -58,9 +412,10 @@ window.onload = async function () {
userTableBody.appendChild(userRow);
});
// Hide spinner and show table after loading
spinner.style.display = 'none';
table.style.display = 'table';
// Show table after loading
if (table) table.classList.remove('d-none');
if (statsError) statsError.classList.add('d-none');
pageLoader.setProgress(92);
// Add event listener for user links
document.querySelectorAll('.user-link').forEach(link => {
@@ -87,8 +442,27 @@ window.onload = async function () {
modal.show();
});
});
pageLoader.done();
} catch (error) {
console.error('Error loading user statistics:', error);
const table = document.querySelector('.user-table');
const statsError = document.getElementById('stats-error');
if (table) table.classList.add('d-none');
if (statsError) {
statsError.textContent = "Impossible de charger le classement pour le moment.";
statsError.classList.remove('d-none');
}
// Still attempt to render these sections even if stats fail (best effort)
try {
if (winnersCache === null) winnersCache = await fetchWinners();
renderWinners(winnersCache);
} catch (_) {}
try {
if (completionsCache === null) completionsCache = await fetchCompletions();
renderCompletions(completionsCache);
} catch (_) {}
pageLoader.fail();
}
// Log-off functionality
@@ -96,4 +470,4 @@ window.onload = async function () {
localStorage.removeItem('auth_token');
window.location.href = '/';
});
};
});
+43 -59
View File
@@ -3,9 +3,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars 2025 - Films</title>
<title>Pool Oscars - Films</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
background-color: black;
@@ -19,42 +21,9 @@
color: #ddd;
}
/* Style for the 'WATCHED' banner */
.watched-banner {
background-color: green;
color: white;
text-align: center;
padding: 5px;
font-weight: bold;
position: absolute;
top: 10px;
width: 100%;
z-index: 1;
}
/* Centering checkbox and making card look better */
.movie-card {
position: relative;
margin-bottom: 20px;
}
.card-body {
position: relative;
}
#movies-list {
margin-top: 10px;
}
/* Style for the filter button */
#filter-button {
margin-left: 15px;
}
/* Style for the ratio display */
.ratio {
font-weight: bold;
margin-top: 10px;
}
.navbar-toggler {
border-color: white; /* Make the border white */
}
@@ -64,13 +33,23 @@
}
</style>
</head>
<body class="bg-light">
<body class="bg-light page-loading">
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">Chargement…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
</div>
</div>
<!-- Black banner with buttons and user info -->
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container">
<a class="navbar-brand" style="margin-left: -7px;">
<img src="img/logo_w.png" style="max-width: 220px; height: 70px; object-fit: contain;">
<img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
@@ -80,6 +59,7 @@
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
</div>
<!-- Move user info inside collapsible navbar and align it properly -->
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
@@ -91,31 +71,35 @@
</nav>
<div class="container mt-5">
<div class="d-flex justify-content-between align-items-center">
<h2>Films</h2>
<h2>Films</h2>
<!-- Summary card (match Statistiques/Checklist structure) -->
<div class="card shadow-sm mt-3">
<div class="card-body">
<div class="d-flex flex-column gap-1">
<div class="text-muted small">
Dernière mise à jour des films: <span class="fw-semibold text-dark" id="movies-last-updated">—</span>
</div>
<div id="watched-ratio" class="fw-semibold">—</div>
<div class="text-muted small">
Temps avant les Oscars (<span id="oscar-date-day-month">—</span> <span id="oscar-year">—</span>) — <span id="time-left">—</span>
</div>
</div>
<div class="d-flex flex-column flex-md-row align-items-start align-items-md-center justify-content-between gap-2 mt-3">
<div class="form-check form-switch m-0">
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault">
<label class="form-check-label" for="flexSwitchCheckDefault">Films non regardés seulement</label>
</div>
<div class="text-muted small">Clique sur l’affiche ou le titre pour ouvrir le lecteur.</div>
</div>
</div>
</div>
<!-- Displaying last updated vod_links -->
<div class="alert alert-warning fade show" role="alert">
Dernière mise à jour des films: <strong>09-02-2025</strong>
</div>
<!-- Displaying the ratio of watched movies -->
<div id="watched-ratio" class="ratio">
<!-- This will display the ratio -->
</div>
<h3 class="fs-6">Temps avant les Oscars (2 mars 2025) - <span id="time-left"></span></h3>
<h3 class="fs-6 mb-0">
</h3>
<br>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault">
<label class="form-check-label" for="flexSwitchCheckDefault">Films non regardés seulement</label>
</div>
<div id="movies-list" class="row">
<!-- Movies will be displayed here -->
<div id="movies-list" class="row g-3 mt-1">
<div class="col-12">
<div class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</div>
</div>
</div>
</div>
+197
View File
@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- IMPORTANT: the player may use ?token=... for cross-origin video streaming -->
<meta name="referrer" content="no-referrer" />
<title>Pool Oscars</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
background-color: black;
color: white;
}
.navbar-custom .navbar-brand,
.navbar-custom .nav-link {
color: white;
}
.navbar-custom .nav-link:hover {
color: #ddd;
}
.navbar-toggler {
border-color: white;
}
.navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
}
.player-wrap {
max-width: 1100px;
margin: 0 auto;
}
.player-surface {
background: #000;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(0,0,0,0.1);
}
video {
width: 100%;
height: auto;
display: block;
background: #000;
}
iframe {
width: 100%;
aspect-ratio: 16 / 9;
border: 0;
display: block;
background: #000;
}
.meta-muted {
color: rgba(0,0,0,0.65);
}
.badge-src {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Progress indicator: icon-first, minimal text */
.progress-indicator {
display: inline-flex;
align-items: center;
gap: .4rem;
min-height: 1rem;
}
.progress-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
color: rgba(0,0,0,.65);
flex: 0 0 1rem;
}
.progress-icon--spinner {
border: 2px solid rgba(0,0,0,.22);
border-top-color: rgba(0,0,0,.65);
border-radius: 50%;
animation: progressSpin .8s linear infinite;
}
.progress-icon svg {
width: 1rem;
height: 1rem;
display: block;
}
.progress-text {
opacity: .8;
white-space: nowrap;
}
.progress-text.is-hidden {
display: none;
}
@keyframes progressSpin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body class="bg-light page-loading">
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">Chargement…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
</div>
</div>
<!-- Black banner with buttons and user info -->
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container">
<a class="navbar-brand" style="margin-left: -7px;" href="movies.html">
<img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<div class="navbar-nav">
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
</div>
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center gap-2">
<span id="user-name" class="navbar-text me-1"></span>
<button id="log-off" class="btn btn-danger">Déconnexion</button>
</div>
</div>
</div>
</nav>
<div class="container mt-5 pb-4 player-wrap">
<div id="alert" class="alert alert-danger d-none" role="alert"></div>
<!-- Header / metadata -->
<div class="card shadow-sm mt-3">
<div class="card-body">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div>
<h2 id="movie-title" class="mb-1">Chargement…</h2>
<div class="text-muted small">
<span id="movie-category"></span><span id="movie-rating"></span>
</div>
</div>
<!-- Admin-only debug/status (Source + Progress) -->
<div id="player-admin-status" class="d-none d-flex flex-column gap-2">
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="badge bg-secondary">Source</span>
<span id="source-label" class="badge bg-light text-dark badge-src">—</span>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<span class="badge bg-secondary">Progress</span>
<span id="progress-status" class="text-muted small progress-indicator">
<span id="progress-status-icon" class="progress-icon" aria-hidden="true"></span>
<span id="progress-status-text" class="progress-text">—</span>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Player surface -->
<div class="card shadow-sm mt-3">
<div class="card-body p-0">
<div class="player-surface">
<video id="video" class="d-none" controls playsinline></video>
<iframe
id="embed"
class="d-none"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
referrerpolicy="no-referrer-when-downgrade"
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-presentation"
></iframe>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
<!-- HLS support (m3u8) -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js"></script>
<script src="./js/app_player.js"></script>
</body>
</html>
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars 2025 - Enregistrement</title>
<title>Pool Oscars - Enregistrement</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
</head>
+107 -25
View File
@@ -3,8 +3,11 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pool Oscars 2025 - Statistiques des utilisateurs</title>
<title>Pool Oscars - Statistiques des utilisateurs</title>
<link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="./css/app.css" rel="stylesheet">
<link rel="preload" as="image" href="img/logo_w.png">
<script src="./js/app_header_boot.js" defer></script>
<style>
.navbar-custom {
background-color: black;
@@ -20,25 +23,39 @@
.user-table {
margin-top: 20px;
}
.ratio {
font-weight: bold;
margin-top: 10px;
}
.navbar-toggler {
border-color: white; /* Make the border white */
}
.navbar-toggler-icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3E%3Cpath stroke='white' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 7h22M4 15h22M4 23h22'/%3E%3C/svg%3E");
}
/* Stats tabs */
.stats-tabs .nav-link {
font-weight: 600;
}
.stats-tab-panel {
padding-top: 1rem;
}
</style>
</head>
<body class="bg-light">
<body class="bg-light page-loading">
<!-- Preloaded loader overlay to avoid initial "clear" flash -->
<div id="page-loader-overlay" class="page-loader-overlay" data-preloaded="true" role="status" aria-live="polite" aria-busy="true">
<div class="page-loader-card page-loader-card--baronly">
<span class="visually-hidden">Chargement…</span>
<div class="page-loader-progress" aria-hidden="true">
<div class="page-loader-bar" id="page-loader-bar" style="width: 8%;"></div>
</div>
</div>
</div>
<!-- Black banner with buttons and user info -->
<nav class="navbar navbar-expand-lg navbar-custom">
<div class="container">
<a class="navbar-brand" style="margin-left: -7px;">
<img src="img/logo_w.png" style="max-width: 220px; height: 70px; object-fit: contain;">
<img src="img/logo_w.png" width="220" height="70" loading="eager" fetchpriority="high" decoding="async" style="max-width: 220px; height: 70px; object-fit: contain;">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
@@ -48,6 +65,7 @@
<a href="movies.html" class="nav-link btn btn-outline-light me-2">Films</a>
<a href="checklist.html" class="nav-link btn btn-outline-light me-2">Checklist</a>
<a href="user-stat.html" class="nav-link btn btn-outline-light me-2">Statistiques</a>
<a id="admin-control-link" href="control.html" class="nav-link btn btn-warning me-2 d-none">Admin</a>
</div>
<!-- Move user info inside collapsible navbar and align it properly -->
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
@@ -60,25 +78,89 @@
<div class="container mt-5">
<h2>Statistiques des utilisateurs</h2>
<!-- Spinner shown while loading -->
<div id="loading-spinner" class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
<!-- Tabs -->
<ul class="nav nav-tabs stats-tabs mt-3" id="statsTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-ranking" data-bs-toggle="tab" data-bs-target="#pane-ranking" type="button" role="tab" aria-controls="pane-ranking" aria-selected="true">
Classement
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-winners" data-bs-toggle="tab" data-bs-target="#pane-winners" type="button" role="tab" aria-controls="pane-winners" aria-selected="false">
Gagnants
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tab-completion" data-bs-toggle="tab" data-bs-target="#pane-completion" type="button" role="tab" aria-controls="pane-completion" aria-selected="false">
100%
</button>
</li>
</ul>
<div class="tab-content stats-tab-panel" id="statsTabsContent">
<!-- Ranking tab -->
<div class="tab-pane fade show active" id="pane-ranking" role="tabpanel" aria-labelledby="tab-ranking" tabindex="0">
<div id="stats-error" class="alert alert-danger d-none" role="alert"></div>
<!-- Table for users and movies watched -->
<table class="table table-striped table-hover align-middle user-table d-none">
<thead>
<tr>
<th scope="col">Nom</th>
<th scope="col">Total des films regardés (pts)</th>
<th scope="col">Ratio</th>
</tr>
</thead>
<tbody id="user-table-body">
<!-- User rows will be inserted here -->
</tbody>
</table>
</div>
<!-- Winners tab -->
<div class="tab-pane fade" id="pane-winners" role="tabpanel" aria-labelledby="tab-winners" tabindex="0">
<h4 class="mb-2">Gagnants</h4>
<div class="table-responsive mb-4">
<table class="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col" style="width: 110px;">Année</th>
<th scope="col">Gagnant(s)</th>
</tr>
</thead>
<tbody id="winners-table-body">
<tr>
<td colspan="2" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- 100% completers tab -->
<div class="tab-pane fade" id="pane-completion" role="tabpanel" aria-labelledby="tab-completion" tabindex="0">
<h4 class="mb-2">Utilisateurs ayant terminé 100% (par année)</h4>
<div class="text-muted mb-2">Completion = 100% pour l’année (tous les films de l’année vus).</div>
<div class="table-responsive mb-4">
<table class="table table-sm table-striped align-middle mb-0">
<thead>
<tr>
<th scope="col" style="width: 110px;">Année</th>
<th scope="col" style="width: 140px;">Finisseur(s)</th>
<th scope="col" style="width: 140px;">Total films</th>
<th scope="col">Noms</th>
</tr>
</thead>
<tbody id="completers-table-body">
<tr>
<td colspan="4" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Table for users and movies watched -->
<table class="table user-table" style="display: none;">
<thead>
<tr>
<th scope="col">Nom</th>
<th scope="col">Total des films regardés (pts)</th>
<th scope="col">Ratio</th>
</tr>
</thead>
<tbody id="user-table-body">
<!-- User rows will be inserted here -->
</tbody>
</table>
</div>
<!-- Modal for displaying movies watched -->
+107
View File
@@ -0,0 +1,107 @@
# Python video server (Range streaming + JWT + Mongo)
This is a small standalone server that mirrors the app’s `/api/video/:id` behavior:
- Looks up `movies._id == :id` in MongoDB and reads the `video_file` field
- Streams the file from `VIDEO_FILES_DIR` with **HTTP Range** support (seeking works)
- Auth via the same JWT (`Authorization: Bearer ...`), plus a `video_auth` cookie for `<video>`
## Run
```bash
python -m pip install -r python_video_server/requirements.txt
export MONGO_URI="..."
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
```
## HTTPS (Let’s Encrypt, Python-only)
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**.
### 1) Issue a certificate (HTTP-01 standalone)
Requirements:
- Your domain’s DNS `A/AAAA` records must point to this machine.
- Port **80** must be reachable from the internet for the ACME challenge.
```bash
python3 -m pip install -r python_video_server/requirements.txt
# Staging by default (recommended first). Add --production when it works.
sudo -E python3 -m python_video_server.letsencrypt certonly \
--email [email protected] \
--domains example.com,www.example.com
```
### 1b) Issue a certificate (DNS-01 manual TXT record — no port 80, no root)
If your host already uses port **80** (or you don't have permission to bind it), use DNS-01.
This does **not** require any inbound ports on the machine, but you must be able to edit DNS for the domain.
```bash
python3 -m pip install -r python_video_server/requirements.txt
# Add --production when staging works.
python3 -m python_video_server.letsencrypt certonly \
--challenge dns \
--email [email protected] \
--domains example.com,www.example.com
```
During issuance, certbot will prompt you to create a TXT record like:
- Name: `_acme-challenge.example.com`
- Type: `TXT`
- Value: (a token certbot prints)
After it propagates, press Enter and certbot will continue.
If you run into a situation where the prompt/token only shows up in logs, you can try the hook-based mode:
```bash
python3 -m python_video_server.letsencrypt certonly \
--challenge dns \
--dns-hooks \
--email [email protected] \
--domains example.com,www.example.com
```
Certificates will be written under:
- `python_video_server/certs/config/live/<primary-domain>/fullchain.pem`
- `python_video_server/certs/config/live/<primary-domain>/privkey.pem`
### 2) Run the video server on 443 with TLS
```bash
sudo -E uvicorn python_video_server.server:app --host 0.0.0.0 --port 443 \
--ssl-certfile python_video_server/certs/config/live/example.com/fullchain.pem \
--ssl-keyfile python_video_server/certs/config/live/example.com/privkey.pem
```
Notes:
- Binding to ports **80/443** usually requires `sudo` (or a reverse proxy / port forwarding).
- If you’re behind a proxy/ingress, keep sending `X-Forwarded-Proto: https` so `/api/video/session` sets `Secure` cookies correctly.
### 3) Renew certificates
```bash
# Dry-run renewal (safe)
sudo -E python3 -m python_video_server.letsencrypt renew --dry-run
# Actual renew (typically run via cron/systemd timer)
sudo -E python3 -m python_video_server.letsencrypt renew --production
```
## Endpoints
- `POST /api/video/session`
Sets `video_auth` cookie (send `Authorization: Bearer <token>`).
- `GET|HEAD /api/video/{movieId}`
Streams the movie file (supports `Range: bytes=...`).
- `GET /healthz`
+423
View File
@@ -0,0 +1,423 @@
"""
Let’s Encrypt certificate issuance/renewal using Python-only certbot.
Why this exists:
- You already run the server with `uvicorn`.
- Uvicorn can serve HTTPS when given cert/key PEM files.
- The missing piece is obtaining and renewing a trusted certificate.
This module wraps the *certbot Python package* (no system `certbot` binary required).
Typical usage (standalone HTTP-01, binds to port 80):
sudo -E python -m python_video_server.letsencrypt certonly \
--email [email protected] \
--domains example.com,www.example.com \
--production
If you *cannot* bind port 80 (shared hosting, no root, port already in use),
use DNS-01 instead (manual TXT record):
python3 -m python_video_server.letsencrypt certonly \
--challenge dns \
--email [email protected] \
--domains example.com,www.example.com \
--production
Then run uvicorn with:
sudo -E uvicorn python_video_server.server:app --host 0.0.0.0 --port 443 \
--ssl-certfile python_video_server/certs/config/live/example.com/fullchain.pem \
--ssl-keyfile python_video_server/certs/config/live/example.com/privkey.pem
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
from typing import Iterable, List
LE_PRODUCTION_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
LE_STAGING_DIRECTORY_URL = "https://acme-staging-v02.api.letsencrypt.org/directory"
def _default_base_dir() -> Path:
# Keep certbot state inside the repo by default (and gitignored).
# This mirrors certbot's default directory structure:
# - config/live/<domain>/{fullchain.pem,privkey.pem,...}
# - config/renewal/<domain>.conf
# - work/*
# - logs/*
return (Path(__file__).resolve().parent / "certs").resolve()
def _split_domains(domains_csv: str) -> List[str]:
domains = [d.strip() for d in (domains_csv or "").split(",") if d.strip()]
# Deduplicate while preserving order
out: List[str] = []
seen = set()
for d in domains:
if d not in seen:
out.append(d)
seen.add(d)
return out
def _ensure_dirs(base_dir: Path) -> tuple[Path, Path, Path]:
config_dir = (base_dir / "config").resolve()
work_dir = (base_dir / "work").resolve()
logs_dir = (base_dir / "logs").resolve()
config_dir.mkdir(parents=True, exist_ok=True)
work_dir.mkdir(parents=True, exist_ok=True)
logs_dir.mkdir(parents=True, exist_ok=True)
return config_dir, work_dir, logs_dir
def _ensure_dns_hook_scripts(work_dir: Path) -> tuple[Path, Path]:
"""
Create small executable hook scripts used by certbot's --manual DNS flow.
Certbot runs these hooks as external executables; we generate them under the
certbot work dir so no special permissions are needed.
"""
auth_hook = (work_dir / "certbot_dns_auth_hook.py").resolve()
cleanup_hook = (work_dir / "certbot_dns_cleanup_hook.py").resolve()
auth_contents = """#!/usr/bin/env python3
import os
import sys
import time
def _open_tty():
# Certbot may capture stdout/stderr of hooks; /dev/tty forces user-visible IO
# when an interactive terminal exists.
try:
return open("/dev/tty", "r+", encoding="utf-8", buffering=1)
except Exception:
return None
def main() -> int:
domain = os.environ.get("CERTBOT_DOMAIN", "").strip()
validation = os.environ.get("CERTBOT_VALIDATION", "").strip()
if not domain or not validation:
print("Missing CERTBOT_DOMAIN/CERTBOT_VALIDATION in environment.", file=sys.stderr, flush=True)
return 2
record_name = f"_acme-challenge.{domain}"
msg = (
"\\n=== DNS-01 challenge required ===\\n"
"Create/Update this TXT record:\\n"
f" Name: {record_name}\\n"
" Type: TXT\\n"
f" Value: {validation}\\n"
"\\nAfter the record is published and has propagated, press Enter to continue.\\n"
"> "
)
tty = _open_tty()
if tty is not None:
tty.write(msg)
tty.flush()
try:
tty.readline()
except KeyboardInterrupt:
tty.write("\\nAborted.\\n")
tty.flush()
return 130
finally:
try:
tty.close()
except Exception:
pass
return 0
# Fallback: best-effort stdout + either wait or read stdin if interactive.
print(msg, end="", flush=True)
if not sys.stdin.isatty():
wait_s = int(os.environ.get("LE_DNS_WAIT_SECONDS", "300"))
print(f"\\n(non-interactive stdin detected; waiting {wait_s}s then continuing...)", flush=True)
time.sleep(max(0, wait_s))
return 0
try:
input("")
except KeyboardInterrupt:
print("\\nAborted.", file=sys.stderr, flush=True)
return 130
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""
cleanup_contents = """#!/usr/bin/env python3
import os
def _open_tty():
try:
return open("/dev/tty", "w", encoding="utf-8", buffering=1)
except Exception:
return None
def main() -> int:
domain = os.environ.get("CERTBOT_DOMAIN", "").strip()
if not domain:
return 0
msg = "\\nDNS-01 cleanup:\\nYou may now remove the TXT record: _acme-challenge.%s\\n" % domain
tty = _open_tty()
if tty is not None:
tty.write(msg)
tty.flush()
try:
tty.close()
except Exception:
pass
return 0
print(msg, end="", flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
"""
auth_hook.write_text(auth_contents, encoding="utf-8")
cleanup_hook.write_text(cleanup_contents, encoding="utf-8")
os.chmod(auth_hook, 0o755)
os.chmod(cleanup_hook, 0o755)
return auth_hook, cleanup_hook
def _run_certbot(argv: Iterable[str]) -> int:
try:
# certbot is a Python package dependency (installed via pip).
# Importing here keeps the server import path clean.
import certbot.main # type: ignore
except Exception as e:
raise SystemExit(
"Missing dependency: certbot is not installed.\n"
"Install it with:\n"
" python -m pip install -r python_video_server/requirements.txt\n"
f"\nOriginal import error: {e!r}"
)
return int(certbot.main.main(list(argv)))
def _common_certbot_flags(
*,
email: str,
domains: List[str],
base_dir: Path,
production: bool,
non_interactive: bool = True,
) -> List[str]:
if not email.strip():
raise SystemExit("--email is required")
if not domains:
raise SystemExit("--domains is required (comma-separated)")
config_dir, work_dir, logs_dir = _ensure_dirs(base_dir)
args: List[str] = [
"--agree-tos",
"--email",
email.strip(),
"--config-dir",
str(config_dir),
"--work-dir",
str(work_dir),
"--logs-dir",
str(logs_dir),
# Keep key type modern; certbot defaults may vary by version.
"--key-type",
"ecdsa",
]
if non_interactive:
args.insert(0, "--non-interactive")
if production:
args += ["--server", LE_PRODUCTION_DIRECTORY_URL]
else:
args += ["--server", LE_STAGING_DIRECTORY_URL]
for d in domains:
args += ["-d", d]
return args
def cmd_certonly(args: argparse.Namespace) -> int:
base_dir = Path(args.base_dir).expanduser().resolve() if args.base_dir else _default_base_dir()
domains = _split_domains(args.domains)
production = bool(args.production)
challenge = (args.challenge or "http").strip().lower()
if challenge not in {"http", "dns"}:
raise SystemExit("--challenge must be one of: http, dns")
if challenge == "dns":
# DNS-01 works without binding any local ports; suitable for shared hosting / no root.
#
# Default: use certbot's built-in interactive manual prompt (most reliable).
# Optional: --dns-hooks to use our hook scripts (useful for automation).
certbot_args = [
"certonly",
"--manual",
"--preferred-challenges",
"dns",
"--manual-public-ip-logging-ok",
]
if bool(args.dns_hooks):
_, work_dir, _ = _ensure_dirs(base_dir)
auth_hook, cleanup_hook = _ensure_dns_hook_scripts(work_dir)
certbot_args += [
"--manual-auth-hook",
str(auth_hook),
"--manual-cleanup-hook",
str(cleanup_hook),
]
certbot_args += _common_certbot_flags(
email=args.email,
domains=domains,
base_dir=base_dir,
production=production,
non_interactive=True,
)
else:
# IMPORTANT: manual DNS prompt requires interactive mode.
certbot_args += _common_certbot_flags(
email=args.email,
domains=domains,
base_dir=base_dir,
production=production,
non_interactive=False,
)
else:
certbot_args = [
"certonly",
"--standalone",
# HTTP-01 is the simplest/most compatible for typical VPS setups.
"--preferred-challenges",
"http",
# If port 80 is already in use, certbot can still succeed behind a reverse proxy
# using `--webroot`, but this wrapper defaults to standalone by design.
"--http-01-port",
str(int(args.http_01_port)),
*_common_certbot_flags(
email=args.email,
domains=domains,
base_dir=base_dir,
production=production,
non_interactive=True,
),
]
if args.force_renewal:
certbot_args.append("--force-renewal")
if args.test_cert:
certbot_args.append("--test-cert")
return _run_certbot(certbot_args)
def cmd_renew(args: argparse.Namespace) -> int:
base_dir = Path(args.base_dir).expanduser().resolve() if args.base_dir else _default_base_dir()
production = bool(args.production)
config_dir, work_dir, logs_dir = _ensure_dirs(base_dir)
certbot_args: List[str] = [
"renew",
"--non-interactive",
"--config-dir",
str(config_dir),
"--work-dir",
str(work_dir),
"--logs-dir",
str(logs_dir),
"--key-type",
"ecdsa",
]
certbot_args += ["--server", LE_PRODUCTION_DIRECTORY_URL if production else LE_STAGING_DIRECTORY_URL]
if args.force_renewal:
certbot_args.append("--force-renewal")
if args.dry_run:
certbot_args.append("--dry-run")
return _run_certbot(certbot_args)
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="python -m python_video_server.letsencrypt",
description="Python-only Let’s Encrypt cert issuance/renewal (wraps certbot package).",
)
p.add_argument(
"--base-dir",
default=os.environ.get("LETSENCRYPT_BASE_DIR", ""),
help="Where certbot state is stored (default: python_video_server/certs).",
)
p.add_argument(
"--production",
action="store_true",
help="Use Let’s Encrypt production endpoint (default uses staging).",
)
sub = p.add_subparsers(dest="cmd", required=True)
c = sub.add_parser("certonly", help="Issue (or re-issue) a certificate via HTTP-01 standalone.")
c.add_argument("--email", required=True, help="Email for Let’s Encrypt account registration.")
c.add_argument(
"--domains",
required=True,
help="Comma-separated domains (e.g. example.com,www.example.com). The first is used as the primary cert name.",
)
c.add_argument(
"--http-01-port",
default=int(os.environ.get("LE_HTTP01_PORT", "80")),
type=int,
help="Port to bind for HTTP-01 challenge (default: 80).",
)
c.add_argument(
"--challenge",
default=os.environ.get("LE_CHALLENGE", "http"),
choices=["http", "dns"],
help="ACME challenge type: http (standalone) or dns (manual TXT record). Default: http.",
)
c.add_argument(
"--dns-hooks",
action="store_true",
default=bool(int(os.environ.get("LE_DNS_HOOKS", "0"))),
help="(DNS challenge only) Use hook scripts to print token and wait. Default uses certbot's interactive manual prompt.",
)
c.add_argument("--force-renewal", action="store_true", help="Force issuance even if not close to expiry.")
c.add_argument(
"--test-cert",
action="store_true",
help="Obtain a test certificate from staging (alias for staging behavior; kept for familiarity).",
)
c.set_defaults(_handler=cmd_certonly)
r = sub.add_parser("renew", help="Renew any existing certificates in the base-dir.")
r.add_argument("--dry-run", action="store_true", help="Perform a dry-run renewal.")
r.add_argument("--force-renewal", action="store_true", help="Force renewal even if not due.")
r.set_defaults(_handler=cmd_renew)
return p
def main(argv: List[str] | None = None) -> int:
parser = _build_parser()
ns = parser.parse_args(argv if argv is not None else sys.argv[1:])
handler = getattr(ns, "_handler", None)
if handler is None:
raise SystemExit("No command selected")
return int(handler(ns))
if __name__ == "__main__":
raise SystemExit(main())
+6
View File
@@ -0,0 +1,6 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
pymongo==4.10.1
PyJWT==2.10.1
certbot>=2.11.0,<3
certifi==2025.11.12
+366
View File
@@ -0,0 +1,366 @@
import mimetypes
import os
import posixpath
import ssl
from functools import lru_cache
from datetime import datetime, timezone
from pathlib import Path
from typing import Generator, Optional, Tuple
from urllib.parse import quote
from urllib.parse import urlparse
import jwt
from bson import ObjectId
from fastapi import FastAPI, Header, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, StreamingResponse
from pymongo import MongoClient
from pymongo.errors import PyMongoError
def _env_str(name: str, default: str = "") -> str:
v = os.environ.get(name)
return v.strip() if isinstance(v, str) and v.strip() else default
def _jwt_secret() -> str:
# Read env at runtime (important on Windows/uvicorn worker reloads).
return _env_str("JWT_SECRET")
def _mongo_uri() -> str:
# Read env at runtime (important on Windows/uvicorn worker reloads).
return _env_str("MONGO_URI")
def _mongo_db_name() -> str:
# Prefer explicit env var, otherwise try to parse from URI path.
explicit = _env_str("MONGO_DB_NAME")
if explicit:
return explicit
uri = _mongo_uri()
if not uri:
return ""
try:
parsed = urlparse(uri)
# Path is like "/dbname"
db = (parsed.path or "").lstrip("/")
# Strip any extra segments just in case
db = db.split("/")[0].strip()
return db
except Exception:
return ""
def _env_int(name: str, default: int) -> int:
try:
return int(_env_str(name, str(default)))
except Exception:
return default
def _video_files_dir() -> Path:
# Read env at runtime (important on Windows/uvicorn worker reloads).
return Path(_env_str("VIDEO_FILES_DIR", str(Path.cwd() / "public" / "video"))).resolve()
def _parse_bearer(authorization: Optional[str]) -> str:
raw = (authorization or "").strip()
if raw.lower().startswith("bearer "):
return raw[7:].strip()
return ""
def _is_safe_relative_file(p: str) -> bool:
if not isinstance(p, str):
return False
s = p.strip()
if not s or "\x00" in s:
return False
if s.startswith("/") or s.startswith("\\"):
return False
norm = posixpath.normpath(s.replace("\\", "/"))
if norm == ".." or norm.startswith("../"):
return False
return True
def _guess_content_type(file_path: Path) -> str:
ctype, _ = mimetypes.guess_type(str(file_path))
return ctype or "application/octet-stream"
def _parse_range(range_header: str, file_size: int) -> Optional[Tuple[int, int]]:
# Only supports a single bytes range: "bytes=start-end"
raw = (range_header or "").strip()
if not raw:
return None
if not raw.lower().startswith("bytes="):
return None
spec = raw[6:].strip()
if "," in spec:
return None
if "-" not in spec:
return None
start_s, end_s = spec.split("-", 1)
start = int(start_s) if start_s else 0
end = int(end_s) if end_s else (file_size - 1)
if start < 0 or end < 0 or start > end or start >= file_size:
return None
end = min(end, file_size - 1)
return start, end
def _file_iter(path: Path, start: int, end: int, chunk_size: int = 1024 * 1024) -> Generator[bytes, None, None]:
with path.open("rb") as f:
f.seek(start)
remaining = end - start + 1
while remaining > 0:
n = min(chunk_size, remaining)
data = f.read(n)
if not data:
break
remaining -= len(data)
yield data
def _get_token(req: Request, authorization: Optional[str]) -> str:
# Prefer cookie (for <video>), then Authorization header, then querystring token.
cookie_token = (req.cookies.get("video_auth") or "").strip()
if cookie_token:
return cookie_token
header_token = _parse_bearer(authorization)
if header_token:
return header_token
qs = (req.query_params.get("token") or "").strip()
return qs
def _verify_token(token: str) -> dict:
if not token:
raise HTTPException(status_code=401, detail="Missing video auth token")
secret = _jwt_secret()
if not secret:
raise HTTPException(status_code=500, detail="JWT_SECRET is not configured")
try:
return jwt.decode(token, secret, algorithms=["HS256"])
except jwt.PyJWTError:
raise HTTPException(status_code=401, detail="Invalid or expired video auth token")
def _mongo_client() -> MongoClient:
uri = _mongo_uri()
if not uri:
raise HTTPException(status_code=500, detail="MONGO_URI is not configured")
kwargs = {
# Fail fast instead of hanging for ~30s on each request.
"serverSelectionTimeoutMS": _env_int("MONGO_SERVER_SELECTION_TIMEOUT_MS", 8000),
"connectTimeoutMS": _env_int("MONGO_CONNECT_TIMEOUT_MS", 8000),
"socketTimeoutMS": _env_int("MONGO_SOCKET_TIMEOUT_MS", 20000),
}
# On some hosts, the system CA store is missing/broken or overridden by env vars,
# causing TLS handshake failures to MongoDB Atlas. Prefer certifi's CA bundle.
ca_file = _env_str("MONGO_TLS_CA_FILE")
if ca_file:
kwargs["tlsCAFile"] = ca_file
else:
try:
import certifi # type: ignore
kwargs["tlsCAFile"] = certifi.where()
except Exception:
# If certifi isn't available, fall back to system trust.
pass
# Some hosts / middleboxes break TLS 1.3 handshakes to Atlas and produce
# `TLSV1_ALERT_INTERNAL_ERROR`. Forcing TLS 1.2 can fix it.
if _env_str("MONGO_TLS_FORCE_TLS12", "0") in ("1", "true", "yes", "on"):
cafile = kwargs.pop("tlsCAFile", None)
ctx = ssl.create_default_context(cafile=cafile)
try:
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.maximum_version = ssl.TLSVersion.TLSv1_2
except Exception:
# Older Python/OpenSSL may not support TLSVersion fields.
pass
kwargs["ssl_context"] = ctx
# Optional escape hatch (NOT recommended): allow invalid certs for debugging only.
if _env_str("MONGO_TLS_INSECURE", "0") in ("1", "true", "yes", "on"):
kwargs["tlsAllowInvalidCertificates"] = True
return MongoClient(uri, **kwargs)
@lru_cache(maxsize=1)
def _mongo_client_cached() -> MongoClient:
# Reuse one client (connection pool) across requests.
return _mongo_client()
app = FastAPI()
# CORS: needed so the app can call the Python host's /api/video/session
_cors_origins_raw = _env_str("CORS_ALLOW_ORIGINS", "*")
_cors_origins = [o.strip() for o in _cors_origins_raw.split(",") if o.strip()]
_cors_allow_origin_regex = None
if len(_cors_origins) == 0 or (len(_cors_origins) == 1 and _cors_origins[0] == "*"):
# With credentials, browsers can't accept Access-Control-Allow-Origin: *
# Using a regex makes Starlette reflect the requesting Origin instead.
_cors_origins = []
_cors_allow_origin_regex = ".*"
app.add_middleware(
CORSMiddleware,
allow_origins=_cors_origins,
allow_origin_regex=_cors_allow_origin_regex,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.post("/api/video/session")
def create_video_session(request: Request, response: Response, authorization: Optional[str] = Header(default=None)):
token = _parse_bearer(authorization)
_verify_token(token)
secure = request.url.scheme == "https" or (request.headers.get("x-forwarded-proto") == "https")
encoded = quote(token, safe="")
cookie_max_age_seconds = int(_env_str("VIDEO_SESSION_MAX_AGE_SECONDS", "28800")) # 8h
cookie_samesite = _env_str("VIDEO_COOKIE_SAMESITE", "Lax") # Lax|Strict|None
samesite = (cookie_samesite or "Lax").strip().lower()
if samesite not in ("lax", "strict", "none"):
samesite = "lax"
samesite_attr = "None" if samesite == "none" else ("Strict" if samesite == "strict" else "Lax")
cookie_parts = [
f"video_auth={encoded}",
"Path=/api/video",
f"Max-Age={cookie_max_age_seconds}",
f"SameSite={samesite_attr}",
"HttpOnly",
]
# Modern browsers require Secure when SameSite=None
if secure or samesite == "none":
cookie_parts.append("Secure")
response.headers["Set-Cookie"] = "; ".join(cookie_parts)
response.status_code = 204
return None
@app.api_route("/api/video/{movie_id}", methods=["GET", "HEAD"])
def stream_video(
movie_id: str,
request: Request,
authorization: Optional[str] = Header(default=None),
range: Optional[str] = Header(default=None), # noqa: A002
):
token = _get_token(request, authorization)
_verify_token(token)
try:
oid = ObjectId(movie_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid movie id")
db_name = _mongo_db_name()
if not db_name:
raise HTTPException(
status_code=500,
detail="Mongo DB name not configured. Add it to MONGO_URI (…/dbname) or set MONGO_DB_NAME.",
)
try:
client = _mongo_client_cached()
db = client[db_name]
movie = db["movies"].find_one({"_id": oid}, {"video_file": 1})
except PyMongoError as e:
raise HTTPException(
status_code=502,
detail=(
"Failed to query MongoDB (TLS/connection issue). "
"On some hosts you may need a CA bundle; try installing certifi or set MONGO_TLS_CA_FILE. "
f"Original error: {type(e).__name__}: {e}"
),
)
video_file = (movie or {}).get("video_file") or ""
if not isinstance(video_file, str) or not video_file.strip():
raise HTTPException(status_code=404, detail="No server video file configured for this movie")
video_file = video_file.strip()
if not _is_safe_relative_file(video_file):
raise HTTPException(status_code=400, detail="Invalid video_file path")
video_dir = _video_files_dir()
full_path = (video_dir / video_file).resolve()
if not str(full_path).startswith(str(video_dir) + os.sep) and full_path != video_dir:
raise HTTPException(status_code=400, detail="Invalid video_file path")
if not full_path.exists() or not full_path.is_file():
raise HTTPException(status_code=404, detail="Video file not found on server")
stat = full_path.stat()
file_size = stat.st_size
content_type = _guess_content_type(full_path)
# Shared headers (match the Node server behavior closely)
base_headers = {
"Accept-Ranges": "bytes",
"Content-Type": content_type,
"Cache-Control": "private, max-age=0, must-revalidate",
}
if request.method == "HEAD":
headers = dict(base_headers)
headers["Content-Length"] = str(file_size)
return Response(status_code=200, headers=headers)
r = _parse_range(range or "", file_size)
if r is None and (range or ""):
# Invalid Range
headers = dict(base_headers)
headers["Content-Range"] = f"bytes */{file_size}"
return Response(status_code=416, headers=headers)
if r is None:
# Full file
return FileResponse(
path=str(full_path),
media_type=content_type,
headers=base_headers,
)
start, end = r
chunk_size = end - start + 1
headers = dict(base_headers)
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
headers["Content-Length"] = str(chunk_size)
return StreamingResponse(
_file_iter(full_path, start, end),
status_code=206,
media_type=content_type,
headers=headers,
)
@app.get("/healthz")
def healthz():
return {
"ok": True,
"time": datetime.now(timezone.utc).isoformat(),
"video_files_dir": str(_video_files_dir()),
"mongo_db_name": _mongo_db_name() or None,
"openssl": ssl.OPENSSL_VERSION,
}
@app.on_event("shutdown")
def _shutdown():
# Close the cached Mongo client cleanly.
try:
client = _mongo_client_cached()
client.close()
except Exception:
pass
+415
View File
@@ -0,0 +1,415 @@
const express = require("express");
const jwt = require("jsonwebtoken");
const mongoose = require("mongoose");
const fs = require("fs");
const path = require("path");
const zlib = require("zlib");
const readline = require("readline");
const multer = require("multer");
const { Transform } = require("stream");
// Use MongoDB Extended JSON to preserve ObjectId/Date/etc.
// bson is a transitive dependency of mongoose (mongodb driver).
let EJSON;
try {
({ EJSON } = require("bson"));
} catch (_) {
EJSON = null;
}
const router = express.Router();
const BACKUP_DIR = path.join(process.cwd(), "backups");
const MAX_UPLOAD_BYTES = 250 * 1024 * 1024; // 250MB
// SECURITY: protect against gzip "zip bombs" (small upload -> massive output).
// This caps the *decompressed* stream size during restore.
const MAX_RESTORE_UNZIPPED_BYTES = 1024 * 1024 * 1024; // 1GB
function createByteLimitStream(maxBytes) {
let seen = 0;
return new Transform({
transform(chunk, _enc, cb) {
seen += chunk.length;
if (seen > maxBytes) {
const err = new Error("Restore failed: decompressed data is too large");
// mimic our handler's error shape
err.statusCode = 413;
cb(err);
return;
}
cb(null, chunk);
},
});
}
function requireAdmin(req) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) {
const err = new Error("Authentication token is required");
err.statusCode = 401;
throw err;
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (!decoded?.admin) {
const err = new Error("You do not have admin privileges");
err.statusCode = 403;
throw err;
}
return decoded;
} catch (e) {
if (e instanceof jwt.JsonWebTokenError) {
const err = new Error("Invalid or expired token");
err.statusCode = 401;
throw err;
}
throw e;
}
}
function safeBackupFileName(raw) {
const name = String(raw || "").trim();
// Only allow simple filenames to avoid traversal.
if (!/^[a-zA-Z0-9._-]+$/.test(name)) return null;
if (!name.endsWith(".ndjson.gz")) return null;
return name;
}
async function ensureBackupDir() {
await fs.promises.mkdir(BACKUP_DIR, { recursive: true });
}
function nowStamp() {
return new Date().toISOString().replace(/[:.]/g, "-");
}
function backupFileNameFromDb(dbName) {
const safeDb = String(dbName || "db").replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 60) || "db";
return `mongo-backup-${safeDb}-${nowStamp()}.ndjson.gz`;
}
function requireDbReady() {
if (!mongoose?.connection || mongoose.connection.readyState !== 1) {
const err = new Error("MongoDB connection is not ready");
err.statusCode = 503;
throw err;
}
if (!EJSON) {
const err = new Error("bson EJSON is not available in this environment");
err.statusCode = 500;
throw err;
}
}
const upload = multer({
storage: multer.diskStorage({
destination: async (req, file, cb) => {
try {
await ensureBackupDir();
cb(null, BACKUP_DIR);
} catch (e) {
cb(e);
}
},
filename: (req, file, cb) => {
// Keep uploads isolated; we rename during restore if needed.
const name = `upload-${nowStamp()}-${Math.random().toString(16).slice(2)}.ndjson.gz`;
cb(null, name);
},
}),
limits: { fileSize: MAX_UPLOAD_BYTES },
});
async function listBackupFiles() {
await ensureBackupDir();
const entries = await fs.promises.readdir(BACKUP_DIR, { withFileTypes: true });
const files = entries
.filter((e) => e.isFile())
.map((e) => e.name)
.filter((n) => safeBackupFileName(n))
.sort()
.reverse();
const out = [];
for (const name of files) {
const full = path.join(BACKUP_DIR, name);
try {
const stat = await fs.promises.stat(full);
out.push({
name,
sizeBytes: stat.size,
mtime: stat.mtime.toISOString(),
});
} catch (_) {
// ignore races
}
}
return out;
}
// Admin: list existing backups (metadata only)
router.get("/db/backups", async (req, res) => {
try {
requireAdmin(req);
const backups = await listBackupFiles();
return res.status(200).json({ backups });
} catch (err) {
return res.status(err.statusCode || 500).json({ message: err.message || "Internal server error" });
}
});
// Admin: create a new backup and store it on the server
router.post("/db/backup", async (req, res) => {
try {
requireAdmin(req);
requireDbReady();
await ensureBackupDir();
const db = mongoose.connection.db;
const dbName = db?.databaseName || "db";
const fileName = backupFileNameFromDb(dbName);
const fullPath = path.join(BACKUP_DIR, fileName);
const gzip = zlib.createGzip({ level: 9 });
const fileOut = fs.createWriteStream(fullPath, { flags: "wx" });
gzip.pipe(fileOut);
function writeLine(obj) {
const line = EJSON.stringify(obj) + "\n";
return gzip.write(line);
}
// Header
writeLine({
type: "meta",
version: 1,
createdAt: new Date().toISOString(),
dbName,
format: "ndjson+gzip",
});
const collections = await db.listCollections().toArray();
const userCollections = collections
.map((c) => c?.name)
.filter(Boolean)
.filter((name) => !String(name).startsWith("system."));
for (const name of userCollections) {
writeLine({ type: "collection", name });
const cursor = db.collection(name).find({});
// Stream docs one-by-one (no giant in-memory array)
for await (const doc of cursor) {
writeLine({ type: "doc", collection: name, doc });
}
}
writeLine({ type: "end" });
gzip.end();
await new Promise((resolve, reject) => {
fileOut.on("finish", resolve);
fileOut.on("error", reject);
gzip.on("error", reject);
});
const stat = await fs.promises.stat(fullPath);
return res.status(201).json({
message: "Backup created",
backup: { name: fileName, sizeBytes: stat.size, createdAt: new Date().toISOString() },
});
} catch (err) {
return res.status(err.statusCode || 500).json({ message: err.message || "Internal server error" });
}
});
// Admin: download a specific backup
router.get("/db/backups/:name", async (req, res) => {
try {
requireAdmin(req);
const safe = safeBackupFileName(req.params?.name);
if (!safe) return res.status(400).json({ message: "Invalid backup name" });
const full = path.join(BACKUP_DIR, safe);
await fs.promises.access(full, fs.constants.R_OK);
res.setHeader("Content-Type", "application/gzip");
res.setHeader("Content-Disposition", `attachment; filename="${safe}"`);
fs.createReadStream(full).pipe(res);
} catch (err) {
const code = err.code === "ENOENT" ? 404 : err.statusCode || 500;
return res.status(code).json({ message: err.message || "Internal server error" });
}
});
// Admin: delete a backup file
router.delete("/db/backups/:name", async (req, res) => {
try {
requireAdmin(req);
const safe = safeBackupFileName(req.params?.name);
if (!safe) return res.status(400).json({ message: "Invalid backup name" });
const full = path.join(BACKUP_DIR, safe);
await fs.promises.unlink(full);
return res.status(200).json({ message: "Backup deleted" });
} catch (err) {
const code = err.code === "ENOENT" ? 404 : err.statusCode || 500;
return res.status(code).json({ message: err.message || "Internal server error" });
}
});
// Admin: restore from an uploaded backup.
// Query: ?drop=true to drop collections before insert (recommended).
router.post("/db/restore", upload.single("backup"), async (req, res) => {
let uploadedPath = null;
try {
requireAdmin(req);
requireDbReady();
const drop = String(req.query?.drop || req.body?.drop || "").toLowerCase() === "true";
const file = req.file;
if (!file?.path) {
return res.status(400).json({ message: "No backup file uploaded" });
}
uploadedPath = file.path;
// Best-effort: refuse obviously wrong file names (UI expects .ndjson.gz).
const original = String(file.originalname || "").trim().toLowerCase();
if (original && !original.endsWith(".gz")) {
const err = new Error("Invalid backup file (expected a .gz file)");
err.statusCode = 400;
throw err;
}
const db = mongoose.connection.db;
const gunzip = zlib.createGunzip();
const byteLimit = createByteLimitStream(MAX_RESTORE_UNZIPPED_BYTES);
const input = fs.createReadStream(uploadedPath).pipe(gunzip).pipe(byteLimit);
const rl = readline.createInterface({ input, crlfDelay: Infinity });
// Ensure stream errors don't become unhandled.
let streamError = null;
input.on("error", (e) => {
streamError = e;
try {
rl.close();
} catch (_) {}
});
let meta = null;
let currentCollection = null;
let batch = [];
const dropped = new Set();
const insertedCountByCollection = new Map();
async function flushBatch() {
if (!currentCollection || batch.length === 0) return;
const coll = db.collection(currentCollection);
try {
const result = await coll.insertMany(batch, { ordered: false });
const inserted = result?.insertedCount || 0;
insertedCountByCollection.set(
currentCollection,
(insertedCountByCollection.get(currentCollection) || 0) + inserted
);
} catch (e) {
// If restoring into an existing DB without dropping, duplicates are likely.
// Surface a clear error instead of half-success.
const err = new Error(
`Restore failed while inserting into "${currentCollection}": ${e?.message || "insertMany error"}`
);
err.statusCode = 400;
throw err;
} finally {
batch = [];
}
}
async function ensureDropped(name) {
if (!drop) return;
if (dropped.has(name)) return;
dropped.add(name);
try {
await db.collection(name).drop();
} catch (e) {
// If collection doesn't exist, ignore.
if (e?.codeName !== "NamespaceNotFound" && e?.code !== 26) throw e;
}
}
for await (const line of rl) {
const trimmed = String(line || "").trim();
if (!trimmed) continue;
let obj;
try {
obj = EJSON.parse(trimmed);
} catch (_) {
const err = new Error("Invalid backup format (cannot parse JSON line)");
err.statusCode = 400;
throw err;
}
if (obj?.type === "meta") {
meta = obj;
continue;
}
if (obj?.type === "collection") {
await flushBatch();
currentCollection = String(obj?.name || "").trim();
if (!currentCollection || currentCollection.startsWith("system.")) {
currentCollection = null;
continue;
}
await ensureDropped(currentCollection);
continue;
}
if (obj?.type === "doc") {
const coll = String(obj?.collection || "").trim();
if (!coll || coll.startsWith("system.")) continue;
if (currentCollection !== coll) {
await flushBatch();
currentCollection = coll;
await ensureDropped(currentCollection);
}
if (obj?.doc && typeof obj.doc === "object") {
batch.push(obj.doc);
if (batch.length >= 500) {
await flushBatch();
}
}
continue;
}
}
await flushBatch();
if (streamError) {
throw streamError;
}
// Cleanup upload after successful restore
try {
await fs.promises.unlink(uploadedPath);
uploadedPath = null;
} catch (_) {}
return res.status(200).json({
message: "Restore completed",
meta,
drop,
inserted: Object.fromEntries(insertedCountByCollection.entries()),
});
} catch (err) {
// Best-effort cleanup
if (uploadedPath) {
try {
await fs.promises.unlink(uploadedPath);
} catch (_) {}
}
return res.status(err.statusCode || 500).json({ message: err.message || "Internal server error" });
}
});
module.exports = router;
+629 -6
View File
@@ -1,14 +1,97 @@
const express = require("express");
const Movie = require("../models/Movie");
const User = require("../models/User");
const PlaybackProgress = require("../models/PlaybackProgress");
const axios = require("axios");
const jwt = require("jsonwebtoken");
const mongoose = require("mongoose");
const authenticate = require("../middleware/authMiddleware");
const router = express.Router();
// Small in-memory cache to reduce repeated DB reads on slow networks (Render).
// This is safe because movie lists are identical for all users and change rarely.
const MOVIES_CACHE_TTL_MS = Number(process.env.MOVIES_CACHE_TTL_MS || "30000"); // 30s
const _cache = new Map(); // key -> { expiresAt:number, value:any }
function cacheGet(key) {
const hit = _cache.get(key);
if (!hit) return null;
if (Date.now() > hit.expiresAt) {
_cache.delete(key);
return null;
}
return hit.value;
}
function cacheSet(key, value) {
_cache.set(key, { expiresAt: Date.now() + MOVIES_CACHE_TTL_MS, value });
}
function cacheClear() {
_cache.clear();
}
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
if (!Number.isInteger(n)) return null;
if (n < 1900 || n > 3000) return null;
return n;
}
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 normalizeVideoFile(v) {
// Stored as a relative path (no leading slash) like: "my_movie.mp4" or "2026/my_movie.mp4"
// Backend streaming endpoint enforces traversal protection too.
if (v === undefined) return undefined;
if (v === null) return null;
const s = String(v).trim();
if (!s) return "";
return s.slice(0, 1024);
}
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);
}
function hasAnySourceIncludingFile({ vod_link, video_src, embed_src, video_file }) {
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() : "";
const d = typeof video_file === "string" ? video_file.trim() : "";
return !!(a || b || c || d);
}
// Fetch movie details from OMDb API
async function fetchMovieDetailsFromOmdb(imdb_id) {
const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key
const apiKey = typeof process.env.OMDB_API === "string" ? process.env.OMDB_API.trim() : "";
if (!apiKey) {
const err = new Error("OMDb n'est pas configuré (env OMDB_API manquante).");
err.code = "OMDB_NOT_CONFIGURED";
throw err;
}
const omdbUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(imdb_id)}&apikey=${apiKey}`;
try {
@@ -28,16 +111,176 @@ async function fetchMovieDetailsFromOmdb(imdb_id) {
}
}
// Get available Oscar years (distinct years from movies)
router.get("/years", async (req, res) => {
try {
const cacheKey = "movies:years";
const cached = cacheGet(cacheKey);
if (cached) {
res.set("Cache-Control", "public, max-age=60");
return res.status(200).json(cached);
}
const years = await Movie.distinct("year");
const cleaned = years
.filter((y) => typeof y === "number" && Number.isFinite(y))
.sort((a, b) => b - a);
cacheSet(cacheKey, cleaned);
res.set("Cache-Control", "public, max-age=60");
res.status(200).json(cleaned);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get all movies
router.get("/", async (req, res) => {
try {
const movies = await Movie.find();
const year = parseOscarYear(req.query.year);
const filter = year ? { year } : {};
// Clients:
// - films page needs details (poster, description, player sources)
// - checklist page only needs imdb_id + title
const view = String(req.query.view || "").trim().toLowerCase();
const isChecklist = view === "checklist" || view === "compact";
const projection = isChecklist
? "imdb_id title"
: "imdb_id title description rating poster year category vod_link player_mode video_src embed_src video_file updatedAt createdAt";
const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`;
const cached = cacheGet(cacheKey);
if (cached) {
res.set("Cache-Control", "public, max-age=30");
return res.status(200).json(cached);
}
const movies = await Movie.find(filter)
.select(projection)
.sort({ title: 1 })
.lean();
cacheSet(cacheKey, movies);
res.set("Cache-Control", "public, max-age=30");
res.status(200).json(movies);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
// Get latest movie update/add timestamp
router.get("/last-update", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Token is required" });
try {
jwt.verify(token, process.env.JWT_SECRET);
const year = parseOscarYear(req.query.year);
const filter = year ? { year } : {};
const cacheKey = `movies:last-update:${year || "all"}`;
const cached = cacheGet(cacheKey);
if (cached) {
res.set("Cache-Control", "public, max-age=30");
return res.status(200).json(cached);
}
const latestMovie = await Movie.findOne(filter)
.select("updatedAt createdAt")
.sort({ updatedAt: -1, createdAt: -1, _id: -1 })
.lean();
if (!latestMovie) {
return res.status(200).json({ lastUpdated: null });
}
const lastUpdated =
latestMovie.updatedAt ||
latestMovie.createdAt ||
(typeof latestMovie._id?.getTimestamp === "function" ? latestMovie._id.getTimestamp() : null);
const payload = { lastUpdated: lastUpdated ? new Date(lastUpdated).toISOString() : null };
cacheSet(cacheKey, payload);
res.set("Cache-Control", "public, max-age=30");
return res.status(200).json(payload);
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: error.message });
}
});
// Get lightweight per-year summary for fast UI (counts, watched-in-year, last update)
router.get("/summary", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ message: "Token is required" });
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const year = parseOscarYear(req.query.year);
const filter = year ? { year } : {};
// Base (year-wide) data can be cached (not user-specific).
const cacheKey = `movies:summary-base:${year || "all"}`;
let base = cacheGet(cacheKey);
if (!base) {
const [idDocs, latestMovie] = await Promise.all([
Movie.find(filter).select("imdb_id").lean(),
Movie.findOne(filter)
.select("updatedAt createdAt")
.sort({ updatedAt: -1, createdAt: -1, _id: -1 })
.lean(),
]);
const imdbIds = idDocs.map((d) => d.imdb_id).filter(Boolean);
const imdbIdSet = new Set(imdbIds);
const lastUpdated =
latestMovie?.updatedAt ||
latestMovie?.createdAt ||
(typeof latestMovie?._id?.getTimestamp === "function" ? latestMovie._id.getTimestamp() : null);
base = {
year: year || null,
totalMoviesCount: imdbIds.length,
imdbIds,
// Store ISO string so it's JSON-safe + stable
lastUpdated: lastUpdated ? new Date(lastUpdated).toISOString() : null,
};
// Avoid storing a Set in cache (non-serializable and not needed elsewhere)
cacheSet(cacheKey, base);
}
const user = await User.findById(decoded.id).select("watchedMovies").lean();
if (!user) return res.status(404).json({ message: "User not found" });
const imdbIdSet = new Set(base.imdbIds || []);
const watchedMoviesAll = Array.isArray(user.watchedMovies) ? user.watchedMovies : [];
const watchedMoviesInYear = watchedMoviesAll.filter((wm) => imdbIdSet.has(wm.imdb_id));
// Since this is user-specific, don't encourage shared caching.
res.set("Cache-Control", "private, max-age=5");
return res.status(200).json({
year: base.year,
totalMoviesCount: base.totalMoviesCount,
watchedMoviesCount: watchedMoviesInYear.length,
watchedMovies: watchedMoviesInYear,
lastUpdated: base.lastUpdated,
});
} catch (error) {
if (error instanceof jwt.JsonWebTokenError) {
return res.status(401).json({ message: "Invalid or expired token" });
}
return res.status(500).json({ error: error.message });
}
});
// Get watched movies for the user
router.get("/watchedMovies", async (req, res) => {
const token = req.headers.authorization?.split(" ")[1]; // Get token from header
@@ -45,7 +288,7 @@ router.get("/watchedMovies", async (req, res) => {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET); // Verify token
const user = await User.findById(decoded.id); // Get user by ID
const user = await User.findById(decoded.id).select("watchedMovies").lean(); // Get user by ID
if (!user) return res.status(404).json({ message: "User not found" });
res.status(200).json(user.watchedMovies); // Return the list of watched movies
@@ -87,7 +330,7 @@ router.patch("/users/updateWatchedMovies", async (req, res) => {
// Add movie
router.post("/add", async (req, res) => {
const { imdb_id, category, vod_link } = req.body;
const { imdb_id, category, vod_link, year, player_mode, video_src, embed_src, video_file } = req.body;
try {
// Get the token from the Authorization header
@@ -105,8 +348,47 @@ router.post("/add", async (req, res) => {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const parsedYear = parseOscarYear(year);
if (!parsedYear) {
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 normalizedFile = normalizeVideoFile(video_file);
const normalizedMode = normalizePlayerMode(player_mode);
if (normalizedMode === null) {
return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" });
}
if (
!hasAnySourceIncludingFile({
vod_link: normalizedVod || "",
video_src: normalizedVideo || "",
embed_src: normalizedEmbed || "",
video_file: normalizedFile || "",
})
) {
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);
let movieDetails;
try {
movieDetails = await fetchMovieDetailsFromOmdb(imdb_id);
} catch (err) {
// If OMDb isn't configured, keep the feature usable: add with placeholders so admin can fill manually later.
if (err?.code === "OMDB_NOT_CONFIGURED") {
movieDetails = {
title: String(imdb_id || "").trim() || "Untitled",
description: "",
rating: "",
poster: ""
};
} else {
throw err;
}
}
// Proceed with adding the movie
const movie = new Movie({
@@ -115,12 +397,24 @@ router.post("/add", async (req, res) => {
description: movieDetails.description,
rating: movieDetails.rating,
poster: movieDetails.poster,
year: parsedYear,
category,
vod_link
vod_link: normalizedVod || undefined,
player_mode: normalizedMode,
video_src: normalizedVideo || undefined,
embed_src: normalizedEmbed || undefined,
video_file: normalizedFile || undefined,
});
await movie.save();
// Convenience: if admin provided a server video_file but no video_src, auto-wire video_src to our streaming endpoint.
if ((normalizedFile || "").trim() && !String(movie.video_src || "").trim()) {
movie.video_src = `/api/video/${movie._id}`;
await movie.save();
}
cacheClear();
res.status(201).json({ message: "Movie added successfully!" });
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
@@ -130,4 +424,333 @@ router.post("/add", async (req, res) => {
}
});
// Admin: delete one or more movies (by Mongo _id or imdb_id)
router.delete("/delete", 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 { ids, imdb_ids } = req.body || {};
const idList = Array.isArray(ids) ? ids.filter(Boolean) : [];
const imdbList = Array.isArray(imdb_ids) ? imdb_ids.filter(Boolean) : [];
if (idList.length === 0 && imdbList.length === 0) {
return res.status(400).json({ message: "Aucun film sélectionné." });
}
const filter = idList.length
? { _id: { $in: idList } }
: { imdb_id: { $in: imdbList } };
const moviesToDelete = await Movie.find(filter).select("imdb_id");
const deletedImdbIds = moviesToDelete.map((m) => m.imdb_id).filter(Boolean);
const result = await Movie.deleteMany(filter);
cacheClear();
if (deletedImdbIds.length > 0) {
await User.updateMany(
{},
{ $pull: { watchedMovies: { imdb_id: { $in: deletedImdbIds } } } }
);
}
return res.status(200).json({
message: "Movies deleted successfully",
deletedCount: result.deletedCount || 0,
});
} 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: update a movie (edit fields, optionally refresh from OMDb)
router.put("/:id", 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 { id } = req.params;
const {
imdb_id,
title,
description,
rating,
poster,
year,
category,
vod_link,
player_mode,
video_src,
embed_src,
video_file,
refreshOmdb,
} = req.body || {};
if (imdb_id !== undefined && imdb_id !== null && !isValidImdbId(imdb_id)) {
return res.status(400).json({ message: "IMDB ID invalide. Exemple attendu: tt1234567" });
}
if (year !== undefined && year !== null && year !== "") {
const parsedYear = parseOscarYear(year);
if (!parsedYear) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
}
if (typeof category === "string" && category.trim() === "") {
return res.status(400).json({ message: "Catégorie invalide." });
}
if (typeof vod_link === "string" && vod_link.trim() === "") {
// 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);
if (!movie) return res.status(404).json({ message: "Movie not found" });
if (imdb_id !== undefined) movie.imdb_id = String(imdb_id).trim();
if (category !== undefined) movie.category = String(category).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 (video_file !== undefined) {
const v = normalizeVideoFile(video_file);
movie.video_file = v ? v : null;
// If we have a server file and no explicit video_src, set it to our streaming endpoint.
if (movie.video_file && !String(movie.video_src || "").trim()) {
movie.video_src = `/api/video/${movie._id}`;
}
}
if (year !== undefined && year !== null && year !== "") {
movie.year = parseOscarYear(year);
} else if (year === null || year === "") {
// allow clearing the year explicitly
movie.year = null;
}
// If refreshOmdb is true, overwrite OMDb-derived fields from the imdb_id
if (refreshOmdb) {
try {
const details = await fetchMovieDetailsFromOmdb(movie.imdb_id);
movie.title = details.title;
movie.description = details.description;
movie.rating = details.rating;
movie.poster = details.poster;
} catch (err) {
if (err?.code === "OMDB_NOT_CONFIGURED") {
return res.status(503).json({ message: "OMDb n'est pas configuré (env OMDB_API manquante)." });
}
throw err;
}
} else {
if (title !== undefined) movie.title = title;
if (description !== undefined) movie.description = description;
if (rating !== undefined) movie.rating = rating;
if (poster !== undefined) movie.poster = poster;
}
if (
!hasAnySourceIncludingFile({
vod_link: movie.vod_link || "",
video_src: movie.video_src || "",
embed_src: movie.embed_src || "",
video_file: movie.video_file || "",
})
) {
return res.status(400).json({ message: "Ajoute au moins une source (VOD / video_src / embed_src / video_file)." });
}
await movie.save();
cacheClear();
return res.status(200).json(movie);
} 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 });
}
});
// Get playback progress for the current user (seconds)
router.get("/:id/progress", authenticate, async (req, res) => {
try {
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(400).json({ message: "Invalid movie id" });
}
const userId = req.user?.id;
if (!userId || !mongoose.Types.ObjectId.isValid(userId)) {
return res.status(401).json({ message: "Invalid user" });
}
const progress = await PlaybackProgress.findOne({ userId, movieId: id }).select("time duration updatedAt");
// User-specific: never allow shared/proxy caching.
res.set("Cache-Control", "private, no-store, must-revalidate");
res.set("Vary", "Authorization");
return res.status(200).json({
time: typeof progress?.time === "number" ? progress.time : 0,
duration: typeof progress?.duration === "number" ? progress.duration : null,
updatedAt: progress?.updatedAt ? new Date(progress.updatedAt).toISOString() : null,
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Update playback progress for the current user (seconds)
router.put("/:id/progress", authenticate, async (req, res) => {
try {
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id)) {
return res.status(400).json({ message: "Invalid movie id" });
}
const userId = req.user?.id;
if (!userId || !mongoose.Types.ObjectId.isValid(userId)) {
return res.status(401).json({ message: "Invalid user" });
}
const rawTime = req.body?.time;
const rawDuration = req.body?.duration;
const rawImdbId = req.body?.imdb_id;
let time = Number(rawTime);
if (!Number.isFinite(time) || time < 0) time = 0;
// Store whole seconds for stable, consistent resume behavior.
time = Math.floor(time);
// Duration is best-effort from the client.
// IMPORTANT: if the client sends null/undefined (common before metadata loads),
// do NOT wipe an existing duration in DB (we simply don't update the field).
let durationCandidate = rawDuration === undefined || rawDuration === null ? null : Number(rawDuration);
if (durationCandidate !== null && (!Number.isFinite(durationCandidate) || durationCandidate <= 0)) durationCandidate = null;
if (durationCandidate !== null) durationCandidate = Math.floor(durationCandidate);
if (durationCandidate !== null) {
// Clamp into [0, duration]
time = Math.min(Math.max(time, 0), durationCandidate);
}
// Defensive guard:
// On some reload/startup sequences (especially on hosted environments),
// the client can emit an early "time=0" save *before* it has restored the
// user's previous progress (and before it knows duration).
// If we already have a meaningful progress saved, never let a "0 with unknown duration"
// overwrite it.
if (time === 0 && durationCandidate === null) {
const existing = await PlaybackProgress.findOne({ userId, movieId: id }).select("time duration updatedAt");
if (existing && typeof existing.time === "number" && existing.time > 1) {
return res.status(200).json({
time: existing.time ?? 0,
duration: typeof existing.duration === "number" ? existing.duration : null,
updatedAt: existing.updatedAt ? new Date(existing.updatedAt).toISOString() : null,
});
}
}
// Auto-check the movie in the checklist when the user reaches the "credit roll".
// Keep it simple: use the timestamp ratio (>= 95%).
const reachedCreditRoll = durationCandidate !== null && durationCandidate > 0 && time / durationCandidate >= 0.95;
// Best UX: if the user is basically at the end, reset to 0 so replay starts from the beginning.
if (durationCandidate !== null && durationCandidate - time <= 3) {
time = 0;
}
const updateSet = { time, updatedAt: new Date() };
if (durationCandidate !== null) updateSet.duration = durationCandidate;
const updated = await PlaybackProgress.findOneAndUpdate(
{ userId, movieId: id },
{ $set: updateSet },
{ upsert: true, new: true, setDefaultsOnInsert: true }
).select("time duration updatedAt");
// If they hit the 95% threshold, mark as watched for checklist.
// Prefer imdb_id provided by the client; fall back to DB lookup if absent/invalid.
if (reachedCreditRoll) {
let imdbId = typeof rawImdbId === "string" ? rawImdbId.trim() : "";
if (!isValidImdbId(imdbId)) imdbId = "";
if (!imdbId) {
const movieDoc = await Movie.findById(id).select("imdb_id").lean();
const mImdb = typeof movieDoc?.imdb_id === "string" ? movieDoc.imdb_id.trim() : "";
if (isValidImdbId(mImdb)) imdbId = mImdb;
}
if (imdbId) {
// Only add if not already present; if user manually unchecked, it can be re-added on next 95% hit.
await User.updateOne(
{ _id: userId, "watchedMovies.imdb_id": { $ne: imdbId } },
{ $push: { watchedMovies: { imdb_id: imdbId, watchedDate: new Date() } } }
);
}
}
return res.status(200).json({
time: updated?.time ?? 0,
duration: typeof updated?.duration === "number" ? updated.duration : null,
updatedAt: updated?.updatedAt ? new Date(updated.updatedAt).toISOString() : null,
});
} catch (err) {
// Handle rare unique-index races on first write
if (err?.code === 11000) {
return res.status(409).json({ message: "Progress already exists, retry." });
}
return res.status(500).json({ error: err.message });
}
});
// 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).lean();
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;
+700
View File
@@ -0,0 +1,700 @@
const express = require("express");
const jwt = require("jsonwebtoken");
const mongoose = require("mongoose");
const Setting = require("../models/Setting");
const Movie = require("../models/Movie");
const User = require("../models/User");
const router = express.Router();
const ACTIVE_YEAR_KEY = "active_oscar_year";
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 crypto = require("crypto");
const DEFAULT_COMPLETION_MODAL = Object.freeze({
title: "Félicitations! very nice 🎉🎉🎉",
bodyText: "",
videoSrc: "video/reward.mp4",
bodyHtml: "",
});
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
if (!Number.isInteger(n)) return null;
if (n < 1900 || n > 3000) return null;
return n;
}
function parseOptionalPoints(raw) {
if (raw === undefined) return null;
if (raw === null) return null;
if (raw === "") return null;
const n = Number(raw);
if (!Number.isFinite(n)) return null;
// Points can be any finite number, but we store a rounded int for consistency.
return Math.round(n);
}
function parseIsoDateString(raw) {
// Expected format: YYYY-MM-DD
if (raw === undefined || raw === null) return null;
const s = String(raw).trim();
if (!s) return null;
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return null;
const year = Number(m[1]);
const month = Number(m[2]);
const day = Number(m[3]);
if (!Number.isInteger(year) || year < 1900 || year > 3000) return null;
if (!Number.isInteger(month) || month < 1 || month > 12) return null;
if (!Number.isInteger(day) || day < 1 || day > 31) return null;
// Validate calendar date (handles month/day overflow like Feb 30).
const d = new Date(Date.UTC(year, month - 1, day));
if (
d.getUTCFullYear() !== year ||
d.getUTCMonth() !== month - 1 ||
d.getUTCDate() !== day
) {
return null;
}
return `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function normalizeOscarDatesByYear(rawValue) {
// Stored format: { "2026": "2026-03-15" }
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const out = {};
for (const [k, v] of Object.entries(value)) {
const year = parseOscarYear(k);
if (!year) continue;
const date = parseIsoDateString(v);
if (!date) continue;
out[String(year)] = date;
}
return out;
}
function normalizeWinnersByYear(rawValue) {
// Stored format:
// - New: { "2026": [ { userId, points? }, { userId, points? } ] }
// - Legacy: { "2026": { userId, points? } }
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const out = {};
for (const [k, v] of Object.entries(value)) {
const year = parseOscarYear(k);
if (!year) continue;
const list = Array.isArray(v) ? v : v && typeof v === "object" ? [v] : [];
const dedup = new Map(); // userId -> {userId, points}
for (const rawEntry of list) {
const entry = rawEntry && typeof rawEntry === "object" && !Array.isArray(rawEntry) ? rawEntry : {};
const userId = typeof entry.userId === "string" ? entry.userId.trim() : "";
if (!userId || !mongoose.Types.ObjectId.isValid(userId)) continue;
const points = parseOptionalPoints(entry.points);
dedup.set(String(userId), { userId: String(userId), points });
}
const normalizedList = Array.from(dedup.values());
if (normalizedList.length) {
out[String(year)] = normalizedList;
}
}
return out;
}
function sanitizeHtml(raw) {
// Basic, defensive sanitization (admin-controlled input, rendered for all users).
// We remove script tags and inline event handlers. This is not a complete HTML sanitizer.
const html = String(raw || "");
const withoutScripts = html.replace(/<script\b[\s\S]*?<\/script>/gi, "");
const withoutOnAttrs = withoutScripts
.replace(/\son\w+="[^"]*"/gi, "")
.replace(/\son\w+='[^']*'/gi, "")
.replace(/\son\w+=\S+/gi, "");
return withoutOnAttrs.trim();
}
function normalizeCompletionModal(rawValue) {
const value = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const title = typeof value.title === "string" ? value.title : DEFAULT_COMPLETION_MODAL.title;
const bodyText = typeof value.bodyText === "string" ? value.bodyText : DEFAULT_COMPLETION_MODAL.bodyText;
const videoSrc = typeof value.videoSrc === "string" ? value.videoSrc : DEFAULT_COMPLETION_MODAL.videoSrc;
const bodyHtml = typeof value.bodyHtml === "string" ? value.bodyHtml : DEFAULT_COMPLETION_MODAL.bodyHtml;
return {
title: String(title || "").slice(0, 200).trim() || DEFAULT_COMPLETION_MODAL.title,
bodyText: String(bodyText || "").slice(0, 8000),
videoSrc: String(videoSrc || "").slice(0, 2048).trim(),
bodyHtml: sanitizeHtml(String(bodyHtml || "").slice(0, 20000)),
};
}
function createId() {
try {
// Node 16+ / 18+: randomUUID
if (crypto && typeof crypto.randomUUID === "function") return crypto.randomUUID();
} catch (_) {}
// Fallback: time-based
return `${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function normalizeAppVersionControl(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const versions = Array.isArray(v.versions) ? v.versions : [];
const cleaned = versions
.map((e) => (e && typeof e === "object" && !Array.isArray(e) ? e : {}))
.map((e) => {
const id = typeof e.id === "string" ? e.id.trim() : "";
const version = typeof e.version === "string" ? e.version.trim() : "";
const message = typeof e.message === "string" ? e.message : "";
const dateISO = typeof e.dateISO === "string" ? e.dateISO.trim() : "";
const d = new Date(dateISO);
const safeDateISO = dateISO && !Number.isNaN(d.getTime()) ? d.toISOString() : null;
return {
id: id || null,
version: version ? version.slice(0, 80) : null,
message: String(message || "").slice(0, 500),
dateISO: safeDateISO,
};
})
.filter((e) => e.id && e.version)
.sort((a, b) => {
const ad = a.dateISO ? new Date(a.dateISO).getTime() : 0;
const bd = b.dateISO ? new Date(b.dateISO).getTime() : 0;
return bd - ad;
});
const activeId = typeof v.activeId === "string" ? v.activeId.trim() : "";
const active = cleaned.find((e) => String(e.id) === String(activeId)) || null;
return {
activeId: active ? String(active.id) : cleaned[0]?.id ? String(cleaned[0].id) : null,
versions: cleaned,
};
}
async function getOrInitActiveYear() {
const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY });
if (existing && typeof existing.value === "number") return existing.value;
// Initialize from latest movie year if available, else from current year.
const latestMovie = await Movie.findOne({ year: { $type: "number" } })
.sort({ year: -1 })
.select("year");
const fallbackYear = latestMovie?.year || new Date().getFullYear();
const year = parseOscarYear(fallbackYear) || new Date().getFullYear();
await Setting.findOneAndUpdate(
{ key: ACTIVE_YEAR_KEY },
{ $set: { value: year } },
{ upsert: true, new: true }
);
return year;
}
// Public: get current active Oscar year used by the site.
router.get("/year", async (req, res) => {
try {
const year = await getOrInitActiveYear();
return res.status(200).json({ year });
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Public: get Oscar date for a given year (with fallback).
// - If no custom date is configured, fallback is March 15 of that year (legacy behavior).
router.get("/oscar-date", async (req, res) => {
try {
const year = parseOscarYear(req.query?.year);
if (!year) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
const dates = normalizeOscarDatesByYear(existing?.value);
const configured = dates[String(year)] || null;
const effectiveDate = configured || `${String(year)}-03-15`;
return res.status(200).json({
year,
date: configured, // configured value (null if unset)
effectiveDate, // always present
});
} catch (err) {
// Even if DB is down, keep the legacy fallback.
const year = parseOscarYear(req.query?.year);
if (year) {
return res.status(200).json({ year, date: null, effectiveDate: `${String(year)}-03-15` });
}
return res.status(500).json({ error: err.message });
}
});
// Public: get all configured Oscar dates.
router.get("/oscar-dates", async (req, res) => {
try {
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
const dates = normalizeOscarDatesByYear(existing?.value);
return res.status(200).json({ dates });
} catch (err) {
return res.status(200).json({ dates: {} });
}
});
// Admin-only: set/clear Oscar date for a given year.
// Body: { date: "YYYY-MM-DD" } or { date: "" } to clear.
router.put("/oscar-date/:year", 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 year = parseOscarYear(req.params?.year);
if (!year) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
const rawDate = req.body?.date;
const date = parseIsoDateString(rawDate);
const wantsClear = rawDate === "" || rawDate === null || rawDate === undefined || (typeof rawDate === "string" && rawDate.trim() === "");
if (!wantsClear && !date) {
return res.status(400).json({ message: "Date invalide (format attendu: YYYY-MM-DD)" });
}
const existing = await Setting.findOne({ key: OSCAR_DATES_BY_YEAR_KEY }).select("value");
const dates = normalizeOscarDatesByYear(existing?.value);
if (wantsClear) {
delete dates[String(year)];
} else {
dates[String(year)] = date;
}
await Setting.findOneAndUpdate(
{ key: OSCAR_DATES_BY_YEAR_KEY },
{ $set: { value: dates } },
{ upsert: true, new: true }
);
return res.status(200).json({ year, date: dates[String(year)] || null });
} 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: set current active Oscar year.
router.put("/year", 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 year = parseOscarYear(req.body?.year);
if (!year) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
const updated = await Setting.findOneAndUpdate(
{ key: ACTIVE_YEAR_KEY },
{ $set: { value: year } },
{ upsert: true, new: true }
);
return res.status(200).json({ year: 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 });
}
});
// Public: get the "100% completion" modal content for checklist.
router.get("/completion-modal", async (req, res) => {
try {
const existing = await Setting.findOne({ key: COMPLETION_MODAL_KEY }).select("value");
const normalized = normalizeCompletionModal(existing?.value);
return res.status(200).json(normalized);
} catch (err) {
// If DB is down, still return a sane default so the client can render.
return res.status(200).json(DEFAULT_COMPLETION_MODAL);
}
});
// Admin-only: set the "100% completion" modal content for checklist.
router.put("/completion-modal", 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 = normalizeCompletionModal(req.body);
const updated = await Setting.findOneAndUpdate(
{ key: COMPLETION_MODAL_KEY },
{ $set: { value: normalized } },
{ upsert: true, new: true }
).select("value");
return res.status(200).json(normalizeCompletionModal(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 });
}
});
// Public: get winners by year (optionally with points)
router.get("/winners", async (req, res) => {
try {
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
const normalized = normalizeWinnersByYear(existing?.value);
const entries = Object.entries(normalized)
.flatMap(([yearStr, list]) =>
(Array.isArray(list) ? list : []).map((v) => ({
year: Number(yearStr),
userId: v.userId,
points: v.points ?? null,
}))
)
.filter((e) => parseOscarYear(e.year))
.sort((a, b) => b.year - a.year);
const userIds = entries.map((e) => e.userId).filter(Boolean);
const users = userIds.length
? await User.find({ _id: { $in: userIds } }).select("name").lean()
: [];
const nameById = new Map(users.map((u) => [String(u._id), u.name]));
return res.status(200).json({
winners: entries.map((e) => ({
year: e.year,
userId: e.userId,
name: nameById.get(String(e.userId)) || null,
points: e.points ?? null,
})),
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Public: get app version control (active + list)
router.get("/app-version", async (req, res) => {
try {
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(existing?.value);
const active = control.activeId
? control.versions.find((v) => String(v.id) === String(control.activeId)) || null
: null;
return res.status(200).json({ active, versions: control.versions });
} catch (_) {
return res.status(200).json({ active: null, versions: [] });
}
});
// 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) => {
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 version = typeof req.body?.version === "string" ? req.body.version.trim() : "";
const message = typeof req.body?.message === "string" ? req.body.message : "";
if (!version) return res.status(400).json({ message: "Version requise (ex: 1.0.0)" });
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(existing?.value);
const entry = {
id: createId(),
version: version.slice(0, 80),
message: String(message || "").slice(0, 500),
dateISO: new Date().toISOString(),
};
const next = {
activeId: control.activeId,
versions: [entry, ...(Array.isArray(control.versions) ? control.versions : [])],
};
const wantsActivate = String(req.query?.activate || "").toLowerCase() === "true";
if (!next.activeId || wantsActivate) {
next.activeId = entry.id;
}
const updated = await Setting.findOneAndUpdate(
{ key: APP_VERSION_KEY },
{ $set: { value: next } },
{ upsert: true, new: true }
).select("value");
const normalized = normalizeAppVersionControl(updated?.value);
const active = normalized.activeId
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
: null;
return res.status(200).json({ active, versions: normalized.versions });
} 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: set active version by id
// Body: { id: "<versionId>" }
router.put("/app-version/active", 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 id = typeof req.body?.id === "string" ? req.body.id.trim() : "";
if (!id) return res.status(400).json({ message: "id requis" });
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(existing?.value);
const exists = control.versions.find((v) => String(v.id) === String(id));
if (!exists) return res.status(404).json({ message: "Version introuvable" });
const next = { activeId: String(id), versions: control.versions };
const updated = await Setting.findOneAndUpdate(
{ key: APP_VERSION_KEY },
{ $set: { value: next } },
{ upsert: true, new: true }
).select("value");
const normalized = normalizeAppVersionControl(updated?.value);
const active = normalized.activeId
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
: null;
return res.status(200).json({ active, versions: normalized.versions });
} 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: delete a version entry by id (re-picks active if needed)
router.delete("/app-version/:id", 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 id = typeof req.params?.id === "string" ? req.params.id.trim() : "";
if (!id) return res.status(400).json({ message: "id requis" });
const existing = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(existing?.value);
const nextVersions = control.versions.filter((v) => String(v.id) !== String(id));
const next = {
activeId: control.activeId && String(control.activeId) === String(id) ? null : control.activeId,
versions: nextVersions,
};
if (!next.activeId && nextVersions.length) next.activeId = String(nextVersions[0].id);
const updated = await Setting.findOneAndUpdate(
{ key: APP_VERSION_KEY },
{ $set: { value: next } },
{ upsert: true, new: true }
).select("value");
const normalized = normalizeAppVersionControl(updated?.value);
const active = normalized.activeId
? normalized.versions.find((v) => String(v.id) === String(normalized.activeId)) || null
: null;
return res.status(200).json({ active, versions: normalized.versions });
} 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: add/upsert/clear winners for a given year. Points optional.
// - Clear all for year: { userId: "" } (legacy behavior)
// - Add/upsert winner for year: { userId: "<mongoId>", points?: number|null }
// - Replace full list for year: { winners: [{ userId, points? }, ...] }
router.put("/winners/:year", 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 year = parseOscarYear(req.params?.year);
if (!year) {
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
}
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
const winners = normalizeWinnersByYear(existing?.value);
// Replace list mode: { winners: [...] }
if (Array.isArray(req.body?.winners)) {
const replacement = normalizeWinnersByYear({ [String(year)]: req.body.winners })[String(year)] || [];
if (!replacement.length) {
delete winners[String(year)];
} else {
winners[String(year)] = replacement;
}
await Setting.findOneAndUpdate(
{ key: WINNERS_BY_YEAR_KEY },
{ $set: { value: winners } },
{ upsert: true, new: true }
);
return res.status(200).json({ year, winners: winners[String(year)] || [] });
}
// Add/upsert/clear mode (legacy-compatible): { userId, points? }
const rawUserId = req.body?.userId;
const userId = typeof rawUserId === "string" ? rawUserId.trim() : rawUserId === null ? "" : "";
const points = parseOptionalPoints(req.body?.points);
// Clear all for year
if (!userId) {
delete winners[String(year)];
await Setting.findOneAndUpdate(
{ key: WINNERS_BY_YEAR_KEY },
{ $set: { value: winners } },
{ upsert: true, new: true }
);
return res.status(200).json({ year, winners: [] });
}
if (!mongoose.Types.ObjectId.isValid(userId)) {
return res.status(400).json({ message: "userId invalide" });
}
const winnerUser = await User.findById(userId).select("name").lean();
if (!winnerUser) return res.status(404).json({ message: "User not found" });
const key = String(year);
const current = Array.isArray(winners[key]) ? winners[key] : [];
const next = current.filter((w) => String(w?.userId || "") !== String(userId));
next.push({ userId: String(userId), points });
winners[key] = next;
await Setting.findOneAndUpdate(
{ key: WINNERS_BY_YEAR_KEY },
{ $set: { value: winners } },
{ upsert: true, new: true }
);
return res.status(200).json({
year,
winner: { year, userId: String(userId), name: winnerUser.name || null, points: points ?? null },
winners: winners[String(year)] || [],
});
} 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: remove a specific winner for a given year.
router.delete("/winners/:year/:userId", 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 year = parseOscarYear(req.params?.year);
if (!year) return res.status(400).json({ message: "Année invalide (ex: 2026)" });
const userId = typeof req.params?.userId === "string" ? req.params.userId.trim() : "";
if (!mongoose.Types.ObjectId.isValid(userId)) {
return res.status(400).json({ message: "userId invalide" });
}
const existing = await Setting.findOne({ key: WINNERS_BY_YEAR_KEY }).select("value");
const winners = normalizeWinnersByYear(existing?.value);
const key = String(year);
const current = Array.isArray(winners[key]) ? winners[key] : [];
const next = current.filter((w) => String(w?.userId || "") !== String(userId));
if (next.length) winners[key] = next;
else delete winners[key];
await Setting.findOneAndUpdate(
{ key: WINNERS_BY_YEAR_KEY },
{ $set: { value: winners } },
{ upsert: true, new: true }
);
return res.status(200).json({ year, winners: winners[key] || [] });
} 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 });
}
});
module.exports = router;
+384 -26
View File
@@ -1,19 +1,32 @@
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const User = require("../models/User");
const Movie = require("../models/Movie");
const Setting = require("../models/Setting");
const PlaybackProgress = require("../models/PlaybackProgress");
const axios = require("axios");
const router = express.Router();
// Set password for verification
const woof = process.env.DOG_NAMES ? process.env.DOG_NAMES.split(",") : [];
const woof =
typeof process.env.DOG_NAMES === "string"
? process.env.DOG_NAMES.split(",").map((s) => s.trim()).filter(Boolean)
: [];
const ACTIVE_YEAR_KEY = "active_oscar_year";
// Fetch movie details from OMDb API
async function fetchMovieDetailsFromOmdb(imdb_id) {
const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key
const apiKey = typeof process.env.OMDB_API === "string" ? process.env.OMDB_API.trim() : "";
if (!apiKey) {
const err = new Error("OMDb n'est pas configuré (env OMDB_API manquante).");
err.code = "OMDB_NOT_CONFIGURED";
throw err;
}
const omdbUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(imdb_id)}&apikey=${apiKey}`;
try {
@@ -49,14 +62,74 @@ function verifyToken(req, res, next) {
});
}
function parseOscarYear(raw) {
if (raw === undefined || raw === null || raw === "") return null;
const n = Number(raw);
if (!Number.isInteger(n)) return null;
if (n < 1900 || n > 3000) return null;
return n;
}
function isAdminFromDecoded(decoded) {
return !!decoded?.admin;
}
function signUserToken(user) {
const isAdmin = user.role === 69;
const expiresIn = typeof process.env.JWT_EXPIRES_IN === "string" && process.env.JWT_EXPIRES_IN.trim()
? process.env.JWT_EXPIRES_IN.trim()
: "8h";
return jwt.sign(
{
id: user._id,
name: user.name,
admin: isAdmin,
mustChangePassword: !!user.mustChangePassword,
},
process.env.JWT_SECRET,
{ expiresIn }
);
}
function generateTempPassword() {
// 12 chars, URL-safe (no spaces/specials that break copy/paste)
return crypto.randomBytes(9).toString("base64url");
}
const TEMP_PASSWORD_TTL_MS = 24 * 60 * 60 * 1000; // 24h
async function getOrInitActiveYear() {
const existing = await Setting.findOne({ key: ACTIVE_YEAR_KEY });
if (existing && typeof existing.value === "number") return existing.value;
// Initialize from latest movie year if available, else from current year.
const latestMovie = await Movie.findOne({ year: { $type: "number" } })
.sort({ year: -1 })
.select("year");
const fallbackYear = latestMovie?.year || new Date().getFullYear();
const year = parseOscarYear(fallbackYear) || new Date().getFullYear();
await Setting.findOneAndUpdate(
{ key: ACTIVE_YEAR_KEY },
{ $set: { value: year } },
{ upsert: true, new: true }
);
return year;
}
// Get user stats with watched movie titles and details from the movies collection
router.get("/stats", verifyToken, async (req, res) => {
try {
// Fetch all users
const users = await User.find();
// Fetch all movies from the database
const allMovies = await Movie.find();
// If a year is explicitly provided, use it; otherwise default to the global active year.
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
// Fetch movies (optionally filtered by year)
const allMovies = await Movie.find({ year });
const totalMoviesCount = allMovies.length;
if (totalMoviesCount === 0) {
@@ -65,10 +138,12 @@ router.get("/stats", verifyToken, async (req, res) => {
// Build stats for each user
const userStats = users.map((user) => {
const movieByImdbId = new Map(allMovies.map((m) => [m.imdb_id, m]));
// Get watched movies with details from the database
const watchedMovies = user.watchedMovies
.map((wm) => allMovies.find((m) => m.imdb_id === wm.imdb_id)) // Match movie details
.filter(Boolean); // Remove any `undefined` values (if movie is not found)
.map((wm) => movieByImdbId.get(wm.imdb_id)) // Match movie details
.filter(Boolean); // Remove any `undefined` values (if movie is not found)
const watchedCount = watchedMovies.length;
const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1);
@@ -80,6 +155,7 @@ router.get("/stats", verifyToken, async (req, res) => {
return {
name: user.name,
year,
watchedCount,
totalMoviesCount,
watchedRatio: `${watchedRatio}%`,
@@ -94,38 +170,134 @@ router.get("/stats", verifyToken, async (req, res) => {
}
});
// Get users who have 100% completion per year (across all years in DB)
// Returns: { years: number[], totals: { [year]: number }, completersByYear: { [year]: {name, watchedCount, totalMoviesCount}[] } }
router.get("/completions", verifyToken, async (req, res) => {
try {
const yearsRaw = await Movie.distinct("year");
const years = (Array.isArray(yearsRaw) ? yearsRaw : [])
.map((y) => Number(y))
.filter((y) => parseOscarYear(y))
.sort((a, b) => b - a);
if (years.length === 0) {
return res.status(200).json({ years: [], totals: {}, completersByYear: {} });
}
// Fetch imdb_id + year for all movies that have a valid year
const movies = await Movie.find({ year: { $in: years } }).select("imdb_id year").lean();
const imdbIdsByYear = new Map();
const totals = {};
movies.forEach((m) => {
const year = Number(m?.year);
if (!parseOscarYear(year)) return;
const imdb = typeof m?.imdb_id === "string" ? m.imdb_id : "";
if (!imdb) return;
if (!imdbIdsByYear.has(year)) imdbIdsByYear.set(year, new Set());
imdbIdsByYear.get(year).add(imdb);
});
for (const y of years) {
const set = imdbIdsByYear.get(y);
totals[String(y)] = set ? set.size : 0;
}
const users = await User.find().select("name watchedMovies.imdb_id").lean();
const completersByYear = {};
for (const y of years) {
const total = totals[String(y)] || 0;
if (total <= 0) {
completersByYear[String(y)] = [];
continue;
}
const yearSet = imdbIdsByYear.get(y) || new Set();
const completers = [];
users.forEach((u) => {
const watched = Array.isArray(u?.watchedMovies) ? u.watchedMovies : [];
const watchedSet = new Set(
watched
.map((wm) => (typeof wm?.imdb_id === "string" ? wm.imdb_id : ""))
.filter(Boolean)
);
let count = 0;
for (const imdb of watchedSet) {
if (yearSet.has(imdb)) count += 1;
}
if (count === total) {
completers.push({
name: u?.name || "(sans nom)",
watchedCount: count,
totalMoviesCount: total,
});
}
});
completers.sort((a, b) => String(a.name).localeCompare(String(b.name), "fr", { sensitivity: "base" }));
completersByYear[String(y)] = completers;
}
return res.status(200).json({ years, totals, completersByYear });
} catch (err) {
console.error("Erreur dans l'extraction des completers:", err);
return res.status(500).json({ error: "Internal server error" });
}
});
// Register User
router.post("/register", async (req, res) => {
const { name, email, password, role, verifoof } = req.body;
const { name, email, password, verifoof } = req.body;
const dogCheckEnabled = woof.length > 0;
function registerVerification() {
// If DOG_NAMES isn't configured, skip the "dog name" verification entirely.
if (!dogCheckEnabled) return true;
return woof.includes(verifoof);
}
try {
const nameNorm = typeof name === "string" ? name.trim() : "";
const emailNorm = typeof email === "string" ? email.trim() : "";
if (!nameNorm) {
throw new Error("Le nom est obligatoire");
}
if (!emailNorm) {
throw new Error("L'email est obligatoire");
}
// Basic sanity check (not a full RFC validator)
if (!/^\S+@\S+\.\S+$/.test(emailNorm)) {
throw new Error("Email invalide.");
}
if (!password) {
throw new Error("Le mot de passe est obligatoire et ne peut être indéfini");
}
if (!verifoof) {
throw new Error("Le nom du chien doit être défini" );
if (dogCheckEnabled && !verifoof) {
throw new Error("Le nom du chien doit être défini");
}
if (!registerVerification()) {
return res.status(201).json({ message: "Mauvais nom de chien" });
return res.status(400).json({ message: "Mauvais nom de chien" });
}
const existingUser = await User.findOne({ email });
const existingUser = await User.findOne({ email: emailNorm });
if (existingUser) {
return res.status(201).json({ message: "L'email existe déjà, veuillez en choisir un autre." });
return res.status(409).json({ message: "L'email existe déjà, veuillez en choisir un autre." });
}
// Hash password and create user
const hashedPassword = await bcrypt.hash(password, 10);
const user = new User({ name, email, password: hashedPassword, role });
// SECURITY: never accept role escalation from a public endpoint.
const user = new User({ name: nameNorm, email: emailNorm, password: hashedPassword, role: 0 });
await user.save();
res.status(201).json({ message: "L'utilisateur s'est enregistré avec succès !" });
} catch (err) {
res.status(400).json({ error: err.message });
res.status(403).json({ message: "Erreur interne lmao" });
return res.status(400).json({ message: err?.message || "Erreur interne" });
}
});
@@ -137,27 +309,213 @@ router.post("/login", async (req, res) => {
const user = await User.findOne({ email });
if (!user) return res.status(404).json({ message: "L'utilisateur n'a pas été trouvé" });
// If this account is in "temp password" mode and the temp password has expired, block login.
if (user.mustChangePassword && user.tempPasswordExpiresAt && user.tempPasswordExpiresAt instanceof Date) {
if (Date.now() > user.tempPasswordExpiresAt.getTime()) {
return res.status(401).json({
message: "Le mot de passe temporaire a expiré. Demandez à un admin de réinitialiser votre mot de passe.",
});
}
}
const isMatch = await bcrypt.compare(password, user.password);
if (!isMatch) return res.status(401).json({ message: "Informations d'identification non valides" });
console.log("[debug] " + user.name + " logged in")
// Check if the user role is 69 and set the admin flag
const isAdmin = user.role === 69;
// Create a token with the user ID, name, and admin flag if the user is an admin
const token = jwt.sign(
{ id: user._id, name: user.name, admin: isAdmin }, // Include name in the token payload
process.env.JWT_SECRET,
{ expiresIn: "1h" }
);
res.status(200).json({ token });
const token = signUserToken(user);
res.status(200).json({ token, mustChangePassword: !!user.mustChangePassword });
} catch (err) {
res.status(400).json({ error: err.message });
}
});
// Admin: reset a user's password to a random temp password and force change on next login.
router.post("/admin/reset-temp-password", verifyToken, async (req, res) => {
if (!isAdminFromDecoded(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { email, userId } = req.body || {};
const emailNorm = typeof email === "string" ? email.trim() : "";
const idNorm = typeof userId === "string" ? userId.trim() : "";
if (!emailNorm && !idNorm) {
return res.status(400).json({ message: "Email ou userId requis." });
}
try {
const user = emailNorm ? await User.findOne({ email: emailNorm }) : await User.findById(idNorm);
if (!user) return res.status(404).json({ message: "User not found" });
const tempPassword = generateTempPassword();
const hashedPassword = await bcrypt.hash(tempPassword, 10);
const expiresAt = new Date(Date.now() + TEMP_PASSWORD_TTL_MS);
user.password = hashedPassword;
user.mustChangePassword = true;
user.tempPasswordIssuedAt = new Date();
user.tempPasswordExpiresAt = expiresAt;
await user.save();
return res.status(200).json({
message: "Mot de passe temporaire généré. L'utilisateur devra le changer à sa prochaine connexion.",
tempPassword,
expiresAt: expiresAt.toISOString(),
user: { id: String(user._id), name: user.name, email: user.email },
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Admin: list users (safe fields only)
router.get("/admin/list", verifyToken, async (req, res) => {
if (!isAdminFromDecoded(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
try {
const users = await User.find()
.select("name email role mustChangePassword")
.sort({ name: 1, email: 1 });
return res.status(200).json(
users.map((u) => ({
id: String(u._id),
name: u.name,
email: u.email,
admin: u.role === 69,
mustChangePassword: !!u.mustChangePassword,
}))
);
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Admin: create a user with a random temp password (forces change on first login).
router.post("/admin/create", verifyToken, async (req, res) => {
if (!isAdminFromDecoded(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { name, email, admin } = req.body || {};
const nameNorm = typeof name === "string" ? name.trim() : "";
const emailNorm = typeof email === "string" ? email.trim() : "";
const makeAdmin = !!admin;
if (!nameNorm) {
return res.status(400).json({ message: "Nom requis." });
}
if (!emailNorm) {
return res.status(400).json({ message: "Email requis." });
}
// Basic sanity check (not a full RFC validator)
if (!/^\S+@\S+\.\S+$/.test(emailNorm)) {
return res.status(400).json({ message: "Email invalide." });
}
try {
const existingUser = await User.findOne({ email: emailNorm });
if (existingUser) {
return res.status(409).json({ message: "L'email existe déjà, veuillez en choisir un autre." });
}
const tempPassword = generateTempPassword();
const hashedPassword = await bcrypt.hash(tempPassword, 10);
const expiresAt = new Date(Date.now() + TEMP_PASSWORD_TTL_MS);
const user = new User({
name: nameNorm,
email: emailNorm,
password: hashedPassword,
role: makeAdmin ? 69 : 0,
mustChangePassword: true,
tempPasswordIssuedAt: new Date(),
tempPasswordExpiresAt: expiresAt,
});
await user.save();
return res.status(201).json({
message: "Utilisateur créé. Mot de passe temporaire généré.",
tempPassword,
expiresAt: expiresAt.toISOString(),
user: { id: String(user._id), name: user.name, email: user.email, admin: user.role === 69, mustChangePassword: true },
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Admin: delete a user (and related playback progress)
router.post("/admin/delete", verifyToken, async (req, res) => {
if (!isAdminFromDecoded(req.user)) {
return res.status(403).json({ message: "You do not have admin privileges" });
}
const { email, userId } = req.body || {};
const emailNorm = typeof email === "string" ? email.trim() : "";
const idNorm = typeof userId === "string" ? userId.trim() : "";
if (!emailNorm && !idNorm) {
return res.status(400).json({ message: "Email ou userId requis." });
}
try {
const user = emailNorm ? await User.findOne({ email: emailNorm }) : await User.findById(idNorm);
if (!user) return res.status(404).json({ message: "User not found" });
// Safety: prevent deleting yourself from the admin UI.
if (String(user._id) === String(req.user?.id || "")) {
return res.status(400).json({ message: "Impossible de supprimer ton propre compte." });
}
// Safety: prevent deleting admin accounts via the UI endpoint.
if (user.role === 69) {
return res.status(400).json({ message: "Impossible de supprimer un compte admin via cette interface." });
}
await PlaybackProgress.deleteMany({ userId: user._id });
await User.deleteOne({ _id: user._id });
return res.status(200).json({
message: "Utilisateur supprimé.",
user: { id: String(user._id), name: user.name, email: user.email },
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// User: change password (used after temp-password login). Returns a fresh token.
router.post("/change-password", verifyToken, async (req, res) => {
const { newPassword } = req.body || {};
const pw = typeof newPassword === "string" ? newPassword : "";
if (!pw || pw.length < 8) {
return res.status(400).json({ message: "Le nouveau mot de passe doit contenir au moins 8 caractères." });
}
try {
const user = await User.findById(req.user?.id);
if (!user) return res.status(404).json({ message: "User not found" });
const hashedPassword = await bcrypt.hash(pw, 10);
user.password = hashedPassword;
user.mustChangePassword = false;
user.tempPasswordIssuedAt = null;
user.tempPasswordExpiresAt = null;
await user.save();
const token = signUserToken(user);
return res.status(200).json({ message: "Mot de passe mis à jour.", token });
} catch (err) {
return res.status(500).json({ error: err.message });
}
});
// Protected route to get movies (example)
router.get("/movies", verifyToken, async (req, res) => {
// Only authorized users will reach this point
+96 -43
View File
@@ -1,62 +1,115 @@
const axios = require("axios");
const express = require("express");
const moment = require("moment-timezone");
const Setting = require("../models/Setting");
const router = express.Router();
let latestCommitCache = null;
let lastFetchedTime = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
const TIMEZONE = "America/New_York";
async function fetchLatestCommitFromGitHub() {
const githubOwner = process.env.GITHUB_OWNER;
const githubRepo = process.env.GITHUB_REPO;
// Stored in Mongo via Setting model (admin-controlled).
// Shape:
// {
// activeId: string|null,
// versions: [{ id, version, message, dateISO }]
// }
const APP_VERSION_KEY = "app_version_control";
function nowFormatted() {
return moment(Date.now()).tz(TIMEZONE).format("YYYY-MM-DD HH:mm:ss");
}
function getPackageVersion() {
try {
const url = `https://api.github.com/repos/${githubOwner}/${githubRepo}/commits`;
const response = await axios.get(url, {
headers: {
"Content-Type": "application/json",
},
});
if (response.data && response.data.length > 0) {
const latestCommit = response.data[0];
latestCommitCache = {
sha: latestCommit.sha,
message: latestCommit.commit.message,
author: latestCommit.commit.author.name,
date: latestCommit.commit.author.date,
};
lastFetchedTime = Date.now();
} else {
throw new Error("No commits found.");
}
} catch (error) {
console.error("Error fetching commit:", error.message);
// routes/version.js -> ../package.json
const pkg = require("../package.json");
return typeof pkg?.version === "string" && pkg.version.trim() ? pkg.version.trim() : "dev";
} catch (_) {
return "dev";
}
}
// Initial fetch to populate cache
fetchLatestCommitFromGitHub();
function normalizeAppVersionControl(rawValue) {
const v = rawValue && typeof rawValue === "object" && !Array.isArray(rawValue) ? rawValue : {};
const versions = Array.isArray(v.versions) ? v.versions : [];
// Refresh cache every 5 minutes
setInterval(fetchLatestCommitFromGitHub, CACHE_DURATION);
const cleaned = versions
.map((e) => (e && typeof e === "object" && !Array.isArray(e) ? e : {}))
.map((e) => {
const id = typeof e.id === "string" ? e.id.trim() : "";
const version = typeof e.version === "string" ? e.version.trim() : "";
const message = typeof e.message === "string" ? e.message.slice(0, 500) : "";
const dateISO = typeof e.dateISO === "string" ? e.dateISO.trim() : "";
const d = new Date(dateISO);
const safeDateISO = dateISO && !Number.isNaN(d.getTime()) ? d.toISOString() : null;
return {
id: id || null,
version: version || null,
message: String(message || ""),
dateISO: safeDateISO,
};
})
.filter((e) => e.id && e.version)
.sort((a, b) => {
const ad = a.dateISO ? new Date(a.dateISO).getTime() : 0;
const bd = b.dateISO ? new Date(b.dateISO).getTime() : 0;
return bd - ad;
});
const activeId = typeof v.activeId === "string" ? v.activeId.trim() : "";
const active = cleaned.find((e) => String(e.id) === String(activeId)) || null;
// If activeId is invalid, fall back to newest entry.
return {
activeId: active ? String(active.id) : cleaned[0]?.id ? String(cleaned[0].id) : null,
versions: cleaned,
};
}
// Route to get the latest commit (cached)
router.get("/", async (req, res) => {
if (!latestCommitCache) {
return res.status(500).json({ message: "Commit data is not available yet." });
}
// Always return 200 and never rely on GitHub.
try {
const setting = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
const control = normalizeAppVersionControl(setting?.value);
res.json({
version: latestCommitCache.sha.substring(0, 5),
message: latestCommitCache.message,
author: latestCommitCache.author,
date: moment(latestCommitCache.date)
.tz("America/New_York")
.format("YYYY-MM-DD HH:mm:ss"),
});
const active = control.activeId
? control.versions.find((v) => String(v.id) === String(control.activeId)) || null
: null;
if (!active) {
return res.status(200).json({
version: getPackageVersion(),
message: "Version non configurée (définis-la dans Admin → Version).",
author: "",
date: nowFormatted(),
configured: false,
source: "fallback",
});
}
const date = active.dateISO
? moment(active.dateISO).tz(TIMEZONE).format("YYYY-MM-DD HH:mm:ss")
: nowFormatted();
return res.status(200).json({
version: active.version,
message: active.message || "",
author: "",
date,
configured: true,
source: "admin",
});
} catch (_) {
// If DB is down, still return something useful.
return res.status(200).json({
version: getPackageVersion(),
message: "Version indisponible (DB).",
author: "",
date: nowFormatted(),
configured: false,
source: "fallback",
});
}
});
module.exports = router;
+209
View File
@@ -0,0 +1,209 @@
const express = require("express");
const fs = require("fs");
const path = require("path");
const jwt = require("jsonwebtoken");
const mongoose = require("mongoose");
const Movie = require("../models/Movie");
const router = express.Router();
function parseCookies(header) {
const cookies = {};
const raw = String(header || "");
if (!raw) return cookies;
raw.split(";").forEach((part) => {
const idx = part.indexOf("=");
if (idx <= 0) return;
const k = part.slice(0, idx).trim();
const v = part.slice(idx + 1).trim();
if (!k) return;
cookies[k] = decodeURIComponent(v);
});
return cookies;
}
function isSafeRelativeFile(p) {
if (typeof p !== "string") return false;
const s = p.trim();
if (!s) return false;
if (s.includes("\0")) return false;
// No absolute paths
if (path.isAbsolute(s)) return false;
// Normalize and prevent traversal
const norm = path.posix.normalize(s.replaceAll("\\", "/"));
if (norm.startsWith("../") || norm === "..") return false;
return true;
}
function guessContentType(filePath) {
const ext = String(path.extname(filePath) || "").toLowerCase();
if (ext === ".mp4") return "video/mp4";
if (ext === ".webm") return "video/webm";
if (ext === ".ogv" || ext === ".ogg") return "video/ogg";
if (ext === ".m4v") return "video/x-m4v";
if (ext === ".mov") return "video/quicktime";
if (ext === ".m3u8") return "application/vnd.apple.mpegurl";
if (ext === ".ts") return "video/mp2t";
return "application/octet-stream";
}
function getVideoBaseDir() {
// Default to your existing public/video folder.
const fromEnv = typeof process.env.VIDEO_FILES_DIR === "string" ? process.env.VIDEO_FILES_DIR.trim() : "";
const base = fromEnv || path.join(process.cwd(), "public", "video");
return path.resolve(base);
}
function getAuthToken(req) {
// <video> can't send Authorization headers, so we use a cookie.
const cookies = parseCookies(req.headers.cookie);
const cookieToken = cookies.video_auth ? String(cookies.video_auth) : "";
if (cookieToken) return cookieToken;
// Optional fallback for non-browser clients:
const authHeader = String(req.headers.authorization || "");
const headerToken = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : "";
if (headerToken) return headerToken;
// Last resort (not recommended, but handy for debugging):
const qs = typeof req.query?.token === "string" ? req.query.token.trim() : "";
return qs || "";
}
function requireAuth(req, res) {
const token = getAuthToken(req);
if (!token) return { ok: false, error: res.status(401).json({ message: "Missing video auth token" }) };
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
return { ok: true, decoded };
} catch (_) {
return { ok: false, error: res.status(401).json({ message: "Invalid or expired video auth token" }) };
}
}
// Called by player.html via fetch() (with Authorization header) to set a cookie usable by <video>.
router.post("/session", (req, res) => {
const authHeader = String(req.headers.authorization || "");
const token = authHeader.toLowerCase().startsWith("bearer ") ? authHeader.slice(7).trim() : "";
if (!token) return res.status(401).json({ message: "Token is required" });
try {
jwt.verify(token, process.env.JWT_SECRET);
// Cookie scoped to /api/video so it doesn't leak broadly.
// httpOnly helps a bit, even though the JWT originally lives in localStorage.
const maxAgeSecondsRaw = typeof process.env.VIDEO_SESSION_MAX_AGE_SECONDS === "string" ? process.env.VIDEO_SESSION_MAX_AGE_SECONDS.trim() : "";
const maxAgeSeconds = Number.isFinite(Number(maxAgeSecondsRaw)) && Number(maxAgeSecondsRaw) > 0
? Math.floor(Number(maxAgeSecondsRaw))
: 60 * 60 * 8; // 8h
const cookie = [
`video_auth=${encodeURIComponent(token)}`,
"Path=/api/video",
`Max-Age=${maxAgeSeconds}`,
"SameSite=Lax",
// If behind HTTPS (recommended), enable Secure.
...(req.secure || req.headers["x-forwarded-proto"] === "https" ? ["Secure"] : []),
"HttpOnly",
].join("; ");
res.setHeader("Set-Cookie", cookie);
return res.status(204).end();
} catch (_) {
return res.status(401).json({ message: "Invalid or expired token" });
}
});
async function streamMovieFile(req, res) {
const auth = requireAuth(req, res);
if (!auth.ok) return;
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).select("video_file").lean();
const videoFile = typeof movie?.video_file === "string" ? movie.video_file.trim() : "";
if (!videoFile) {
return res.status(404).json({ message: "No server video file configured for this movie" });
}
if (!isSafeRelativeFile(videoFile)) {
return res.status(400).json({ message: "Invalid video_file path" });
}
const baseDir = getVideoBaseDir();
const fullPath = path.resolve(baseDir, videoFile);
if (!fullPath.startsWith(baseDir + path.sep) && fullPath !== baseDir) {
return res.status(400).json({ message: "Invalid video_file path" });
}
let stat;
try {
stat = await fs.promises.stat(fullPath);
} catch (_) {
return res.status(404).json({ message: "Video file not found on server" });
}
if (!stat.isFile()) return res.status(404).json({ message: "Video file not found on server" });
const fileSize = stat.size;
const range = String(req.headers.range || "");
const contentType = guessContentType(fullPath);
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Type", contentType);
res.setHeader("Cache-Control", "private, max-age=0, must-revalidate");
// HEAD: only headers (no body)
if (req.method === "HEAD") {
res.setHeader("Content-Length", String(fileSize));
return res.status(200).end();
}
if (!range) {
res.setHeader("Content-Length", String(fileSize));
return fs.createReadStream(fullPath).pipe(res);
}
// Example: "bytes=0-1023"
const match = /^bytes=(\d*)-(\d*)$/i.exec(range);
if (!match) {
res.setHeader("Content-Range", `bytes */${fileSize}`);
return res.status(416).end();
}
const startRaw = match[1];
const endRaw = match[2];
let start = startRaw ? Number(startRaw) : 0;
let end = endRaw ? Number(endRaw) : fileSize - 1;
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < 0 || start > end) {
res.setHeader("Content-Range", `bytes */${fileSize}`);
return res.status(416).end();
}
if (start >= fileSize) {
res.setHeader("Content-Range", `bytes */${fileSize}`);
return res.status(416).end();
}
end = Math.min(end, fileSize - 1);
const chunkSize = end - start + 1;
res.status(206);
res.setHeader("Content-Range", `bytes ${start}-${end}/${fileSize}`);
res.setHeader("Content-Length", String(chunkSize));
return fs.createReadStream(fullPath, { start, end }).pipe(res);
}
router.get("/:id", (req, res) => {
streamMovieFile(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Video streaming error" });
});
});
router.head("/:id", (req, res) => {
streamMovieFile(req, res).catch((err) => {
return res.status(500).json({ error: err?.message || "Video streaming error" });
});
});
module.exports = router;