use crate::captioner::{self, FlorenceCaptioner}; use crate::db::{self, DbPool, EmbeddingJob, FolderJobProgress, ImageRecord, IndexedMediaEntry}; use crate::embedder::{embedding_source_path, ClipImageEmbedder}; use crate::media::{probe_video_metadata, MediaTools}; use crate::storage::{detect_storage_profile, RuntimeAdaptiveProfile, StorageProfile}; use crate::tagger::{self, WdTagger}; use crate::thumbnail; use crate::vector; use anyhow::Result; use notify::{RecommendedWatcher, RecursiveMode, Watcher}; use rayon::prelude::*; use serde::Serialize; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use tauri::{AppHandle, Emitter}; use walkdir::WalkDir; const IMAGE_EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "gif", "bmp", "tiff", "tif", "webp", "avif", ]; const VIDEO_EXTENSIONS: &[&str] = &["mp4", "mov", "m4v", "webm"]; const JOB_PROGRESS_EMIT_INTERVAL: Duration = Duration::from_millis(750); #[allow(dead_code)] // caption worker disabled (lib.rs) const CAPTION_BATCH_SIZE: usize = 1; static LAST_JOB_PROGRESS_EMIT: OnceLock>> = OnceLock::new(); static ACTIVE_INDEXING_FOLDERS: OnceLock>> = OnceLock::new(); static PAUSED_WORKER_FOLDERS: OnceLock> = OnceLock::new(); #[derive(Default)] struct PausedWorkerFolders { thumbnail: HashSet, metadata: HashSet, embedding: HashSet, caption: HashSet, tagging: HashSet, } #[derive(Clone, Copy)] pub struct FolderWorkerPausedState { pub thumbnail: bool, pub metadata: bool, pub embedding: bool, pub caption: bool, pub tagging: bool, } pub fn set_worker_paused(worker: &str, folder_id: i64, paused: bool) { if let Ok(mut paused_folders) = PAUSED_WORKER_FOLDERS .get_or_init(|| Mutex::new(PausedWorkerFolders::default())) .lock() { let folder_set = match worker { "thumbnail" => Some(&mut paused_folders.thumbnail), "metadata" => Some(&mut paused_folders.metadata), "embedding" => Some(&mut paused_folders.embedding), "caption" => Some(&mut paused_folders.caption), "tagging" => Some(&mut paused_folders.tagging), _ => None, }; if let Some(folder_set) = folder_set { if paused { folder_set.insert(folder_id); } else { folder_set.remove(&folder_id); } } } } pub fn get_worker_paused_states(folder_ids: &[i64]) -> HashMap { let Ok(paused_folders) = PAUSED_WORKER_FOLDERS .get_or_init(|| Mutex::new(PausedWorkerFolders::default())) .lock() else { return HashMap::new(); }; folder_ids .iter() .copied() .map(|folder_id| { ( folder_id, FolderWorkerPausedState { thumbnail: paused_folders.thumbnail.contains(&folder_id), metadata: paused_folders.metadata.contains(&folder_id), embedding: paused_folders.embedding.contains(&folder_id), caption: paused_folders.caption.contains(&folder_id), tagging: paused_folders.tagging.contains(&folder_id), }, ) }) .collect() } fn paused_folder_ids(worker: &str) -> HashSet { let Ok(paused_folders) = PAUSED_WORKER_FOLDERS .get_or_init(|| Mutex::new(PausedWorkerFolders::default())) .lock() else { return HashSet::new(); }; match worker { "thumbnail" => paused_folders.thumbnail.clone(), "metadata" => paused_folders.metadata.clone(), "embedding" => paused_folders.embedding.clone(), "caption" => paused_folders.caption.clone(), "tagging" => paused_folders.tagging.clone(), _ => HashSet::new(), } } static FOLDER_STORAGE_PROFILES: OnceLock>> = OnceLock::new(); static DB_WRITE_LOCK: OnceLock> = OnceLock::new(); const EMBEDDING_BATCH_SIZE: usize = 8; /// Background workers form a strict priority pipeline: a worker only claims /// jobs when no higher-priority claimable work exists, so stages drain one /// at a time (thumbnails → metadata → embeddings → tagging) instead of all /// workers contending for CPU, disk, and the DB writer at once. #[derive(Clone, Copy, PartialEq, PartialOrd)] enum WorkerTier { Thumbnail = 0, Metadata = 1, Embedding = 2, Tagging = 3, } /// True if any tier above `own_tier` still has claimable work. Each check /// uses the same exclusion set the corresponding claim would (that worker's /// paused folders plus actively-indexing folders), so paused or mid-scan /// work never gates lower tiers. fn higher_priority_work_pending(pool: &DbPool, own_tier: WorkerTier) -> Result { let conn = pool.get()?; let active_folders = active_indexing_folders(); // Video jobs are unclaimable until FFmpeg is provisioned; they must not // count as pending higher-priority work or they'd stall lower tiers. let ffmpeg_ready = crate::media::ffmpeg_ready(); if own_tier > WorkerTier::Thumbnail { let mut excluded = paused_folder_ids("thumbnail"); excluded.extend(active_folders.iter().copied()); if db::has_claimable_thumbnail_jobs(&conn, &excluded, ffmpeg_ready)? { return Ok(true); } } if own_tier > WorkerTier::Metadata && ffmpeg_ready { let mut excluded = paused_folder_ids("metadata"); excluded.extend(active_folders.iter().copied()); if db::has_claimable_metadata_jobs(&conn, &excluded)? { return Ok(true); } } if own_tier > WorkerTier::Embedding { let mut excluded = paused_folder_ids("embedding"); excluded.extend(active_folders.iter().copied()); if db::has_claimable_embedding_jobs(&conn, &excluded)? { return Ok(true); } } Ok(false) } #[derive(Clone, Serialize)] pub struct IndexProgress { pub folder_id: i64, pub total: usize, pub indexed: usize, pub current_file: String, pub done: bool, } #[derive(Clone, Serialize)] pub struct IndexedImagesBatch { pub folder_id: i64, pub images: Vec, } #[derive(Clone, Serialize)] pub struct MediaUpdateBatch { pub images: Vec, } #[derive(Clone, Serialize)] pub struct MediaJobProgressEvent { pub progress: Vec, } pub fn index_folder(app: AppHandle, pool: DbPool, folder_id: i64, folder_path: PathBuf) { std::thread::spawn(move || { set_folder_indexing_state(folder_id, true); // If the folder path no longer exists on disk, record the error and // emit done. Images are intentionally kept in the DB so the user can // choose to relocate the folder or remove it explicitly — they should // not be silently destroyed. if !folder_path.is_dir() { let error_msg = format!("Folder not found: {}", folder_path.display()); log::error!("Indexing error for folder {folder_id}: {error_msg}"); if let Ok(conn) = pool.get() { let _ = db::set_folder_scan_error(&conn, folder_id, &error_msg); } emit_progress( &app, &IndexProgress { folder_id, total: 0, indexed: 0, current_file: String::new(), done: true, }, ); set_folder_indexing_state(folder_id, false); return; } let storage_profile = detect_storage_profile(&folder_path); set_folder_storage_profile(folder_id, RuntimeAdaptiveProfile::new(storage_profile)); if let Err(error) = do_index(app.clone(), &pool, folder_id, folder_path) { log::error!("Indexing error for folder {folder_id}: {error}"); if let Ok(conn) = pool.get() { let _ = db::set_folder_scan_error(&conn, folder_id, &error.to_string()); } // Always emit done so the frontend reloads and recovers from partial state. emit_progress( &app, &IndexProgress { folder_id, total: 0, indexed: 0, current_file: String::new(), done: true, }, ); } set_folder_indexing_state(folder_id, false); }); } pub fn start_thumbnail_worker( app: AppHandle, pool: DbPool, media_tools: MediaTools, cache_dir: PathBuf, ) { std::thread::spawn(move || loop { // Only back off when the queue is empty (or errored); while jobs are // pending, claim the next batch immediately. match process_thumbnail_batch(&app, &pool, &media_tools, &cache_dir) { Ok(true) => {} Ok(false) => std::thread::sleep(std::time::Duration::from_millis(250)), Err(error) => { log::error!("Thumbnail worker error: {error}"); std::thread::sleep(std::time::Duration::from_millis(250)); } } }); } pub fn start_metadata_worker(app: AppHandle, pool: DbPool, media_tools: MediaTools) { std::thread::spawn(move || loop { // Only back off when the queue is empty (or errored); while jobs are // pending, claim the next batch immediately. match process_metadata_batch(&app, &pool, &media_tools) { Ok(true) => {} Ok(false) => std::thread::sleep(std::time::Duration::from_millis(250)), Err(error) => { log::error!("Metadata worker error: {error}"); std::thread::sleep(std::time::Duration::from_millis(250)); } } }); } pub fn start_embedding_worker(app: AppHandle, pool: DbPool) { std::thread::spawn(move || { let mut embedder: Option = None; log::info!("Embedding worker started."); loop { // Only back off when the queue is empty (or errored); while jobs // are pending, claim the next batch immediately. match process_embedding_batch(&app, &pool, &mut embedder) { Ok(true) => {} Ok(false) => std::thread::sleep(std::time::Duration::from_millis(500)), Err(error) => { log::error!("Embedding worker error: {error}"); std::thread::sleep(std::time::Duration::from_millis(500)); } } } }); } #[allow(dead_code)] // caption worker disabled (lib.rs) pub fn start_caption_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) { std::thread::spawn(move || { let mut captioner: Option = None; log::info!("Caption worker started."); loop { // If the acceleration setting changed, drop the cached session so // the next batch picks it up with the new execution provider. if captioner::CAPTION_SESSION_DIRTY.swap(false, std::sync::atomic::Ordering::Relaxed) { log::info!("Caption worker: acceleration setting changed — resetting session."); captioner = None; } if let Err(error) = process_caption_batch(&app, &pool, &app_data_dir, &mut captioner) { log::error!("Caption worker error: {error}"); captioner = None; } std::thread::sleep(std::time::Duration::from_millis(750)); } }); } pub fn start_tagging_worker(app: AppHandle, pool: DbPool, app_data_dir: PathBuf) { std::thread::spawn(move || { let mut tagger_instance: Option = None; log::info!("Tagging worker started."); loop { // If the acceleration setting changed, drop the cached session so // the next batch picks it up with the new execution provider. if tagger::TAGGER_SESSION_DIRTY.swap(false, std::sync::atomic::Ordering::Relaxed) { log::info!("Tagging worker: acceleration setting changed — resetting session."); tagger_instance = None; } // Only back off when the queue is empty (or errored); while jobs // are pending, claim the next batch immediately. match process_tagging_batch(&app, &pool, &app_data_dir, &mut tagger_instance) { Ok(true) => {} Ok(false) => std::thread::sleep(std::time::Duration::from_millis(750)), Err(error) => { log::error!("Tagging worker error: {error}"); tagger_instance = None; std::thread::sleep(std::time::Duration::from_millis(750)); } } } }); } fn do_index(app: AppHandle, pool: &DbPool, folder_id: i64, folder_path: PathBuf) -> Result<()> { let existing_entries = { let conn = pool.get()?; db::get_folder_media_index(&conn, folder_id)? }; let existing_by_path = existing_entries .into_iter() .map(|entry| (entry.path.clone(), entry)) .collect::>(); // Collect traversal errors separately. An unreadable subdirectory means we // cannot distinguish absent files from inaccessible ones; tracking errors // lets us skip the deletion step for paths under affected subtrees. let mut unreadable_prefixes: Vec = Vec::new(); let media_paths: Vec = WalkDir::new(&folder_path) .follow_links(true) .into_iter() .filter_map(|entry| match entry { Ok(e) if e.file_type().is_file() && is_supported_media(e.path()) => { Some(e.path().to_path_buf()) } Ok(_) => None, Err(err) => { // Record the inaccessible path so we can protect its descendants // from the missing-file deletion pass. if let Some(path) = err.path() { unreadable_prefixes.push(path.to_string_lossy().into_owned()); } log::error!( "WalkDir error while scanning folder {}: {}", folder_path.display(), err ); None } }) .collect(); let total = media_paths.len(); emit_progress( &app, &IndexProgress { folder_id, total, indexed: 0, current_file: String::new(), done: false, }, ); let mut seen_paths = HashSet::with_capacity(total); let mut processed = 0usize; let mut offset = 0usize; while offset < media_paths.len() { let storage_profile = folder_storage_profile(folder_id); let end = (offset + storage_profile.index_batch_size()).min(media_paths.len()); let path_chunk = &media_paths[offset..end]; let batch_start = Instant::now(); let records: Vec = path_chunk .par_iter() .filter_map(|path| { let path_str = path.to_string_lossy().to_string(); let existing = existing_by_path.get(&path_str); build_record(path, folder_id, existing) }) .collect(); for path in path_chunk { seen_paths.insert(path.to_string_lossy().to_string()); } if !records.is_empty() { let committed = commit_batch(pool, &records)?; emit_images( &app, &IndexedImagesBatch { folder_id, images: committed, }, ); emit_folder_job_progress(&app, pool, &[folder_id], false); } processed += path_chunk.len(); observe_folder_scan_batch(folder_id, path_chunk.len(), batch_start.elapsed()); let current_file = path_chunk .last() .and_then(|path| path.file_name()) .map(|name| name.to_string_lossy().to_string()) .unwrap_or_default(); emit_progress( &app, &IndexProgress { folder_id, total, indexed: processed, current_file, done: false, }, ); offset = end; } let missing_ids = existing_by_path .values() .filter(|entry| !seen_paths.contains(&entry.path)) .filter(|entry| { // If this path lives under a subtree that WalkDir couldn't read, // we don't know whether the file is gone or just temporarily // inaccessible — keep the record to avoid false deletions. // Use Path::starts_with (component-aware) so a sibling directory // whose name shares a prefix is not incorrectly protected. let entry_path = std::path::Path::new(&entry.path); unreadable_prefixes .iter() .all(|prefix| !entry_path.starts_with(prefix)) }) .map(|entry| entry.id) .collect::>(); { let conn = pool.get()?; if !missing_ids.is_empty() { db::delete_images_by_ids(&conn, &missing_ids)?; } let _ = db::backfill_embedding_jobs(&conn)?; db::update_folder_count(&conn, folder_id)?; let _ = db::clear_folder_scan_error(&conn, folder_id); // Invalidate duplicate scan cache — any reindex can change file contents // or the set of files, making cached duplicate groups stale. let folder_scope = format!("folder:{folder_id}"); let _ = db::clear_duplicate_scan_cache(&conn, &folder_scope); let _ = db::clear_duplicate_scan_cache(&conn, "all"); } emit_progress( &app, &IndexProgress { folder_id, total, indexed: processed, current_file: String::new(), done: true, }, ); emit_folder_job_progress(&app, pool, &[folder_id], true); Ok(()) } /// Extract the capture date from EXIF metadata, returned as an ISO 8601 string /// (`"YYYY-MM-DDTHH:MM:SS"`). Tries `DateTimeOriginal` first, then /// `DateTimeDigitized`, then `DateTime`. Returns `None` for video files or /// images with no readable EXIF date. fn extract_exif_date(path: &Path) -> Option { use exif::{In, Tag, Value}; use std::io::BufReader; let file = std::fs::File::open(path).ok()?; let mut reader = BufReader::new(file); let exif = exif::Reader::new().read_from_container(&mut reader).ok()?; for tag in [Tag::DateTimeOriginal, Tag::DateTimeDigitized, Tag::DateTime] { if let Some(field) = exif.get_field(tag, In::PRIMARY) { if let Value::Ascii(ref parts) = field.value { if let Some(bytes) = parts.first() { // EXIF datetime format: "YYYY:MM:DD HH:MM:SS" (19 bytes) if bytes.len() >= 19 { let s = String::from_utf8_lossy(bytes); // Reject all-zero sentinel dates written by some cameras // for uninitialised EXIF fields ("0000:00:00 00:00:00"). let year: u32 = s[0..4].parse().unwrap_or(0); if year == 0 { continue; } let iso = format!( "{}-{}-{}T{}:{}:{}", &s[0..4], &s[5..7], &s[8..10], &s[11..13], &s[14..16], &s[17..19] ); return Some(iso); } } } } } None } fn build_record( path: &Path, folder_id: i64, existing: Option<&IndexedMediaEntry>, ) -> Option { let path_str = path.to_string_lossy().to_string(); let filename = path.file_name()?.to_string_lossy().to_string(); let metadata = std::fs::metadata(path).ok()?; let file_size = metadata.len() as i64; if file_size == 0 { return None; } let modified_at = metadata.modified().ok().map(|time| { let date_time: chrono::DateTime = time.into(); date_time.to_rfc3339() }); let ext = path .extension() .and_then(|value| value.to_str()) .unwrap_or("jpg"); let media_kind = media_kind_for_ext(ext).to_string(); if let Some(existing) = existing { if existing.file_size == file_size && existing.modified_at == modified_at && existing.media_kind == media_kind { return None; } } Some(ImageRecord { id: existing.map(|entry| entry.id).unwrap_or(0), folder_id, path: path_str, filename, thumbnail_path: None, width: None, height: None, file_size, created_at: None, modified_at, taken_at: extract_exif_date(path), mime_type: mime_for_ext(ext).to_string(), media_kind: media_kind.clone(), duration_ms: None, video_codec: None, audio_codec: None, metadata_updated_at: None, metadata_error: None, favorite: false, rating: 0, embedding_status: "pending".to_string(), embedding_model: Some(vector::CLIP_MODEL_NAME.to_string()), embedding_updated_at: None, embedding_error: None, generated_caption: None, caption_model: None, caption_updated_at: None, caption_error: None, ai_rating: None, ai_tagger_model: None, ai_tagged_at: None, ai_tagger_error: None, }) } fn commit_batch(pool: &DbPool, records: &[ImageRecord]) -> Result> { let mut conn = pool.get()?; let tx = conn.transaction()?; let mut committed = Vec::with_capacity(records.len()); for record in records { let mut committed_record = record.clone(); committed_record.id = db::upsert_image(&tx, record)?; db::enqueue_embedding_job(&tx, committed_record.id)?; db::enqueue_thumbnail_job(&tx, committed_record.id)?; if committed_record.media_kind == "video" { db::enqueue_metadata_job(&tx, committed_record.id)?; } committed.push(committed_record); } tx.commit()?; Ok(committed) } /// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if /// the queue was empty. fn process_thumbnail_batch( app: &AppHandle, pool: &DbPool, media_tools: &MediaTools, cache_dir: &Path, ) -> Result { let jobs = { with_db_write_lock(|| { let mut conn = pool.get()?; let active_folders = active_indexing_folders(); let paused_folders = paused_folder_ids("thumbnail"); let worker_batch_size = max_worker_batch_size(&active_folders); let worker_fetch_size = max_worker_fetch_size(&active_folders); db::claim_thumbnail_jobs( &mut conn, &active_folders, &paused_folders, crate::media::ffmpeg_ready(), worker_fetch_size, worker_batch_size, ) })? }; if jobs.is_empty() { return Ok(false); } log::info!("Thumbnail batch claimed: {} items", jobs.len()); let (image_jobs, video_jobs): (Vec<_>, Vec<_>) = jobs.into_iter().partition(|job| job.media_kind == "image"); // Images: parallel decode, committed as one batch. if !image_jobs.is_empty() { let results = image_jobs .par_iter() .map(|job| { ( job.image_id, thumbnail::generate_image_thumbnail(Path::new(&job.path), cache_dir).map(Some), ) }) .collect::>(); persist_thumbnail_results(app, pool, results)?; } // Videos: sequential, off the rayon pool — each ffmpeg call blocks its // thread, and a video-heavy batch on the shared pool would starve image // decoding across all workers. Committed per item so progress keeps // moving through slow stretches. for job in &video_jobs { let result = thumbnail::generate_video_thumbnail(media_tools, Path::new(&job.path), cache_dir) .map(Some); persist_thumbnail_results(app, pool, vec![(job.image_id, result)])?; } Ok(true) } fn persist_thumbnail_results( app: &AppHandle, pool: &DbPool, results: Vec<(i64, anyhow::Result>)>, ) -> Result<()> { let updated_images = { with_db_write_lock(|| { let mut conn = pool.get()?; let tx = conn.transaction()?; let mut updated_images = Vec::new(); for (image_id, thumbnail_result) in results { let generated = match thumbnail_result { Ok(path) => path, Err(error) => { db::mark_thumbnail_failed(&tx, image_id, &error.to_string())?; continue; } }; let thumbnail_path = generated .as_ref() .map(|thumb| thumb.path.to_string_lossy().to_string()); let width = generated.as_ref().and_then(|thumb| thumb.width); let height = generated.as_ref().and_then(|thumb| thumb.height); updated_images.push(db::mark_thumbnail_ready( &tx, image_id, thumbnail_path.as_deref(), width, height, )?); } tx.commit()?; Ok(updated_images) })? }; if !updated_images.is_empty() { let folder_ids = updated_images .iter() .map(|image| image.folder_id) .collect::>(); emit_media_updates( app, &MediaUpdateBatch { images: updated_images, }, ); emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::>(), true); } Ok(()) } /// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if /// the queue was empty. fn process_metadata_batch( app: &AppHandle, pool: &DbPool, media_tools: &MediaTools, ) -> Result { // Metadata jobs are video-only and need ffprobe; leave them pending until // FFmpeg is provisioned — the worker poll loop drains them once ready. if !crate::media::ffmpeg_ready() { return Ok(false); } if higher_priority_work_pending(pool, WorkerTier::Metadata)? { return Ok(false); } let jobs = { with_db_write_lock(|| { let mut conn = pool.get()?; let active_folders = active_indexing_folders(); let paused_folders = paused_folder_ids("metadata"); let worker_batch_size = max_worker_batch_size(&active_folders); let worker_fetch_size = max_worker_fetch_size(&active_folders); db::claim_metadata_jobs( &mut conn, &active_folders, &paused_folders, worker_fetch_size, worker_batch_size, ) })? }; if jobs.is_empty() { return Ok(false); } // Probes run sequentially (each ffprobe blocks on its process); results // are committed per item so progress keeps moving through slow stretches. for job in jobs { let metadata_result = probe_video_metadata(media_tools, Path::new(&job.path)); let updated_image = with_db_write_lock(|| { let mut conn = pool.get()?; let tx = conn.transaction()?; let updated = match metadata_result { Ok(metadata) => Some(db::mark_metadata_ready( &tx, job.image_id, metadata.duration_ms, metadata.width, metadata.height, metadata.video_codec.as_deref(), metadata.audio_codec.as_deref(), )?), Err(error) => { db::mark_metadata_failed(&tx, job.image_id, &error.to_string())?; None } }; tx.commit()?; Ok(updated) })?; if let Some(image) = updated_image { let folder_id = image.folder_id; emit_media_updates( app, &MediaUpdateBatch { images: vec![image], }, ); emit_folder_job_progress(app, pool, &[folder_id], false); } } Ok(true) } /// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if /// the queue was empty. fn process_embedding_batch( app: &AppHandle, pool: &DbPool, embedder: &mut Option, ) -> Result { if higher_priority_work_pending(pool, WorkerTier::Embedding)? { return Ok(false); } let batch_started_at = Instant::now(); let claim_started_at = Instant::now(); // Exclude folders that are actively indexing (matching the thumbnail and // metadata workers): embedding mid-scan competes with the scanner for // CPU, disk, and the DB writer, and video jobs would fail-fast anyway // because their thumbnails are deferred until indexing completes. let mut excluded_folders = paused_folder_ids("embedding"); excluded_folders.extend(active_indexing_folders()); let jobs = with_db_write_lock(|| { let mut conn = pool.get()?; db::claim_embedding_jobs(&mut conn, &excluded_folders, EMBEDDING_BATCH_SIZE) })?; let claim_elapsed = claim_started_at.elapsed(); if jobs.is_empty() { return Ok(false); } if embedder.is_none() { *embedder = Some(ClipImageEmbedder::new()?); } log::info!("Embedding batch claimed: {} items", jobs.len()); let folder_ids = jobs.iter().map(|job| job.folder_id).collect::>(); emit_folder_job_progress( app, pool, &folder_ids.iter().copied().collect::>(), false, ); let embedder = embedder.as_ref().expect("embedder should be initialized"); let infer_started_at = Instant::now(); // Resolve each source path. Video jobs without thumbnails are not claimable, so an // error here represents a real race or missing thumbnail rather than normal deferral. let source_results: Vec> = jobs .iter() .map(|job| embedding_source_path(&job.path, job.thumbnail_path.as_deref(), &job.media_kind)) .collect(); // Separate jobs with a valid source from genuine early failures. let mut embeddable_indices: Vec = Vec::new(); let mut embeddable_paths: Vec = Vec::new(); // image_id -> early error message for jobs that cannot be embedded yet let mut pre_failed: HashMap = HashMap::new(); for (i, (job, result)) in jobs.iter().zip(source_results.into_iter()).enumerate() { match result { Ok(path) => { embeddable_indices.push(i); embeddable_paths.push(path); } Err(e) => { pre_failed.insert(job.image_id, e.to_string()); } } } // Run CLIP only on the jobs that have a valid source image. // image_id -> embedding result let mut embed_results: HashMap>> = HashMap::new(); if !embeddable_indices.is_empty() { let embeddable_jobs: Vec<&EmbeddingJob> = embeddable_indices.iter().map(|&i| &jobs[i]).collect(); match embedder.embed_images(&embeddable_paths) { Ok(embeddings) => { for (job, embedding) in embeddable_jobs.iter().zip(embeddings.into_iter()) { embed_results.insert(job.image_id, Ok(embedding)); } } Err(batch_error) => { log::error!("Embedding batch fallback to per-image mode: {batch_error}"); for (job, source_path) in embeddable_jobs .into_iter() .zip(embeddable_paths.into_iter()) { embed_results.insert(job.image_id, embedder.embed_image(&source_path)); } } } } let infer_elapsed = infer_started_at.elapsed(); let write_started_at = Instant::now(); let updated_images = with_db_write_lock(|| { let mut conn = pool.get()?; let tx = conn.transaction()?; let mut updated_images = Vec::new(); for job in &jobs { let embedding_result: Result> = if let Some(err) = pre_failed.remove(&job.image_id) { Err(anyhow::anyhow!("{err}")) } else if let Some(r) = embed_results.remove(&job.image_id) { r } else { Err(anyhow::anyhow!("no result for image {}", job.image_id)) }; match embedding_result { Ok(embedding) => { vector::upsert_embedding(&tx, job.image_id, &embedding)?; db::mark_embedding_ready(&tx, job.image_id, vector::CLIP_MODEL_NAME)?; } Err(error) => { db::mark_embedding_failed(&tx, job.image_id, &error.to_string())?; } } updated_images.push(db::get_image_by_id(&tx, job.image_id)?); } tx.commit()?; Ok(updated_images) })?; if !updated_images.is_empty() { log::info!("Embedding batch completed: {} items", updated_images.len()); let folder_ids = updated_images .iter() .map(|image| image.folder_id) .collect::>(); emit_media_updates( app, &MediaUpdateBatch { images: updated_images, }, ); emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::>(), true); } let write_elapsed = write_started_at.elapsed(); let batch_elapsed = batch_started_at.elapsed(); log::info!( "Embedding batch timing: claimed {EMBEDDING_BATCH_SIZE} in {claim_elapsed:?}, infer {infer_elapsed:?}, write {write_elapsed:?}, total {batch_elapsed:?}" ); Ok(true) } #[allow(dead_code)] // caption worker disabled (lib.rs) fn process_caption_batch( app: &AppHandle, pool: &DbPool, app_data_dir: &Path, captioner: &mut Option, ) -> Result<()> { if !captioner::caption_model_status(app_data_dir).ready { return Ok(()); } let paused_folders = paused_folder_ids("caption"); let jobs = with_db_write_lock(|| { let mut conn = pool.get()?; db::claim_caption_jobs(&mut conn, &paused_folders, CAPTION_BATCH_SIZE) })?; if jobs.is_empty() { return Ok(()); } if captioner.is_none() { match FlorenceCaptioner::new(app_data_dir) { Ok(model) => *captioner = Some(model), Err(error) => { with_db_write_lock(|| { let conn = pool.get()?; db::requeue_caption_jobs( &conn, &jobs.iter().map(|job| job.image_id).collect::>(), ) })?; return Err(error); } } } let folder_ids = jobs.iter().map(|job| job.folder_id).collect::>(); emit_folder_job_progress( app, pool, &folder_ids.iter().copied().collect::>(), false, ); let captioner = captioner .as_mut() .expect("captioner should be initialized before caption batch processing"); let caption_results = jobs .iter() .map(|job| (job.clone(), captioner.generate(Path::new(&job.path)))) .collect::>(); let updated_images = with_db_write_lock(|| { let mut conn = pool.get()?; let tx = conn.transaction()?; let mut updated_images = Vec::with_capacity(caption_results.len()); for (job, caption_result) in &caption_results { if db::is_caption_job_cancelled(&tx, job.image_id)? { tx.execute( "DELETE FROM caption_jobs WHERE image_id = ?1", [job.image_id], )?; continue; } match caption_result { Ok(caption) => { updated_images.push(db::update_generated_caption( &tx, job.image_id, caption, captioner::FLORENCE_CAPTION_MODEL_NAME, )?); } Err(error) => { db::mark_caption_failed(&tx, job.image_id, &error.to_string())?; updated_images.push(db::get_image_by_id(&tx, job.image_id)?); } } } tx.commit()?; Ok(updated_images) })?; if !updated_images.is_empty() { let folder_ids = updated_images .iter() .map(|image| image.folder_id) .collect::>(); emit_media_updates( app, &MediaUpdateBatch { images: updated_images, }, ); emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::>(), true); } Ok(()) } /// Returns `Ok(true)` if a batch was claimed and processed, `Ok(false)` if /// the queue was empty or the model is not ready. fn process_tagging_batch( app: &AppHandle, pool: &DbPool, app_data_dir: &Path, tagger_instance: &mut Option, ) -> Result { if !tagger::tagger_model_status(app_data_dir).ready { return Ok(false); } if higher_priority_work_pending(pool, WorkerTier::Tagging)? { return Ok(false); } // Exclude actively-indexing folders for the same reason as the other // workers: don't compete with a running scan. let mut excluded_folders = paused_folder_ids("tagging"); excluded_folders.extend(active_indexing_folders()); let batch_size = crate::tagger::tagger_batch_size(app_data_dir); let jobs = with_db_write_lock(|| { let mut conn = pool.get()?; db::claim_tagging_jobs(&mut conn, &excluded_folders, batch_size) })?; if jobs.is_empty() { return Ok(false); } if tagger_instance.is_none() { match WdTagger::new(app_data_dir) { Ok(model) => *tagger_instance = Some(model), Err(error) => { with_db_write_lock(|| { let conn = pool.get()?; db::requeue_tagging_jobs( &conn, &jobs.iter().map(|job| job.image_id).collect::>(), ) })?; return Err(error); } } } let folder_ids = jobs.iter().map(|job| job.folder_id).collect::>(); emit_folder_job_progress( app, pool, &folder_ids.iter().copied().collect::>(), false, ); let tagger_ref = tagger_instance .as_mut() .expect("tagger should be initialized before tagging batch processing"); let tag_results = jobs .iter() .map(|job| { ( job.clone(), tagger_ref.run(Path::new(&job.path), tagger::DEFAULT_MAX_TAGS), ) }) .collect::>(); let updated_images = with_db_write_lock(|| { let mut conn = pool.get()?; let tx = conn.transaction()?; let mut updated_images = Vec::with_capacity(tag_results.len()); for (job, tag_result) in &tag_results { // If the job is no longer in 'processing' state it was either // cancelled or reset to 'pending' (pause) while inference was running. // Discard the result in both cases — for cancelled rows also delete // the row; for paused rows leave it so the job is retried later. if !db::is_tagging_job_processing(&tx, job.image_id)? { tx.execute( "DELETE FROM tagging_jobs WHERE image_id = ?1 AND status = 'cancelled'", [job.image_id], )?; continue; } match tag_result { Ok(output) => { let tag_pairs: Vec<(String, f64)> = output .tags .iter() .map(|t| (t.tag.clone(), t.confidence as f64)) .collect(); db::update_ai_tags( &tx, job.image_id, &tag_pairs, &output.rating, tagger::WD_TAGGER_MODEL_NAME, )?; } Err(error) => { db::mark_tagging_failed(&tx, job.image_id, &error.to_string())?; } } updated_images.push(db::get_image_by_id(&tx, job.image_id)?); } tx.commit()?; Ok(updated_images) }) .inspect_err(|_db_err| { // The DB write failed. Try to requeue the claimed jobs so they aren't // left stuck in 'processing' until the next app restart. let image_ids: Vec = jobs.iter().map(|job| job.image_id).collect(); let _ = with_db_write_lock(|| { let conn = pool.get()?; db::requeue_tagging_jobs(&conn, &image_ids) }); })?; if !updated_images.is_empty() { let folder_ids = updated_images .iter() .map(|image| image.folder_id) .collect::>(); emit_media_updates( app, &MediaUpdateBatch { images: updated_images, }, ); emit_folder_job_progress(app, pool, &folder_ids.into_iter().collect::>(), true); } Ok(true) } fn active_indexing_folders() -> HashSet { ACTIVE_INDEXING_FOLDERS .get_or_init(|| Mutex::new(HashSet::new())) .lock() .map(|folders| folders.clone()) .unwrap_or_default() } fn set_folder_indexing_state(folder_id: i64, is_active: bool) { if let Ok(mut folders) = ACTIVE_INDEXING_FOLDERS .get_or_init(|| Mutex::new(HashSet::new())) .lock() { if is_active { folders.insert(folder_id); } else { folders.remove(&folder_id); } } } fn set_folder_storage_profile(folder_id: i64, profile: RuntimeAdaptiveProfile) { if let Ok(mut profiles) = FOLDER_STORAGE_PROFILES .get_or_init(|| Mutex::new(HashMap::new())) .lock() { profiles.insert(folder_id, profile); } } fn folder_storage_profile(folder_id: i64) -> StorageProfile { FOLDER_STORAGE_PROFILES .get_or_init(|| Mutex::new(HashMap::new())) .lock() .ok() .and_then(|profiles| profiles.get(&folder_id).copied()) .map(|profile| profile.profile()) .unwrap_or(StorageProfile::Balanced) } fn observe_folder_scan_batch(folder_id: i64, item_count: usize, elapsed: Duration) { if let Ok(mut profiles) = FOLDER_STORAGE_PROFILES .get_or_init(|| Mutex::new(HashMap::new())) .lock() { if let Some(profile) = profiles.get_mut(&folder_id) { profile.observe_scan_batch(item_count, elapsed); } } } fn max_worker_batch_size(active_folders: &HashSet) -> usize { active_folders .iter() .map(|folder_id| folder_storage_profile(*folder_id).worker_batch_size()) .min() .unwrap_or(StorageProfile::Balanced.worker_batch_size()) } fn max_worker_fetch_size(active_folders: &HashSet) -> usize { active_folders .iter() .map(|folder_id| folder_storage_profile(*folder_id).worker_fetch_size()) .min() .unwrap_or(StorageProfile::Balanced.worker_fetch_size()) } pub fn with_db_write_lock(operation: impl FnOnce() -> Result) -> Result { let lock = DB_WRITE_LOCK.get_or_init(|| Mutex::new(())); let _guard = lock.lock().unwrap(); operation() } fn emit_progress(app: &AppHandle, progress: &IndexProgress) { let _ = app.emit("index-progress", progress); } fn emit_images(app: &AppHandle, batch: &IndexedImagesBatch) { let _ = app.emit("indexed-images", batch); } fn emit_media_updates(app: &AppHandle, batch: &MediaUpdateBatch) { let _ = app.emit("media-updated", batch); } pub fn emit_folder_job_progress(app: &AppHandle, pool: &DbPool, folder_ids: &[i64], force: bool) { let mut unique_folder_ids = folder_ids.to_vec(); unique_folder_ids.sort_unstable(); unique_folder_ids.dedup(); let now = Instant::now(); let emit_tracker = LAST_JOB_PROGRESS_EMIT.get_or_init(|| Mutex::new(HashMap::new())); let mut tracker = match emit_tracker.lock() { Ok(guard) => guard, Err(_) => return, }; unique_folder_ids.retain(|folder_id| { let should_emit = force || tracker .get(folder_id) .map(|last_emit| now.duration_since(*last_emit) >= JOB_PROGRESS_EMIT_INTERVAL) .unwrap_or(true); if should_emit { tracker.insert(*folder_id, now); } should_emit }); drop(tracker); if unique_folder_ids.is_empty() { return; } let Ok(conn) = pool.get() else { return; }; let progress = unique_folder_ids .into_iter() .filter_map(|folder_id| db::get_folder_job_progress(&conn, folder_id).ok()) .collect::>(); if !progress.is_empty() { let _ = app.emit("media-job-progress", MediaJobProgressEvent { progress }); } } fn is_supported_media(path: &Path) -> bool { path.extension() .and_then(|value| value.to_str()) .map(|value| { let extension = value.to_lowercase(); IMAGE_EXTENSIONS.contains(&extension.as_str()) || VIDEO_EXTENSIONS.contains(&extension.as_str()) }) .unwrap_or(false) } fn media_kind_for_ext(ext: &str) -> &'static str { match ext.to_lowercase().as_str() { "mp4" | "mov" | "m4v" | "webm" => "video", _ => "image", } } fn mime_for_ext(ext: &str) -> &'static str { match ext.to_lowercase().as_str() { "jpg" | "jpeg" => "image/jpeg", "png" => "image/png", "gif" => "image/gif", "bmp" => "image/bmp", "webp" => "image/webp", "tiff" | "tif" => "image/tiff", "avif" => "image/avif", "mp4" | "m4v" => "video/mp4", "mov" => "video/quicktime", "webm" => "video/webm", _ => "image/jpeg", } } // ── Filesystem watcher ──────────────────────────────────────────────────────── /// How long to wait after the last event for a path before processing it. /// Absorbs bursts of OS events that accompany a single logical file write. const WATCHER_DEBOUNCE: Duration = Duration::from_millis(500); struct WatcherInner { watcher: Mutex, /// Maps each watched folder root → its folder_id in the DB. folder_map: Arc>>, } /// Shared handle that lets command handlers register and deregister watched /// directories without touching the debounce thread directly. #[derive(Clone)] pub struct WatcherHandle { inner: Arc, } impl WatcherHandle { pub fn add_folder(&self, path: PathBuf, folder_id: i64) { { let mut w = self.inner.watcher.lock().unwrap(); if path.is_dir() { if let Err(e) = w.watch(&path, RecursiveMode::Recursive) { log::error!("Watcher: failed to watch {path:?}: {e}"); } } } self.inner .folder_map .lock() .unwrap() .insert(path, folder_id); } pub fn remove_folder(&self, path: &Path) { { let mut w = self.inner.watcher.lock().unwrap(); let _ = w.unwatch(path); } self.inner.folder_map.lock().unwrap().remove(path); } pub fn update_folder(&self, old_path: &Path, new_path: PathBuf, folder_id: i64) { { let mut w = self.inner.watcher.lock().unwrap(); let _ = w.unwatch(old_path); if new_path.is_dir() { if let Err(e) = w.watch(&new_path, RecursiveMode::Recursive) { log::error!("Watcher: failed to watch {new_path:?}: {e}"); } } } let mut map = self.inner.folder_map.lock().unwrap(); map.remove(old_path); map.insert(new_path, folder_id); } } /// Start the filesystem watcher. Watches all folders currently in the DB and /// returns a handle that command handlers can use to add/remove watched paths. /// /// The debounce loop uses adaptive blocking: /// - `recv()` when no events are pending — zero CPU /// - `recv_timeout(earliest_deadline)` when events are pending — wakes exactly /// when the soonest debounce window expires, no busy-polling pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> WatcherHandle { let (tx, rx) = std::sync::mpsc::channel::>(); let raw_watcher = notify::recommended_watcher(move |result| { let _ = tx.send(result); }) .expect("Failed to create filesystem watcher"); // Seed the folder map from the DB so existing folders are watched on startup. let folder_map: Arc>> = Arc::new(Mutex::new(HashMap::new())); { let conn = pool .get() .expect("Watcher: failed to get DB connection for init"); let folders = db::get_folders(&conn).unwrap_or_default(); let mut map = folder_map.lock().unwrap(); for f in folders { map.insert(PathBuf::from(f.path), f.id); } } // Register each known folder with the OS watcher. let raw_watcher = { let mut w = raw_watcher; let map = folder_map.lock().unwrap(); for path in map.keys() { if path.is_dir() { if let Err(e) = w.watch(path, RecursiveMode::Recursive) { log::error!("Watcher: failed to watch {path:?}: {e}"); } } } w }; let handle = WatcherHandle { inner: Arc::new(WatcherInner { watcher: Mutex::new(raw_watcher), folder_map: Arc::clone(&folder_map), }), }; // Spawn the debounce loop on its own thread. let folder_map_thread = Arc::clone(&folder_map); std::thread::spawn(move || { // path → deadline: the earliest instant at which this path should be processed. let mut pending: HashMap = HashMap::new(); // old_path → new_path for rename events that carry both sides atomically. let mut pending_renames: Vec<(PathBuf, PathBuf)> = Vec::new(); loop { // Adaptive blocking: block forever when idle, wake at the earliest // deadline when events are queued. let received = if pending.is_empty() && pending_renames.is_empty() { match rx.recv() { Ok(e) => Some(e), Err(_) => break, // sender dropped — app is shutting down } } else { let earliest = pending.values().copied().min().unwrap_or(Instant::now()); let timeout = earliest.saturating_duration_since(Instant::now()); match rx.recv_timeout(timeout) { Ok(e) => Some(e), Err(std::sync::mpsc::RecvTimeoutError::Timeout) => None, Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break, } }; let now = Instant::now(); // Absorb incoming event — coalesces rapid writes into one deadline. if let Some(Ok(event)) = received { use notify::{ event::{ModifyKind, RenameMode}, EventKind, }; if !matches!(event.kind, EventKind::Access(_)) { // Intercept atomic rename events (old + new path in one event). // Handle these separately to preserve embeddings and thumbnails. if matches!( event.kind, EventKind::Modify(ModifyKind::Name(RenameMode::Both)) ) && event.paths.len() == 2 { let old = event.paths[0].clone(); let new = event.paths[1].clone(); if is_supported_media(&old) || is_supported_media(&new) { // Remove either side from regular pending so it isn't // processed as an independent delete/create. pending.remove(&old); pending.remove(&new); pending_renames.push((old, new)); } } else { for path in event.paths { if is_supported_media(&path) { pending.insert(path, now + WATCHER_DEBOUNCE); } } } } } // Process renames immediately — they are atomic filesystem operations // and do not benefit from debouncing. for (old_path, new_path) in pending_renames.drain(..) { process_watcher_rename( &app, &pool, &folder_map_thread, &thumb_dir, &old_path, &new_path, ); } // Process all paths whose debounce window has expired. let ready: Vec = pending .iter() .filter(|(_, &deadline)| deadline <= now) .map(|(p, _)| p.clone()) .collect(); for path in ready { pending.remove(&path); process_watcher_path(&app, &pool, &folder_map_thread, &path); } } }); handle } /// Decide what to do with a path whose debounce window just expired. /// If the file exists → upsert (with change-detection to avoid clobbering /// metadata like thumbnail_path). If the file is gone → delete from DB. fn process_watcher_path( app: &AppHandle, pool: &DbPool, folder_map: &Arc>>, path: &Path, ) { // Determine which registered folder owns this file. let folder_id = { let map = folder_map.lock().unwrap(); map.iter() .find(|(folder_path, _)| path.starts_with(folder_path.as_path())) .map(|(_, &id)| id) }; let Some(folder_id) = folder_id else { return }; let conn = match pool.get() { Ok(c) => c, Err(e) => { log::error!("Watcher: DB pool error: {e}"); return; } }; if path.exists() { // File still on disk — upsert if content changed. let path_str = path.to_string_lossy(); let existing = db::get_indexed_entry_by_path(&conn, &path_str).unwrap_or(None); let Some(record) = build_record(path, folder_id, existing.as_ref()) else { return; // file unchanged (same size + mtime) — nothing to do }; drop(conn); // commit_batch acquires its own connection from the pool match commit_batch(pool, &[record]) { Ok(committed) if !committed.is_empty() => { // Always emit the images — they are committed to the DB. emit_images( app, &IndexedImagesBatch { folder_id, images: committed, }, ); emit_folder_job_progress(app, pool, &[folder_id], false); // Update the sidebar count only if we successfully write the new // count; skip the frontend notification on pool/DB failure to // avoid showing a stale number. if let Ok(count_conn) = pool.get() { if db::update_folder_count(&count_conn, folder_id).is_ok() { let _ = app.emit("folder-counts-changed", ()); } } } Ok(_) => {} Err(e) => log::error!("Watcher: commit error for {path:?}: {e}"), } } else { // File removed from disk — clean up DB row and thumbnail. let path_str = path.to_string_lossy(); match db::get_image_id_and_thumbnail_by_path(&conn, &path_str) { Ok(Some((image_id, thumb_path))) => { if db::delete_images_by_ids(&conn, &[image_id]).is_ok() { if let Some(thumb) = thumb_path { let _ = std::fs::remove_file(thumb); } db::update_folder_count(&conn, folder_id).ok(); let _ = app.emit("watcher-deleted", vec![image_id]); let _ = app.emit("folder-counts-changed", ()); } } Ok(None) => {} // never indexed or already removed Err(e) => log::error!("Watcher: lookup error for {path:?}: {e}"), } } } /// Handles a filesystem rename/move event where both the old and new paths are /// known. Updates the DB row in-place (preserving the embedding) and renames /// the thumbnail file to match the new path hash. fn process_watcher_rename( app: &AppHandle, pool: &DbPool, folder_map: &Arc>>, thumb_dir: &Path, old_path: &Path, new_path: &Path, ) { // Resolve folder_id from the old path first, fall back to new path. let folder_id = { let map = folder_map.lock().unwrap(); map.iter() .find(|(fp, _)| { old_path.starts_with(fp.as_path()) || new_path.starts_with(fp.as_path()) }) .map(|(_, &id)| id) }; let Some(folder_id) = folder_id else { return }; let conn = match pool.get() { Ok(c) => c, Err(e) => { log::error!("Watcher rename: DB pool error: {e}"); return; } }; let old_path_str = old_path.to_string_lossy(); let new_path_str = new_path.to_string_lossy(); let (image_id, old_thumb) = match db::get_image_id_and_thumbnail_by_path(&conn, &old_path_str) { Ok(Some(record)) => record, Ok(None) => { // Not yet indexed under the old path — treat as a brand-new file. drop(conn); process_watcher_path(app, pool, folder_map, new_path); return; } Err(e) => { log::error!("Watcher rename: DB lookup error: {e}"); return; } }; // Compute new thumbnail path and attempt to rename the file on disk. let new_thumb_path = crate::thumbnail::thumb_path(thumb_dir, &new_path_str); let new_thumb_str = new_thumb_path.to_string_lossy().into_owned(); let thumb_ok = old_thumb .as_deref() .map(|old| std::fs::rename(old, &new_thumb_path).is_ok()) .unwrap_or(false); // If rename failed (e.g. old thumb never existed), clear thumbnail_path so // the thumbnail worker regenerates it. let effective_thumb: Option<&str> = if thumb_ok { Some(&new_thumb_str) } else { None }; let new_filename = new_path.file_name().and_then(|f| f.to_str()).unwrap_or(""); if let Err(e) = db::update_image_path( &conn, image_id, &new_path_str, new_filename, effective_thumb, ) { log::error!("Watcher rename: DB update error: {e}"); return; } // Emit the updated record so the frontend refreshes without a full reload. match db::get_image_by_id(&conn, image_id) { Ok(record) => emit_images( app, &IndexedImagesBatch { folder_id, images: vec![record], }, ), Err(e) => log::error!("Watcher rename: post-update fetch error: {e}"), } }