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 }); }