diff --git a/public/js/app_control.js b/public/js/app_control.js
index df5271c..b5d1a87 100644
--- a/public/js/app_control.js
+++ b/public/js/app_control.js
@@ -82,6 +82,7 @@ window.onload = async function () {
const adminMoviesBody = document.getElementById('admin-movies-body');
const refreshMoviesBtn = document.getElementById('refresh-movies');
const deleteSelectedBtn = document.getElementById('delete-selected');
+ const updateOmdbSelectedBtn = document.getElementById('update-omdb-selected');
const selectAllBox = document.getElementById('select-all-movies');
const selectedCountEl = document.getElementById('selected-count');
@@ -121,6 +122,7 @@ window.onload = async function () {
const editRefreshOmdbEl = document.getElementById('edit_refresh_omdb');
const editTitleEl = document.getElementById('edit_title');
const editRatingEl = document.getElementById('edit_rating');
+ const editRuntimeEl = document.getElementById('edit_runtime');
const editPosterEl = document.getElementById('edit_poster');
const editDescriptionEl = document.getElementById('edit_description');
const editCamFlagEl = document.getElementById('edit_cam_flag');
@@ -2238,6 +2240,7 @@ window.onload = async function () {
const selectedIds = getSelectedMovieIds();
selectedCountEl.textContent = String(selectedIds.length);
deleteSelectedBtn.disabled = selectedIds.length === 0;
+ if (updateOmdbSelectedBtn) updateOmdbSelectedBtn.disabled = selectedIds.length === 0;
const allBoxes = Array.from(adminMoviesBody.querySelectorAll('input[type="checkbox"][data-movie-id]'));
const checked = allBoxes.filter((b) => b.checked).length;
@@ -2324,6 +2327,7 @@ window.onload = async function () {
editTitleEl.value = movie.title || '';
editRatingEl.value = movie.rating || '';
+ if (editRuntimeEl) editRuntimeEl.value = movie.runtime || '';
editPosterEl.value = movie.poster || '';
editDescriptionEl.value = movie.description || '';
if (editCamFlagEl) editCamFlagEl.checked = !!movie.cam;
@@ -2359,6 +2363,7 @@ window.onload = async function () {
const title = editTitleEl.value;
const rating = editRatingEl.value;
+ const runtime = editRuntimeEl ? editRuntimeEl.value : '';
const poster = editPosterEl.value;
const description = editDescriptionEl.value;
@@ -2407,6 +2412,7 @@ window.onload = async function () {
body.title = title;
body.description = description;
body.rating = rating;
+ body.runtime = runtime;
body.poster = poster;
}
@@ -2619,6 +2625,65 @@ window.onload = async function () {
}
});
+ if (updateOmdbSelectedBtn) {
+ updateOmdbSelectedBtn.addEventListener('click', async () => {
+ const selectedIds = getSelectedMovieIds();
+ if (!selectedIds.length) return;
+
+ const yearLabel = manageYearSelect.value ? ` (${manageYearSelect.value})` : '';
+ const ok = window.confirm(`Rafraîchir ${selectedIds.length} film(s)${yearLabel} depuis OMDb ? Cette opération peut écraser les infos (titre, plot, rating, poster, runtime).`);
+ if (!ok) return;
+
+ updateOmdbSelectedBtn.disabled = true;
+ updateOmdbSelectedBtn.textContent = 'Refresh OMDb...';
+
+ let successCount = 0;
+ let errorCount = 0;
+ const errors = [];
+
+ for (const movieId of selectedIds) {
+ try {
+ const res = await fetch(`/api/movies/${encodeURIComponent(movieId)}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${token}`
+ },
+ body: JSON.stringify({ refreshOmdb: true })
+ });
+
+ if (!res.ok) {
+ let msg = `Erreur (${res.status})`;
+ try {
+ const data = await res.json();
+ msg = data.message || data.error || msg;
+ } catch (_) {}
+ errors.push(`${movieId}: ${msg}`);
+ errorCount++;
+ } else {
+ successCount++;
+ }
+ } catch (err) {
+ errors.push(`${movieId}: ${err.message || 'Erreur réseau'}`);
+ errorCount++;
+ }
+ }
+
+ updateOmdbSelectedBtn.disabled = false;
+ updateOmdbSelectedBtn.textContent = 'Refresh OMDb';
+
+ if (errorCount === 0) {
+ showResponse('success', `${successCount} film(s) mis à jour depuis OMDb.`);
+ } else if (successCount === 0) {
+ showResponse('danger', `Échec pour ${errorCount} film(s). ${errors.slice(0, 3).join('; ')}`);
+ } else {
+ showResponse('warning', `${successCount} succès, ${errorCount} échec(s). ${errors.slice(0, 2).join('; ')}`);
+ }
+
+ await loadMoviesForManagement();
+ });
+ }
+
if (saveMovieChangesBtn) {
saveMovieChangesBtn.addEventListener('click', saveMovieChanges);
}
diff --git a/public/js/app_index.js b/public/js/app_index.js
index e3efd56..f42b0a4 100644
--- a/public/js/app_index.js
+++ b/public/js/app_index.js
@@ -22,49 +22,132 @@ async function applyActiveOscarYearToTitle() {
// Fire and forget.
applyActiveOscarYearToTitle();
-// Check if the user is already logged in by looking for auth_token in localStorage
-const existingToken = localStorage.getItem('auth_token');
-if (existingToken) {
+// Check setup status first
+async function checkSetupStatus() {
try {
- const decoded = decodeJwt(existingToken);
- const currentTime = Math.floor(Date.now() / 1000);
- if (decoded.exp && decoded.exp < currentTime) {
- localStorage.removeItem('auth_token');
- } else if (decoded.mustChangePassword) {
- window.location.href = '/change-password.html';
- } else {
- window.location.href = '/movies.html';
- }
+ const res = await fetch('/api/users/setup/status', { method: 'GET', cache: 'no-store' });
+ if (!res.ok) return { needsSetup: false };
+ return await res.json();
} catch (_) {
- localStorage.removeItem('auth_token');
+ return { needsSetup: false };
}
}
- // Handle login form submission
- document.getElementById('login-form').addEventListener('submit', async function (e) {
- e.preventDefault();
-
- const email = document.getElementById('email').value;
- const password = document.getElementById('password').value;
-
- const res = await fetch('/api/users/login', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ email, password })
- });
+async function initPage() {
+ const setupCard = document.getElementById('setup-card');
+ const loginCard = document.getElementById('login-card');
- if (res.ok) {
- const data = await res.json();
- // Save the token to localStorage
- localStorage.setItem('auth_token', data.token);
+ // Check if setup is needed
+ const status = await checkSetupStatus();
- if (data.mustChangePassword) {
+ if (status.needsSetup) {
+ // Show setup card, hide login card
+ if (setupCard) setupCard.classList.remove('d-none');
+ if (loginCard) loginCard.classList.add('d-none');
+ return;
+ }
+
+ // Check if the user is already logged in
+ const existingToken = localStorage.getItem('auth_token');
+ if (existingToken) {
+ try {
+ const decoded = decodeJwt(existingToken);
+ const currentTime = Math.floor(Date.now() / 1000);
+ if (decoded.exp && decoded.exp < currentTime) {
+ localStorage.removeItem('auth_token');
+ } else if (decoded.mustChangePassword) {
window.location.href = '/change-password.html';
+ return;
} else {
- window.location.href = '/movies.html'; // Redirect to movies page on success
+ window.location.href = '/movies.html';
+ return;
}
- } else {
- const error = await res.json();
- alert(`${error.message}`);
+ } catch (_) {
+ localStorage.removeItem('auth_token');
}
- });
\ No newline at end of file
+ }
+}
+
+// Initialize the page
+initPage();
+
+// Handle setup form submission
+const setupForm = document.getElementById('setup-form');
+if (setupForm) {
+ setupForm.addEventListener('submit', async function (e) {
+ e.preventDefault();
+
+ const setupError = document.getElementById('setup-error');
+ const name = document.getElementById('setup-name').value.trim();
+ const email = document.getElementById('setup-email').value.trim();
+ const password = document.getElementById('setup-password').value;
+ const passwordConfirm = document.getElementById('setup-password-confirm').value;
+
+ // Hide previous error
+ if (setupError) setupError.classList.add('d-none');
+
+ // Validate passwords match
+ if (password !== passwordConfirm) {
+ if (setupError) {
+ setupError.textContent = 'Les mots de passe ne correspondent pas';
+ setupError.classList.remove('d-none');
+ }
+ return;
+ }
+
+ try {
+ const res = await fetch('/api/users/setup/admin', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, email, password })
+ });
+
+ const data = await res.json();
+
+ if (res.ok) {
+ // Save the token and redirect
+ localStorage.setItem('auth_token', data.token);
+ window.location.href = '/movies.html';
+ } else {
+ if (setupError) {
+ setupError.textContent = data.message || 'Erreur lors de la création du compte';
+ setupError.classList.remove('d-none');
+ }
+ }
+ } catch (err) {
+ if (setupError) {
+ setupError.textContent = 'Erreur réseau. Veuillez réessayer.';
+ setupError.classList.remove('d-none');
+ }
+ }
+ });
+}
+
+// Handle login form submission
+document.getElementById('login-form').addEventListener('submit', async function (e) {
+ e.preventDefault();
+
+ const email = document.getElementById('email').value;
+ const password = document.getElementById('password').value;
+
+ const res = await fetch('/api/users/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password })
+ });
+
+ if (res.ok) {
+ const data = await res.json();
+ // Save the token to localStorage
+ localStorage.setItem('auth_token', data.token);
+
+ if (data.mustChangePassword) {
+ window.location.href = '/change-password.html';
+ } else {
+ window.location.href = '/movies.html'; // Redirect to movies page on success
+ }
+ } else {
+ const error = await res.json();
+ alert(`${error.message}`);
+ }
+});
\ No newline at end of file
diff --git a/public/js/app_movies.js b/public/js/app_movies.js
index 16b15d9..11d81c2 100644
--- a/public/js/app_movies.js
+++ b/public/js/app_movies.js
@@ -160,6 +160,20 @@ window.addEventListener('DOMContentLoaded', async function () {
return `${m}:${String(sec).padStart(2, '0')}`;
}
+ function formatRuntime(runtime) {
+ // Convert "142 min" or "142" to "2h 22min" format
+ if (!runtime || runtime === 'N/A') return '';
+ const raw = String(runtime).trim();
+ // Extract minutes from strings like "142 min", "142min", or just "142"
+ const match = raw.match(/^(\d+)\s*min/i);
+ const minutes = match ? parseInt(match[1], 10) : parseInt(raw, 10);
+ if (!Number.isFinite(minutes) || minutes <= 0) return raw;
+ if (minutes < 60) return `${minutes}min`;
+ const h = Math.floor(minutes / 60);
+ const m = minutes % 60;
+ return m > 0 ? `${h}h ${m}min` : `${h}h`;
+ }
+
async function fetchPlaybackProgressByYear(year, token) {
try {
const res = await fetch(`/api/movies/progress?year=${encodeURIComponent(String(year))}`, {
@@ -571,6 +585,7 @@ window.addEventListener('DOMContentLoaded', async function () {
: `
${movie.title}`
}
+ ${movie.runtime && movie.runtime !== 'N/A' ? `
${formatRuntime(movie.runtime)}
` : ''}
${userRatingText ? `Note utilisateurs ${userRatingText}` : ''}
diff --git a/public/js/app_register.js b/public/js/app_register.js
index 91fa923..93845ff 100644
--- a/public/js/app_register.js
+++ b/public/js/app_register.js
@@ -22,24 +22,55 @@ async function applyActiveOscarYearToTitle() {
// Fire and forget.
applyActiveOscarYearToTitle();
-// Check if the user is already logged in by looking for auth_token in localStorage
-const existingToken = localStorage.getItem('auth_token');
-if (existingToken) {
+// Check setup status
+async function checkSetupStatus() {
try {
- const decoded = decodeJwt(existingToken);
- const currentTime = Math.floor(Date.now() / 1000);
- if (decoded.exp && decoded.exp < currentTime) {
- localStorage.removeItem('auth_token');
- } else if (decoded.mustChangePassword) {
- window.location.href = '/change-password.html';
- } else {
- window.location.href = '/movies.html';
- }
+ const res = await fetch('/api/users/setup/status', { method: 'GET', cache: 'no-store' });
+ if (!res.ok) return { needsSetup: false };
+ return await res.json();
} catch (_) {
- localStorage.removeItem('auth_token');
+ return { needsSetup: false };
}
}
+async function initPage() {
+ const setupNeededCard = document.getElementById('setup-needed-card');
+ const registerCard = document.getElementById('register-card');
+
+ // Check if setup is needed
+ const status = await checkSetupStatus();
+
+ if (status.needsSetup) {
+ // Show setup-needed banner, hide registration form
+ if (setupNeededCard) setupNeededCard.classList.remove('d-none');
+ if (registerCard) registerCard.classList.add('d-none');
+ return;
+ }
+
+ // Check if the user is already logged in by looking for auth_token in localStorage
+ const existingToken = localStorage.getItem('auth_token');
+ if (existingToken) {
+ try {
+ const decoded = decodeJwt(existingToken);
+ const currentTime = Math.floor(Date.now() / 1000);
+ if (decoded.exp && decoded.exp < currentTime) {
+ localStorage.removeItem('auth_token');
+ } else if (decoded.mustChangePassword) {
+ window.location.href = '/change-password.html';
+ return;
+ } else {
+ window.location.href = '/movies.html';
+ return;
+ }
+ } catch (_) {
+ localStorage.removeItem('auth_token');
+ }
+ }
+}
+
+// Initialize the page
+initPage();
+
// Handle registration form submission
document.getElementById('register-form').addEventListener('submit', async function (e) {
e.preventDefault();
diff --git a/public/movies.html b/public/movies.html
index 7775dc4..ccf9e77 100644
--- a/public/movies.html
+++ b/public/movies.html
@@ -60,7 +60,7 @@
diff --git a/public/picks.html b/public/picks.html
index 508d30f..6923603 100644
--- a/public/picks.html
+++ b/public/picks.html
@@ -133,7 +133,7 @@
diff --git a/public/player.html b/public/player.html
index ec8658e..377ce24 100644
--- a/public/player.html
+++ b/public/player.html
@@ -214,7 +214,7 @@
diff --git a/public/register.html b/public/register.html
index 1504693..5db047d 100644
--- a/public/register.html
+++ b/public/register.html
@@ -40,7 +40,20 @@
-
+
+
+
+
+
diff --git a/public/user-stat.html b/public/user-stat.html
index 74d9628..ee916d6 100644
--- a/public/user-stat.html
+++ b/public/user-stat.html
@@ -116,7 +116,7 @@
diff --git a/public/view-picks.html b/public/view-picks.html
index 0abf0a1..9f3d452 100644
--- a/public/view-picks.html
+++ b/public/view-picks.html
@@ -60,7 +60,7 @@
Films
Checklist
-
Statistiques
+
Stats
Mes picks
Admin
Voir Tous les Choix
diff --git a/routes/movies.js b/routes/movies.js
index be918a9..a3ba38a 100644
--- a/routes/movies.js
+++ b/routes/movies.js
@@ -242,6 +242,7 @@ async function fetchMovieDetailsFromOmdb(imdb_id) {
title: response.data.Title,
description: response.data.Plot,
rating: response.data.imdbRating,
+ runtime: response.data.Runtime,
poster: response.data.Poster
};
} else {
@@ -289,7 +290,7 @@ router.get("/", async (req, res) => {
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";
+ : "imdb_id title description rating runtime 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);
@@ -583,6 +584,7 @@ router.post("/add", async (req, res) => {
title: String(imdb_id || "").trim() || "Untitled",
description: "",
rating: "",
+ runtime: "",
poster: ""
};
} else {
@@ -596,6 +598,7 @@ router.post("/add", async (req, res) => {
title: movieDetails.title,
description: movieDetails.description,
rating: movieDetails.rating,
+ runtime: movieDetails.runtime,
poster: movieDetails.poster,
cam: !!cam,
year: parsedYear,
@@ -852,6 +855,7 @@ router.put("/:id", async (req, res) => {
title,
description,
rating,
+ runtime,
poster,
year,
category,
@@ -1008,6 +1012,7 @@ router.put("/:id", async (req, res) => {
movie.title = details.title;
movie.description = details.description;
movie.rating = details.rating;
+ movie.runtime = details.runtime;
movie.poster = details.poster;
} catch (err) {
if (err?.code === "OMDB_NOT_CONFIGURED") {
@@ -1019,6 +1024,7 @@ router.put("/:id", async (req, res) => {
if (title !== undefined) movie.title = title;
if (description !== undefined) movie.description = description;
if (rating !== undefined) movie.rating = rating;
+ if (runtime !== undefined) movie.runtime = runtime;
if (poster !== undefined) movie.poster = poster;
}
diff --git a/routes/users.js b/routes/users.js
index 3e8d9db..e75c5d2 100644
--- a/routes/users.js
+++ b/routes/users.js
@@ -128,6 +128,90 @@ async function getOrInitActiveYear() {
return year;
}
+// ============================================================================
+// FIRST-TIME SETUP ENDPOINTS
+// These allow creating the first admin user when the database is empty/new
+// ============================================================================
+
+// Check if the app needs initial setup (no admin users exist)
+router.get("/setup/status", async (req, res) => {
+ try {
+ const totalUsers = await User.countDocuments({});
+ const adminCount = await User.countDocuments({ role: 69 });
+
+ res.set("Cache-Control", "no-store");
+ return res.status(200).json({
+ needsSetup: adminCount === 0,
+ hasUsers: totalUsers > 0,
+ hasAdmin: adminCount > 0,
+ });
+ } catch (err) {
+ return res.status(500).json({ error: err.message });
+ }
+});
+
+// Create the first admin user (only works if no admin exists)
+router.post("/setup/admin", async (req, res) => {
+ try {
+ // Check if an admin already exists
+ const adminCount = await User.countDocuments({ role: 69 });
+ if (adminCount > 0) {
+ return res.status(403).json({
+ message: "Un administrateur existe déjà. Cette fonctionnalité est désactivée.",
+ });
+ }
+
+ const { name, email, password } = req.body;
+
+ // Validate input
+ const nameNorm = typeof name === "string" ? name.trim() : "";
+ const emailNorm = typeof email === "string" ? email.trim().toLowerCase() : "";
+
+ if (!nameNorm) {
+ return res.status(400).json({ message: "Le nom est obligatoire" });
+ }
+ if (!emailNorm) {
+ return res.status(400).json({ message: "L'email est obligatoire" });
+ }
+ if (!/^\S+@\S+\.\S+$/.test(emailNorm)) {
+ return res.status(400).json({ message: "Email invalide" });
+ }
+ if (!password || password.length < 6) {
+ return res.status(400).json({ message: "Le mot de passe doit contenir au moins 6 caractères" });
+ }
+
+ // Check if email is already taken
+ const existing = await User.findOne({ email: emailNorm });
+ if (existing) {
+ return res.status(409).json({ message: "Cet email est déjà utilisé" });
+ }
+
+ // Create the admin user
+ const hashedPassword = await bcrypt.hash(password, 10);
+ const adminUser = new User({
+ name: nameNorm,
+ email: emailNorm,
+ password: hashedPassword,
+ role: 69, // Admin role
+ mustChangePassword: false,
+ });
+
+ await adminUser.save();
+
+ console.log(`[setup] First admin user created: ${emailNorm}`);
+
+ // Return a token so the admin is logged in immediately
+ const token = signUserToken(adminUser);
+
+ return res.status(201).json({
+ message: "Administrateur créé avec succès",
+ token,
+ });
+ } catch (err) {
+ return res.status(500).json({ error: err.message });
+ }
+});
+
// Get user stats with watched movie titles and details from the movies collection
router.get("/stats", verifyToken, async (req, res) => {
try {