diff --git a/src/scanner/library_scanner.rs b/src/scanner/library_scanner.rs index 587cd79b..a4c5c155 100644 --- a/src/scanner/library_scanner.rs +++ b/src/scanner/library_scanner.rs @@ -18,7 +18,7 @@ use crate::db::entities::{books, series}; use crate::db::repositories::{ BookRepository, LibraryRepository, SeriesRepository, TaskRepository, }; -use crate::events::EventBroadcaster; +use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::models::SeriesStrategy; use crate::tasks::types::TaskType; @@ -154,16 +154,29 @@ struct SharedScanState { result: Arc>, /// Progress channel sender progress_tx: Option>, + /// Optional task id + broadcaster for emitting TaskProgressEvent + task_id: Option, + library_name: String, + event_broadcaster: Option>, } impl SharedScanState { - fn new(library_id: Uuid, progress_tx: Option>) -> Self { + fn new( + library_id: Uuid, + library_name: String, + progress_tx: Option>, + task_id: Option, + event_broadcaster: Option>, + ) -> Self { let mut progress = ScanProgress::new(library_id); progress.start(); Self { progress: Arc::new(Mutex::new(progress)), result: Arc::new(Mutex::new(ScanResult::new())), progress_tx, + task_id, + library_name, + event_broadcaster, } } @@ -199,13 +212,39 @@ impl SharedScanState { } } - /// Send current progress through the channel + /// Send current progress through the channel and emit a TaskProgressEvent + /// if a task id and broadcaster are available. async fn send_progress(&self) { - if let Some(ref tx) = self.progress_tx { - let progress = self.progress.lock().await.clone(); - if let Err(e) = tx.send(progress).await { - warn!("Failed to send progress update: {}", e); - } + let progress = self.progress.lock().await.clone(); + + if let (Some(task_id), Some(broadcaster)) = (self.task_id, self.event_broadcaster.as_ref()) + { + let total = progress.files_total; + let current = progress.files_processed.min(total.max(1)); + let message = if total == 0 { + format!("Scanning {} (discovering files…)", self.library_name) + } else { + format!( + "Scanning {} ({}/{} files, {} series, {} books)", + self.library_name, current, total, progress.series_found, progress.books_found, + ) + }; + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task_id, + "scan_library", + current, + total.max(current), + Some(message), + Some(progress.library_id), + None, + None, + )); + } + + if let Some(ref tx) = self.progress_tx + && let Err(e) = tx.send(progress).await + { + warn!("Failed to send progress update: {}", e); } } @@ -244,6 +283,9 @@ impl Clone for SharedScanState { progress: Arc::clone(&self.progress), result: Arc::clone(&self.result), progress_tx: self.progress_tx.clone(), + task_id: self.task_id, + library_name: self.library_name.clone(), + event_broadcaster: self.event_broadcaster.clone(), } } } @@ -376,6 +418,7 @@ pub async fn scan_library( mode: ScanMode, progress_tx: Option>, event_broadcaster: Option<&Arc>, + task_id: Option, ) -> Result { let scan_start = Instant::now(); info!("Starting {} scan for library {}", mode, library_id); @@ -401,7 +444,15 @@ pub async fn scan_library( // Execute optimized batched scan (handles both Normal and Deep modes) // The batched scan manages its own progress tracking internally - let result = scan_batched(db, &library, mode, progress_tx.clone(), event_broadcaster).await; + let result = scan_batched( + db, + &library, + mode, + progress_tx.clone(), + event_broadcaster, + task_id, + ) + .await; // Send final progress update let mut final_progress = ScanProgress::new(library_id); @@ -482,6 +533,7 @@ async fn scan_batched( mode: ScanMode, progress_tx: Option>, event_broadcaster: Option<&Arc>, + task_id: Option, ) -> Result { // Load scanner configuration from database settings let config = ScannerConfig::load(db).await; @@ -491,7 +543,13 @@ async fn scan_batched( ); // Create shared state for thread-safe progress tracking - let shared_state = SharedScanState::new(library.id, progress_tx); + let shared_state = SharedScanState::new( + library.id, + library.name.clone(), + progress_tx, + task_id, + event_broadcaster.cloned(), + ); // Always load existing books from database // - Normal mode: used for change detection (skip unchanged files) diff --git a/src/tasks/handlers/analyze_series.rs b/src/tasks/handlers/analyze_series.rs index ea907aeb..92557760 100644 --- a/src/tasks/handlers/analyze_series.rs +++ b/src/tasks/handlers/analyze_series.rs @@ -6,7 +6,7 @@ use tracing::{error, info}; use crate::db::entities::tasks; use crate::db::repositories::{BookRepository, TaskRepository}; -use crate::events::EventBroadcaster; +use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; @@ -29,7 +29,7 @@ impl TaskHandler for AnalyzeSeriesHandler { &'a self, task: &'a tasks::Model, db: &'a DatabaseConnection, - _event_broadcaster: Option<&'a Arc>, + event_broadcaster: Option<&'a Arc>, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { let series_id = task @@ -53,11 +53,24 @@ impl TaskHandler for AnalyzeSeriesHandler { )); } + if let Some(broadcaster) = event_broadcaster { + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "analyze_series", + 0, + total_books, + Some(format!("Enqueueing analysis for {} book(s)", total_books)), + task.library_id, + Some(series_id), + None, + )); + } + // Enqueue individual AnalyzeBook tasks with force=true let mut enqueued = 0; let mut errors = Vec::new(); - for book in books { + for (idx, book) in books.iter().enumerate() { match TaskRepository::enqueue( db, TaskType::AnalyzeBook { @@ -75,6 +88,25 @@ impl TaskHandler for AnalyzeSeriesHandler { errors.push(err_msg); } } + + if let Some(broadcaster) = event_broadcaster { + let current = idx + 1; + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "analyze_series", + current, + total_books, + Some(format!( + "Enqueueing analysis ({}/{}, {} failed)", + current, + total_books, + errors.len() + )), + task.library_id, + Some(series_id), + Some(book.id), + )); + } } info!( diff --git a/src/tasks/handlers/cleanup_series_exports.rs b/src/tasks/handlers/cleanup_series_exports.rs index 3d764f3b..44ea86a8 100644 --- a/src/tasks/handlers/cleanup_series_exports.rs +++ b/src/tasks/handlers/cleanup_series_exports.rs @@ -13,7 +13,7 @@ use tracing::{info, warn}; use crate::db::entities::tasks; use crate::db::repositories::SeriesExportRepository; -use crate::events::EventBroadcaster; +use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::services::SettingsService; use crate::services::export_storage::ExportStorage; use crate::tasks::handlers::TaskHandler; @@ -41,12 +41,32 @@ impl TaskHandler for CleanupSeriesExportsHandler { &'a self, task: &'a tasks::Model, db: &'a DatabaseConnection, - _event_broadcaster: Option<&'a Arc>, + event_broadcaster: Option<&'a Arc>, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { let task_id = task.id; info!("Task {task_id}: Starting series exports cleanup"); + // Three sequential phases: expired → stale tmp → cap evict. + // We expose them as 3 high-level steps so the user always sees progress + // even when individual phases are short. + const PHASE_TOTAL: usize = 3; + + let emit = |current: usize, message: String| { + if let Some(broadcaster) = event_broadcaster { + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "cleanup_series_exports", + current, + PHASE_TOTAL, + Some(message), + None, + None, + None, + )); + } + }; + let now = Utc::now(); let mut expired_count = 0u64; let mut stale_tmp_count = 0u64; @@ -54,6 +74,7 @@ impl TaskHandler for CleanupSeriesExportsHandler { // 1. Delete expired exports (completed + expires_at < now) let expired = SeriesExportRepository::list_expired(db, now).await?; + emit(0, format!("Deleting {} expired export(s)", expired.len())); for export in &expired { // Delete file let _ = self @@ -74,6 +95,10 @@ impl TaskHandler for CleanupSeriesExportsHandler { if expired_count > 0 { info!("Task {task_id}: Deleted {expired_count} expired exports"); } + emit( + 1, + format!("Sweeping stale .tmp files (deleted {expired_count} expired)"), + ); // 2. Sweep stale .tmp files (older than 1 hour) let stale_duration = std::time::Duration::from_secs(3600); @@ -102,6 +127,13 @@ impl TaskHandler for CleanupSeriesExportsHandler { } } + emit( + 2, + format!( + "Enforcing storage cap ({expired_count} expired, {stale_tmp_count} stale removed)" + ), + ); + // 3. Enforce global storage cap let cap_bytes = self .settings_service @@ -157,6 +189,13 @@ impl TaskHandler for CleanupSeriesExportsHandler { } } + emit( + PHASE_TOTAL, + format!( + "Cleanup complete ({expired_count} expired, {stale_tmp_count} stale, {cap_evicted_count} evicted)" + ), + ); + let total_cleaned = expired_count + stale_tmp_count + cap_evicted_count; let message = if total_cleaned == 0 { "No exports needed cleanup".to_string() diff --git a/src/tasks/handlers/generate_series_thumbnails.rs b/src/tasks/handlers/generate_series_thumbnails.rs index fe0650bc..8feb962e 100644 --- a/src/tasks/handlers/generate_series_thumbnails.rs +++ b/src/tasks/handlers/generate_series_thumbnails.rs @@ -11,7 +11,7 @@ use tracing::{debug, info, warn}; use crate::db::entities::tasks; use crate::db::repositories::{SeriesRepository, TaskRepository}; -use crate::events::EventBroadcaster; +use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; @@ -31,7 +31,7 @@ impl TaskHandler for GenerateSeriesThumbnailsHandler { &'a self, task: &'a tasks::Model, db: &'a DatabaseConnection, - _event_broadcaster: Option<&'a Arc>, + event_broadcaster: Option<&'a Arc>, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { info!( @@ -114,11 +114,27 @@ impl TaskHandler for GenerateSeriesThumbnailsHandler { )); } + if let Some(broadcaster) = event_broadcaster { + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "generate_series_thumbnails", + 0, + to_process, + Some(format!( + "Enqueueing series thumbnail tasks for {} series ({} skipped)", + to_process, skipped + )), + library_id, + None, + None, + )); + } + // Enqueue individual GenerateSeriesThumbnail tasks for each series let mut enqueued = 0; let mut errors = Vec::new(); - for series in series_to_process { + for (idx, series) in series_to_process.iter().enumerate() { let task_type = TaskType::GenerateSeriesThumbnail { series_id: series.id, force, @@ -141,6 +157,25 @@ impl TaskHandler for GenerateSeriesThumbnailsHandler { errors.push(error_msg); } } + + if let Some(broadcaster) = event_broadcaster { + let current = idx + 1; + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "generate_series_thumbnails", + current, + to_process, + Some(format!( + "Enqueueing series thumbnail tasks ({}/{}, {} failed)", + current, + to_process, + errors.len() + )), + library_id, + Some(series.id), + None, + )); + } } info!( diff --git a/src/tasks/handlers/generate_thumbnails.rs b/src/tasks/handlers/generate_thumbnails.rs index 4648b0c8..cc3ea70b 100644 --- a/src/tasks/handlers/generate_thumbnails.rs +++ b/src/tasks/handlers/generate_thumbnails.rs @@ -5,7 +5,7 @@ use tracing::{debug, info, warn}; use crate::db::entities::tasks; use crate::db::repositories::{BookRepository, TaskRepository}; -use crate::events::EventBroadcaster; +use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::services::ThumbnailService; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; @@ -25,7 +25,7 @@ impl TaskHandler for GenerateThumbnailsHandler { &'a self, task: &'a tasks::Model, db: &'a DatabaseConnection, - _event_broadcaster: Option<&'a Arc>, + event_broadcaster: Option<&'a Arc>, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { info!( @@ -124,11 +124,27 @@ impl TaskHandler for GenerateThumbnailsHandler { )); } + if let Some(broadcaster) = event_broadcaster { + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "generate_thumbnails", + 0, + to_process, + Some(format!( + "Enqueueing thumbnail tasks for {} book(s) ({} skipped)", + to_process, skipped + )), + library_id, + series_id, + None, + )); + } + // Enqueue individual GenerateThumbnail tasks for each book let mut enqueued = 0; let mut errors = Vec::new(); - for book in books_to_process { + for (idx, book) in books_to_process.iter().enumerate() { let task_type = TaskType::GenerateThumbnail { book_id: book.id, force, @@ -151,6 +167,25 @@ impl TaskHandler for GenerateThumbnailsHandler { errors.push(error_msg); } } + + if let Some(broadcaster) = event_broadcaster { + let current = idx + 1; + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "generate_thumbnails", + current, + to_process, + Some(format!( + "Enqueueing thumbnail tasks ({}/{}, {} failed)", + current, + to_process, + errors.len() + )), + library_id, + series_id, + Some(book.id), + )); + } } info!( diff --git a/src/tasks/handlers/plugin_auto_match.rs b/src/tasks/handlers/plugin_auto_match.rs index 15adcd79..ac0a950c 100644 --- a/src/tasks/handlers/plugin_auto_match.rs +++ b/src/tasks/handlers/plugin_auto_match.rs @@ -24,7 +24,7 @@ use crate::db::repositories::{ BookExternalIdRepository, BookMetadataRepository, BookRepository, LibraryRepository, PluginsRepository, SeriesExternalIdRepository, SeriesMetadataRepository, SeriesRepository, }; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; +use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; use crate::services::ThumbnailService; use crate::services::metadata::preprocessing::{ AutoMatchConditions, PreprocessingRule, SeriesContext, SeriesContextBuilder, apply_rules, @@ -163,6 +163,23 @@ impl PluginAutoMatchHandler { series_id ); + let total_books = books.len(); + if let Some(broadcaster) = event_broadcaster { + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "plugin_auto_match", + 0, + total_books, + Some(format!( + "Matching {} book(s) via plugin '{}'", + total_books, plugin.name + )), + Some(library_id), + Some(series_id), + None, + )); + } + let min_confidence = self.get_confidence_threshold().await; let use_existing = PluginsRepository::use_existing_external_id(plugin); @@ -176,7 +193,24 @@ impl PluginAutoMatchHandler { let mut last_matched_title: Option = None; let mut any_used_existing = false; - for book in &books { + for (idx, book) in books.iter().enumerate() { + if let Some(broadcaster) = event_broadcaster { + let current = idx + 1; + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "plugin_auto_match", + current, + total_books, + Some(format!( + "Matching books ({}/{}, {} matched, {} skipped)", + current, total_books, books_matched, _books_skipped + )), + Some(library_id), + Some(series_id), + Some(book.id), + )); + } + // Check for existing external ID for this book/plugin let existing_ext_id = if use_existing { BookExternalIdRepository::get_for_plugin(db, book.id, &plugin.name).await? diff --git a/src/tasks/handlers/renumber_series.rs b/src/tasks/handlers/renumber_series.rs index 4f1c0b3a..07bf722b 100644 --- a/src/tasks/handlers/renumber_series.rs +++ b/src/tasks/handlers/renumber_series.rs @@ -13,7 +13,7 @@ use uuid::Uuid; use crate::db::entities::tasks; use crate::db::repositories::{SeriesRepository, TaskRepository}; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; +use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; @@ -116,7 +116,7 @@ impl TaskHandler for RenumberSeriesBatchHandler { &'a self, task: &'a tasks::Model, db: &'a DatabaseConnection, - _event_broadcaster: Option<&'a Arc>, + event_broadcaster: Option<&'a Arc>, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { info!( @@ -147,12 +147,27 @@ impl TaskHandler for RenumberSeriesBatchHandler { )); } + if let Some(broadcaster) = event_broadcaster { + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "renumber_series_batch", + 0, + total, + Some(format!("Enqueueing renumber tasks for {} series", total)), + task.library_id, + None, + None, + )); + } + // Enqueue individual RenumberSeries tasks for each series let mut enqueued = 0; let mut errors = Vec::new(); - for series_id in series_to_process { - let task_type = TaskType::RenumberSeries { series_id }; + for (idx, series_id) in series_to_process.iter().enumerate() { + let task_type = TaskType::RenumberSeries { + series_id: *series_id, + }; match TaskRepository::enqueue(db, task_type, None).await { Ok(task_id) => { @@ -171,6 +186,25 @@ impl TaskHandler for RenumberSeriesBatchHandler { errors.push(error_msg); } } + + if let Some(broadcaster) = event_broadcaster { + let current = idx + 1; + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "renumber_series_batch", + current, + total, + Some(format!( + "Enqueueing renumber tasks ({}/{}, {} failed)", + current, + total, + errors.len() + )), + task.library_id, + Some(*series_id), + None, + )); + } } info!( diff --git a/src/tasks/handlers/reprocess_series_titles.rs b/src/tasks/handlers/reprocess_series_titles.rs index 1fb31202..70915460 100644 --- a/src/tasks/handlers/reprocess_series_titles.rs +++ b/src/tasks/handlers/reprocess_series_titles.rs @@ -14,7 +14,7 @@ use crate::db::entities::{series_metadata, tasks}; use crate::db::repositories::{ LibraryRepository, SeriesMetadataRepository, SeriesRepository, TaskRepository, }; -use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster}; +use crate::events::{EntityChangeEvent, EntityEvent, EventBroadcaster, TaskProgressEvent}; use crate::services::metadata::preprocessing::apply_rules; use crate::tasks::handlers::TaskHandler; use crate::tasks::types::{TaskResult, TaskType}; @@ -127,7 +127,7 @@ impl TaskHandler for ReprocessSeriesTitlesHandler { &'a self, task: &'a tasks::Model, db: &'a DatabaseConnection, - _event_broadcaster: Option<&'a Arc>, + event_broadcaster: Option<&'a Arc>, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { info!( @@ -172,12 +172,30 @@ impl TaskHandler for ReprocessSeriesTitlesHandler { )); } + if let Some(broadcaster) = event_broadcaster { + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "reprocess_series_titles", + 0, + total, + Some(format!( + "Enqueueing reprocess title tasks for {} series", + total + )), + library_id, + None, + None, + )); + } + // Enqueue individual ReprocessSeriesTitle tasks for each series let mut enqueued = 0; let mut errors = Vec::new(); - for series_id in series_to_process { - let task_type = TaskType::ReprocessSeriesTitle { series_id }; + for (idx, series_id) in series_to_process.iter().enumerate() { + let task_type = TaskType::ReprocessSeriesTitle { + series_id: *series_id, + }; match TaskRepository::enqueue(db, task_type, None).await { Ok(task_id) => { @@ -196,6 +214,25 @@ impl TaskHandler for ReprocessSeriesTitlesHandler { errors.push(error_msg); } } + + if let Some(broadcaster) = event_broadcaster { + let current = idx + 1; + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "reprocess_series_titles", + current, + total, + Some(format!( + "Enqueueing reprocess title tasks ({}/{}, {} failed)", + current, + total, + errors.len() + )), + library_id, + Some(*series_id), + None, + )); + } } info!( diff --git a/src/tasks/handlers/scan_library.rs b/src/tasks/handlers/scan_library.rs index 2424bd5d..def7a42a 100644 --- a/src/tasks/handlers/scan_library.rs +++ b/src/tasks/handlers/scan_library.rs @@ -214,7 +214,16 @@ impl TaskHandler for ScanLibraryHandler { // Execute scan (without progress channel for now, pass event_broadcaster) // Note: Analysis tasks are now queued during the scan itself (streaming), // so workers can start processing immediately rather than waiting for scan to complete. - match scan_library(db, library_id, scan_mode, None, event_broadcaster).await { + match scan_library( + db, + library_id, + scan_mode, + None, + event_broadcaster, + Some(task.id), + ) + .await + { Ok(result) => { info!( "Task {}: Library scan completed - {} files processed, {} series, {} books, {} analysis tasks queued", diff --git a/src/tasks/handlers/user_plugin_sync/mod.rs b/src/tasks/handlers/user_plugin_sync/mod.rs index 1db22149..fd0b9916 100644 --- a/src/tasks/handlers/user_plugin_sync/mod.rs +++ b/src/tasks/handlers/user_plugin_sync/mod.rs @@ -27,7 +27,7 @@ use uuid::Uuid; use crate::db::entities::tasks; use crate::db::repositories::{UserPluginDataRepository, UserPluginsRepository}; -use crate::events::EventBroadcaster; +use crate::events::{EventBroadcaster, TaskProgressEvent}; use crate::services::SettingsService; use crate::services::plugin::PluginManager; use crate::services::plugin::protocol::methods; @@ -124,7 +124,7 @@ impl TaskHandler for UserPluginSyncHandler { &'a self, task: &'a tasks::Model, db: &'a DatabaseConnection, - _event_broadcaster: Option<&'a Arc>, + event_broadcaster: Option<&'a Arc>, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { // Extract task parameters @@ -150,6 +150,25 @@ impl TaskHandler for UserPluginSyncHandler { task.id, plugin_id, user_id ); + // Four high-level phases: connect → pull → push → finalize. + const SYNC_PHASES: usize = 4; + let emit_phase = |current: usize, message: String| { + if let Some(broadcaster) = event_broadcaster { + let _ = broadcaster.emit_task(TaskProgressEvent::progress( + task.id, + "user_plugin_sync", + current, + SYNC_PHASES, + Some(message), + None, + None, + None, + )); + } + }; + + emit_phase(0, "Connecting to plugin…".to_string()); + // Read user plugin config let user_config = match UserPluginsRepository::get_by_user_and_plugin(db, user_id, plugin_id).await { @@ -248,6 +267,15 @@ impl TaskHandler for UserPluginSyncHandler { ); } + emit_phase( + 1, + if do_pull { + "Pulling progress from external service…".to_string() + } else { + "Skipping pull (push-only)…".to_string() + }, + ); + // Step 2: Pull progress from external service let (pulled_count, pull_incomplete, matched_count, applied_count, pull_error) = if do_pull { @@ -303,6 +331,15 @@ impl TaskHandler for UserPluginSyncHandler { (0, false, 0, 0, None) }; + emit_phase( + 2, + if do_push { + format!("Pushing progress (pulled {pulled_count}, applied {applied_count})…") + } else { + format!("Skipping push (pull-only, pulled {pulled_count})…") + }, + ); + // Step 3: Push progress to external service let (pushed_count, push_failures, push_error) = if do_push { let entries = if let Some(ref source) = external_id_source { @@ -354,6 +391,13 @@ impl TaskHandler for UserPluginSyncHandler { (0, 0, None) }; + emit_phase( + 3, + format!( + "Finalizing (pulled {pulled_count}, pushed {pushed_count}, applied {applied_count})" + ), + ); + // Stop the user plugin handle to clean up the spawned process if let Err(e) = handle.stop().await { warn!("Task {}: Failed to stop plugin handle: {}", task.id, e); diff --git a/tests/scanner/allowed_formats.rs b/tests/scanner/allowed_formats.rs index d314f19e..80805a5c 100644 --- a/tests/scanner/allowed_formats.rs +++ b/tests/scanner/allowed_formats.rs @@ -71,7 +71,7 @@ async fn test_scan_respects_allowed_formats_cbr_only() { .unwrap(); // Run scan - should find 0 files (no CBR files exist) - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -102,7 +102,7 @@ async fn test_scan_respects_allowed_formats_cbz_only() { .unwrap(); // Run scan - should only find CBZ files (2 files) - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -139,7 +139,7 @@ async fn test_scan_respects_allowed_formats_multiple_formats() { .unwrap(); // Run scan - should find CBZ and EPUB files (4 files total) - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -174,7 +174,7 @@ async fn test_scan_respects_allowed_formats_none_restriction() { // Don't set allowed_formats (None) - should allow all formats // Run scan - should find all files (6 files: 2 CBZ, 2 EPUB, 2 PDF) - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -214,7 +214,7 @@ async fn test_scan_respects_allowed_formats_deep_scan() { .unwrap(); // Run deep scan - should only find EPUB files (2 files) - let result = scan_library(db, library.id, ScanMode::Deep, None, None) + let result = scan_library(db, library.id, ScanMode::Deep, None, None, None) .await .unwrap(); diff --git a/tests/scanner/series_matching.rs b/tests/scanner/series_matching.rs index 8560f53f..c96f8460 100644 --- a/tests/scanner/series_matching.rs +++ b/tests/scanner/series_matching.rs @@ -65,7 +65,7 @@ async fn test_series_rename_preserves_identity() { .await; // Run initial scan - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_created, 2); @@ -88,7 +88,7 @@ async fn test_series_rename_preserves_identity() { assert!(new_series_path.exists()); // Run scan again - should match by fingerprint and update the series - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -127,7 +127,7 @@ async fn test_series_copy_creates_new_series() { .await; // Run initial scan - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_created, 2); @@ -149,7 +149,7 @@ async fn test_series_copy_creates_new_series() { assert!(copied_series_path.exists()); // Run scan again - should create a NEW series for the copy - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -202,7 +202,7 @@ async fn test_nested_folder_copy_creates_new_series() { setup_library_with_series(db, &temp_dir, "My Series", &["book.cbz"]).await; // Run initial scan - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_created, 1); @@ -232,7 +232,7 @@ async fn test_nested_folder_copy_creates_new_series() { assert!(nested_file.exists()); // Run scan again - should create a NEW series for the nested copy - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -278,7 +278,7 @@ async fn test_deep_scan_respects_rename_vs_copy() { .await; // Run initial scan - scan_library(db, library.id, ScanMode::Normal, None, None) + scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -292,7 +292,7 @@ async fn test_deep_scan_respects_rename_vs_copy() { copy_dir_recursive(&series_path, &copied_series_path).unwrap(); // Run DEEP scan - should also create new series for copy - let result = scan_library(db, library.id, ScanMode::Deep, None, None) + let result = scan_library(db, library.id, ScanMode::Deep, None, None, None) .await .unwrap(); diff --git a/tests/scanner/soft_delete.rs b/tests/scanner/soft_delete.rs index f5592483..47d66460 100644 --- a/tests/scanner/soft_delete.rs +++ b/tests/scanner/soft_delete.rs @@ -53,7 +53,7 @@ async fn test_scan_marks_missing_books_deleted() { let library = setup_library_with_files(db, &temp_dir, 3).await; // Run initial scan to populate database - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -77,7 +77,7 @@ async fn test_scan_marks_missing_books_deleted() { fs::remove_file(file_to_delete).unwrap(); // Run scan again - should mark book as deleted - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -113,7 +113,7 @@ async fn test_scan_restores_reappeared_books() { let library = setup_library_with_files(db, &temp_dir, 2).await; // Run initial scan - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_created, 2); @@ -129,7 +129,7 @@ async fn test_scan_restores_reappeared_books() { fs::rename(&file_path, &backup_path).unwrap(); // Scan - should mark as deleted - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_deleted, 1); @@ -144,7 +144,7 @@ async fn test_scan_restores_reappeared_books() { fs::rename(&backup_path, &file_path).unwrap(); // Scan again - should restore the book - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_restored, 1); @@ -174,7 +174,7 @@ async fn test_scan_leaves_deleted_books_unchanged() { let library = setup_library_with_files(db, &temp_dir, 2).await; // Run initial scan - scan_library(db, library.id, ScanMode::Normal, None, None) + scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -187,13 +187,13 @@ async fn test_scan_leaves_deleted_books_unchanged() { fs::remove_file(series_path.join("book1.cbz")).unwrap(); // First scan - marks as deleted - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_deleted, 1); // Second scan - file still missing, should not change anything - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_deleted, 0); // Already deleted @@ -217,7 +217,7 @@ async fn test_scan_multiple_files_deleted_and_restored() { let library = setup_library_with_files(db, &temp_dir, 4).await; // Run initial scan - scan_library(db, library.id, ScanMode::Normal, None, None) + scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -231,7 +231,7 @@ async fn test_scan_multiple_files_deleted_and_restored() { fs::remove_file(series_path.join("book3.cbz")).unwrap(); // Scan - should mark 2 as deleted - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_deleted, 2); @@ -247,7 +247,7 @@ async fn test_scan_multiple_files_deleted_and_restored() { fs::copy(&cbz_path, series_path.join("book1.cbz")).unwrap(); // Scan - should restore 1 book - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_restored, 1); @@ -280,7 +280,7 @@ async fn test_deep_scan_reprocesses_all_files() { let library = setup_library_with_files(db, &temp_dir, 2).await; // Run initial scan - scan_library(db, library.id, ScanMode::Normal, None, None) + scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -289,7 +289,7 @@ async fn test_deep_scan_reprocesses_all_files() { .unwrap(); // Run deep scan - should reprocess all files but not report updates (since nothing changed) - let result = scan_library(db, library.id, ScanMode::Deep, None, None) + let result = scan_library(db, library.id, ScanMode::Deep, None, None, None) .await .unwrap(); @@ -326,7 +326,7 @@ async fn test_purge_deleted_on_scan_config() { LibraryRepository::update(db, &library).await.unwrap(); // Run initial scan - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_created, 3); @@ -348,7 +348,7 @@ async fn test_purge_deleted_on_scan_config() { fs::remove_file(&file_to_delete).unwrap(); // Run scan again - should mark book as deleted - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_deleted, 1); @@ -441,7 +441,7 @@ async fn test_deleted_books_have_number_cleared() { let library = setup_library_with_files(db, &temp_dir, 3).await; // Run initial scan to create books - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_created, 3); @@ -476,7 +476,7 @@ async fn test_deleted_books_have_number_cleared() { fs::remove_file(series_path.join("book2.cbz")).unwrap(); // Run scan - should mark as deleted AND clear number on deleted book - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_deleted, 1); @@ -526,7 +526,7 @@ async fn test_restored_books_get_renumbered() { let library = setup_library_with_files(db, &temp_dir, 3).await; // Run initial scan and create metadata - scan_library(db, library.id, ScanMode::Normal, None, None) + scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -541,7 +541,7 @@ async fn test_restored_books_get_renumbered() { // Delete a file and scan to mark it deleted let backup_path = temp_dir.path().join("backup_book2.cbz"); fs::rename(series_path.join("book2.cbz"), &backup_path).unwrap(); - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_deleted, 1); @@ -564,7 +564,7 @@ async fn test_restored_books_get_renumbered() { fs::rename(&backup_path, series_path.join("book2.cbz")).unwrap(); // Scan again - should restore and renumber - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_restored, 1); @@ -598,7 +598,7 @@ async fn test_remaining_books_renumbered_contiguously_after_deletion() { let library = setup_library_with_files(db, &temp_dir, 4).await; // Run initial scan and create metadata - scan_library(db, library.id, ScanMode::Normal, None, None) + scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); @@ -615,7 +615,7 @@ async fn test_remaining_books_renumbered_contiguously_after_deletion() { fs::remove_file(series_path.join("book3.cbz")).unwrap(); // Scan - should delete 2 books and renumber remaining - let result = scan_library(db, library.id, ScanMode::Normal, None, None) + let result = scan_library(db, library.id, ScanMode::Normal, None, None, None) .await .unwrap(); assert_eq!(result.books_deleted, 2);