From 3884ce6492f7e6df6906545893c3223549b4dfc3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Dec 2025 21:24:27 +0000 Subject: [PATCH] feat: Add compression and caching to movie routes Adds gzip compression middleware and an in-memory cache for movie data to improve performance. Also adds database indexes for common query patterns. Co-authored-by: mathieumoran1 --- index.js | 2 + models/Movie.js | 7 ++++ package-lock.json | 49 ++++++++++++++++++++++ package.json | 1 + public/js/app_checklist.js | 31 +++++++------- public/js/app_movies.js | 22 +++++----- routes/movies.js | 83 +++++++++++++++++++++++++++++++++++--- 7 files changed, 165 insertions(+), 30 deletions(-) diff --git a/index.js b/index.js index dbe6aa8..2ed5a7b 100644 --- a/index.js +++ b/index.js @@ -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 diff --git a/models/Movie.js b/models/Movie.js index 8f7cddf..a854260 100644 --- a/models/Movie.js +++ b/models/Movie.js @@ -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); diff --git a/package-lock.json b/package-lock.json index 01819a4..b5897d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 73adf4a..216c776 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/js/app_checklist.js b/public/js/app_checklist.js index 5c00fe9..85c51a2 100644 --- a/public/js/app_checklist.js +++ b/public/js/app_checklist.js @@ -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(); diff --git a/public/js/app_movies.js b/public/js/app_movies.js index 654d598..b871423 100644 --- a/public/js/app_movies.js +++ b/public/js/app_movies.js @@ -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(); diff --git a/routes/movies.js b/routes/movies.js index bfcd595..943e6f1 100644 --- a/routes/movies.js +++ b/routes/movies.js @@ -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) {