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:
@@ -264,10 +264,15 @@ pub async fn remove_folder(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|f| f.id == folder_id)
|
.find(|f| f.id == folder_id)
|
||||||
.map(|f| PathBuf::from(f.path));
|
.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())?;
|
db::delete_folder(&conn, folder_id).map_err(|e| e.to_string())?;
|
||||||
if let Some(path) = folder_path {
|
if let Some(path) = folder_path {
|
||||||
watcher.remove_folder(&path);
|
watcher.remove_folder(&path);
|
||||||
}
|
}
|
||||||
|
for thumb in &thumb_paths {
|
||||||
|
let _ = std::fs::remove_file(thumb);
|
||||||
|
}
|
||||||
Ok(())
|
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)) {
|
for r in records.into_iter().filter(|r| id_set.contains(&r.id)) {
|
||||||
if std::fs::remove_file(&r.path).is_ok() {
|
if std::fs::remove_file(&r.path).is_ok() {
|
||||||
succeeded_ids.push(r.id);
|
succeeded_ids.push(r.id);
|
||||||
|
if let Some(thumb) = &r.thumbnail_path {
|
||||||
|
let _ = std::fs::remove_file(thumb);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !succeeded_ids.is_empty() {
|
if !succeeded_ids.is_empty() {
|
||||||
|
|||||||
+52
-1
@@ -1676,6 +1676,7 @@ pub fn search_tags_autocomplete(
|
|||||||
pub struct ImagePathRecord {
|
pub struct ImagePathRecord {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub path: String,
|
pub path: String,
|
||||||
|
pub thumbnail_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_all_image_paths(
|
pub fn get_all_image_paths(
|
||||||
@@ -1683,19 +1684,69 @@ pub fn get_all_image_paths(
|
|||||||
folder_id: Option<i64>,
|
folder_id: Option<i64>,
|
||||||
) -> Result<Vec<ImagePathRecord>> {
|
) -> Result<Vec<ImagePathRecord>> {
|
||||||
let mut stmt = conn.prepare(
|
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
|
let rows = stmt
|
||||||
.query_map(params![folder_id], |row| {
|
.query_map(params![folder_id], |row| {
|
||||||
Ok(ImagePathRecord {
|
Ok(ImagePathRecord {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
path: row.get(1)?,
|
path: row.get(1)?,
|
||||||
|
thumbnail_path: row.get(2)?,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
Ok(rows)
|
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(
|
pub fn get_explore_tags(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
folder_id: Option<i64>,
|
folder_id: Option<i64>,
|
||||||
|
|||||||
+109
-8
@@ -1347,7 +1347,7 @@ impl WatcherHandle {
|
|||||||
/// - `recv()` when no events are pending — zero CPU
|
/// - `recv()` when no events are pending — zero CPU
|
||||||
/// - `recv_timeout(earliest_deadline)` when events are pending — wakes exactly
|
/// - `recv_timeout(earliest_deadline)` when events are pending — wakes exactly
|
||||||
/// when the soonest debounce window expires, no busy-polling
|
/// 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 (tx, rx) = std::sync::mpsc::channel::<notify::Result<notify::Event>>();
|
||||||
|
|
||||||
let raw_watcher = notify::recommended_watcher(move |result| {
|
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 || {
|
std::thread::spawn(move || {
|
||||||
// path → deadline: the earliest instant at which this path should be processed.
|
// path → deadline: the earliest instant at which this path should be processed.
|
||||||
let mut pending: HashMap<PathBuf, Instant> = HashMap::new();
|
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 {
|
loop {
|
||||||
// Adaptive blocking: block forever when idle, wake at the earliest
|
// Adaptive blocking: block forever when idle, wake at the earliest
|
||||||
// deadline when events are queued.
|
// deadline when events are queued.
|
||||||
let received = if pending.is_empty() {
|
let received = if pending.is_empty() && pending_renames.is_empty() {
|
||||||
match rx.recv() {
|
match rx.recv() {
|
||||||
Ok(e) => Some(e),
|
Ok(e) => Some(e),
|
||||||
Err(_) => break, // sender dropped — app is shutting down
|
Err(_) => break, // sender dropped — app is shutting down
|
||||||
}
|
}
|
||||||
} else {
|
} 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());
|
let timeout = earliest.saturating_duration_since(Instant::now());
|
||||||
match rx.recv_timeout(timeout) {
|
match rx.recv_timeout(timeout) {
|
||||||
Ok(e) => Some(e),
|
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.
|
// Absorb incoming event — coalesces rapid writes into one deadline.
|
||||||
if let Some(Ok(event)) = received {
|
if let Some(Ok(event)) = received {
|
||||||
use notify::EventKind;
|
use notify::{EventKind, event::{ModifyKind, RenameMode}};
|
||||||
// Skip pure access events (reads); they never change file content.
|
|
||||||
if !matches!(event.kind, EventKind::Access(_)) {
|
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 {
|
for path in event.paths {
|
||||||
if is_supported_media(&path) {
|
if is_supported_media(&path) {
|
||||||
pending.insert(path, now + WATCHER_DEBOUNCE);
|
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.
|
// Process all paths whose debounce window has expired.
|
||||||
let ready: Vec<PathBuf> = pending
|
let ready: Vec<PathBuf> = pending
|
||||||
@@ -1496,11 +1521,14 @@ fn process_watcher_path(
|
|||||||
Err(e) => eprintln!("Watcher: commit error for {:?}: {}", path, e),
|
Err(e) => eprintln!("Watcher: commit error for {:?}: {}", path, e),
|
||||||
}
|
}
|
||||||
} else {
|
} 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();
|
let path_str = path.to_string_lossy();
|
||||||
match db::get_image_id_by_path(&conn, &path_str) {
|
match db::get_image_id_and_thumbnail_by_path(&conn, &path_str) {
|
||||||
Ok(Some(image_id)) => {
|
Ok(Some((image_id, thumb_path))) => {
|
||||||
if db::delete_images_by_ids(&conn, &[image_id]).is_ok() {
|
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();
|
db::update_folder_count(&conn, folder_id).ok();
|
||||||
let _ = app.emit("watcher-deleted", vec![image_id]);
|
let _ = app.emit("watcher-deleted", vec![image_id]);
|
||||||
let _ = app.emit("folder-counts-changed", ());
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ pub fn run() {
|
|||||||
// indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
|
// indexer::start_caption_worker(app.handle().clone(), pool.clone(), app_dir.clone());
|
||||||
indexer::start_tagging_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(pool);
|
||||||
app.manage(media_tools);
|
app.manage(media_tools);
|
||||||
|
|||||||
Reference in New Issue
Block a user