diff --git a/models/Movie.js b/models/Movie.js
index 9a81bdf..8e8d3ef 100644
--- a/models/Movie.js
+++ b/models/Movie.js
@@ -6,6 +6,8 @@ const movieSchema = new mongoose.Schema({
description: { type: String, required: false },
rating: { type: String, required: false },
poster: { type: String, required: false },
+ // Oscar pool year / edition (used to segment movies + stats)
+ year: { type: Number, required: false, index: true },
category: { type: String, required: true },
vod_link: { type: String, required: true },
watchedBy: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }]
diff --git a/public/control.html b/public/control.html
index 954c45e..4bafe42 100644
--- a/public/control.html
+++ b/public/control.html
@@ -60,6 +60,11 @@
+
+
+
+ Admin - Gérer les films
+
+ Sélectionne une année, puis coche les films à supprimer.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0 sélectionné(s)
+
+
+
+
+
+
+
+ | Sel. |
+ Titre |
+ Catégorie |
+ Année |
+
+
+
+ | Charge la liste… |
+
+
+
+
diff --git a/public/js/app_control.js b/public/js/app_control.js
index 3e48a86..7df46a2 100644
--- a/public/js/app_control.js
+++ b/public/js/app_control.js
@@ -31,6 +31,14 @@ window.onload = async function () {
const responseEl = document.getElementById('response');
const form = document.getElementById('add-movie-form');
+ const yearInput = document.getElementById('oscar_year');
+
+ const manageYearSelect = document.getElementById('manage_year');
+ const adminMoviesBody = document.getElementById('admin-movies-body');
+ const refreshMoviesBtn = document.getElementById('refresh-movies');
+ const deleteSelectedBtn = document.getElementById('delete-selected');
+ const selectAllBox = document.getElementById('select-all-movies');
+ const selectedCountEl = document.getElementById('selected-count');
function showResponse(kind, message) {
responseEl.classList.remove('d-none', 'alert-success', 'alert-danger', 'alert-warning');
@@ -38,6 +46,13 @@ window.onload = async function () {
responseEl.textContent = message;
}
+ function parseYear(value) {
+ const n = Number(value);
+ if (!Number.isInteger(n)) return null;
+ if (n < 1900 || n > 3000) return null;
+ return n;
+ }
+
document.getElementById('reset-form').addEventListener('click', function () {
form.reset();
responseEl.classList.add('d-none');
@@ -51,10 +66,16 @@ window.onload = async function () {
form.addEventListener('submit', async function (e) {
e.preventDefault();
+ const year = parseYear(yearInput.value);
const imdb_id = document.getElementById('imdb_id').value.trim();
const category = document.getElementById('category').value.trim();
const vod_link = document.getElementById('vod_link').value.trim();
+ if (!year) {
+ showResponse('warning', 'Année invalide. Exemple attendu: 2026');
+ return;
+ }
+
if (!/^tt\d{5,}$/.test(imdb_id)) {
showResponse('warning', 'IMDB ID invalide. Exemple attendu: tt1234567');
return;
@@ -67,12 +88,14 @@ window.onload = async function () {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
- body: JSON.stringify({ imdb_id, category, vod_link })
+ body: JSON.stringify({ year, imdb_id, category, vod_link })
});
if (res.ok) {
showResponse('success', 'Film ajouté avec succès.');
form.reset();
+ await refreshYears();
+ await loadMoviesForManagement();
return;
}
@@ -88,5 +111,146 @@ window.onload = async function () {
showResponse('danger', err.message || 'Erreur réseau');
}
});
+
+ async function refreshYears() {
+ try {
+ const yearsRes = await fetch('/api/movies/years');
+ const years = yearsRes.ok ? await yearsRes.json() : [];
+
+ // Populate both the add form (default) and manage dropdown
+ const existingValues = new Set([...manageYearSelect.options].map((o) => o.value));
+ years.forEach((y) => {
+ const value = String(y);
+ if (!existingValues.has(value)) {
+ const opt = document.createElement('option');
+ opt.value = value;
+ opt.textContent = value;
+ manageYearSelect.appendChild(opt);
+ }
+ });
+
+ // Default add-year to latest year if empty
+ if (years.length > 0 && !yearInput.value) {
+ yearInput.value = String(years[0]);
+ }
+ } catch (_) {
+ // ignore
+ }
+ }
+
+ function getSelectedMovieIds() {
+ return Array.from(adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]:checked'))
+ .map((el) => el.getAttribute('data-movie-id'))
+ .filter(Boolean);
+ }
+
+ function updateSelectionUI() {
+ const selectedIds = getSelectedMovieIds();
+ selectedCountEl.textContent = String(selectedIds.length);
+ deleteSelectedBtn.disabled = selectedIds.length === 0;
+
+ const allBoxes = Array.from(adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]'));
+ const checked = allBoxes.filter((b) => b.checked).length;
+ selectAllBox.indeterminate = checked > 0 && checked < allBoxes.length;
+ selectAllBox.checked = allBoxes.length > 0 && checked === allBoxes.length;
+ }
+
+ async function loadMoviesForManagement() {
+ const year = manageYearSelect.value;
+ const url = year ? `/api/movies?year=${encodeURIComponent(year)}` : '/api/movies';
+ adminMoviesBody.innerHTML = `| Chargement… |
`;
+
+ try {
+ const res = await fetch(url, { method: 'GET' });
+ if (!res.ok) {
+ adminMoviesBody.innerHTML = `| Erreur lors du chargement (${res.status}) |
`;
+ return;
+ }
+ const movies = await res.json();
+ movies.sort((a, b) => (a.title || '').localeCompare((b.title || ''), 'fr', { sensitivity: 'base' }));
+
+ if (!movies.length) {
+ adminMoviesBody.innerHTML = `| Aucun film. |
`;
+ updateSelectionUI();
+ return;
+ }
+
+ adminMoviesBody.innerHTML = '';
+ movies.forEach((m) => {
+ const tr = document.createElement('tr');
+ tr.innerHTML = `
+
+
+ |
+
+ ${m.title || '(sans titre)'}
+ ${m.imdb_id || ''}
+ |
+ ${m.category || ''} |
+ ${m.year || ''} |
+ `;
+ adminMoviesBody.appendChild(tr);
+ });
+
+ adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]').forEach((cb) => {
+ cb.addEventListener('change', updateSelectionUI);
+ });
+
+ updateSelectionUI();
+ } catch (err) {
+ adminMoviesBody.innerHTML = `| ${err.message || 'Erreur réseau'} |
`;
+ }
+ }
+
+ refreshMoviesBtn.addEventListener('click', loadMoviesForManagement);
+ manageYearSelect.addEventListener('change', loadMoviesForManagement);
+
+ selectAllBox.addEventListener('change', () => {
+ const checked = selectAllBox.checked;
+ adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]').forEach((cb) => {
+ cb.checked = checked;
+ });
+ updateSelectionUI();
+ });
+
+ deleteSelectedBtn.addEventListener('click', async () => {
+ const selectedIds = getSelectedMovieIds();
+ if (!selectedIds.length) return;
+
+ const yearLabel = manageYearSelect.value ? ` (${manageYearSelect.value})` : '';
+ const ok = window.confirm(`Supprimer ${selectedIds.length} film(s)${yearLabel} ? Cette action est irréversible.`);
+ if (!ok) return;
+
+ try {
+ const res = await fetch('/api/movies/delete', {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ },
+ body: JSON.stringify({ ids: selectedIds })
+ });
+
+ if (!res.ok) {
+ let msg = `Erreur (${res.status})`;
+ try {
+ const data = await res.json();
+ msg = data.message || data.error || msg;
+ } catch (_) {}
+ showResponse('danger', msg);
+ return;
+ }
+
+ showResponse('success', 'Film(s) supprimé(s) avec succès.');
+ await refreshYears();
+ await loadMoviesForManagement();
+ } catch (err) {
+ showResponse('danger', err.message || 'Erreur réseau');
+ }
+ });
+
+ // Initial load
+ await refreshYears();
+ await loadMoviesForManagement();
};
diff --git a/routes/movies.js b/routes/movies.js
index 1794197..6642d5b 100644
--- a/routes/movies.js
+++ b/routes/movies.js
@@ -6,6 +6,14 @@ const jwt = require("jsonwebtoken");
const router = express.Router();
+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;
+}
+
// Fetch movie details from OMDb API
async function fetchMovieDetailsFromOmdb(imdb_id) {
const apiKey = process.env.OMDB_API; // Replace with your actual OMDb API key
@@ -28,10 +36,25 @@ async function fetchMovieDetailsFromOmdb(imdb_id) {
}
}
+// Get available Oscar years (distinct years from movies)
+router.get("/years", async (req, res) => {
+ try {
+ const years = await Movie.distinct("year");
+ const cleaned = years
+ .filter((y) => typeof y === "number" && Number.isFinite(y))
+ .sort((a, b) => b - a);
+ res.status(200).json(cleaned);
+ } catch (err) {
+ res.status(500).json({ error: err.message });
+ }
+});
+
// Get all movies
router.get("/", async (req, res) => {
try {
- const movies = await Movie.find();
+ const year = parseOscarYear(req.query.year);
+ const filter = year ? { year } : {};
+ const movies = await Movie.find(filter);
res.status(200).json(movies);
} catch (err) {
res.status(500).json({ error: err.message });
@@ -114,7 +137,7 @@ router.patch("/users/updateWatchedMovies", async (req, res) => {
// Add movie
router.post("/add", async (req, res) => {
- const { imdb_id, category, vod_link } = req.body;
+ const { imdb_id, category, vod_link, year } = req.body;
try {
// Get the token from the Authorization header
@@ -132,6 +155,11 @@ router.post("/add", async (req, res) => {
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)" });
+ }
+
// Fetch movie details from OMDb API
const movieDetails = await fetchMovieDetailsFromOmdb(imdb_id);
@@ -142,6 +170,7 @@ router.post("/add", async (req, res) => {
description: movieDetails.description,
rating: movieDetails.rating,
poster: movieDetails.poster,
+ year: parsedYear,
category,
vod_link
});
@@ -157,4 +186,51 @@ router.post("/add", async (req, res) => {
}
});
+// 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);
+
+ 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 });
+ }
+});
+
module.exports = router;
diff --git a/routes/users.js b/routes/users.js
index 0a1f5ee..d87cda1 100644
--- a/routes/users.js
+++ b/routes/users.js
@@ -49,14 +49,24 @@ function verifyToken(req, res, next) {
});
}
+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;
+}
+
// Get user stats with watched movie titles and details from the movies collection
router.get("/stats", verifyToken, async (req, res) => {
try {
// Fetch all users
const users = await User.find();
- // Fetch all movies from the database
- const allMovies = await Movie.find();
+ const year = parseOscarYear(req.query.year);
+
+ // Fetch movies (optionally filtered by year)
+ const allMovies = await Movie.find(year ? { year } : {});
const totalMoviesCount = allMovies.length;
if (totalMoviesCount === 0) {
@@ -65,10 +75,12 @@ router.get("/stats", verifyToken, async (req, res) => {
// Build stats for each user
const userStats = users.map((user) => {
+ const movieByImdbId = new Map(allMovies.map((m) => [m.imdb_id, m]));
+
// Get watched movies with details from the database
const watchedMovies = user.watchedMovies
- .map((wm) => allMovies.find((m) => m.imdb_id === wm.imdb_id)) // Match movie details
- .filter(Boolean); // Remove any `undefined` values (if movie is not found)
+ .map((wm) => movieByImdbId.get(wm.imdb_id)) // Match movie details
+ .filter(Boolean); // Remove any `undefined` values (if movie is not found)
const watchedCount = watchedMovies.length;
const watchedRatio = ((watchedCount / totalMoviesCount) * 100).toFixed(1);
@@ -80,6 +92,7 @@ router.get("/stats", verifyToken, async (req, res) => {
return {
name: user.name,
+ year: year || null,
watchedCount,
totalMoviesCount,
watchedRatio: `${watchedRatio}%`,