Enable toggling ratings off by re-clicking the same star in the player and movie list, and tighten the movies card layout around rating and categories.
1366 lines
47 KiB
JavaScript
1366 lines
47 KiB
JavaScript
const express = require("express");
|
|
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");
|
|
const jwt = require("jsonwebtoken");
|
|
const mongoose = require("mongoose");
|
|
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 }
|
|
const SUBTITLE_FILES_DIR = path.resolve(process.env.SUBTITLE_FILES_DIR || path.join(process.cwd(), "subtitles"));
|
|
const MAX_SUBTITLE_UPLOAD_BYTES = 10 * 1024 * 1024; // 10MB
|
|
|
|
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);
|
|
if (!Number.isInteger(n)) return null;
|
|
if (n < 1900 || n > 3000) return null;
|
|
return n;
|
|
}
|
|
|
|
function isValidImdbId(value) {
|
|
return typeof value === "string" && /^tt\d{5,}$/.test(value.trim());
|
|
}
|
|
|
|
function normalizeOptionalString(v, maxLen = 2048) {
|
|
if (v === undefined) return undefined;
|
|
if (v === null) return null;
|
|
const s = String(v).trim();
|
|
if (!s) return "";
|
|
return s.slice(0, maxLen);
|
|
}
|
|
|
|
function normalizeVideoFile(v) {
|
|
// Stored as a relative path (no leading slash) like: "my_movie.mp4" or "2026/my_movie.mp4"
|
|
// Backend streaming endpoint enforces traversal protection too.
|
|
if (v === undefined) return undefined;
|
|
if (v === null) return null;
|
|
const s = String(v).trim();
|
|
if (!s) return "";
|
|
return s.slice(0, 1024);
|
|
}
|
|
|
|
function normalizeSubtitleLang(v) {
|
|
return normalizeOptionalString(v, 24);
|
|
}
|
|
|
|
function normalizeSubtitleLabel(v) {
|
|
return normalizeOptionalString(v, 80);
|
|
}
|
|
|
|
function parseSubtitleDefault(v) {
|
|
if (v === undefined) return undefined;
|
|
if (typeof v === "boolean") return v;
|
|
const s = String(v).trim().toLowerCase();
|
|
if (!s) return undefined;
|
|
if (["true", "1", "yes", "on"].includes(s)) return true;
|
|
if (["false", "0", "no", "off"].includes(s)) return false;
|
|
return undefined;
|
|
}
|
|
|
|
function parseBooleanFlag(v) {
|
|
if (v === undefined) return undefined;
|
|
if (typeof v === "boolean") return v;
|
|
const s = String(v).trim().toLowerCase();
|
|
if (!s) return undefined;
|
|
if (["true", "1", "yes", "on"].includes(s)) return true;
|
|
if (["false", "0", "no", "off"].includes(s)) return false;
|
|
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;
|
|
return {
|
|
file: safeFile,
|
|
lang: normalizeSubtitleLang(lang) || null,
|
|
label: normalizeSubtitleLabel(label) || null,
|
|
default: !!isDefault,
|
|
};
|
|
}
|
|
|
|
function getLegacySubtitle(movie) {
|
|
const file = typeof movie?.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
|
|
if (!file) return null;
|
|
return {
|
|
_id: "legacy",
|
|
file,
|
|
lang: typeof movie?.subtitle_lang === "string" ? movie.subtitle_lang : null,
|
|
label: typeof movie?.subtitle_label === "string" ? movie.subtitle_label : null,
|
|
default: !!movie?.subtitle_default,
|
|
};
|
|
}
|
|
|
|
function getSubtitlesForResponse(movie) {
|
|
const subs = Array.isArray(movie?.subtitles) ? movie.subtitles.filter(Boolean) : [];
|
|
if (subs.length > 0) return subs;
|
|
const legacy = getLegacySubtitle(movie);
|
|
return legacy ? [legacy] : [];
|
|
}
|
|
|
|
function syncLegacyFromSubtitles(movie) {
|
|
const subs = Array.isArray(movie?.subtitles) ? movie.subtitles : [];
|
|
if (!subs.length) {
|
|
movie.subtitle_file = null;
|
|
movie.subtitle_lang = null;
|
|
movie.subtitle_label = null;
|
|
movie.subtitle_default = false;
|
|
return;
|
|
}
|
|
const preferred = subs.find((s) => s?.default) || subs[0];
|
|
movie.subtitle_file = preferred?.file || null;
|
|
movie.subtitle_lang = preferred?.lang || null;
|
|
movie.subtitle_label = preferred?.label || null;
|
|
movie.subtitle_default = !!preferred?.default;
|
|
}
|
|
|
|
async function ensureSubtitleDir() {
|
|
await fs.promises.mkdir(SUBTITLE_FILES_DIR, { recursive: true });
|
|
}
|
|
|
|
function safeFilenameBase(raw) {
|
|
return String(raw || "").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 32) || "movie";
|
|
}
|
|
|
|
const subtitleUpload = multer({
|
|
storage: multer.diskStorage({
|
|
destination: async (_req, _file, cb) => {
|
|
try {
|
|
await ensureSubtitleDir();
|
|
cb(null, SUBTITLE_FILES_DIR);
|
|
} catch (err) {
|
|
cb(err);
|
|
}
|
|
},
|
|
filename: (req, file, cb) => {
|
|
const safeId = safeFilenameBase(req.params?.id);
|
|
const ext = path.extname(file.originalname || "").toLowerCase() || ".vtt";
|
|
const name = `${safeId}-${Date.now()}-${Math.random().toString(16).slice(2)}${ext}`;
|
|
cb(null, name);
|
|
},
|
|
}),
|
|
limits: { fileSize: MAX_SUBTITLE_UPLOAD_BYTES },
|
|
fileFilter: (_req, file, cb) => {
|
|
const ext = path.extname(file.originalname || "").toLowerCase();
|
|
if (ext !== ".vtt" && ext !== ".srt") return cb(new Error("Only .srt or .vtt subtitle files are allowed"));
|
|
return cb(null, true);
|
|
},
|
|
});
|
|
|
|
async function convertSrtToVtt(filePath) {
|
|
const content = await fs.promises.readFile(filePath, "utf8");
|
|
const normalized = content.replace(/\r\n/g, "\n");
|
|
const body = normalized.replace(/(\d{2}:\d{2}:\d{2}),(\d{3})/g, "$1.$2");
|
|
const positioned = body.replace(
|
|
/^(\d{2}:\d{2}:\d{2}\.\d{3}\s+-->\s+\d{2}:\d{2}:\d{2}\.\d{3})(.*)$/gm,
|
|
(_match, timing, settings) => {
|
|
const tail = String(settings || "");
|
|
if (/\bline:/i.test(tail)) return `${timing}${tail}`;
|
|
return `${timing}${tail} line:90%`;
|
|
},
|
|
);
|
|
const vtt = `WEBVTT\n\n${positioned.trim()}\n`;
|
|
const vttPath = filePath.replace(/\.srt$/i, ".vtt");
|
|
await fs.promises.writeFile(vttPath, vtt, "utf8");
|
|
try { await fs.promises.unlink(filePath); } catch (_) {}
|
|
return vttPath;
|
|
}
|
|
|
|
function normalizePlayerMode(v) {
|
|
const s = String(v || "").trim().toLowerCase();
|
|
if (!s) return "auto";
|
|
if (s === "auto" || s === "video" || s === "embed") return s;
|
|
return null;
|
|
}
|
|
|
|
function hasAnySource({ vod_link, video_src, embed_src }) {
|
|
const a = typeof vod_link === "string" ? vod_link.trim() : "";
|
|
const b = typeof video_src === "string" ? video_src.trim() : "";
|
|
const c = typeof embed_src === "string" ? embed_src.trim() : "";
|
|
return !!(a || b || c);
|
|
}
|
|
|
|
function hasAnySourceIncludingFile({ vod_link, video_src, embed_src, video_file }) {
|
|
const a = typeof vod_link === "string" ? vod_link.trim() : "";
|
|
const b = typeof video_src === "string" ? video_src.trim() : "";
|
|
const c = typeof embed_src === "string" ? embed_src.trim() : "";
|
|
const d = typeof video_file === "string" ? video_file.trim() : "";
|
|
return !!(a || b || c || d);
|
|
}
|
|
|
|
// Fetch movie details from OMDb API
|
|
async function fetchMovieDetailsFromOmdb(imdb_id) {
|
|
const apiKey = typeof process.env.OMDB_API === "string" ? process.env.OMDB_API.trim() : "";
|
|
if (!apiKey) {
|
|
const err = new Error("OMDb n'est pas configuré (env OMDB_API manquante).");
|
|
err.code = "OMDB_NOT_CONFIGURED";
|
|
throw err;
|
|
}
|
|
const omdbUrl = `https://www.omdbapi.com/?i=${encodeURIComponent(imdb_id)}&apikey=${apiKey}`;
|
|
|
|
try {
|
|
const response = await axios.get(omdbUrl);
|
|
if (response.data.Response === "True") {
|
|
return {
|
|
title: response.data.Title,
|
|
description: response.data.Plot,
|
|
rating: response.data.imdbRating,
|
|
poster: response.data.Poster
|
|
};
|
|
} else {
|
|
throw new Error('Movie not found on OMDb API');
|
|
}
|
|
} catch (error) {
|
|
throw new Error(`Error fetching movie details: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// 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 });
|
|
}
|
|
});
|
|
|
|
// Get all movies
|
|
router.get("/", async (req, res) => {
|
|
try {
|
|
const year = parseOscarYear(req.query.year);
|
|
const filter = year ? { year } : {};
|
|
|
|
// 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 cam year category vod_link player_mode video_src embed_src video_file video_file_low subtitles subtitle_file subtitle_lang subtitle_label subtitle_default updatedAt createdAt";
|
|
|
|
const cacheKey = `movies:list:${year || "all"}:${isChecklist ? "checklist" : "films"}`;
|
|
const cached = cacheGet(cacheKey);
|
|
|
|
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,
|
|
};
|
|
});
|
|
|
|
res.set("Cache-Control", "public, max-age=30");
|
|
res.status(200).json(payload);
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get latest movie update/add timestamp
|
|
router.get("/last-update", async (req, res) => {
|
|
const token = req.headers.authorization?.split(" ")[1];
|
|
if (!token) return res.status(401).json({ message: "Token is required" });
|
|
|
|
try {
|
|
jwt.verify(token, process.env.JWT_SECRET);
|
|
|
|
const year = parseOscarYear(req.query.year);
|
|
const filter = year ? { year } : {};
|
|
|
|
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 });
|
|
}
|
|
|
|
const lastUpdated =
|
|
latestMovie.updatedAt ||
|
|
latestMovie.createdAt ||
|
|
(typeof latestMovie._id?.getTimestamp === "function" ? latestMovie._id.getTimestamp() : 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" });
|
|
}
|
|
return res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Get lightweight per-year summary for fast UI (counts, watched-in-year, last update)
|
|
router.get("/summary", async (req, res) => {
|
|
const token = req.headers.authorization?.split(" ")[1];
|
|
if (!token) return res.status(401).json({ message: "Token is required" });
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
|
|
const year = parseOscarYear(req.query.year);
|
|
const filter = year ? { year } : {};
|
|
|
|
// Base (year-wide) data can be cached (not user-specific).
|
|
const cacheKey = `movies:summary-base:${year || "all"}`;
|
|
let base = cacheGet(cacheKey);
|
|
|
|
if (!base) {
|
|
const [idDocs, latestMovie] = await Promise.all([
|
|
Movie.find(filter).select("imdb_id").lean(),
|
|
Movie.findOne(filter)
|
|
.select("updatedAt createdAt")
|
|
.sort({ updatedAt: -1, createdAt: -1, _id: -1 })
|
|
.lean(),
|
|
]);
|
|
|
|
const imdbIds = idDocs.map((d) => d.imdb_id).filter(Boolean);
|
|
const imdbIdSet = new Set(imdbIds);
|
|
|
|
const lastUpdated =
|
|
latestMovie?.updatedAt ||
|
|
latestMovie?.createdAt ||
|
|
(typeof latestMovie?._id?.getTimestamp === "function" ? latestMovie._id.getTimestamp() : null);
|
|
|
|
base = {
|
|
year: year || null,
|
|
totalMoviesCount: imdbIds.length,
|
|
imdbIds,
|
|
// Store ISO string so it's JSON-safe + stable
|
|
lastUpdated: lastUpdated ? new Date(lastUpdated).toISOString() : null,
|
|
};
|
|
|
|
// Avoid storing a Set in cache (non-serializable and not needed elsewhere)
|
|
cacheSet(cacheKey, base);
|
|
}
|
|
|
|
const user = await User.findById(decoded.id).select("watchedMovies").lean();
|
|
if (!user) return res.status(404).json({ message: "User not found" });
|
|
|
|
const imdbIdSet = new Set(base.imdbIds || []);
|
|
const watchedMoviesAll = Array.isArray(user.watchedMovies) ? user.watchedMovies : [];
|
|
const watchedMoviesInYear = watchedMoviesAll.filter((wm) => imdbIdSet.has(wm.imdb_id));
|
|
|
|
// Since this is user-specific, don't encourage shared caching.
|
|
res.set("Cache-Control", "private, max-age=5");
|
|
return res.status(200).json({
|
|
year: base.year,
|
|
totalMoviesCount: base.totalMoviesCount,
|
|
watchedMoviesCount: watchedMoviesInYear.length,
|
|
watchedMovies: watchedMoviesInYear,
|
|
lastUpdated: base.lastUpdated,
|
|
});
|
|
} catch (error) {
|
|
if (error instanceof jwt.JsonWebTokenError) {
|
|
return res.status(401).json({ message: "Invalid or expired token" });
|
|
}
|
|
return res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Get watched movies for the user
|
|
router.get("/watchedMovies", async (req, res) => {
|
|
const token = req.headers.authorization?.split(" ")[1]; // Get token from header
|
|
if (!token) return res.status(401).json({ message: "Token is required" });
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET); // Verify token
|
|
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" });
|
|
|
|
// User-specific: never allow shared/proxy caching.
|
|
res.set("Cache-Control", "private, no-store, must-revalidate");
|
|
res.set("Vary", "Authorization");
|
|
res.status(200).json(user.watchedMovies); // Return the list of watched movies
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Update watched movies for the user
|
|
router.patch("/users/updateWatchedMovies", async (req, res) => {
|
|
const token = req.headers.authorization?.split(" ")[1]; // Get token from header
|
|
if (!token) return res.status(401).json({ message: "Token is required" });
|
|
|
|
const { imdb_id, isChecked } = req.body;
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET); // Verify token
|
|
const user = await User.findById(decoded.id); // Get user by ID
|
|
if (!user) return res.status(404).json({ message: "User not found" });
|
|
|
|
if (isChecked) {
|
|
// Add movie to watchedMovies with the watched date if not already added
|
|
const movieIndex = user.watchedMovies.findIndex(movie => movie.imdb_id === imdb_id);
|
|
if (movieIndex === -1) {
|
|
user.watchedMovies.push({ imdb_id, watchedDate: new Date() });
|
|
}
|
|
} else {
|
|
// Remove movie from watchedMovies array
|
|
user.watchedMovies = user.watchedMovies.filter(movie => movie.imdb_id !== imdb_id);
|
|
}
|
|
|
|
await user.save(); // Save updated user
|
|
res.status(200).json({ message: "Watched movies updated successfully" });
|
|
} catch (error) {
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
|
|
// Add movie
|
|
router.post("/add", async (req, res) => {
|
|
const {
|
|
imdb_id,
|
|
category,
|
|
vod_link,
|
|
year,
|
|
player_mode,
|
|
video_src,
|
|
embed_src,
|
|
video_file,
|
|
video_file_low,
|
|
subtitle_lang,
|
|
subtitle_label,
|
|
subtitle_default,
|
|
cam,
|
|
} = req.body;
|
|
|
|
try {
|
|
// Get the token from the Authorization header
|
|
const token = req.headers.authorization?.split(" ")[1];
|
|
|
|
if (!token) {
|
|
return res.status(401).json({ message: "Authentication token is required" });
|
|
}
|
|
|
|
// Verify and decode the token
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
|
|
// Check if the user is an admin
|
|
if (!decoded.admin) {
|
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
|
}
|
|
|
|
const parsedYear = parseOscarYear(year);
|
|
if (!parsedYear) {
|
|
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
|
|
}
|
|
|
|
const normalizedVod = normalizeOptionalString(vod_link, 4096);
|
|
const normalizedVideo = normalizeOptionalString(video_src, 4096);
|
|
const normalizedEmbed = normalizeOptionalString(embed_src, 20000);
|
|
const normalizedFile = normalizeVideoFile(video_file);
|
|
const normalizedLowFile = normalizeVideoFile(video_file_low);
|
|
const normalizedMode = normalizePlayerMode(player_mode);
|
|
if (normalizedMode === null) {
|
|
return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" });
|
|
}
|
|
const normalizedSubtitleLang = normalizeSubtitleLang(subtitle_lang);
|
|
const normalizedSubtitleLabel = normalizeSubtitleLabel(subtitle_label);
|
|
const normalizedSubtitleDefault = parseSubtitleDefault(subtitle_default) ?? false;
|
|
|
|
// Fetch movie details from OMDb API
|
|
let movieDetails;
|
|
try {
|
|
movieDetails = await fetchMovieDetailsFromOmdb(imdb_id);
|
|
} catch (err) {
|
|
// If OMDb isn't configured, keep the feature usable: add with placeholders so admin can fill manually later.
|
|
if (err?.code === "OMDB_NOT_CONFIGURED") {
|
|
movieDetails = {
|
|
title: String(imdb_id || "").trim() || "Untitled",
|
|
description: "",
|
|
rating: "",
|
|
poster: ""
|
|
};
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
// Proceed with adding the movie
|
|
const movie = new Movie({
|
|
imdb_id,
|
|
title: movieDetails.title,
|
|
description: movieDetails.description,
|
|
rating: movieDetails.rating,
|
|
poster: movieDetails.poster,
|
|
cam: !!cam,
|
|
year: parsedYear,
|
|
category,
|
|
vod_link: normalizedVod || undefined,
|
|
player_mode: normalizedMode,
|
|
video_src: normalizedVideo || undefined,
|
|
embed_src: normalizedEmbed || undefined,
|
|
video_file: normalizedFile || undefined,
|
|
video_file_low: normalizedLowFile || undefined,
|
|
subtitle_lang: normalizedSubtitleLang || undefined,
|
|
subtitle_label: normalizedSubtitleLabel || undefined,
|
|
subtitle_default: normalizedSubtitleDefault,
|
|
});
|
|
|
|
await movie.save();
|
|
|
|
// Convenience: if admin provided a server video_file but no video_src, auto-wire video_src to our streaming endpoint.
|
|
if ((normalizedFile || "").trim() && !String(movie.video_src || "").trim()) {
|
|
movie.video_src = `/api/video/${movie._id}`;
|
|
await movie.save();
|
|
}
|
|
cacheClear();
|
|
|
|
res.status(201).json({ message: "Movie added successfully!", movie });
|
|
} catch (err) {
|
|
if (err instanceof jwt.JsonWebTokenError) {
|
|
return res.status(401).json({ message: "Invalid or expired token" });
|
|
}
|
|
res.status(400).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Admin: upload subtitles for a movie (VTT)
|
|
router.post("/:id/subtitles", (req, res) => {
|
|
const token = req.headers.authorization?.split(" ")[1];
|
|
if (!token) return res.status(401).json({ message: "Authentication token is required" });
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
if (!decoded.admin) {
|
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof jwt.JsonWebTokenError) {
|
|
return res.status(401).json({ message: "Invalid or expired token" });
|
|
}
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
|
|
subtitleUpload.single("subtitle")(req, res, async (err) => {
|
|
if (err) {
|
|
const isSize = err instanceof multer.MulterError && err.code === "LIMIT_FILE_SIZE";
|
|
return res.status(isSize ? 413 : 400).json({ message: err.message || "Upload failed" });
|
|
}
|
|
|
|
const { id } = req.params;
|
|
if (!mongoose.Types.ObjectId.isValid(id)) {
|
|
if (req.file?.path) {
|
|
try { await fs.promises.unlink(req.file.path); } catch (_) {}
|
|
}
|
|
return res.status(400).json({ message: "Invalid movie id" });
|
|
}
|
|
|
|
const movie = await Movie.findById(id);
|
|
if (!movie) {
|
|
if (req.file?.path) {
|
|
try { await fs.promises.unlink(req.file.path); } catch (_) {}
|
|
}
|
|
return res.status(404).json({ message: "Movie not found" });
|
|
}
|
|
|
|
if (!req.file?.path) {
|
|
return res.status(400).json({ message: "No subtitle file uploaded" });
|
|
}
|
|
|
|
let storedPath = req.file.path;
|
|
if (storedPath.toLowerCase().endsWith(".srt")) {
|
|
try {
|
|
storedPath = await convertSrtToVtt(storedPath);
|
|
} catch (e) {
|
|
return res.status(400).json({ message: "Failed to convert .srt to .vtt" });
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(movie.subtitles)) movie.subtitles = [];
|
|
if (movie.subtitles.length === 0) {
|
|
const legacy = normalizeSubtitleEntry({
|
|
file: movie.subtitle_file,
|
|
lang: movie.subtitle_lang,
|
|
label: movie.subtitle_label,
|
|
isDefault: movie.subtitle_default,
|
|
});
|
|
if (legacy) movie.subtitles.push(legacy);
|
|
}
|
|
|
|
const rel = path.relative(SUBTITLE_FILES_DIR, storedPath).replace(/\\/g, "/");
|
|
const incomingLang = normalizeSubtitleLang(req.body?.subtitle_lang);
|
|
const incomingLabel = normalizeSubtitleLabel(req.body?.subtitle_label);
|
|
const incomingDefault = parseSubtitleDefault(req.body?.subtitle_default);
|
|
|
|
const shouldDefault = incomingDefault !== undefined
|
|
? incomingDefault
|
|
: movie.subtitles.length === 0;
|
|
|
|
if (shouldDefault) {
|
|
movie.subtitles.forEach((s) => { s.default = false; });
|
|
}
|
|
|
|
const entry = normalizeSubtitleEntry({
|
|
file: rel,
|
|
lang: incomingLang,
|
|
label: incomingLabel,
|
|
isDefault: shouldDefault,
|
|
});
|
|
if (entry) movie.subtitles.push(entry);
|
|
|
|
syncLegacyFromSubtitles(movie);
|
|
await movie.save();
|
|
cacheClear();
|
|
return res.status(200).json(movie);
|
|
});
|
|
});
|
|
|
|
// Admin: delete one subtitle track from a movie
|
|
router.delete("/:id/subtitles/:subtitleId", async (req, res) => {
|
|
const token = req.headers.authorization?.split(" ")[1];
|
|
if (!token) return res.status(401).json({ message: "Authentication token is required" });
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
if (!decoded.admin) {
|
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
|
}
|
|
} catch (err) {
|
|
if (err instanceof jwt.JsonWebTokenError) {
|
|
return res.status(401).json({ message: "Invalid or expired token" });
|
|
}
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
|
|
const { id, subtitleId } = req.params;
|
|
if (!mongoose.Types.ObjectId.isValid(id)) {
|
|
return res.status(400).json({ message: "Invalid movie id" });
|
|
}
|
|
|
|
const movie = await Movie.findById(id);
|
|
if (!movie) return res.status(404).json({ message: "Movie not found" });
|
|
|
|
if (subtitleId === "legacy") {
|
|
const existing = typeof movie.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
|
|
if (!existing) return res.status(404).json({ message: "Subtitle not found" });
|
|
const oldPath = path.resolve(SUBTITLE_FILES_DIR, existing);
|
|
if (oldPath.startsWith(SUBTITLE_FILES_DIR + path.sep)) {
|
|
try { await fs.promises.unlink(oldPath); } catch (_) {}
|
|
}
|
|
movie.subtitle_file = null;
|
|
movie.subtitle_lang = null;
|
|
movie.subtitle_label = null;
|
|
movie.subtitle_default = false;
|
|
await movie.save();
|
|
cacheClear();
|
|
return res.status(200).json(movie);
|
|
}
|
|
|
|
const subs = Array.isArray(movie.subtitles) ? movie.subtitles : [];
|
|
const idx = subs.findIndex((s) => String(s?._id || "") === String(subtitleId));
|
|
if (idx < 0) {
|
|
return res.status(404).json({ message: "Subtitle not found" });
|
|
}
|
|
|
|
const removed = subs[idx];
|
|
if (removed?.file) {
|
|
const full = path.resolve(SUBTITLE_FILES_DIR, String(removed.file));
|
|
if (full.startsWith(SUBTITLE_FILES_DIR + path.sep)) {
|
|
try { await fs.promises.unlink(full); } catch (_) {}
|
|
}
|
|
}
|
|
|
|
subs.splice(idx, 1);
|
|
if (subs.length > 0 && !subs.some((s) => s?.default)) {
|
|
subs[0].default = true;
|
|
}
|
|
movie.subtitles = subs;
|
|
syncLegacyFromSubtitles(movie);
|
|
|
|
await movie.save();
|
|
cacheClear();
|
|
return res.status(200).json(movie);
|
|
});
|
|
|
|
// Admin: delete one or more movies (by Mongo _id or imdb_id)
|
|
router.delete("/delete", async (req, res) => {
|
|
const token = req.headers.authorization?.split(" ")[1];
|
|
if (!token) return res.status(401).json({ message: "Authentication token is required" });
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
if (!decoded.admin) {
|
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
|
}
|
|
|
|
const { ids, imdb_ids } = req.body || {};
|
|
const idList = Array.isArray(ids) ? ids.filter(Boolean) : [];
|
|
const imdbList = Array.isArray(imdb_ids) ? imdb_ids.filter(Boolean) : [];
|
|
|
|
if (idList.length === 0 && imdbList.length === 0) {
|
|
return res.status(400).json({ message: "Aucun film sélectionné." });
|
|
}
|
|
|
|
const filter = idList.length
|
|
? { _id: { $in: idList } }
|
|
: { imdb_id: { $in: imdbList } };
|
|
|
|
const moviesToDelete = await Movie.find(filter).select("imdb_id");
|
|
const deletedImdbIds = moviesToDelete.map((m) => m.imdb_id).filter(Boolean);
|
|
|
|
const result = await Movie.deleteMany(filter);
|
|
cacheClear();
|
|
|
|
if (deletedImdbIds.length > 0) {
|
|
await User.updateMany(
|
|
{},
|
|
{ $pull: { watchedMovies: { imdb_id: { $in: deletedImdbIds } } } }
|
|
);
|
|
}
|
|
|
|
return res.status(200).json({
|
|
message: "Movies deleted successfully",
|
|
deletedCount: result.deletedCount || 0,
|
|
});
|
|
} catch (err) {
|
|
if (err instanceof jwt.JsonWebTokenError) {
|
|
return res.status(401).json({ message: "Invalid or expired token" });
|
|
}
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Admin: update a movie (edit fields, optionally refresh from OMDb)
|
|
router.put("/:id", async (req, res) => {
|
|
const token = req.headers.authorization?.split(" ")[1];
|
|
if (!token) return res.status(401).json({ message: "Authentication token is required" });
|
|
|
|
try {
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
if (!decoded.admin) {
|
|
return res.status(403).json({ message: "You do not have admin privileges" });
|
|
}
|
|
|
|
const { id } = req.params;
|
|
const {
|
|
imdb_id,
|
|
title,
|
|
description,
|
|
rating,
|
|
poster,
|
|
year,
|
|
category,
|
|
vod_link,
|
|
player_mode,
|
|
video_src,
|
|
embed_src,
|
|
video_file,
|
|
video_file_low,
|
|
subtitle_lang,
|
|
subtitle_label,
|
|
subtitle_default,
|
|
subtitle_remove,
|
|
refreshOmdb,
|
|
cam,
|
|
} = req.body || {};
|
|
|
|
if (imdb_id !== undefined && imdb_id !== null && !isValidImdbId(imdb_id)) {
|
|
return res.status(400).json({ message: "IMDB ID invalide. Exemple attendu: tt1234567" });
|
|
}
|
|
|
|
if (year !== undefined && year !== null && year !== "") {
|
|
const parsedYear = parseOscarYear(year);
|
|
if (!parsedYear) {
|
|
return res.status(400).json({ message: "Année invalide (ex: 2026)" });
|
|
}
|
|
}
|
|
|
|
if (typeof category === "string" && category.trim() === "") {
|
|
return res.status(400).json({ message: "Catégorie invalide." });
|
|
}
|
|
|
|
if (typeof vod_link === "string" && vod_link.trim() === "") {
|
|
// Allow clearing vod_link; enforce "at least one source" after applying changes.
|
|
}
|
|
|
|
if (player_mode !== undefined) {
|
|
const normalizedMode = normalizePlayerMode(player_mode);
|
|
if (normalizedMode === null) {
|
|
return res.status(400).json({ message: "player_mode invalide (auto|video|embed)" });
|
|
}
|
|
}
|
|
|
|
const movie = await Movie.findById(id);
|
|
if (!movie) return res.status(404).json({ message: "Movie not found" });
|
|
|
|
if (imdb_id !== undefined) movie.imdb_id = String(imdb_id).trim();
|
|
if (category !== undefined) movie.category = String(category).trim();
|
|
if (vod_link !== undefined) {
|
|
const v = normalizeOptionalString(vod_link, 4096);
|
|
movie.vod_link = v ? v : null;
|
|
}
|
|
if (player_mode !== undefined) {
|
|
const normalizedMode = normalizePlayerMode(player_mode);
|
|
movie.player_mode = normalizedMode === null ? movie.player_mode : normalizedMode;
|
|
}
|
|
if (video_src !== undefined) {
|
|
const v = normalizeOptionalString(video_src, 4096);
|
|
movie.video_src = v ? v : null;
|
|
}
|
|
if (embed_src !== undefined) {
|
|
const v = normalizeOptionalString(embed_src, 20000);
|
|
movie.embed_src = v ? v : null;
|
|
}
|
|
if (video_file !== undefined) {
|
|
const v = normalizeVideoFile(video_file);
|
|
movie.video_file = v ? v : null;
|
|
// If we have a server file and no explicit video_src, set it to our streaming endpoint.
|
|
if (movie.video_file && !String(movie.video_src || "").trim()) {
|
|
movie.video_src = `/api/video/${movie._id}`;
|
|
}
|
|
}
|
|
if (video_file_low !== undefined) {
|
|
const v = normalizeVideoFile(video_file_low);
|
|
movie.video_file_low = v ? v : null;
|
|
}
|
|
|
|
const removeSubtitles = parseBooleanFlag(subtitle_remove);
|
|
if (removeSubtitles) {
|
|
const entries = Array.isArray(movie.subtitles) ? movie.subtitles : [];
|
|
for (const entry of entries) {
|
|
const rel = typeof entry?.file === "string" ? entry.file.trim() : "";
|
|
if (!rel) continue;
|
|
const full = path.resolve(SUBTITLE_FILES_DIR, rel);
|
|
if (full.startsWith(SUBTITLE_FILES_DIR + path.sep)) {
|
|
try { await fs.promises.unlink(full); } catch (_) {}
|
|
}
|
|
}
|
|
|
|
const existing = typeof movie.subtitle_file === "string" ? movie.subtitle_file.trim() : "";
|
|
if (existing) {
|
|
const oldPath = path.resolve(SUBTITLE_FILES_DIR, existing);
|
|
if (oldPath.startsWith(SUBTITLE_FILES_DIR + path.sep)) {
|
|
try { await fs.promises.unlink(oldPath); } catch (_) {}
|
|
}
|
|
}
|
|
|
|
movie.subtitles = [];
|
|
movie.subtitle_file = null;
|
|
movie.subtitle_lang = null;
|
|
movie.subtitle_label = null;
|
|
movie.subtitle_default = false;
|
|
} else {
|
|
const subs = Array.isArray(movie.subtitles) ? movie.subtitles : [];
|
|
if (subs.length > 0) {
|
|
const target = subs.find((s) => s?.default) || subs[0];
|
|
if (subtitle_lang !== undefined) {
|
|
const v = normalizeSubtitleLang(subtitle_lang);
|
|
target.lang = v ? v : null;
|
|
}
|
|
if (subtitle_label !== undefined) {
|
|
const v = normalizeSubtitleLabel(subtitle_label);
|
|
target.label = v ? v : null;
|
|
}
|
|
if (subtitle_default !== undefined) {
|
|
const v = parseSubtitleDefault(subtitle_default);
|
|
if (v !== undefined) {
|
|
subs.forEach((s) => { s.default = false; });
|
|
target.default = !!v;
|
|
}
|
|
}
|
|
syncLegacyFromSubtitles(movie);
|
|
} else {
|
|
if (subtitle_lang !== undefined) {
|
|
const v = normalizeSubtitleLang(subtitle_lang);
|
|
movie.subtitle_lang = v ? v : null;
|
|
}
|
|
|
|
if (subtitle_label !== undefined) {
|
|
const v = normalizeSubtitleLabel(subtitle_label);
|
|
movie.subtitle_label = v ? v : null;
|
|
}
|
|
|
|
if (subtitle_default !== undefined) {
|
|
const v = parseSubtitleDefault(subtitle_default);
|
|
if (v !== undefined) movie.subtitle_default = v;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (year !== undefined && year !== null && year !== "") {
|
|
movie.year = parseOscarYear(year);
|
|
} else if (year === null || year === "") {
|
|
// allow clearing the year explicitly
|
|
movie.year = null;
|
|
}
|
|
|
|
// If refreshOmdb is true, overwrite OMDb-derived fields from the imdb_id
|
|
if (cam !== undefined) movie.cam = !!cam;
|
|
|
|
if (refreshOmdb) {
|
|
try {
|
|
const details = await fetchMovieDetailsFromOmdb(movie.imdb_id);
|
|
movie.title = details.title;
|
|
movie.description = details.description;
|
|
movie.rating = details.rating;
|
|
movie.poster = details.poster;
|
|
} catch (err) {
|
|
if (err?.code === "OMDB_NOT_CONFIGURED") {
|
|
return res.status(503).json({ message: "OMDb n'est pas configuré (env OMDB_API manquante)." });
|
|
}
|
|
throw err;
|
|
}
|
|
} else {
|
|
if (title !== undefined) movie.title = title;
|
|
if (description !== undefined) movie.description = description;
|
|
if (rating !== undefined) movie.rating = rating;
|
|
if (poster !== undefined) movie.poster = poster;
|
|
}
|
|
|
|
await movie.save();
|
|
cacheClear();
|
|
return res.status(200).json(movie);
|
|
} catch (err) {
|
|
if (err instanceof jwt.JsonWebTokenError) {
|
|
return res.status(401).json({ message: "Invalid or expired token" });
|
|
}
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get playback progress for the current user (seconds)
|
|
router.get("/:id/progress", 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 userId = req.user?.id;
|
|
if (!userId || !mongoose.Types.ObjectId.isValid(userId)) {
|
|
return res.status(401).json({ message: "Invalid user" });
|
|
}
|
|
|
|
const progress = await PlaybackProgress.findOne({ userId, movieId: id }).select("time duration updatedAt");
|
|
// User-specific: never allow shared/proxy caching.
|
|
res.set("Cache-Control", "private, no-store, must-revalidate");
|
|
res.set("Vary", "Authorization");
|
|
return res.status(200).json({
|
|
time: typeof progress?.time === "number" ? progress.time : 0,
|
|
duration: typeof progress?.duration === "number" ? progress.duration : null,
|
|
updatedAt: progress?.updatedAt ? new Date(progress.updatedAt).toISOString() : null,
|
|
});
|
|
} catch (err) {
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Update playback progress for the current user (seconds)
|
|
router.put("/:id/progress", 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 userId = req.user?.id;
|
|
if (!userId || !mongoose.Types.ObjectId.isValid(userId)) {
|
|
return res.status(401).json({ message: "Invalid user" });
|
|
}
|
|
|
|
const rawTime = req.body?.time;
|
|
const rawDuration = req.body?.duration;
|
|
const rawImdbId = req.body?.imdb_id;
|
|
|
|
let time = Number(rawTime);
|
|
if (!Number.isFinite(time) || time < 0) time = 0;
|
|
// Store whole seconds for stable, consistent resume behavior.
|
|
time = Math.floor(time);
|
|
|
|
// Duration is best-effort from the client.
|
|
// IMPORTANT: if the client sends null/undefined (common before metadata loads),
|
|
// do NOT wipe an existing duration in DB (we simply don't update the field).
|
|
let durationCandidate = rawDuration === undefined || rawDuration === null ? null : Number(rawDuration);
|
|
if (durationCandidate !== null && (!Number.isFinite(durationCandidate) || durationCandidate <= 0)) durationCandidate = null;
|
|
if (durationCandidate !== null) durationCandidate = Math.floor(durationCandidate);
|
|
|
|
if (durationCandidate !== null) {
|
|
// Clamp into [0, duration]
|
|
time = Math.min(Math.max(time, 0), durationCandidate);
|
|
}
|
|
|
|
// Defensive guard:
|
|
// On some reload/startup sequences (especially on hosted environments),
|
|
// the client can emit an early "time=0" save *before* it has restored the
|
|
// user's previous progress (and before it knows duration).
|
|
// If we already have a meaningful progress saved, never let a "0 with unknown duration"
|
|
// overwrite it.
|
|
if (time === 0 && durationCandidate === null) {
|
|
const existing = await PlaybackProgress.findOne({ userId, movieId: id }).select("time duration updatedAt");
|
|
if (existing && typeof existing.time === "number" && existing.time > 1) {
|
|
return res.status(200).json({
|
|
time: existing.time ?? 0,
|
|
duration: typeof existing.duration === "number" ? existing.duration : null,
|
|
updatedAt: existing.updatedAt ? new Date(existing.updatedAt).toISOString() : null,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Auto-check the movie in the checklist when the user reaches the "credit roll".
|
|
// Keep it simple: use the timestamp ratio (>= 95%).
|
|
const reachedCreditRoll = durationCandidate !== null && durationCandidate > 0 && time / durationCandidate >= 0.95;
|
|
|
|
// Best UX: if the user is basically at the end, reset to 0 so replay starts from the beginning.
|
|
if (durationCandidate !== null && durationCandidate - time <= 3) {
|
|
time = 0;
|
|
}
|
|
|
|
const updateSet = { time, updatedAt: new Date() };
|
|
if (durationCandidate !== null) updateSet.duration = durationCandidate;
|
|
|
|
const updated = await PlaybackProgress.findOneAndUpdate(
|
|
{ userId, movieId: id },
|
|
{ $set: updateSet },
|
|
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
).select("time duration updatedAt");
|
|
|
|
// If they hit the 95% threshold, mark as watched for checklist.
|
|
// Prefer imdb_id provided by the client; fall back to DB lookup if absent/invalid.
|
|
if (reachedCreditRoll) {
|
|
let imdbId = typeof rawImdbId === "string" ? rawImdbId.trim() : "";
|
|
if (!isValidImdbId(imdbId)) imdbId = "";
|
|
|
|
if (!imdbId) {
|
|
const movieDoc = await Movie.findById(id).select("imdb_id").lean();
|
|
const mImdb = typeof movieDoc?.imdb_id === "string" ? movieDoc.imdb_id.trim() : "";
|
|
if (isValidImdbId(mImdb)) imdbId = mImdb;
|
|
}
|
|
|
|
if (imdbId) {
|
|
// Only add if not already present; if user manually unchecked, it can be re-added on next 95% hit.
|
|
await User.updateOne(
|
|
{ _id: userId, "watchedMovies.imdb_id": { $ne: imdbId } },
|
|
{ $push: { watchedMovies: { imdb_id: imdbId, watchedDate: new Date() } } }
|
|
);
|
|
}
|
|
}
|
|
|
|
return res.status(200).json({
|
|
time: updated?.time ?? 0,
|
|
duration: typeof updated?.duration === "number" ? updated.duration : null,
|
|
updatedAt: updated?.updatedAt ? new Date(updated.updatedAt).toISOString() : null,
|
|
});
|
|
} catch (err) {
|
|
// Handle rare unique-index races on first write
|
|
if (err?.code === 11000) {
|
|
return res.status(409).json({ message: "Progress already exists, retry." });
|
|
}
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// Get playback progress for the current user (bulk, used by movies.html)
|
|
// Optional query: ?year=2026 (filters to that Oscar year)
|
|
router.get("/progress", authenticate, async (req, res) => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
if (!userId || !mongoose.Types.ObjectId.isValid(userId)) {
|
|
return res.status(401).json({ message: "Invalid user" });
|
|
}
|
|
|
|
const year = parseOscarYear(req.query.year);
|
|
let movieIds = null;
|
|
|
|
if (year) {
|
|
const idDocs = await Movie.find({ year }).select("_id").lean();
|
|
movieIds = idDocs.map((d) => d?._id).filter(Boolean);
|
|
if (movieIds.length === 0) {
|
|
res.set("Cache-Control", "private, no-store, must-revalidate");
|
|
res.set("Vary", "Authorization");
|
|
return res.status(200).json([]);
|
|
}
|
|
}
|
|
|
|
const filter = { userId, ...(movieIds ? { movieId: { $in: movieIds } } : {}) };
|
|
const rows = await PlaybackProgress.find(filter).select("movieId time duration updatedAt").lean();
|
|
|
|
// User-specific: never allow shared/proxy caching.
|
|
res.set("Cache-Control", "private, no-store, must-revalidate");
|
|
res.set("Vary", "Authorization");
|
|
return res.status(200).json(
|
|
(rows || []).map((r) => ({
|
|
movieId: String(r.movieId),
|
|
time: typeof r.time === "number" ? r.time : 0,
|
|
duration: typeof r.duration === "number" ? r.duration : null,
|
|
updatedAt: r.updatedAt ? new Date(r.updatedAt).toISOString() : null,
|
|
}))
|
|
);
|
|
} catch (err) {
|
|
return res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// 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 });
|
|
}
|
|
});
|
|
|
|
// Remove a user rating
|
|
router.delete("/: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" });
|
|
|
|
await MovieRating.deleteOne({ movieId, userId });
|
|
|
|
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: null,
|
|
});
|
|
} catch (err) {
|
|
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 {
|
|
const { id } = req.params;
|
|
if (!mongoose.Types.ObjectId.isValid(id)) {
|
|
return res.status(400).json({ message: "Invalid movie id" });
|
|
}
|
|
|
|
const movie = await Movie.findById(id).lean();
|
|
if (!movie) return res.status(404).json({ message: "Movie not found" });
|
|
|
|
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 });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|