Add movie runtime, bulk OMDb refresh, and first-time setup

- Add runtime field to Movie model and display formatted duration (e.g., 2h 22min) on movie cards
- Add bulk "Refresh OMDb" button in admin panel for selected movies
- Add runtime field to movie edit modal in admin panel
- Implement first-time setup flow: show admin creation form on login page when no admin exists
- Block registration page until initial setup is complete

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
Mathieu Moran
2026-02-01 10:08:15 -05:00
co-authored by Claude Opus 4.5
parent 087f4f4023
commit 6186f3a051
10 changed files with 391 additions and 54 deletions
+1
View File
@@ -5,6 +5,7 @@ const movieSchema = new mongoose.Schema({
title: { type: String, required: false },
description: { type: String, required: false },
rating: { type: String, required: false },
runtime: { type: String, required: false },
poster: { type: String, required: false },
cam: { type: Boolean, required: false, default: false },
// Oscar pool year / edition (used to segment movies + stats)
+9 -3
View File
@@ -645,6 +645,7 @@
<div class="col-12 col-md-8 col-xl-9 d-flex gap-2 justify-content-end">
<button type="button" id="refresh-movies" class="btn btn-outline-primary btn-compact">Rafraîchir</button>
<button type="button" id="update-omdb-selected" class="btn btn-outline-warning btn-compact" disabled>Refresh OMDb</button>
<button type="button" id="delete-selected" class="btn btn-danger btn-compact" disabled>Supprimer</button>
</div>
@@ -1255,11 +1256,16 @@
<input type="text" class="form-control" id="edit_category" placeholder="Best Picture, etc." required>
</div>
<div class="col-md-6">
<div class="col-md-4">
<label for="edit_rating" class="form-label">Rating (imdbRating)</label>
<input type="text" class="form-control" id="edit_rating" placeholder="7.9">
</div>
<div class="col-md-6 d-flex align-items-end">
<div class="col-md-4">
<label for="edit_runtime" class="form-label">Runtime</label>
<input type="text" class="form-control" id="edit_runtime" placeholder="142 min">
<div class="form-text">Ex: 142 min</div>
</div>
<div class="col-md-4 d-flex align-items-end">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit_cam_flag">
<label class="form-check-label" for="edit_cam_flag">Tag CAM</label>
@@ -1270,7 +1276,7 @@
<div class="form-check">
<input class="form-check-input" type="checkbox" id="edit_refresh_omdb">
<label class="form-check-label" for="edit_refresh_omdb">
Recharger depuis OMDb (écrase titre / plot / rating / poster)
Recharger depuis OMDb (écrase titre / plot / rating / runtime / poster)
</label>
</div>
<div class="form-text">Si coché, le serveur refait un appel OMDb via l’IMDB ID au moment de l’enregistrement.</div>
+34 -1
View File
@@ -47,7 +47,40 @@
</button>
<div class="container">
<div class="card p-4" style="max-width: 400px; margin: auto;">
<!-- First-time setup card (hidden by default) -->
<div id="setup-card" class="card p-4 d-none" style="max-width: 450px; margin: auto;">
<div class="text-center mb-3">
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
</div>
<div class="alert alert-info mb-3">
<strong>Première installation</strong><br>
Aucun administrateur n'existe. Créez le premier compte admin pour commencer.
</div>
<form id="setup-form">
<div class="mb-3">
<label for="setup-name" class="form-label">Nom</label>
<input type="text" class="form-control" id="setup-name" placeholder="Votre nom" required>
</div>
<div class="mb-3">
<label for="setup-email" class="form-label">Courriel</label>
<input type="email" class="form-control" id="setup-email" placeholder="[email protected]" required>
</div>
<div class="mb-3">
<label for="setup-password" class="form-label">Mot de passe</label>
<input type="password" class="form-control" id="setup-password" minlength="6" required>
<div class="form-text">Minimum 6 caractères</div>
</div>
<div class="mb-3">
<label for="setup-password-confirm" class="form-label">Confirmer le mot de passe</label>
<input type="password" class="form-control" id="setup-password-confirm" minlength="6" required>
</div>
<div id="setup-error" class="alert alert-danger d-none" role="alert"></div>
<button type="submit" class="btn btn-warning w-100">Créer le compte admin</button>
</form>
</div>
<!-- Normal login card -->
<div id="login-card" class="card p-4" style="max-width: 400px; margin: auto;">
<div class="text-center mb-4">
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
</div>
+65
View File
@@ -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);
}
+84 -1
View File
@@ -22,7 +22,32 @@ async function applyActiveOscarYearToTitle() {
// Fire and forget.
applyActiveOscarYearToTitle();
// Check if the user is already logged in by looking for auth_token in localStorage
// Check setup status first
async function checkSetupStatus() {
try {
const res = await fetch('/api/users/setup/status', { method: 'GET', cache: 'no-store' });
if (!res.ok) return { needsSetup: false };
return await res.json();
} catch (_) {
return { needsSetup: false };
}
}
async function initPage() {
const setupCard = document.getElementById('setup-card');
const loginCard = document.getElementById('login-card');
// Check if setup is needed
const status = await checkSetupStatus();
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 {
@@ -32,13 +57,71 @@ if (existingToken) {
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 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) {
+15
View File
@@ -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 () {
: `<span class="text-dark">${movie.title}</span>`
}
</h5>
${movie.runtime && movie.runtime !== 'N/A' ? `<div class="text-muted small mb-1">${formatRuntime(movie.runtime)}</div>` : ''}
<div class="movie-rating-block mt-1">
<div class="movie-rating-sub movie-rating-avg${userRatingText ? '' : ' movie-rating-sub--empty'}" data-role="avg-rating">${userRatingText ? `Note utilisateurs ${userRatingText}` : ''}</div>
</div>
+31
View File
@@ -22,6 +22,31 @@ async function applyActiveOscarYearToTitle() {
// Fire and forget.
applyActiveOscarYearToTitle();
// Check setup status
async function checkSetupStatus() {
try {
const res = await fetch('/api/users/setup/status', { method: 'GET', cache: 'no-store' });
if (!res.ok) return { needsSetup: false };
return await res.json();
} catch (_) {
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) {
@@ -32,13 +57,19 @@ if (existingToken) {
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) {
+14 -1
View File
@@ -40,7 +40,20 @@
</button>
<div class="container d-flex justify-content-center align-items-center vh-100">
<div class="card p-4" style="max-width: 400px;">
<!-- Setup needed banner (hidden by default) -->
<div id="setup-needed-card" class="card p-4 d-none" style="max-width: 400px;">
<div class="text-center mb-4">
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
</div>
<div class="alert alert-warning mb-3">
<strong>Configuration requise</strong><br>
L'application doit d'abord être configurée par un administrateur.
</div>
<a href="/" class="btn btn-primary w-100">Aller à la page de configuration</a>
</div>
<!-- Normal registration card -->
<div id="register-card" class="card p-4" style="max-width: 400px;">
<div class="text-center mb-4">
<img src="img/logo.png" class="logo-invert" style="width: 80%;" alt="Pool Oscars">
</div>
+7 -1
View File
@@ -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;
}
+84
View File
@@ -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 {