From 1c4b18f8aa9569b42b3b551119c04573cf29d7a1 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 22 Dec 2025 21:22:28 -0500 Subject: [PATCH 1/2] fix logo (#65) --- public/img/logo_w.png | Bin 12312 -> 11916 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/public/img/logo_w.png b/public/img/logo_w.png index e848fd30181127e6e5d39cf38ee42a81d64d6a2b..a4ea16eba8d51c4b124c29f16976b81eeb7bbf81 100644 GIT binary patch literal 11916 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 -- 2.54.0 From fe21a12c82f019c26a9d05a25b199d6b20887300 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Sun, 25 Jan 2026 16:16:07 -0500 Subject: [PATCH 2/2] Add user movie ratings and refresh movie cards Store per-user 5-star ratings with averages and surface them in the player. Refresh movie list layout to show ratings and details in a cleaner card layout. --- models/MovieRating.js | 11 +++ public/css/app.css | 56 ++++++++++++ public/js/app_movies.js | 57 +++++++++---- public/js/app_player.js | 108 +++++++++++++++++++++++ public/player.html | 48 ++++++++++- routes/movies.js | 185 +++++++++++++++++++++++++++++++++++++--- 6 files changed, 436 insertions(+), 29 deletions(-) create mode 100644 models/MovieRating.js diff --git a/models/MovieRating.js b/models/MovieRating.js new file mode 100644 index 0000000..fc7feac --- /dev/null +++ b/models/MovieRating.js @@ -0,0 +1,11 @@ +const mongoose = require("mongoose"); + +const movieRatingSchema = new mongoose.Schema({ + movieId: { type: mongoose.Schema.Types.ObjectId, ref: "Movie", required: true, index: true }, + userId: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true }, + rating: { type: Number, required: true, min: 1, max: 5 }, +}, { timestamps: true }); + +movieRatingSchema.index({ movieId: 1, userId: 1 }, { unique: true }); + +module.exports = mongoose.model("MovieRating", movieRatingSchema); diff --git a/public/css/app.css b/public/css/app.css index d94328e..ed307b1 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -208,6 +208,62 @@ footer { object-fit: contain; } + .movie-rating-block { + display: flex; + flex-direction: column; + align-items: flex-start; + text-align: left; + gap: 2px; + font-size: 0.72rem; + line-height: 1.15; + color: rgba(0,0,0,0.6); + } + + .movie-imdb-block { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.75rem; + line-height: 1.1; + color: rgba(0,0,0,0.7); + white-space: nowrap; + } + + .movie-rating-sub { + font-size: 0.7rem; + color: rgba(0,0,0,0.55); + } + + .movie-rating-block .imdb-icon { + width: 18px; + height: 18px; + margin-left: 4px; + } + + .movie-info-box { + margin-top: 10px; + margin-bottom: 10px; + padding: 8px 10px; + border-radius: 10px; + background: rgba(0, 0, 0, 0.03); + border: 1px solid rgba(0, 0, 0, 0.06); + } + + .movie-info-category { + font-size: 0.78rem; + color: rgba(0, 0, 0, 0.65); + font-weight: 600; + font-style: italic; + margin-bottom: 6px; + } + + .movie-info-desc { + margin: 0; + font-size: 0.82rem; + line-height: 1.35; + color: rgba(0, 0, 0, 0.75); + } + .imdb-icon { width: 26px; height: 26px; diff --git a/public/js/app_movies.js b/public/js/app_movies.js index 25dc4c6..7105f31 100644 --- a/public/js/app_movies.js +++ b/public/js/app_movies.js @@ -403,6 +403,15 @@ window.addEventListener('DOMContentLoaded', async function () { const legacyUrl = cleanUrl(movie?.vod_link); const hasLegacy = !!legacyUrl; const movieId = movie && movie._id ? String(movie._id) : ''; + const userRatingAvg = Number(movie?.user_rating_avg); + const userRatingCount = Number(movie?.user_rating_count) || 0; + const userRating = Number(movie?.user_rating); + const userRatingText = + Number.isFinite(userRatingAvg) && userRatingAvg > 0 + ? `${userRatingAvg.toFixed(1)}${userRatingCount ? ` (${userRatingCount})` : ''}` + : ''; + const userRatingOwn = + Number.isFinite(userRating) && userRating > 0 ? `${userRating}/5` : ''; // Navigation: // - Prefer in-app player when we have a non-legacy source @@ -470,26 +479,38 @@ window.addEventListener('DOMContentLoaded', async function () { : `${posterBlock}` }
-
-
- ${ - isClickable - ? `${movie.title}` - : `${movie.title}` - } -
-
- ⭐ ${movie.rating} - - IMDb - +
+
+
+
+ ${ + isClickable + ? `${movie.title}` + : `${movie.title}` + } +
+
+ ${userRatingText ? `
Note utilisateurs ${userRatingText}
` : ''} + ${userRatingOwn ? `
Votre note ${userRatingOwn}
` : ''} +
+
+
+ ${movie.rating ? `⭐ ${movie.rating}` : ''} + + IMDb + +
+
+ ${!isClickable ? '
Aucune source
' : ''} +
+
${movie.category}
+

${movie.description}

- ${!isClickable ? '
Aucune source
' : ''} -
${movie.category}
-

${movie.description}

- ${isChecked ? `
Regardé le: ${watchedDate}
` : ''} - ${progressHtml} +
+ ${isChecked ? `
Regardé le: ${watchedDate}
` : ''} + ${progressHtml} +
`; moviesList.appendChild(movieDiv); diff --git a/public/js/app_player.js b/public/js/app_player.js index b982b9c..ef5018d 100644 --- a/public/js/app_player.js +++ b/public/js/app_player.js @@ -546,6 +546,105 @@ function setHeader(movie, { activeYear } = {}) { document.title = movie?.title ? `${titlePrefix} - ${movie.title}` : titlePrefix; } +function normalizeRatingValue(v) { + const n = Number(v); + if (!Number.isFinite(n)) return null; + const rounded = Math.round(n); + if (rounded < 1 || rounded > 5) return null; + return rounded; +} + +function applyRatingState({ wrap, buttons, metaEl, averageRating, ratingsCount, userRating }) { + if (!wrap || !buttons?.length || !metaEl) return; + const avg = Number.isFinite(Number(averageRating)) ? Number(averageRating) : null; + const count = Number.isFinite(Number(ratingsCount)) ? Number(ratingsCount) : 0; + const user = Number.isFinite(Number(userRating)) ? Number(userRating) : null; + + buttons.forEach((btn) => { + const value = Number(btn.getAttribute('data-value')); + const isActive = user && value <= user; + btn.classList.toggle('is-active', !!isActive); + btn.setAttribute('aria-pressed', isActive ? 'true' : 'false'); + }); + + const parts = []; + if (avg && count > 0) { + parts.push(`Note utilisateurs: ${avg.toFixed(1)} (${count})`); + } else { + parts.push('Note utilisateurs: —'); + } + metaEl.textContent = parts.join(' • '); +} + +async function saveUserRating(movieId, rating, token) { + try { + const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}/ratings`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(token ? { 'Authorization': `Bearer ${token}` } : {}), + }, + body: JSON.stringify({ rating }), + }); + if (!res.ok) return null; + return await res.json().catch(() => null); + } catch (_) { + return null; + } +} + +function initUserRating({ movieId, token, initial } = {}) { + const wrap = document.getElementById('user-rating'); + const metaEl = document.getElementById('user-rating-meta'); + if (!wrap || !metaEl) return; + + const buttons = Array.from(wrap.querySelectorAll('.star-btn')); + const starting = { + averageRating: initial?.averageRating ?? null, + ratingsCount: initial?.ratingsCount ?? 0, + userRating: initial?.userRating ?? null, + }; + + applyRatingState({ wrap, buttons, metaEl, ...starting }); + + if (!token) { + metaEl.textContent = 'Connecte-toi pour noter.'; + buttons.forEach((btn) => btn.setAttribute('disabled', 'disabled')); + return; + } + + let isSaving = false; + buttons.forEach((btn) => { + btn.addEventListener('click', async () => { + if (isSaving) return; + const value = normalizeRatingValue(btn.getAttribute('data-value')); + if (!value) return; + + isSaving = true; + buttons.forEach((b) => b.setAttribute('disabled', 'disabled')); + metaEl.textContent = 'Enregistrement…'; + + const result = await saveUserRating(movieId, value, token); + if (result) { + applyRatingState({ + wrap, + buttons, + metaEl, + averageRating: result?.averageRating, + ratingsCount: result?.ratingsCount, + userRating: result?.userRating, + }); + } else { + applyRatingState({ wrap, buttons, metaEl, ...starting }); + metaEl.textContent = 'Impossible d’enregistrer la note.'; + } + + buttons.forEach((b) => b.removeAttribute('disabled')); + isSaving = false; + }); + }); +} + function normalizeVodLink(raw) { const v = String(raw || '').trim(); if (!v) return null; @@ -1152,6 +1251,15 @@ window.onload = async function () { const [activeYear, playerUi] = await Promise.all([activeYearPromise, playerUiPromise]); setHeader(movie, { activeYear }); + initUserRating({ + movieId: id, + token, + initial: { + averageRating: movie?.user_rating_avg, + ratingsCount: movie?.user_rating_count, + userRating: movie?.user_rating, + }, + }); pageLoader.setProgress(58); // Admin-only status block (Source + Progress) can be globally disabled via settings. diff --git a/public/player.html b/public/player.html index a11b6cf..82ecf2b 100644 --- a/public/player.html +++ b/public/player.html @@ -106,6 +106,41 @@ outline-offset: 2px; } + .user-rating { + display: flex; + flex-direction: column; + gap: .25rem; + align-items: center; + text-align: center; + width: 100%; + } + .rating-stars { + display: inline-flex; + align-items: center; + gap: .15rem; + justify-content: center; + } + .star-btn { + border: 0; + background: transparent; + color: rgba(0,0,0,0.25); + font-size: 1.1rem; + line-height: 1; + padding: .15rem .2rem; + } + .star-btn.is-active { + color: #f4b400; + } + .star-btn:focus-visible { + outline: 2px solid rgba(13,110,253,.35); + outline-offset: 2px; + border-radius: 4px; + } + #user-rating-meta { + color: rgba(0,0,0,0.6); + font-weight: 500; + } + /* Progress indicator: icon-first, minimal text */ .progress-indicator { display: inline-flex; @@ -195,7 +230,7 @@
-
+
Qualité
@@ -204,6 +239,17 @@
+
+
+ + + + + +
+
—
+
+
diff --git a/routes/movies.js b/routes/movies.js index f60beea..076f53c 100644 --- a/routes/movies.js +++ b/routes/movies.js @@ -3,6 +3,7 @@ const fs = require("fs"); const path = require("path"); const multer = require("multer"); const Movie = require("../models/Movie"); +const MovieRating = require("../models/MovieRating"); const User = require("../models/User"); const PlaybackProgress = require("../models/PlaybackProgress"); const axios = require("axios"); @@ -95,6 +96,15 @@ function parseBooleanFlag(v) { return undefined; } +function parseFiveStarRating(v) { + if (v === undefined || v === null || v === "") return null; + const n = Number(v); + if (!Number.isFinite(n)) return null; + const rounded = Math.round(n); + if (rounded < 1 || rounded > 5) return null; + return rounded; +} + function normalizeSubtitleEntry({ file, lang, label, isDefault } = {}) { const safeFile = typeof file === "string" ? file.trim() : ""; if (!safeFile) return null; @@ -283,18 +293,65 @@ router.get("/", async (req, res) => { 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); + + let basePayload = cached; + if (!basePayload) { + const movies = await Movie.find(filter) + .select(projection) + .sort({ title: 1 }) + .lean(); + basePayload = movies.map((m) => ({ ...m, subtitles: getSubtitlesForResponse(m) })); + cacheSet(cacheKey, basePayload); } - const movies = await Movie.find(filter) - .select(projection) - .sort({ title: 1 }) - .lean(); + if (isChecklist) { + res.set("Cache-Control", "public, max-age=30"); + return res.status(200).json(basePayload); + } + + const movieIds = basePayload.map((m) => m?._id).filter(Boolean); + const ratingsAgg = movieIds.length + ? await MovieRating.aggregate([ + { $match: { movieId: { $in: movieIds } } }, + { $group: { _id: "$movieId", avgRating: { $avg: "$rating" }, count: { $sum: 1 } } }, + ]) + : []; + + const ratingsByMovieId = new Map( + (ratingsAgg || []).map((r) => [String(r._id), { avgRating: r.avgRating, count: r.count }]) + ); + + let userId = null; + const token = req.headers.authorization?.split(" ")[1]; + if (token) { + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + if (decoded?.id) userId = decoded.id; + } catch (_) {} + } + + let userRatingsByMovieId = new Map(); + if (userId && movieIds.length) { + const userRatings = await MovieRating.find({ userId, movieId: { $in: movieIds } }) + .select("movieId rating") + .lean(); + userRatingsByMovieId = new Map( + (userRatings || []).map((r) => [String(r.movieId), r.rating]) + ); + } + + const payload = basePayload.map((m) => { + const key = String(m?._id || ""); + const agg = ratingsByMovieId.get(key); + const userRating = userRatingsByMovieId.get(key); + return { + ...m, + user_rating_avg: agg && typeof agg.avgRating === "number" ? Number(agg.avgRating.toFixed(2)) : null, + user_rating_count: agg && typeof agg.count === "number" ? agg.count : 0, + user_rating: typeof userRating === "number" ? userRating : null, + }; + }); - const payload = movies.map((m) => ({ ...m, subtitles: getSubtitlesForResponse(m) })); - cacheSet(cacheKey, payload); res.set("Cache-Control", "public, max-age=30"); res.status(200).json(payload); } catch (err) { @@ -1148,6 +1205,85 @@ router.get("/progress", authenticate, async (req, res) => { } }); +// Get rating info for a movie (user-specific + global average) +router.get("/:id/ratings", 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 movieId = new mongoose.Types.ObjectId(id); + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "User not found" }); + + const [agg, userRow] = await Promise.all([ + MovieRating.aggregate([ + { $match: { movieId } }, + { $group: { _id: "$movieId", avgRating: { $avg: "$rating" }, count: { $sum: 1 } } }, + ]), + MovieRating.findOne({ movieId, userId }).select("rating").lean(), + ]); + + const avg = agg?.[0]?.avgRating; + const count = agg?.[0]?.count; + + res.set("Cache-Control", "private, no-store, must-revalidate"); + res.set("Vary", "Authorization"); + return res.status(200).json({ + averageRating: typeof avg === "number" ? Number(avg.toFixed(2)) : null, + ratingsCount: typeof count === "number" ? count : 0, + userRating: typeof userRow?.rating === "number" ? userRow.rating : null, + }); + } catch (err) { + return res.status(500).json({ error: err.message }); + } +}); + +// Save/update a user rating (1-5) +router.put("/:id/ratings", 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 movieId = new mongoose.Types.ObjectId(id); + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "User not found" }); + + const rating = parseFiveStarRating(req.body?.rating); + if (!rating) { + return res.status(400).json({ message: "Invalid rating (expected 1-5)" }); + } + + await MovieRating.findOneAndUpdate( + { movieId, userId }, + { $set: { rating } }, + { upsert: true, new: true, setDefaultsOnInsert: true } + ); + + const agg = await MovieRating.aggregate([ + { $match: { movieId } }, + { $group: { _id: "$movieId", avgRating: { $avg: "$rating" }, count: { $sum: 1 } } }, + ]); + + const avg = agg?.[0]?.avgRating; + const count = agg?.[0]?.count; + + res.set("Cache-Control", "private, no-store, must-revalidate"); + res.set("Vary", "Authorization"); + return res.status(200).json({ + averageRating: typeof avg === "number" ? Number(avg.toFixed(2)) : null, + ratingsCount: typeof count === "number" ? count : 0, + userRating: rating, + }); + } catch (err) { + if (err && err.code === 11000) { + return res.status(409).json({ message: "Rating 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 { @@ -1158,7 +1294,36 @@ router.get("/:id", async (req, res) => { const movie = await Movie.findById(id).lean(); if (!movie) return res.status(404).json({ message: "Movie not found" }); - return res.status(200).json({ ...movie, subtitles: getSubtitlesForResponse(movie) }); + + let userId = null; + const token = req.headers.authorization?.split(" ")[1]; + if (token) { + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET); + if (decoded?.id) userId = decoded.id; + } catch (_) {} + } + + let userRating = null; + if (userId) { + const row = await MovieRating.findOne({ movieId: movie._id, userId }).select("rating").lean(); + userRating = typeof row?.rating === "number" ? row.rating : null; + } + + const agg = await MovieRating.aggregate([ + { $match: { movieId: movie._id } }, + { $group: { _id: "$movieId", avgRating: { $avg: "$rating" }, count: { $sum: 1 } } }, + ]); + const avg = agg?.[0]?.avgRating; + const count = agg?.[0]?.count; + + return res.status(200).json({ + ...movie, + subtitles: getSubtitlesForResponse(movie), + user_rating_avg: typeof avg === "number" ? Number(avg.toFixed(2)) : null, + user_rating_count: typeof count === "number" ? count : 0, + user_rating: userRating, + }); } catch (err) { return res.status(500).json({ error: err.message }); } -- 2.54.0