Merge pull request #33 from quoije/cursor/films-checklist-page-performance-ddde
Films checklist page performance
This commit was merged in pull request #33.
This commit is contained in:
@@ -2,12 +2,14 @@ const express = require("express");
|
||||
const mongoose = require("mongoose");
|
||||
const dotenv = require("dotenv");
|
||||
const cors = require("cors");
|
||||
const compression = require("compression");
|
||||
|
||||
dotenv.config();
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
|
||||
// Connect to MongoDB
|
||||
|
||||
@@ -20,4 +20,11 @@ const movieSchema = new mongoose.Schema({
|
||||
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);
|
||||
|
||||
Generated
+49
@@ -12,6 +12,7 @@
|
||||
"axios": "^1.7.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"body-parser": "^1.20.3",
|
||||
"compression": "^1.8.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.22.1",
|
||||
@@ -285,6 +286,45 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/compressible": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
"integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": ">= 1.43.0 < 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/compression": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
|
||||
"integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "3.1.2",
|
||||
"compressible": "~2.0.18",
|
||||
"debug": "2.6.9",
|
||||
"negotiator": "~0.6.4",
|
||||
"on-headers": "~1.1.0",
|
||||
"safe-buffer": "5.2.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/compression/node_modules/negotiator": {
|
||||
"version": "0.6.4",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
|
||||
"integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -1368,6 +1408,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/on-headers": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
|
||||
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"axios": "^1.7.9",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"body-parser": "^1.20.3",
|
||||
"compression": "^1.8.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.22.1",
|
||||
|
||||
+16
-15
@@ -110,10 +110,6 @@ window.onload = async function () {
|
||||
const oscarYearEl = document.getElementById('oscar-year');
|
||||
if (oscarYearEl) oscarYearEl.textContent = String(activeYear);
|
||||
|
||||
// Load (public) "100% completion" modal content at runtime.
|
||||
const completionModalContent = await fetchCompletionModalContent();
|
||||
applyCompletionModalContent(completionModalContent);
|
||||
|
||||
const oscarEffectiveDate = await fetchOscarEffectiveDate(activeYear);
|
||||
const targetDate =
|
||||
parseLocalNoonFromIsoDate(oscarEffectiveDate) ||
|
||||
@@ -159,27 +155,32 @@ window.onload = async function () {
|
||||
window.location.href = '/';
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
// Fetch remaining data in parallel (Render latency is high).
|
||||
const [completionModalContent, moviesRes, watchedRes] = await Promise.all([
|
||||
fetchCompletionModalContent(),
|
||||
fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}&view=checklist`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
}),
|
||||
fetch('/api/movies/watchedMovies', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
})
|
||||
]);
|
||||
|
||||
if (!res.ok) {
|
||||
applyCompletionModalContent(completionModalContent);
|
||||
|
||||
if (!moviesRes.ok) {
|
||||
localStorage.removeItem('auth_token');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
|
||||
const movies = await res.json();
|
||||
const movies = await moviesRes.json();
|
||||
|
||||
// Sort movies alphabetically by title
|
||||
movies.sort((a, b) => a.title.localeCompare(b.title, 'fr', { sensitivity: 'base' }));
|
||||
|
||||
const watchedRes = await fetch('/api/movies/watchedMovies', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
|
||||
let watchedMovies = [];
|
||||
if (watchedRes.ok) {
|
||||
watchedMovies = await watchedRes.json();
|
||||
|
||||
+12
-10
@@ -108,10 +108,17 @@ window.onload = async function () {
|
||||
// Populate "Dernière mise à jour des films" banner (per active year)
|
||||
await fetchMoviesLastUpdated(activeYear, token);
|
||||
|
||||
const res = await fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
// Fetch movies + watched list in parallel (Render latency is high).
|
||||
const [res, watchedRes] = await Promise.all([
|
||||
fetch(`/api/movies?year=${encodeURIComponent(String(activeYear))}`, {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
}),
|
||||
fetch('/api/movies/watchedMovies', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
})
|
||||
]);
|
||||
|
||||
if (!res.ok) {
|
||||
localStorage.removeItem('auth_token');
|
||||
@@ -121,14 +128,9 @@ window.onload = async function () {
|
||||
|
||||
const movies = await res.json();
|
||||
|
||||
// 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' }));
|
||||
|
||||
const watchedRes = await fetch('/api/movies/watchedMovies', {
|
||||
method: 'GET',
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
let watchedMovies = [];
|
||||
if (watchedRes.ok) {
|
||||
watchedMovies = await watchedRes.json();
|
||||
|
||||
+78
-5
@@ -9,6 +9,29 @@ const authenticate = require("../middleware/authMiddleware");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Small in-memory cache to reduce repeated DB reads on slow networks (Render).
|
||||
// This is safe because movie lists are identical for all users and change rarely.
|
||||
const MOVIES_CACHE_TTL_MS = Number(process.env.MOVIES_CACHE_TTL_MS || "30000"); // 30s
|
||||
const _cache = new Map(); // key -> { expiresAt:number, value:any }
|
||||
|
||||
function cacheGet(key) {
|
||||
const hit = _cache.get(key);
|
||||
if (!hit) return null;
|
||||
if (Date.now() > hit.expiresAt) {
|
||||
_cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return hit.value;
|
||||
}
|
||||
|
||||
function cacheSet(key, value) {
|
||||
_cache.set(key, { expiresAt: Date.now() + MOVIES_CACHE_TTL_MS, value });
|
||||
}
|
||||
|
||||
function cacheClear() {
|
||||
_cache.clear();
|
||||
}
|
||||
|
||||
function parseOscarYear(raw) {
|
||||
if (raw === undefined || raw === null || raw === "") return null;
|
||||
const n = Number(raw);
|
||||
@@ -73,10 +96,20 @@ 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 });
|
||||
@@ -88,7 +121,31 @@ router.get("/", async (req, res) => {
|
||||
try {
|
||||
const year = parseOscarYear(req.query.year);
|
||||
const filter = year ? { year } : {};
|
||||
const movies = await Movie.find(filter);
|
||||
|
||||
// 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 updatedAt createdAt";
|
||||
|
||||
const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`;
|
||||
const cached = cacheGet(cacheKey);
|
||||
if (cached) {
|
||||
res.set("Cache-Control", "public, max-age=30");
|
||||
return res.status(200).json(cached);
|
||||
}
|
||||
|
||||
const movies = await Movie.find(filter)
|
||||
.select(projection)
|
||||
.sort({ title: 1 })
|
||||
.lean();
|
||||
|
||||
cacheSet(cacheKey, movies);
|
||||
res.set("Cache-Control", "public, max-age=30");
|
||||
res.status(200).json(movies);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -106,7 +163,17 @@ router.get("/last-update", async (req, res) => {
|
||||
const year = parseOscarYear(req.query.year);
|
||||
const filter = year ? { year } : {};
|
||||
|
||||
const latestMovie = await Movie.findOne(filter).sort({ updatedAt: -1, createdAt: -1, _id: -1 });
|
||||
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 });
|
||||
}
|
||||
@@ -116,7 +183,10 @@ router.get("/last-update", async (req, res) => {
|
||||
latestMovie.createdAt ||
|
||||
(typeof latestMovie._id?.getTimestamp === "function" ? latestMovie._id.getTimestamp() : null);
|
||||
|
||||
return res.status(200).json({ lastUpdated: lastUpdated ? new Date(lastUpdated).toISOString() : 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" });
|
||||
@@ -132,7 +202,7 @@ router.get("/watchedMovies", async (req, res) => {
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET); // Verify token
|
||||
const user = await User.findById(decoded.id); // Get user by ID
|
||||
const user = await User.findById(decoded.id).select("watchedMovies").lean(); // Get user by ID
|
||||
if (!user) return res.status(404).json({ message: "User not found" });
|
||||
|
||||
res.status(200).json(user.watchedMovies); // Return the list of watched movies
|
||||
@@ -242,6 +312,7 @@ router.post("/add", async (req, res) => {
|
||||
});
|
||||
|
||||
await movie.save();
|
||||
cacheClear();
|
||||
|
||||
res.status(201).json({ message: "Movie added successfully!" });
|
||||
} catch (err) {
|
||||
@@ -279,6 +350,7 @@ router.delete("/delete", async (req, res) => {
|
||||
const deletedImdbIds = moviesToDelete.map((m) => m.imdb_id).filter(Boolean);
|
||||
|
||||
const result = await Movie.deleteMany(filter);
|
||||
cacheClear();
|
||||
|
||||
if (deletedImdbIds.length > 0) {
|
||||
await User.updateMany(
|
||||
@@ -411,6 +483,7 @@ router.put("/:id", async (req, res) => {
|
||||
}
|
||||
|
||||
await movie.save();
|
||||
cacheClear();
|
||||
return res.status(200).json(movie);
|
||||
} catch (err) {
|
||||
if (err instanceof jwt.JsonWebTokenError) {
|
||||
@@ -504,7 +577,7 @@ router.get("/:id", async (req, res) => {
|
||||
return res.status(400).json({ message: "Invalid movie id" });
|
||||
}
|
||||
|
||||
const movie = await Movie.findById(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) {
|
||||
|
||||
Reference in New Issue
Block a user