Merge pull request #54 from quoije/2026-video.backend
add video backend server, fix styling and more
@@ -137,3 +137,6 @@ dist
|
|||||||
|
|
||||||
# ayylmao
|
# ayylmao
|
||||||
ayylmao.js
|
ayylmao.js
|
||||||
|
|
||||||
|
# Python video server - generated TLS material (certbot state)
|
||||||
|
python_video_server/certs/
|
||||||
@@ -1,18 +1,55 @@
|
|||||||
## Oscars Pool 2025
|
## Oscars Pool 2026
|
||||||
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.
|
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.
|

|
||||||
|
|
||||||

|
## 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
|
## 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` (for "verification")<br>
|
Minimal steps:
|
||||||
**GITHUB_OWNER=**`quoije` (for version history)<br>
|
|
||||||
**GITHUB_REPO=**`oscars-pool` (for version history)<br>
|
|
||||||
**JWT_SECRET=**`RANDOM STRING` (for authentication token)<br>
|
|
||||||
**MONGO_URI=**`mongodb+srv://USER:[email protected]/?retryWrites=true&w=majority&appName=Cluster69`<br>
|
|
||||||
**OMDB_API=**`1234567` (for movie info)
|
|
||||||
|
|
||||||
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`).
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 868 KiB After Width: | Height: | Size: 689 KiB |
@@ -2,12 +2,14 @@ const express = require("express");
|
|||||||
const mongoose = require("mongoose");
|
const mongoose = require("mongoose");
|
||||||
const dotenv = require("dotenv");
|
const dotenv = require("dotenv");
|
||||||
const cors = require("cors");
|
const cors = require("cors");
|
||||||
|
const compression = require("compression");
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
|
app.use(compression());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Connect to MongoDB
|
// Connect to MongoDB
|
||||||
@@ -20,11 +22,17 @@ mongoose
|
|||||||
const userRoutes = require("./routes/users");
|
const userRoutes = require("./routes/users");
|
||||||
const movieRoutes = require("./routes/movies");
|
const movieRoutes = require("./routes/movies");
|
||||||
const versionRoutes = require("./routes/version");
|
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(express.static('public'));
|
||||||
app.use("/api/users", userRoutes);
|
app.use("/api/users", userRoutes);
|
||||||
app.use("/api/movies", movieRoutes);
|
app.use("/api/movies", movieRoutes);
|
||||||
app.use("/api/version", versionRoutes);
|
app.use("/api/version", versionRoutes);
|
||||||
|
app.use("/api/settings", settingsRoutes);
|
||||||
|
app.use("/api/admin", adminDbRoutes);
|
||||||
|
app.use("/api/video", videoRoutes);
|
||||||
|
|
||||||
// Start Server
|
// Start Server
|
||||||
const PORT = process.env.PORT || 5000;
|
const PORT = process.env.PORT || 5000;
|
||||||
|
|||||||
@@ -6,9 +6,28 @@ const movieSchema = new mongoose.Schema({
|
|||||||
description: { type: String, required: false },
|
description: { type: String, required: false },
|
||||||
rating: { type: String, required: false },
|
rating: { type: String, required: false },
|
||||||
poster: { 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 },
|
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' }]
|
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);
|
module.exports = mongoose.model("Movie", movieSchema);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
|
||||||
@@ -5,6 +5,10 @@ const userSchema = new mongoose.Schema({
|
|||||||
email: { type: String, required: true, unique: true },
|
email: { type: String, required: true, unique: true },
|
||||||
password: { type: String, required: true },
|
password: { type: String, required: true },
|
||||||
role: { type: Number, default: 0 },
|
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: [{
|
watchedMovies: [{
|
||||||
imdb_id: { type: String, required: false },
|
imdb_id: { type: String, required: false },
|
||||||
watchedDate: { type: Date, default: Date.now } // Store the watched date
|
watchedDate: { type: Date, default: Date.now } // Store the watched date
|
||||||
|
|||||||
@@ -12,12 +12,14 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"body-parser": "^1.20.3",
|
"body-parser": "^1.20.3",
|
||||||
|
"compression": "^1.8.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.22.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"moment-timezone": "^0.5.47",
|
"moment-timezone": "^0.5.47",
|
||||||
"mongoose": "^8.9.5"
|
"mongoose": "^8.9.5",
|
||||||
|
"multer": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.9"
|
"nodemon": "^3.1.9"
|
||||||
@@ -74,6 +76,12 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/array-flatten": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||||
@@ -87,13 +95,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.7.9",
|
"version": "1.13.2",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
|
||||||
"integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==",
|
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.4",
|
||||||
"proxy-from-env": "^1.1.0"
|
"proxy-from-env": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -148,9 +156,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.11",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -186,6 +194,23 @@
|
|||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -261,6 +286,45 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -268,6 +332,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/content-disposition": {
|
||||||
"version": "0.5.4",
|
"version": "0.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||||
@@ -434,6 +513,21 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@@ -450,39 +544,39 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.21.2",
|
"version": "4.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||||
"integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==",
|
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
"body-parser": "1.20.3",
|
"body-parser": "~1.20.3",
|
||||||
"content-disposition": "0.5.4",
|
"content-disposition": "~0.5.4",
|
||||||
"content-type": "~1.0.4",
|
"content-type": "~1.0.4",
|
||||||
"cookie": "0.7.1",
|
"cookie": "~0.7.1",
|
||||||
"cookie-signature": "1.0.6",
|
"cookie-signature": "~1.0.6",
|
||||||
"debug": "2.6.9",
|
"debug": "2.6.9",
|
||||||
"depd": "2.0.0",
|
"depd": "2.0.0",
|
||||||
"encodeurl": "~2.0.0",
|
"encodeurl": "~2.0.0",
|
||||||
"escape-html": "~1.0.3",
|
"escape-html": "~1.0.3",
|
||||||
"etag": "~1.8.1",
|
"etag": "~1.8.1",
|
||||||
"finalhandler": "1.3.1",
|
"finalhandler": "~1.3.1",
|
||||||
"fresh": "0.5.2",
|
"fresh": "~0.5.2",
|
||||||
"http-errors": "2.0.0",
|
"http-errors": "~2.0.0",
|
||||||
"merge-descriptors": "1.0.3",
|
"merge-descriptors": "1.0.3",
|
||||||
"methods": "~1.1.2",
|
"methods": "~1.1.2",
|
||||||
"on-finished": "2.4.1",
|
"on-finished": "~2.4.1",
|
||||||
"parseurl": "~1.3.3",
|
"parseurl": "~1.3.3",
|
||||||
"path-to-regexp": "0.1.12",
|
"path-to-regexp": "~0.1.12",
|
||||||
"proxy-addr": "~2.0.7",
|
"proxy-addr": "~2.0.7",
|
||||||
"qs": "6.13.0",
|
"qs": "~6.14.0",
|
||||||
"range-parser": "~1.2.1",
|
"range-parser": "~1.2.1",
|
||||||
"safe-buffer": "5.2.1",
|
"safe-buffer": "5.2.1",
|
||||||
"send": "0.19.0",
|
"send": "~0.19.0",
|
||||||
"serve-static": "1.16.2",
|
"serve-static": "~1.16.2",
|
||||||
"setprototypeof": "1.2.0",
|
"setprototypeof": "1.2.0",
|
||||||
"statuses": "2.0.1",
|
"statuses": "~2.0.1",
|
||||||
"type-is": "~1.6.18",
|
"type-is": "~1.6.18",
|
||||||
"utils-merge": "1.0.1",
|
"utils-merge": "1.0.1",
|
||||||
"vary": "~1.1.2"
|
"vary": "~1.1.2"
|
||||||
@@ -495,6 +589,21 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/fill-range": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
@@ -547,13 +656,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.1",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
"integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==",
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
|
"es-set-tostringtag": "^2.1.0",
|
||||||
|
"hasown": "^2.0.2",
|
||||||
"mime-types": "^2.1.12"
|
"mime-types": "^2.1.12"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -686,6 +797,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
@@ -823,23 +949,23 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/jwa": {
|
"node_modules/jwa": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz",
|
||||||
"integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==",
|
"integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-equal-constant-time": "1.0.1",
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
"ecdsa-sig-formatter": "1.0.11",
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jws": {
|
"node_modules/jws": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz",
|
||||||
"integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==",
|
"integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"jwa": "^1.4.1",
|
"jwa": "^1.4.2",
|
||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -982,6 +1108,27 @@
|
|||||||
"node": "*"
|
"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": {
|
"node_modules/moment": {
|
||||||
"version": "2.30.1",
|
"version": "2.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||||
@@ -1137,6 +1284,24 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/negotiator": {
|
||||||
"version": "0.6.3",
|
"version": "0.6.3",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||||
@@ -1243,6 +1408,15 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -1345,6 +1519,20 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@@ -1565,6 +1753,23 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/supports-color": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||||
@@ -1635,6 +1840,12 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/undefsafe": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||||
@@ -1651,6 +1862,12 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/utils-merge": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||||
@@ -1690,6 +1907,15 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"body-parser": "^1.20.3",
|
"body-parser": "^1.20.3",
|
||||||
|
"compression": "^1.8.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"express": "^4.21.2",
|
"express": "^4.22.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"moment-timezone": "^0.5.47",
|
"moment-timezone": "^0.5.47",
|
||||||
"mongoose": "^8.9.5"
|
"mongoose": "^8.9.5",
|
||||||
|
"multer": "^2.0.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.9"
|
"nodemon": "^3.1.9"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="./css/app.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>
|
<style>
|
||||||
.navbar-custom {
|
.navbar-custom {
|
||||||
background-color: black;
|
background-color: black;
|
||||||
@@ -42,20 +44,25 @@
|
|||||||
.navbar-toggler-icon {
|
.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");
|
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>
|
</style>
|
||||||
</head>
|
</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 -->
|
<!-- Black banner with buttons and user info -->
|
||||||
<nav class="navbar navbar-expand-lg navbar-custom">
|
<nav class="navbar navbar-expand-lg navbar-custom">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" style="margin-left: -7px;">
|
<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>
|
</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">
|
<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>
|
<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="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="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="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>
|
||||||
<!-- Move user info inside collapsible navbar and align it properly -->
|
<!-- Move user info inside collapsible navbar and align it properly -->
|
||||||
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
|
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
|
||||||
@@ -77,14 +85,27 @@
|
|||||||
|
|
||||||
<div class="container mt-5">
|
<div class="container mt-5">
|
||||||
<h2>Checklist</h2>
|
<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>
|
<!-- Summary card (match Stats page spacing/structure) -->
|
||||||
<h3 class="fs-6"><span id="movies-per-day"></span></h3>
|
<div class="card shadow-sm mt-3">
|
||||||
<div class="progress my-3">
|
<div class="card-body">
|
||||||
<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>
|
<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>
|
||||||
|
<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 for movies -->
|
||||||
<table class="table table-hover movie-table">
|
<div class="table-responsive mt-3">
|
||||||
|
<table class="table table-striped table-hover align-middle movie-table mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Vu</th>
|
<th scope="col">Vu</th>
|
||||||
@@ -93,9 +114,13 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="movie-table-body">
|
<tbody id="movie-table-body">
|
||||||
<!-- Movie rows will be inserted here -->
|
<tr>
|
||||||
|
<td colspan="3" class="text-center py-4 text-muted" aria-live="polite" aria-busy="true">Chargement…</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<br><br>
|
<br><br>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,8 +136,11 @@
|
|||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body text-center">
|
<div class="modal-body text-center">
|
||||||
<video id="rewardVideo" width="75%" controls>
|
<div id="completion-modal-custom" class="text-start d-none"></div>
|
||||||
<source src="video/reward.mp4" type="video/mp4">
|
<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.
|
Votre navigateur ne supporte pas la vidéo.
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 & 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 & 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><script></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/<movieId></code>.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="video_file" class="form-label">Server video file (optional)</label>
|
||||||
|
<input type="text" class="form-control font-monospace" id="video_file" placeholder="my_movie.mp4 or 2026/my_movie.mp4">
|
||||||
|
<div class="form-text">
|
||||||
|
Relative path under <code>VIDEO_FILES_DIR</code> (default: <code>public/video</code>). Supports seeking (Range).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="embed_src" class="form-label">Embed URL or iframe code</label>
|
||||||
|
<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/<movieId></code>.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="edit_video_file" class="form-label">Server video file (optional)</label>
|
||||||
|
<input type="text" class="form-control font-monospace" id="edit_video_file" placeholder="my_movie.mp4 or 2026/my_movie.mp4">
|
||||||
|
<div class="form-text">Relative path under <code>VIDEO_FILES_DIR</code> (default: <code>public/video</code>).</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="edit_embed_src" class="form-label">Embed URL or iframe code</label>
|
||||||
|
<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>
|
||||||
|
|
||||||
@@ -21,3 +21,166 @@ footer {
|
|||||||
#copy {
|
#copy {
|
||||||
text-decoration: none;
|
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;
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="./css/app.css" rel="stylesheet">
|
<link href="./css/app.css" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
<!-- Footer Section -->
|
<!-- Footer Section -->
|
||||||
<footer>
|
<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>
|
<p id="footer-text"></p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
@@ -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 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 currentDate = new Date();
|
||||||
const timeDifference = targetDate - currentDate;
|
let timeDifference = fallbackDate - currentDate;
|
||||||
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
let daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||||
document.getElementById('time-left').textContent = `${daysLeft} jours`;
|
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) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const decoded = JSON.parse(atob(token.split('.')[1]));
|
const decoded = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
if (decoded.mustChangePassword) {
|
||||||
|
window.location.href = '/change-password.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const userName = decoded.name;
|
const userName = decoded.name;
|
||||||
document.getElementById('user-name').textContent = userName;
|
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);
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
if (decoded.exp && decoded.exp < currentTime) {
|
if (decoded.exp && decoded.exp < currentTime) {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
@@ -25,35 +317,71 @@ window.onload = async function () {
|
|||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch('/api/movies', {
|
if (watchedRatioEl) watchedRatioEl.textContent = '—';
|
||||||
|
const progressBarEl = document.getElementById('progress-bar');
|
||||||
|
pageLoader.setProgress(18);
|
||||||
|
|
||||||
|
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',
|
method: 'GET',
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
});
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
if (!res.ok) {
|
applyCompletionModalContent(completionModalContent);
|
||||||
|
pageLoader.setProgress(55);
|
||||||
|
|
||||||
|
if (!moviesRes.ok) {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const movies = await res.json();
|
movies = await moviesRes.json();
|
||||||
|
pageLoader.setProgress(62);
|
||||||
|
|
||||||
// Sort movies alphabetically by title
|
// Sort movies alphabetically by title
|
||||||
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
|
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
|
||||||
|
|
||||||
const watchedRes = await fetch('/api/movies/watchedMovies', {
|
const summary = await summaryPromise;
|
||||||
method: 'GET',
|
watchedMovies = Array.isArray(summary?.watchedMovies) ? summary.watchedMovies : [];
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
|
|
||||||
let watchedMovies = [];
|
if (progressBarEl) progressBarEl.removeAttribute('aria-busy');
|
||||||
if (watchedRes.ok) {
|
} catch (e) {
|
||||||
watchedMovies = await watchedRes.json();
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 totalMoviesCount = movies.length;
|
||||||
const watchedMoviesCount = watchedMovies.length;
|
const watchedMoviesCount = watchedMoviesInYear.length;
|
||||||
document.getElementById('watched-ratio').innerText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1)}%)`;
|
const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
|
||||||
|
document.getElementById('watched-ratio').innerText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
|
||||||
|
|
||||||
function launchConfetti() {
|
function launchConfetti() {
|
||||||
const duration = 3 * 1000; // 3 seconds
|
const duration = 3 * 1000; // 3 seconds
|
||||||
@@ -80,10 +408,12 @@ window.onload = async function () {
|
|||||||
frame();
|
frame();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateProgressBar(watchedCount, totalCount) {
|
function updateProgressBar(watchedCount, totalCount, celebrate = true) {
|
||||||
const progressBar = document.getElementById('progress-bar');
|
const progressBar = document.getElementById('progress-bar');
|
||||||
const percentage = (watchedCount / totalCount) * 100;
|
if (!progressBar) return;
|
||||||
console.log(percentage);
|
const safeTotal = Number(totalCount);
|
||||||
|
const safeWatched = Number(watchedCount);
|
||||||
|
const percentage = safeTotal > 0 && Number.isFinite(safeWatched) ? (safeWatched / safeTotal) * 100 : 0;
|
||||||
progressBar.style.width = `${percentage}%`;
|
progressBar.style.width = `${percentage}%`;
|
||||||
|
|
||||||
if (percentage <= 50) {
|
if (percentage <= 50) {
|
||||||
@@ -94,20 +424,25 @@ window.onload = async function () {
|
|||||||
|
|
||||||
progressBar.setAttribute('aria-valuenow', Math.round(percentage));
|
progressBar.setAttribute('aria-valuenow', Math.round(percentage));
|
||||||
|
|
||||||
|
if (celebrate) {
|
||||||
const videoModal = new bootstrap.Modal(document.getElementById("videoModal"));
|
const videoModal = new bootstrap.Modal(document.getElementById("videoModal"));
|
||||||
|
|
||||||
if (percentage === 100) {
|
if (percentage === 100) {
|
||||||
videoModal.show();
|
videoModal.show();
|
||||||
launchConfetti();
|
launchConfetti();
|
||||||
document.getElementById("rewardVideo").play();
|
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);
|
updateProgressBar(watchedMoviesCount, totalMoviesCount);
|
||||||
|
|
||||||
const movieTableBody = document.getElementById('movie-table-body');
|
if (movieTableBody) movieTableBody.innerHTML = '';
|
||||||
movies.forEach(movie => {
|
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 watchedDate = watchedMovie ? new Date(watchedMovie.watchedDate).toLocaleString() : '';
|
||||||
const movieRow = document.createElement('tr');
|
const movieRow = document.createElement('tr');
|
||||||
movieRow.innerHTML = `
|
movieRow.innerHTML = `
|
||||||
@@ -118,12 +453,9 @@ window.onload = async function () {
|
|||||||
<td id="watched-date-${movie.imdb_id}">${watchedDate}</td>
|
<td id="watched-date-${movie.imdb_id}">${watchedDate}</td>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Set initial row color based on watched state
|
// Use Bootstrap table state classes (matches Stats page styling better than custom backgrounds)
|
||||||
if (watchedMovie) {
|
movieRow.classList.toggle('table-success', Boolean(watchedMovie));
|
||||||
movieRow.style.backgroundColor = 'rgba(144, 238, 144, 0.6)'; // Lighter light green with transparency
|
movieRow.classList.toggle('table-danger', !watchedMovie);
|
||||||
} else {
|
|
||||||
movieRow.style.backgroundColor = 'rgba(255, 99, 71, 0.6)'; // Lighter light red with transparency
|
|
||||||
}
|
|
||||||
|
|
||||||
movieTableBody.appendChild(movieRow);
|
movieTableBody.appendChild(movieRow);
|
||||||
|
|
||||||
@@ -143,10 +475,16 @@ window.onload = async function () {
|
|||||||
|
|
||||||
if (isChecked) {
|
if (isChecked) {
|
||||||
watchedDateCell.textContent = new Date().toLocaleString();
|
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 {
|
} else {
|
||||||
watchedDateCell.textContent = '';
|
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', {
|
await fetch('/api/movies/users/updateWatchedMovies', {
|
||||||
@@ -165,8 +503,10 @@ window.onload = async function () {
|
|||||||
|
|
||||||
if (updatedWatchedMoviesRes.ok) {
|
if (updatedWatchedMoviesRes.ok) {
|
||||||
const updatedWatchedMovies = await updatedWatchedMoviesRes.json();
|
const updatedWatchedMovies = await updatedWatchedMoviesRes.json();
|
||||||
const updatedWatchedCount = updatedWatchedMovies.length;
|
watchedMoviesInYear = updatedWatchedMovies.filter((wm) => movieImdbIds.has(wm.imdb_id));
|
||||||
document.getElementById('watched-ratio').innerText = `Vu: ${updatedWatchedCount} / ${totalMoviesCount} (${((updatedWatchedCount / totalMoviesCount) * 100).toFixed(1)}%)`;
|
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);
|
updateProgressBar(updatedWatchedCount, totalMoviesCount);
|
||||||
|
|
||||||
// Recalculate and update movies per day
|
// Recalculate and update movies per day
|
||||||
@@ -181,6 +521,10 @@ window.onload = async function () {
|
|||||||
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
|
const moviesPerDay = daysLeft > 0 ? (moviesLeft / daysLeft).toFixed(2) : moviesLeft;
|
||||||
document.getElementById('movies-per-day').textContent = `À voir par jour: ${moviesPerDay} films`;
|
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', () => {
|
document.getElementById('log-off').addEventListener('click', () => {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
@@ -190,7 +534,8 @@ window.onload = async function () {
|
|||||||
const videoModal = document.getElementById("videoModal");
|
const videoModal = document.getElementById("videoModal");
|
||||||
const rewardVideo = document.getElementById("rewardVideo");
|
const rewardVideo = document.getElementById("rewardVideo");
|
||||||
videoModal.addEventListener("hidden.bs.modal", function () {
|
videoModal.addEventListener("hidden.bs.modal", function () {
|
||||||
|
if (!rewardVideo) return;
|
||||||
rewardVideo.pause();
|
rewardVideo.pause();
|
||||||
rewardVideo.currentTime = 0;
|
rewardVideo.currentTime = 0;
|
||||||
});
|
});
|
||||||
};
|
});
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
@@ -1,8 +1,44 @@
|
|||||||
|
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
|
// Check if the user is already logged in by looking for auth_token in localStorage
|
||||||
if (localStorage.getItem('auth_token')) {
|
const existingToken = localStorage.getItem('auth_token');
|
||||||
// If the token is found, redirect to /movies.html
|
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';
|
window.location.href = '/movies.html';
|
||||||
}
|
}
|
||||||
|
} catch (_) {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle login form submission
|
// Handle login form submission
|
||||||
document.getElementById('login-form').addEventListener('submit', async function (e) {
|
document.getElementById('login-form').addEventListener('submit', async function (e) {
|
||||||
@@ -21,7 +57,12 @@ if (localStorage.getItem('auth_token')) {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
// Save the token to localStorage
|
// Save the token to localStorage
|
||||||
localStorage.setItem('auth_token', data.token);
|
localStorage.setItem('auth_token', data.token);
|
||||||
|
|
||||||
|
if (data.mustChangePassword) {
|
||||||
|
window.location.href = '/change-password.html';
|
||||||
|
} else {
|
||||||
window.location.href = '/movies.html'; // Redirect to movies page on success
|
window.location.href = '/movies.html'; // Redirect to movies page on success
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const error = await res.json();
|
const error = await res.json();
|
||||||
alert(`${error.message}`);
|
alert(`${error.message}`);
|
||||||
|
|||||||
@@ -1,16 +1,283 @@
|
|||||||
window.onload = async function () {
|
function createPageLoader(options = {}) {
|
||||||
const targetDate = new Date('March 2, 2025');
|
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 currentDate = new Date();
|
||||||
const timeDifference = targetDate - currentDate;
|
const timeDifference = targetDate - currentDate;
|
||||||
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
const daysLeft = Math.floor(timeDifference / (1000 * 60 * 60 * 24));
|
||||||
document.getElementById('time-left').textContent = `${daysLeft} jours`;
|
timeLeftEl.textContent = `${daysLeft} jours`;
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
const token = localStorage.getItem('auth_token');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (token) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const decoded = JSON.parse(atob(token.split('.')[1]));
|
const decoded = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
if (decoded.mustChangePassword) {
|
||||||
|
window.location.href = '/change-password.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
const userName = decoded.name;
|
const userName = decoded.name;
|
||||||
document.getElementById('user-name').textContent = userName;
|
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);
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
if (decoded.exp && decoded.exp < currentTime) {
|
if (decoded.exp && decoded.exp < currentTime) {
|
||||||
@@ -25,7 +292,31 @@ window.onload = async function () {
|
|||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch('/api/movies', {
|
const moviesList = document.getElementById('movies-list');
|
||||||
|
pageLoader.setProgress(18);
|
||||||
|
|
||||||
|
let watchedMoviesInYear = [];
|
||||||
|
|
||||||
|
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',
|
method: 'GET',
|
||||||
headers: { 'Authorization': `Bearer ${token}` },
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
});
|
});
|
||||||
@@ -37,51 +328,80 @@ window.onload = async function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const movies = await res.json();
|
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
|
// 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' }));
|
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 totalMoviesCount = movies.length;
|
||||||
const watchedMoviesCount = watchedMovies.length;
|
const watchedMoviesCount = watchedMoviesInYear.length;
|
||||||
const ratioText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1)}%)`;
|
const ratioPct = totalMoviesCount > 0 ? ((watchedMoviesCount / totalMoviesCount) * 100).toFixed(1) : '0.0';
|
||||||
|
const ratioText = `Vu: ${watchedMoviesCount} / ${totalMoviesCount} (${ratioPct}%)`;
|
||||||
document.getElementById('watched-ratio').innerText = ratioText;
|
document.getElementById('watched-ratio').innerText = ratioText;
|
||||||
|
|
||||||
|
if (moviesList) moviesList.innerHTML = '';
|
||||||
|
|
||||||
movies.forEach(movie => {
|
movies.forEach(movie => {
|
||||||
const isChecked = watchedMovies.some(watchedMovie => watchedMovie.imdb_id === movie.imdb_id);
|
const isChecked = watchedMoviesInYear.some(watchedMovie => watchedMovie.imdb_id === movie.imdb_id);
|
||||||
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 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');
|
const movieDiv = document.createElement('div');
|
||||||
movieDiv.classList.add('col-md-4', 'mb-4', 'movie-card');
|
movieDiv.classList.add('col-12', 'col-sm-6', 'col-lg-4', 'movie-card');
|
||||||
movieDiv.setAttribute('data-imdb-id', movie.imdb_id);
|
movieDiv.setAttribute('data-imdb-id', movie.imdb_id);
|
||||||
movieDiv.innerHTML = `
|
movieDiv.innerHTML = `
|
||||||
<div class="card">
|
<div class="card h-100 shadow-sm">
|
||||||
${isChecked ? '<div class="watched-banner">VISIONNÉ</div>' : ''}
|
${isChecked ? '<span class="badge bg-success position-absolute top-0 start-0 m-2 watched-badge">VISIONNÉ</span>' : ''}
|
||||||
<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>
|
<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none">
|
||||||
<div class="card-body">
|
<div class="text-center pt-3 px-3">
|
||||||
<h5 class="card-title d-flex justify-content-between align-items-center">
|
<img src="${movie.poster}" class="img-fluid rounded movie-poster" alt="${movie.title}">
|
||||||
${movie.title}
|
</div>
|
||||||
<span>⭐ ${movie.rating} <a href="https://www.imdb.com/title/${movie.imdb_id}/"><img src="/img/imdb.png"></a></span>
|
</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>
|
</h5>
|
||||||
<p class="fw-bold fst-italic card-text" style="font-size: 0.75rem;">${movie.category}</p>
|
<div class="text-nowrap small text-muted">
|
||||||
<p class="card-text">${movie.description}</p>
|
⭐ ${movie.rating}
|
||||||
${isChecked ? `<p class="card-text"><strong>Regardé le:</strong> ${watchedDate}</p>` : ''}
|
<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>
|
||||||
</div>`;
|
</div>`;
|
||||||
moviesList.appendChild(movieDiv);
|
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 () {
|
document.getElementById('log-off').addEventListener('click', function () {
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
@@ -104,7 +424,7 @@ window.onload = async function () {
|
|||||||
|
|
||||||
movieCards.forEach(card => {
|
movieCards.forEach(card => {
|
||||||
const imdbId = card.getAttribute('data-imdb-id');
|
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) {
|
if (showUnwatchedOnly && isWatched) {
|
||||||
card.style.display = 'none';
|
card.style.display = 'none';
|
||||||
@@ -115,4 +435,4 @@ window.onload = async function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filterMovies();
|
filterMovies();
|
||||||
};
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@@ -1,8 +1,44 @@
|
|||||||
|
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
|
// Check if the user is already logged in by looking for auth_token in localStorage
|
||||||
if (localStorage.getItem('auth_token')) {
|
const existingToken = localStorage.getItem('auth_token');
|
||||||
// If the token is found, redirect to /movies.html
|
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';
|
window.location.href = '/movies.html';
|
||||||
}
|
}
|
||||||
|
} catch (_) {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle registration form submission
|
// Handle registration form submission
|
||||||
document.getElementById('register-form').addEventListener('submit', async function (e) {
|
document.getElementById('register-form').addEventListener('submit', async function (e) {
|
||||||
|
|||||||
@@ -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');
|
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) {
|
if (token) {
|
||||||
try {
|
try {
|
||||||
const decoded = JSON.parse(atob(token.split('.')[1])); // Manually decoding JWT
|
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;
|
const userName = decoded.name;
|
||||||
document.getElementById('user-name').textContent = userName;
|
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
|
// Verify JWT expiration
|
||||||
const currentTime = Math.floor(Date.now() / 1000);
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
@@ -24,21 +354,45 @@ window.onload = async function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const statsRes = await fetch('/api/users/stats', {
|
pageLoader.setProgress(12);
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
// Show loading states immediately (avoid "blank" panels)
|
||||||
'Authorization': `Bearer ${token}`
|
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) {
|
if (!statsRes.ok) {
|
||||||
throw new Error('Failed to fetch user stats');
|
throw new Error('Failed to fetch user stats');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pageLoader.setProgress(70);
|
||||||
const stats = await statsRes.json();
|
const stats = await statsRes.json();
|
||||||
const userTableBody = document.getElementById('user-table-body');
|
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
|
// Ensure watchedRatio is treated as a number and sort in descending order
|
||||||
stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio));
|
stats.sort((a, b) => parseFloat(b.watchedRatio) - parseFloat(a.watchedRatio));
|
||||||
@@ -58,9 +412,10 @@ window.onload = async function () {
|
|||||||
userTableBody.appendChild(userRow);
|
userTableBody.appendChild(userRow);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hide spinner and show table after loading
|
// Show table after loading
|
||||||
spinner.style.display = 'none';
|
if (table) table.classList.remove('d-none');
|
||||||
table.style.display = 'table';
|
if (statsError) statsError.classList.add('d-none');
|
||||||
|
pageLoader.setProgress(92);
|
||||||
|
|
||||||
// Add event listener for user links
|
// Add event listener for user links
|
||||||
document.querySelectorAll('.user-link').forEach(link => {
|
document.querySelectorAll('.user-link').forEach(link => {
|
||||||
@@ -87,8 +442,27 @@ window.onload = async function () {
|
|||||||
modal.show();
|
modal.show();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pageLoader.done();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading user statistics:', 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
|
// Log-off functionality
|
||||||
@@ -96,4 +470,4 @@ window.onload = async function () {
|
|||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
});
|
});
|
||||||
};
|
});
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="./css/app.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>
|
<style>
|
||||||
.navbar-custom {
|
.navbar-custom {
|
||||||
background-color: black;
|
background-color: black;
|
||||||
@@ -19,42 +21,9 @@
|
|||||||
color: #ddd;
|
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 {
|
#movies-list {
|
||||||
margin-top: 10px;
|
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 {
|
.navbar-toggler {
|
||||||
border-color: white; /* Make the border white */
|
border-color: white; /* Make the border white */
|
||||||
}
|
}
|
||||||
@@ -64,13 +33,23 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</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 -->
|
<!-- Black banner with buttons and user info -->
|
||||||
<nav class="navbar navbar-expand-lg navbar-custom">
|
<nav class="navbar navbar-expand-lg navbar-custom">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" style="margin-left: -7px;">
|
<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>
|
</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">
|
<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>
|
<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="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="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="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>
|
||||||
<!-- Move user info inside collapsible navbar and align it properly -->
|
<!-- Move user info inside collapsible navbar and align it properly -->
|
||||||
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
|
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
|
||||||
@@ -91,31 +71,35 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="container mt-5">
|
<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>
|
||||||
|
|
||||||
<!-- Displaying last updated vod_links -->
|
<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="alert alert-warning fade show" role="alert">
|
<div class="form-check form-switch m-0">
|
||||||
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">
|
<input class="form-check-input" type="checkbox" id="flexSwitchCheckDefault">
|
||||||
<label class="form-check-label" for="flexSwitchCheckDefault">Films non regardés seulement</label>
|
<label class="form-check-label" for="flexSwitchCheckDefault">Films non regardés seulement</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="movies-list" class="row">
|
<div class="text-muted small">Clique sur l’affiche ou le titre pour ouvrir le lecteur.</div>
|
||||||
<!-- Movies will be displayed here -->
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="./css/app.css" rel="stylesheet">
|
<link href="./css/app.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -3,8 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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="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>
|
<style>
|
||||||
.navbar-custom {
|
.navbar-custom {
|
||||||
background-color: black;
|
background-color: black;
|
||||||
@@ -20,25 +23,39 @@
|
|||||||
.user-table {
|
.user-table {
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
}
|
}
|
||||||
.ratio {
|
|
||||||
font-weight: bold;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
.navbar-toggler {
|
.navbar-toggler {
|
||||||
border-color: white; /* Make the border white */
|
border-color: white; /* Make the border white */
|
||||||
}
|
}
|
||||||
.navbar-toggler-icon {
|
.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");
|
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>
|
</style>
|
||||||
</head>
|
</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 -->
|
<!-- Black banner with buttons and user info -->
|
||||||
<nav class="navbar navbar-expand-lg navbar-custom">
|
<nav class="navbar navbar-expand-lg navbar-custom">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" style="margin-left: -7px;">
|
<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>
|
</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">
|
<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>
|
<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="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="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="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>
|
||||||
<!-- Move user info inside collapsible navbar and align it properly -->
|
<!-- Move user info inside collapsible navbar and align it properly -->
|
||||||
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
|
<div class="d-flex ms-auto mt-2 mt-lg-0 align-items-center">
|
||||||
@@ -61,13 +79,32 @@
|
|||||||
<div class="container mt-5">
|
<div class="container mt-5">
|
||||||
<h2>Statistiques des utilisateurs</h2>
|
<h2>Statistiques des utilisateurs</h2>
|
||||||
|
|
||||||
<!-- Spinner shown while loading -->
|
<!-- Tabs -->
|
||||||
<div id="loading-spinner" class="spinner-border text-primary" role="status">
|
<ul class="nav nav-tabs stats-tabs mt-3" id="statsTabs" role="tablist">
|
||||||
<span class="visually-hidden">Loading...</span>
|
<li class="nav-item" role="presentation">
|
||||||
</div>
|
<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 for users and movies watched -->
|
||||||
<table class="table user-table" style="display: none;">
|
<table class="table table-striped table-hover align-middle user-table d-none">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Nom</th>
|
<th scope="col">Nom</th>
|
||||||
@@ -81,6 +118,51 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Modal for displaying movies watched -->
|
<!-- Modal for displaying movies watched -->
|
||||||
<div class="modal fade" id="moviesModal" tabindex="-1" aria-labelledby="moviesModalLabel" aria-hidden="true">
|
<div class="modal fade" id="moviesModal" tabindex="-1" aria-labelledby="moviesModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|
||||||
@@ -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())
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -1,14 +1,97 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const Movie = require("../models/Movie");
|
const Movie = require("../models/Movie");
|
||||||
const User = require("../models/User");
|
const User = require("../models/User");
|
||||||
|
const PlaybackProgress = require("../models/PlaybackProgress");
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
|
const mongoose = require("mongoose");
|
||||||
|
const authenticate = require("../middleware/authMiddleware");
|
||||||
|
|
||||||
const router = express.Router();
|
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
|
// Fetch movie details from OMDb API
|
||||||
async function fetchMovieDetailsFromOmdb(imdb_id) {
|
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}`;
|
const omdbUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(imdb_id)}&apikey=${apiKey}`;
|
||||||
|
|
||||||
try {
|
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
|
// Get all movies
|
||||||
router.get("/", async (req, res) => {
|
router.get("/", async (req, res) => {
|
||||||
try {
|
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);
|
res.status(200).json(movies);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
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
|
// Get watched movies for the user
|
||||||
router.get("/watchedMovies", async (req, res) => {
|
router.get("/watchedMovies", async (req, res) => {
|
||||||
const token = req.headers.authorization?.split(" ")[1]; // Get token from header
|
const token = req.headers.authorization?.split(" ")[1]; // Get token from header
|
||||||
@@ -45,7 +288,7 @@ router.get("/watchedMovies", async (req, res) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET); // Verify token
|
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" });
|
if (!user) return res.status(404).json({ message: "User not found" });
|
||||||
|
|
||||||
res.status(200).json(user.watchedMovies); // Return the list of watched movies
|
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
|
// Add movie
|
||||||
router.post("/add", async (req, res) => {
|
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 {
|
try {
|
||||||
// Get the token from the Authorization header
|
// 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" });
|
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
|
// 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
|
// Proceed with adding the movie
|
||||||
const movie = new Movie({
|
const movie = new Movie({
|
||||||
@@ -115,12 +397,24 @@ router.post("/add", async (req, res) => {
|
|||||||
description: movieDetails.description,
|
description: movieDetails.description,
|
||||||
rating: movieDetails.rating,
|
rating: movieDetails.rating,
|
||||||
poster: movieDetails.poster,
|
poster: movieDetails.poster,
|
||||||
|
year: parsedYear,
|
||||||
category,
|
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();
|
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!" });
|
res.status(201).json({ message: "Movie added successfully!" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof jwt.JsonWebTokenError) {
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -1,19 +1,32 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const bcrypt = require("bcryptjs");
|
const bcrypt = require("bcryptjs");
|
||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
|
const crypto = require("crypto");
|
||||||
const User = require("../models/User");
|
const User = require("../models/User");
|
||||||
const Movie = require("../models/Movie");
|
const Movie = require("../models/Movie");
|
||||||
|
const Setting = require("../models/Setting");
|
||||||
|
const PlaybackProgress = require("../models/PlaybackProgress");
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Set password for verification
|
// 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
|
// Fetch movie details from OMDb API
|
||||||
async function fetchMovieDetailsFromOmdb(imdb_id) {
|
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}`;
|
const omdbUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(imdb_id)}&apikey=${apiKey}`;
|
||||||
|
|
||||||
try {
|
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
|
// Get user stats with watched movie titles and details from the movies collection
|
||||||
router.get("/stats", verifyToken, async (req, res) => {
|
router.get("/stats", verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
// Fetch all users
|
// Fetch all users
|
||||||
const users = await User.find();
|
const users = await User.find();
|
||||||
|
|
||||||
// Fetch all movies from the database
|
// If a year is explicitly provided, use it; otherwise default to the global active year.
|
||||||
const allMovies = await Movie.find();
|
const year = parseOscarYear(req.query.year) || await getOrInitActiveYear();
|
||||||
|
|
||||||
|
// Fetch movies (optionally filtered by year)
|
||||||
|
const allMovies = await Movie.find({ year });
|
||||||
const totalMoviesCount = allMovies.length;
|
const totalMoviesCount = allMovies.length;
|
||||||
|
|
||||||
if (totalMoviesCount === 0) {
|
if (totalMoviesCount === 0) {
|
||||||
@@ -65,9 +138,11 @@ router.get("/stats", verifyToken, async (req, res) => {
|
|||||||
|
|
||||||
// Build stats for each user
|
// Build stats for each user
|
||||||
const userStats = users.map((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
|
// Get watched movies with details from the database
|
||||||
const watchedMovies = user.watchedMovies
|
const watchedMovies = user.watchedMovies
|
||||||
.map((wm) => allMovies.find((m) => m.imdb_id === wm.imdb_id)) // Match movie details
|
.map((wm) => movieByImdbId.get(wm.imdb_id)) // Match movie details
|
||||||
.filter(Boolean); // Remove any `undefined` values (if movie is not found)
|
.filter(Boolean); // Remove any `undefined` values (if movie is not found)
|
||||||
|
|
||||||
const watchedCount = watchedMovies.length;
|
const watchedCount = watchedMovies.length;
|
||||||
@@ -80,6 +155,7 @@ router.get("/stats", verifyToken, async (req, res) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
name: user.name,
|
name: user.name,
|
||||||
|
year,
|
||||||
watchedCount,
|
watchedCount,
|
||||||
totalMoviesCount,
|
totalMoviesCount,
|
||||||
watchedRatio: `${watchedRatio}%`,
|
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
|
// Register User
|
||||||
router.post("/register", async (req, res) => {
|
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() {
|
function registerVerification() {
|
||||||
|
// If DOG_NAMES isn't configured, skip the "dog name" verification entirely.
|
||||||
|
if (!dogCheckEnabled) return true;
|
||||||
return woof.includes(verifoof);
|
return woof.includes(verifoof);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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) {
|
if (!password) {
|
||||||
throw new Error("Le mot de passe est obligatoire et ne peut être indéfini");
|
throw new Error("Le mot de passe est obligatoire et ne peut être indéfini");
|
||||||
}
|
}
|
||||||
if (!verifoof) {
|
if (dogCheckEnabled && !verifoof) {
|
||||||
throw new Error("Le nom du chien doit être défini");
|
throw new Error("Le nom du chien doit être défini");
|
||||||
}
|
}
|
||||||
if (!registerVerification()) {
|
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) {
|
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
|
// Hash password and create user
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
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();
|
await user.save();
|
||||||
|
|
||||||
res.status(201).json({ message: "L'utilisateur s'est enregistré avec succès !" });
|
res.status(201).json({ message: "L'utilisateur s'est enregistré avec succès !" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ error: err.message });
|
return res.status(400).json({ message: err?.message || "Erreur interne" });
|
||||||
res.status(403).json({ message: "Erreur interne lmao" });
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -137,27 +309,213 @@ router.post("/login", async (req, res) => {
|
|||||||
const user = await User.findOne({ email });
|
const user = await User.findOne({ email });
|
||||||
if (!user) return res.status(404).json({ message: "L'utilisateur n'a pas été trouvé" });
|
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);
|
const isMatch = await bcrypt.compare(password, user.password);
|
||||||
if (!isMatch) return res.status(401).json({ message: "Informations d'identification non valides" });
|
if (!isMatch) return res.status(401).json({ message: "Informations d'identification non valides" });
|
||||||
|
|
||||||
console.log("[debug] " + user.name + " logged in")
|
console.log("[debug] " + user.name + " logged in")
|
||||||
|
|
||||||
// Check if the user role is 69 and set the admin flag
|
const token = signUserToken(user);
|
||||||
const isAdmin = user.role === 69;
|
res.status(200).json({ token, mustChangePassword: !!user.mustChangePassword });
|
||||||
|
|
||||||
// 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 });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ error: err.message });
|
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)
|
// Protected route to get movies (example)
|
||||||
router.get("/movies", verifyToken, async (req, res) => {
|
router.get("/movies", verifyToken, async (req, res) => {
|
||||||
// Only authorized users will reach this point
|
// Only authorized users will reach this point
|
||||||
|
|||||||
@@ -1,62 +1,115 @@
|
|||||||
const axios = require("axios");
|
|
||||||
const express = require("express");
|
const express = require("express");
|
||||||
const moment = require("moment-timezone");
|
const moment = require("moment-timezone");
|
||||||
|
const Setting = require("../models/Setting");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
let latestCommitCache = null;
|
const TIMEZONE = "America/New_York";
|
||||||
let lastFetchedTime = 0;
|
|
||||||
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
|
||||||
|
|
||||||
async function fetchLatestCommitFromGitHub() {
|
// Stored in Mongo via Setting model (admin-controlled).
|
||||||
const githubOwner = process.env.GITHUB_OWNER;
|
// Shape:
|
||||||
const githubRepo = process.env.GITHUB_REPO;
|
// {
|
||||||
|
// 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 {
|
try {
|
||||||
const url = `https://api.github.com/repos/${githubOwner}/${githubRepo}/commits`;
|
// routes/version.js -> ../package.json
|
||||||
const response = await axios.get(url, {
|
const pkg = require("../package.json");
|
||||||
headers: {
|
return typeof pkg?.version === "string" && pkg.version.trim() ? pkg.version.trim() : "dev";
|
||||||
"Content-Type": "application/json",
|
} catch (_) {
|
||||||
},
|
return "dev";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data && response.data.length > 0) {
|
const activeId = typeof v.activeId === "string" ? v.activeId.trim() : "";
|
||||||
const latestCommit = response.data[0];
|
const active = cleaned.find((e) => String(e.id) === String(activeId)) || null;
|
||||||
latestCommitCache = {
|
|
||||||
sha: latestCommit.sha,
|
// If activeId is invalid, fall back to newest entry.
|
||||||
message: latestCommit.commit.message,
|
return {
|
||||||
author: latestCommit.commit.author.name,
|
activeId: active ? String(active.id) : cleaned[0]?.id ? String(cleaned[0].id) : null,
|
||||||
date: latestCommit.commit.author.date,
|
versions: cleaned,
|
||||||
};
|
};
|
||||||
lastFetchedTime = Date.now();
|
|
||||||
} else {
|
|
||||||
throw new Error("No commits found.");
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching commit:", error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initial fetch to populate cache
|
|
||||||
fetchLatestCommitFromGitHub();
|
|
||||||
|
|
||||||
// Refresh cache every 5 minutes
|
|
||||||
setInterval(fetchLatestCommitFromGitHub, CACHE_DURATION);
|
|
||||||
|
|
||||||
// Route to get the latest commit (cached)
|
// Route to get the latest commit (cached)
|
||||||
router.get("/", async (req, res) => {
|
router.get("/", async (req, res) => {
|
||||||
if (!latestCommitCache) {
|
// Always return 200 and never rely on GitHub.
|
||||||
return res.status(500).json({ message: "Commit data is not available yet." });
|
try {
|
||||||
|
const setting = await Setting.findOne({ key: APP_VERSION_KEY }).select("value").lean();
|
||||||
|
const control = normalizeAppVersionControl(setting?.value);
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({
|
const date = active.dateISO
|
||||||
version: latestCommitCache.sha.substring(0, 5),
|
? moment(active.dateISO).tz(TIMEZONE).format("YYYY-MM-DD HH:mm:ss")
|
||||||
message: latestCommitCache.message,
|
: nowFormatted();
|
||||||
author: latestCommitCache.author,
|
|
||||||
date: moment(latestCommitCache.date)
|
return res.status(200).json({
|
||||||
.tz("America/New_York")
|
version: active.version,
|
||||||
.format("YYYY-MM-DD HH:mm:ss"),
|
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;
|
module.exports = router;
|
||||||
@@ -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;
|
||||||
|
|
||||||