Compare commits
2 Commits
ed7665248e
...
560087a897
| Author | SHA1 | Date | |
|---|---|---|---|
| 560087a897 | |||
| 27f553b005 |
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -64,7 +64,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "api"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -1232,7 +1232,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "indexer"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -1771,7 +1771,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "parsers"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"flate2",
|
||||
@@ -2906,7 +2906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "stripstream-core"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"serde",
|
||||
|
||||
@@ -9,7 +9,7 @@ resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
|
||||
@@ -16,6 +16,10 @@ pub struct RebuildRequest {
|
||||
pub library_id: Option<Uuid>,
|
||||
#[schema(value_type = Option<bool>, example = false)]
|
||||
pub full: Option<bool>,
|
||||
/// Deep rescan: clears directory mtimes to force re-walking all directories,
|
||||
/// discovering newly supported formats without deleting existing data.
|
||||
#[schema(value_type = Option<bool>, example = false)]
|
||||
pub rescan: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@@ -117,7 +121,8 @@ pub async fn enqueue_rebuild(
|
||||
) -> Result<Json<IndexJobResponse>, 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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -93,6 +93,7 @@ export function StatusBadge({ status, className = "" }: StatusBadgeProps) {
|
||||
// Job type badge
|
||||
const jobTypeVariants: Record<string, BadgeVariant> = {
|
||||
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<string, string> = {
|
||||
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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.rebuildShort")}</p>
|
||||
</button>
|
||||
<button type="submit" formAction={triggerFullRebuild}
|
||||
className="w-full text-left rounded-lg border border-warning/30 bg-warning/5 p-3 hover:bg-warning/10 transition-colors group cursor-pointer">
|
||||
<button type="submit" formAction={triggerRescan}
|
||||
className="w-full text-left rounded-lg border border-input bg-background p-3 hover:bg-accent/50 transition-colors group cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-warning shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 text-primary shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<span className="font-medium text-sm text-foreground">{t("jobs.rescan")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.rescanShort")}</p>
|
||||
</button>
|
||||
<button type="submit" formAction={triggerFullRebuild}
|
||||
className="w-full text-left rounded-lg border border-destructive/30 bg-destructive/5 p-3 hover:bg-destructive/10 transition-colors group cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-destructive shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="font-medium text-sm text-warning">{t("jobs.fullRebuild")}</span>
|
||||
<span className="font-medium text-sm text-destructive">{t("jobs.fullRebuild")}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1 ml-6">{t("jobs.fullRebuildShort")}</p>
|
||||
</button>
|
||||
|
||||
@@ -221,10 +221,11 @@ export async function listJobs() {
|
||||
return apiFetch<IndexJobDto[]>("/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<IndexJobDto>("/index/rebuild", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
|
||||
@@ -186,6 +186,7 @@ const en: Record<TranslationKey, string> = {
|
||||
"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<TranslationKey, string> = {
|
||||
"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<TranslationKey, string> = {
|
||||
|
||||
// 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<TranslationKey, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "stripstream-backoffice",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 7082",
|
||||
|
||||
@@ -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<uuid::Uuid> = 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)"
|
||||
)
|
||||
|
||||
7
infra/migrations/0047_add_rescan_job_type.sql
Normal file
7
infra/migrations/0047_add_rescan_job_type.sql
Normal file
@@ -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'));
|
||||
Reference in New Issue
Block a user