Merge pull request #74 from quoije/2026
Add user movie ratings and refresh movie cards
This commit was merged in pull request #74.
This commit is contained in:
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
+39
-18
@@ -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}`
|
||||
}
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||
<h5 class="card-title mb-1 flex-grow-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}
|
||||
<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 class="flex-grow-1">
|
||||
<div class="d-flex justify-content-between align-items-start gap-2">
|
||||
<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="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="movie-info-box">
|
||||
<div class="movie-info-category">${movie.category}</div>
|
||||
<p class="movie-info-desc">${movie.description}</p>
|
||||
</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>
|
||||
${isChecked ? `<div class="text-muted small"><span class="fw-semibold text-dark">Regardé le:</span> ${watchedDate}</div>` : ''}
|
||||
${progressHtml}
|
||||
<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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
+47
-1
@@ -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">
|
||||
|
||||
+175
-10
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user