Add user movie ratings and refresh movie cards #74

Merged
quoije merged 1 commits from 2026 into prod 2026-01-25 17:14:07 -05:00
6 changed files with 436 additions and 29 deletions
+11
View File
@@ -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);
+56
View File
@@ -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;
+26 -5
View File
@@ -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,27 +479,39 @@ window.addEventListener('DOMContentLoaded', async function () {
: `${posterBlock}`
}
<div class="card-body d-flex flex-column">
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start gap-2">
<h5 class="card-title mb-1 flex-grow-1">
<div class="flex-grow-1">
<h5 class="card-title mb-1">
${
isClickable
? `<a href="${playerUrl}" target="_self" rel="noopener noreferrer" class="text-decoration-none text-dark">${movie.title}</a>`
: `<span class="text-dark">${movie.title}</span>`
}
</h5>
<div class="text-nowrap small text-muted">
⭐ ${movie.rating}
<div class="movie-rating-block mt-1">
${userRatingText ? `<div class="movie-rating-sub">Note utilisateurs ${userRatingText}</div>` : ''}
${userRatingOwn ? `<div class="movie-rating-sub">Votre note ${userRatingOwn}</div>` : ''}
</div>
</div>
<div class="movie-imdb-block">
${movie.rating ? `<span>⭐ ${movie.rating}</span>` : ''}
<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>
${!isClickable ? '<div class="text-muted small mb-1">Aucune source</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>
<div class="movie-info-box">
<div class="movie-info-category">${movie.category}</div>
<p class="movie-info-desc">${movie.description}</p>
</div>
</div>
<div class="mt-auto">
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">Regardé le:</span> ${watchedDate}</div>` : ''}
${progressHtml}
</div>
</div>
</div>`;
moviesList.appendChild(movieDiv);
});
+108
View File
@@ -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,
};
cursor[bot] commented 2026-01-25 17:20:36 -05:00 (Migrated from github.com)
Review

Stale fallback state after successful rating save

Medium Severity

The starting object in initUserRating captures the initial page-load rating state but is never updated after a successful save. If a user successfully saves a rating (e.g., changes from 3 to 4 stars) and then a subsequent save fails (e.g., trying to change to 5 stars), the UI reverts to the original page-load state (3 stars) instead of the last successfully saved state (4 stars). This causes the displayed rating to be inconsistent with the actual server state.

Additional Locations (1)

Fix in Cursor Fix in Web

### Stale fallback state after successful rating save **Medium Severity** <!-- DESCRIPTION START --> The `starting` object in `initUserRating` captures the initial page-load rating state but is never updated after a successful save. If a user successfully saves a rating (e.g., changes from 3 to 4 stars) and then a subsequent save fails (e.g., trying to change to 5 stars), the UI reverts to the original page-load state (3 stars) instead of the last successfully saved state (4 stars). This causes the displayed rating to be inconsistent with the actual server state. <!-- DESCRIPTION END --> <!-- BUGBOT_BUG_ID: ref1_2599e9bb-7d25-4f43-a0f6-0f5dfc6622f0 --> <!-- LOCATIONS START public/js/app_player.js#L601-L606 public/js/app_player.js#L636-L639 LOCATIONS END --> <details> <summary>Additional Locations (1)</summary> - [`public/js/app_player.js#L636-L639`](https://github.com/quoije/oscars-pool/blob/fe21a12c82f019c26a9d05a25b199d6b20887300/public/js/app_player.js#L636-L639) </details> <a href="https://cursor.com/open?data=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJ1Z2JvdC12MiJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9DVVJTT1IiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjMyMGYxZjc5LWIwMmYtNGYyNy04ZGNmLTZlOGFlY2NhNTJkYiIsImVuY3J5cHRpb25LZXkiOiJDWGpxMXp6Z3dpUllTQjlsZ1lhaFU5UmtlWmFtaVdVY2JrN09rQ2Q4d1NRIiwiYnJhbmNoIjoiMjAyNiJ9LCJpYXQiOjE3NjkzNzk2MzYsImV4cCI6MTc3MTk3MTYzNn0.pCsGqL8yGkNCfJzG4c5wX9WrJ1dRzUi49CRBk1IYaxXauBwlCR13tuHHmI65YGwh8KkfrmRX1x2CzHvC1_P7cowcwDkSZpZ8Pufn1BrabWAnVHxkGWQ6BWjtuS34OVwNyQcyYKaGcW9YGW85_VcMidnXyJa8dH7QOp5_9Vi6Hi5RHpmTA6TvD0K6ByikriVxOHFuH0bhY2ynAUwPFx1OGj6RTbCjFJ-oqEiwErGJ-Nj41FeCIKf-xhP4r9WVfso2WkI44KN1eNz82eCJqMmzWPsLR0i4aN1iMQx8aHIVefuAB1Ab78QBkrcYabUWoXAjmd8sLcmGAiAIWQuXq0TW3g" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/fix-in-cursor-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/fix-in-cursor-light.svg"><img alt="Fix in Cursor" src="https://cursor.com/fix-in-cursor.svg"></picture></a>&nbsp;<a href="https://cursor.com/agents?data=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJ1Z2JvdC12MiJ9.eyJ2ZXJzaW9uIjoxLCJ0eXBlIjoiQlVHQk9UX0ZJWF9JTl9XRUIiLCJkYXRhIjp7InJlZGlzS2V5IjoiYnVnYm90OjMyMGYxZjc5LWIwMmYtNGYyNy04ZGNmLTZlOGFlY2NhNTJkYiIsImVuY3J5cHRpb25LZXkiOiJDWGpxMXp6Z3dpUllTQjlsZ1lhaFU5UmtlWmFtaVdVY2JrN09rQ2Q4d1NRIiwiYnJhbmNoIjoiMjAyNiIsInJlcG9Pd25lciI6InF1b2lqZSIsInJlcG9OYW1lIjoib3NjYXJzLXBvb2wiLCJwck51bWJlciI6NzQsImNvbW1pdFNoYSI6ImZlMjFhMTJjODJmMDE5YzI2YTlkMDVhMjViMTk5ZDZiMjA4ODczMDAiLCJwcm92aWRlciI6ImdpdGh1YiJ9LCJpYXQiOjE3NjkzNzk2MzYsImV4cCI6MTc3MTk3MTYzNn0.TgHApQFuDMLY0HjPgfm4VaeOz3bZ2zm9DsWMnhBaJvocdu0Ig2FA7RCstx9651iTTifhgsxR29U_j8zYuSzIylzP6c-x1y3P3zflfv4zY8vrT8VH0Kn8WODGGjFmpfyk_oPa_R8Cm5toHZfN_GaOe3zcWtW76os9NFjRUuKcl8xxK6jF1gaULBwnd2WgT4vtZZgdwWXLW-pWl2DnbIOSQdW7FiLzlnGuiBI26F4cuzElawb9_tcsrmn_ym1sWZLgvoh3UlbgQVwcUVrEKQsDioYLRVb_HMNRSGRchyV7--mq1fPCuKkbvaq_4ePfDZ4s_HJexqF5XlXKZnnXt9wqsg" target="_blank" rel="noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/fix-in-web-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/fix-in-web-light.svg"><img alt="Fix in Web" src="https://cursor.com/fix-in-web.svg"></picture></a>
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.
+47 -1
View File
@@ -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 @@
</div>
</div>
<div class="d-flex flex-column gap-2 align-items-start align-items-lg-end">
<div class="d-flex flex-column gap-2 align-items-center align-items-lg-center">
<div id="quality-toggle" class="d-none quality-toggle">
<span class="quality-label">Qualité</span>
<div class="quality-pill" role="group" aria-label="Qualité">
@@ -204,6 +239,17 @@
</div>
</div>
<div id="user-rating" class="user-rating">
<div class="rating-stars" role="group" aria-label="Noter ce film">
<button type="button" class="star-btn" data-value="1" aria-label="1 étoile" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="2" aria-label="2 étoiles" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="3" aria-label="3 étoiles" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="4" aria-label="4 étoiles" aria-pressed="false">★</button>
<button type="button" class="star-btn" data-value="5" aria-label="5 étoiles" aria-pressed="false">★</button>
</div>
<div id="user-rating-meta" class="text-muted small">—</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">
+172 -7
View File
@@ -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);
}
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) {
1
@@ -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 });
}