feat: smart thumbnail cleanup and rename-aware file tracking

Delete thumbnails alongside DB rows in all three removal paths (watcher
detect, delete-from-disk command, remove-folder command) so orphans no
longer accumulate passively.

Intercept RenameMode::Both watcher events and handle them as in-place
path updates: renames the thumbnail file to the new hash and updates the
DB row without touching the embedding, avoiding a full rebuild on move.
Falls back to thumbnail regeneration if the file rename fails.
This commit is contained in:
2026-06-09 01:16:12 +01:00
parent 8eaa0bd8e8
commit ceb51f8fad
4 changed files with 173 additions and 13 deletions
+8
View File
@@ -264,10 +264,15 @@ pub async fn remove_folder(
.into_iter()
.find(|f| f.id == folder_id)
.map(|f| PathBuf::from(f.path));
// Collect thumbnail paths before the cascade delete removes the rows.
let thumb_paths = db::get_thumbnail_paths_for_folder(&conn, folder_id).unwrap_or_default();
db::delete_folder(&conn, folder_id).map_err(|e| e.to_string())?;
if let Some(path) = folder_path {
watcher.remove_folder(&path);
}
for thumb in &thumb_paths {
let _ = std::fs::remove_file(thumb);
}
Ok(())
}
@@ -1192,6 +1197,9 @@ pub async fn delete_images_from_disk(
for r in records.into_iter().filter(|r| id_set.contains(&r.id)) {
if std::fs::remove_file(&r.path).is_ok() {
succeeded_ids.push(r.id);
if let Some(thumb) = &r.thumbnail_path {
let _ = std::fs::remove_file(thumb);
}
}
}
if !succeeded_ids.is_empty() {
+52 -1
View File
@@ -1676,6 +1676,7 @@ pub fn search_tags_autocomplete(
pub struct ImagePathRecord {
pub id: i64,
pub path: String,
pub thumbnail_path: Option<String>,
}
pub fn get_all_image_paths(
@@ -1683,19 +1684,69 @@ pub fn get_all_image_paths(
folder_id: Option<i64>,
) -> Result<Vec<ImagePathRecord>> {
let mut stmt = conn.prepare(
"SELECT id, path FROM images WHERE (?1 IS NULL OR folder_id = ?1) ORDER BY id",
"SELECT id, path, thumbnail_path FROM images WHERE (?1 IS NULL OR folder_id = ?1) ORDER BY id",
)?;
let rows = stmt
.query_map(params![folder_id], |row| {
Ok(ImagePathRecord {
id: row.get(0)?,
path: row.get(1)?,
thumbnail_path: row.get(2)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
/// Returns (image_id, thumbnail_path) for the given path. Used by the watcher
/// delete branch so it can clean up the thumbnail file in the same step.
pub fn get_image_id_and_thumbnail_by_path(
conn: &Connection,
path: &str,
) -> Result<Option<(i64, Option<String>)>> {
let result = conn.query_row(
"SELECT id, thumbnail_path FROM images WHERE path = ?1",
params![path],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option<String>>(1)?)),
);
match result {
Ok(v) => Ok(Some(v)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
/// Returns all non-null thumbnail_path values for images in a folder.
/// Called before folder deletion so callers can remove the files from disk.
pub fn get_thumbnail_paths_for_folder(
conn: &Connection,
folder_id: i64,
) -> Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT thumbnail_path FROM images WHERE folder_id = ?1 AND thumbnail_path IS NOT NULL",
)?;
let rows = stmt
.query_map([folder_id], |row| row.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
/// Updates a moved/renamed image's path and thumbnail_path in-place.
/// Pass `new_thumbnail_path = None` to clear it (triggers regeneration).
pub fn update_image_path(
conn: &Connection,
image_id: i64,
new_path: &str,
new_filename: &str,
new_thumbnail_path: Option<&str>,
) -> Result<()> {
conn.execute(
"UPDATE images SET path = ?2, filename = ?3, thumbnail_path = ?4 WHERE id = ?1",
params![image_id, new_path, new_filename, new_thumbnail_path],
)?;
Ok(())
}
pub fn get_explore_tags(
conn: &Connection,
folder_id: Option<i64>,
+109 -8
View File
@@ -1347,7 +1347,7 @@ impl WatcherHandle {
/// - `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) -> WatcherHandle {
pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> WatcherHandle {
let (tx, rx) = std::sync::mpsc::channel::<notify::Result<notify::Event>>();
let raw_watcher = notify::recommended_watcher(move |result| {
@@ -1392,17 +1392,19 @@ pub fn start_watcher(app: AppHandle, pool: DbPool) -> WatcherHandle {
std::thread::spawn(move || {
// path → deadline: the earliest instant at which this path should be processed.
let mut pending: HashMap<PathBuf, Instant> = 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() {
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(); // safe: non-empty
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),
@@ -1415,9 +1417,25 @@ pub fn start_watcher(app: AppHandle, pool: DbPool) -> WatcherHandle {
// Absorb incoming event — coalesces rapid writes into one deadline.
if let Some(Ok(event)) = received {
use notify::EventKind;
// Skip pure access events (reads); they never change file content.
use notify::{EventKind, event::{ModifyKind, RenameMode}};
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);
@@ -1425,6 +1443,13 @@ pub fn start_watcher(app: AppHandle, pool: DbPool) -> WatcherHandle {
}
}
}
}
// 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<PathBuf> = pending
@@ -1496,11 +1521,14 @@ fn process_watcher_path(
Err(e) => eprintln!("Watcher: commit error for {:?}: {}", path, e),
}
} else {
// File removed from disk — clean up DB row.
// File removed from disk — clean up DB row and thumbnail.
let path_str = path.to_string_lossy();
match db::get_image_id_by_path(&conn, &path_str) {
Ok(Some(image_id)) => {
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", ());
@@ -1511,3 +1539,76 @@ fn process_watcher_path(
}
}
}
/// 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<Mutex<HashMap<PathBuf, i64>>>,
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) => {
eprintln!("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) => {
eprintln!("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) {
eprintln!("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) => eprintln!("Watcher rename: post-update fetch error: {}", e),
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ pub fn run() {
// indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
indexer::start_tagging_worker(app.handle().clone(), pool.clone(), app_dir.clone());
let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone());
let watcher_handle = indexer::start_watcher(app.handle().clone(), pool.clone(), thumb_dir.clone());
app.manage(pool);
app.manage(media_tools);