From 27f553b00503cafa4ddfd785359cd4f4da13cf93 Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Sat, 21 Mar 2026 07:23:38 +0100 Subject: [PATCH] feat: add rescan job type and improve full rebuild UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add "Deep rescan" job type that clears directory mtimes to force re-walking all directories, discovering newly supported formats (e.g. EPUB) without deleting existing data or metadata. Also improve full rebuild button: red destructive styling instead of warning, and FR description explicitly mentions metadata/reading status loss. Rename FR rebuild label to "Mise à jour". Co-Authored-By: Claude Opus 4.6 --- apps/api/src/index_jobs.rs | 7 ++++- apps/api/src/libraries.rs | 3 ++- apps/backoffice/app/components/ui/Badge.tsx | 2 ++ apps/backoffice/app/jobs/[id]/page.tsx | 5 ++++ apps/backoffice/app/jobs/page.tsx | 26 ++++++++++++++++--- apps/backoffice/lib/api.ts | 5 ++-- apps/backoffice/lib/i18n/en.ts | 6 +++++ apps/backoffice/lib/i18n/fr.ts | 12 ++++++--- apps/indexer/src/job.rs | 25 +++++++++++++++--- infra/migrations/0047_add_rescan_job_type.sql | 7 +++++ 10 files changed, 84 insertions(+), 14 deletions(-) create mode 100644 infra/migrations/0047_add_rescan_job_type.sql diff --git a/apps/api/src/index_jobs.rs b/apps/api/src/index_jobs.rs index 8019615..143d6eb 100644 --- a/apps/api/src/index_jobs.rs +++ b/apps/api/src/index_jobs.rs @@ -16,6 +16,10 @@ pub struct RebuildRequest { pub library_id: Option, #[schema(value_type = Option, example = false)] pub full: Option, + /// Deep rescan: clears directory mtimes to force re-walking all directories, + /// discovering newly supported formats without deleting existing data. + #[schema(value_type = Option, example = false)] + pub rescan: Option, } #[derive(Serialize, ToSchema)] @@ -117,7 +121,8 @@ pub async fn enqueue_rebuild( ) -> Result, ApiError> { let library_id = payload.as_ref().and_then(|p| p.0.library_id); let is_full = payload.as_ref().and_then(|p| p.0.full).unwrap_or(false); - let job_type = if is_full { "full_rebuild" } else { "rebuild" }; + let is_rescan = payload.as_ref().and_then(|p| p.0.rescan).unwrap_or(false); + let job_type = if is_full { "full_rebuild" } else if is_rescan { "rescan" } else { "rebuild" }; let id = Uuid::new_v4(); sqlx::query( diff --git a/apps/api/src/libraries.rs b/apps/api/src/libraries.rs index 0ec24ec..8b5c147 100644 --- a/apps/api/src/libraries.rs +++ b/apps/api/src/libraries.rs @@ -219,7 +219,8 @@ pub async fn scan_library( } let is_full = payload.as_ref().and_then(|p| p.full).unwrap_or(false); - let job_type = if is_full { "full_rebuild" } else { "rebuild" }; + let is_rescan = payload.as_ref().and_then(|p| p.rescan).unwrap_or(false); + let job_type = if is_full { "full_rebuild" } else if is_rescan { "rescan" } else { "rebuild" }; // Create indexing job for this library let job_id = Uuid::new_v4(); diff --git a/apps/backoffice/app/components/ui/Badge.tsx b/apps/backoffice/app/components/ui/Badge.tsx index a43fa65..0153204 100644 --- a/apps/backoffice/app/components/ui/Badge.tsx +++ b/apps/backoffice/app/components/ui/Badge.tsx @@ -93,6 +93,7 @@ export function StatusBadge({ status, className = "" }: StatusBadgeProps) { // Job type badge const jobTypeVariants: Record = { rebuild: "primary", + rescan: "primary", full_rebuild: "warning", thumbnail_rebuild: "secondary", thumbnail_regenerate: "warning", @@ -109,6 +110,7 @@ export function JobTypeBadge({ type, className = "" }: JobTypeBadgeProps) { const variant = jobTypeVariants[key] || "default"; const jobTypeLabels: Record = { rebuild: t("jobType.rebuild"), + rescan: t("jobType.rescan"), full_rebuild: t("jobType.full_rebuild"), thumbnail_rebuild: t("jobType.thumbnail_rebuild"), thumbnail_regenerate: t("jobType.thumbnail_regenerate"), diff --git a/apps/backoffice/app/jobs/[id]/page.tsx b/apps/backoffice/app/jobs/[id]/page.tsx index 95b371d..80e7c08 100644 --- a/apps/backoffice/app/jobs/[id]/page.tsx +++ b/apps/backoffice/app/jobs/[id]/page.tsx @@ -102,6 +102,11 @@ export default async function JobDetailPage({ params }: JobDetailPageProps) { description: t("jobType.full_rebuildDesc"), isThumbnailOnly: false, }, + rescan: { + label: t("jobType.rescanLabel"), + description: t("jobType.rescanDesc"), + isThumbnailOnly: false, + }, thumbnail_rebuild: { label: t("jobType.thumbnail_rebuildLabel"), description: t("jobType.thumbnail_rebuildDesc"), diff --git a/apps/backoffice/app/jobs/page.tsx b/apps/backoffice/app/jobs/page.tsx index 056120d..4c27aed 100644 --- a/apps/backoffice/app/jobs/page.tsx +++ b/apps/backoffice/app/jobs/page.tsx @@ -33,6 +33,14 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise redirect(`/jobs?highlight=${result.id}`); } + async function triggerRescan(formData: FormData) { + "use server"; + const libraryId = formData.get("library_id") as string; + const result = await rebuildIndex(libraryId || undefined, false, true); + revalidatePath("/jobs"); + redirect(`/jobs?highlight=${result.id}`); + } + async function triggerThumbnailsRebuild(formData: FormData) { "use server"; const libraryId = formData.get("library_id") as string; @@ -127,13 +135,23 @@ export default async function JobsPage({ searchParams }: { searchParams: Promise

{t("jobs.rebuildShort")}

- + diff --git a/apps/backoffice/lib/api.ts b/apps/backoffice/lib/api.ts index 4109fbd..f772832 100644 --- a/apps/backoffice/lib/api.ts +++ b/apps/backoffice/lib/api.ts @@ -221,10 +221,11 @@ export async function listJobs() { return apiFetch("/index/status"); } -export async function rebuildIndex(libraryId?: string, full?: boolean) { - const body: { library_id?: string; full?: boolean } = {}; +export async function rebuildIndex(libraryId?: string, full?: boolean, rescan?: boolean) { + const body: { library_id?: string; full?: boolean; rescan?: boolean } = {}; if (libraryId) body.library_id = libraryId; if (full) body.full = true; + if (rescan) body.rescan = true; return apiFetch("/index/rebuild", { method: "POST", body: JSON.stringify(body), diff --git a/apps/backoffice/lib/i18n/en.ts b/apps/backoffice/lib/i18n/en.ts index 0039083..16ae40f 100644 --- a/apps/backoffice/lib/i18n/en.ts +++ b/apps/backoffice/lib/i18n/en.ts @@ -186,6 +186,7 @@ const en: Record = { "jobs.startJobDescription": "Select a library (or all) and choose the action to perform.", "jobs.allLibraries": "All libraries", "jobs.rebuild": "Rebuild", + "jobs.rescan": "Deep rescan", "jobs.fullRebuild": "Full rebuild", "jobs.generateThumbnails": "Generate thumbnails", "jobs.regenerateThumbnails": "Regenerate thumbnails", @@ -198,12 +199,14 @@ const en: Record = { "jobs.groupMetadata": "Metadata", "jobs.requiresLibrary": "Requires a specific library", "jobs.rebuildShort": "Scan new & modified files", + "jobs.rescanShort": "Re-walk all directories to discover new formats", "jobs.fullRebuildShort": "Delete all & re-scan from scratch", "jobs.generateThumbnailsShort": "Missing thumbnails only", "jobs.regenerateThumbnailsShort": "Recreate all thumbnails", "jobs.batchMetadataShort": "Auto-match unlinked series", "jobs.refreshMetadataShort": "Update existing linked series", "jobs.rebuildDescription": "Incremental scan: detects files added, modified, or deleted since the last scan, indexes them, and generates missing thumbnails. Existing unmodified data is preserved. This is the most common and fastest action.", + "jobs.rescanDescription": "Re-walks all directories regardless of whether they changed, discovering files in newly supported formats (e.g. EPUB). Existing books and metadata are fully preserved — only genuinely new files are added. Slower than a rebuild but safe for your data.", "jobs.fullRebuildDescription": "Deletes all indexed data (books, series, thumbnails) then performs a full scan from scratch. Useful if the database is out of sync or corrupted. Long and destructive operation: reading statuses and manual metadata will be lost.", "jobs.generateThumbnailsDescription": "Generates thumbnails only for books that don't have one yet. Existing thumbnails are not affected. Useful after an import or if some thumbnails are missing.", "jobs.regenerateThumbnailsDescription": "Regenerates all thumbnails from scratch, replacing existing ones. Useful if thumbnail quality or size has changed in the configuration, or if thumbnails are corrupted.", @@ -310,6 +313,7 @@ const en: Record = { // Job types "jobType.rebuild": "Indexing", + "jobType.rescan": "Deep rescan", "jobType.full_rebuild": "Full indexing", "jobType.thumbnail_rebuild": "Thumbnails", "jobType.thumbnail_regenerate": "Regen. thumbnails", @@ -318,6 +322,8 @@ const en: Record = { "jobType.metadata_refresh": "Refresh meta.", "jobType.rebuildLabel": "Incremental indexing", "jobType.rebuildDesc": "Scans new/modified files, analyzes them, and generates missing thumbnails.", + "jobType.rescanLabel": "Deep rescan", + "jobType.rescanDesc": "Re-walks all directories to discover files in newly supported formats (e.g. EPUB). Existing data is preserved — only new files are added.", "jobType.full_rebuildLabel": "Full reindexing", "jobType.full_rebuildDesc": "Deletes all existing data then performs a full scan, re-analysis, and thumbnail generation.", "jobType.thumbnail_rebuildLabel": "Thumbnail rebuild", diff --git a/apps/backoffice/lib/i18n/fr.ts b/apps/backoffice/lib/i18n/fr.ts index cf3fc6b..951610f 100644 --- a/apps/backoffice/lib/i18n/fr.ts +++ b/apps/backoffice/lib/i18n/fr.ts @@ -183,8 +183,9 @@ const fr = { "jobs.startJob": "Lancer une tâche", "jobs.startJobDescription": "Sélectionnez une bibliothèque (ou toutes) et choisissez l'action à effectuer.", "jobs.allLibraries": "Toutes les bibliothèques", - "jobs.rebuild": "Reconstruction", - "jobs.fullRebuild": "Reconstruction complète", + "jobs.rebuild": "Mise à jour", + "jobs.rescan": "Rescan complet", + "jobs.fullRebuild": "Reconstruction complète (destructif)", "jobs.generateThumbnails": "Générer les miniatures", "jobs.regenerateThumbnails": "Regénérer les miniatures", "jobs.batchMetadata": "Métadonnées en lot", @@ -196,12 +197,14 @@ const fr = { "jobs.groupMetadata": "Métadonnées", "jobs.requiresLibrary": "Requiert une bibliothèque spécifique", "jobs.rebuildShort": "Scanner les fichiers nouveaux et modifiés", - "jobs.fullRebuildShort": "Tout supprimer et re-scanner depuis zéro", + "jobs.rescanShort": "Re-parcourir tous les dossiers pour découvrir de nouveaux formats", + "jobs.fullRebuildShort": "Tout supprimer et re-scanner depuis zéro. Les métadonnées, statuts de lecture et liens seront perdus.", "jobs.generateThumbnailsShort": "Miniatures manquantes uniquement", "jobs.regenerateThumbnailsShort": "Recréer toutes les miniatures", "jobs.batchMetadataShort": "Lier automatiquement les séries non liées", "jobs.refreshMetadataShort": "Mettre à jour les séries déjà liées", "jobs.rebuildDescription": "Scan incrémental : détecte les fichiers ajoutés, modifiés ou supprimés depuis le dernier scan, les indexe et génère les miniatures manquantes. Les données existantes non modifiées sont conservées. C'est l'action la plus courante et la plus rapide.", + "jobs.rescanDescription": "Re-parcourt tous les dossiers même s'ils n'ont pas changé, pour découvrir les fichiers dans les formats nouvellement supportés (ex. EPUB). Les livres et métadonnées existants sont entièrement préservés — seuls les fichiers réellement nouveaux sont ajoutés. Plus lent qu'un rebuild mais sans risque pour vos données.", "jobs.fullRebuildDescription": "Supprime toutes les données indexées (livres, séries, miniatures) puis effectue un scan complet depuis zéro. Utile si la base de données est désynchronisée ou corrompue. Opération longue et destructive : les statuts de lecture et les métadonnées manuelles seront perdus.", "jobs.generateThumbnailsDescription": "Génère les miniatures uniquement pour les livres qui n'en ont pas encore. Les miniatures existantes ne sont pas touchées. Utile après un import ou si certaines miniatures sont manquantes.", "jobs.regenerateThumbnailsDescription": "Regénère toutes les miniatures depuis zéro, en remplaçant les existantes. Utile si la qualité ou la taille des miniatures a changé dans la configuration, ou si des miniatures sont corrompues.", @@ -308,6 +311,7 @@ const fr = { // Job types "jobType.rebuild": "Indexation", + "jobType.rescan": "Rescan complet", "jobType.full_rebuild": "Indexation complète", "jobType.thumbnail_rebuild": "Miniatures", "jobType.thumbnail_regenerate": "Régén. miniatures", @@ -316,6 +320,8 @@ const fr = { "jobType.metadata_refresh": "Rafraîchir méta.", "jobType.rebuildLabel": "Indexation incrémentale", "jobType.rebuildDesc": "Scanne les fichiers nouveaux/modifiés, les analyse et génère les miniatures manquantes.", + "jobType.rescanLabel": "Rescan complet", + "jobType.rescanDesc": "Re-parcourt tous les dossiers pour découvrir les fichiers dans les formats nouvellement supportés (ex. EPUB). Les données existantes sont préservées — seuls les nouveaux fichiers sont ajoutés.", "jobType.full_rebuildLabel": "Réindexation complète", "jobType.full_rebuildDesc": "Supprime toutes les données existantes puis effectue un scan complet, une ré-analyse et la génération des miniatures.", "jobType.thumbnail_rebuildLabel": "Reconstruction des miniatures", diff --git a/apps/indexer/src/job.rs b/apps/indexer/src/job.rs index 429856b..27dc92a 100644 --- a/apps/indexer/src/job.rs +++ b/apps/indexer/src/job.rs @@ -43,6 +43,7 @@ const API_ONLY_JOB_TYPES: &[&str] = &["metadata_batch", "metadata_refresh"]; const EXCLUSIVE_JOB_TYPES: &[&str] = &[ "rebuild", "full_rebuild", + "rescan", "scan", "thumbnail_rebuild", "thumbnail_regenerate", @@ -211,11 +212,29 @@ pub async fn process_job( } let is_full_rebuild = job_type == "full_rebuild"; + let is_rescan = job_type == "rescan"; info!( - "[JOB] {} type={} full_rebuild={}", - job_id, job_type, is_full_rebuild + "[JOB] {} type={} full_rebuild={} rescan={}", + job_id, job_type, is_full_rebuild, is_rescan ); + // Rescan: clear directory mtimes to force re-walking all directories, + // but keep existing data intact (unlike full_rebuild) + if is_rescan { + if let Some(library_id) = target_library_id { + let _ = sqlx::query("DELETE FROM directory_mtimes WHERE library_id = $1") + .bind(library_id) + .execute(&state.pool) + .await; + info!("[JOB] Rescan: cleared directory mtimes for library {}", library_id); + } else { + let _ = sqlx::query("DELETE FROM directory_mtimes") + .execute(&state.pool) + .await; + info!("[JOB] Rescan: cleared all directory mtimes"); + } + } + // Full rebuild: delete existing data first if is_full_rebuild { info!("[JOB] Full rebuild: deleting existing data"); @@ -258,7 +277,7 @@ pub async fn process_job( // For full rebuilds, the DB is already cleared, so we must walk the filesystem. let library_ids: Vec = libraries.iter().map(|r| r.get("id")).collect(); - let total_files: usize = if !is_full_rebuild { + let total_files: usize = if !is_full_rebuild && !is_rescan { let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM book_files bf JOIN books b ON b.id = bf.book_id WHERE b.library_id = ANY($1)" ) diff --git a/infra/migrations/0047_add_rescan_job_type.sql b/infra/migrations/0047_add_rescan_job_type.sql new file mode 100644 index 0000000..442b4f0 --- /dev/null +++ b/infra/migrations/0047_add_rescan_job_type.sql @@ -0,0 +1,7 @@ +-- Add rescan job type: clears directory mtimes to force re-walking all directories +-- while preserving existing data (unlike full_rebuild which deletes everything). +-- Useful for discovering newly supported formats (e.g. EPUB) without losing metadata. +ALTER TABLE index_jobs + DROP CONSTRAINT IF EXISTS index_jobs_type_check, + ADD CONSTRAINT index_jobs_type_check + CHECK (type IN ('scan', 'rebuild', 'full_rebuild', 'rescan', 'thumbnail_rebuild', 'thumbnail_regenerate', 'cbr_to_cbz', 'metadata_batch', 'metadata_refresh'));