From 3707a35cc4fef3aa4da0878a3a73256c022f4a77 Mon Sep 17 00:00:00 2001 From: LyAhn Date: Tue, 9 Jun 2026 01:09:29 +0100 Subject: [PATCH] feat(settings): add orphaned thumbnail cleanup to General section Scans the thumbnails directory and cross-references against thumbnail_path values in the images table, deleting any files not linked to an indexed image. Surfaces count and reclaimable MB in the General section alongside the existing Compact database card. --- src-tauri/src/commands.rs | 97 ++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/components/SettingsModal.tsx | 66 +++++++++++++++++++++- src/store.ts | 16 ++++++ 4 files changed, 179 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 78ad8e4..7be9948 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1747,3 +1747,100 @@ pub async fn vacuum_database(app: AppHandle, db: State<'_, DbState>) -> Result Result, String> { + let mut stmt = conn + .prepare("SELECT thumbnail_path FROM images WHERE thumbnail_path IS NOT NULL") + .map_err(|e| e.to_string())?; + let set = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .filter_map(|p| { + std::path::Path::new(&p) + .file_name() + .and_then(|f| f.to_str()) + .map(|s| s.to_owned()) + }) + .collect(); + Ok(set) +} + +#[tauri::command] +pub async fn get_orphaned_thumbnails_info( + app: AppHandle, + db: State<'_, DbState>, +) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let thumb_dir = app_dir.join("thumbnails"); + + let conn = db.get().map_err(|e| e.to_string())?; + let db_filenames = collect_db_thumbnail_filenames(&conn)?; + drop(conn); + + let mut count = 0u64; + let mut size_bytes = 0u64; + + if thumb_dir.exists() { + for entry in std::fs::read_dir(&thumb_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let fname = entry.file_name(); + if !db_filenames.contains(fname.to_string_lossy().as_ref()) { + size_bytes += entry.metadata().map(|m| m.len()).unwrap_or(0); + count += 1; + } + } + } + + Ok(OrphanedThumbnailsInfo { + count, + size_mb: size_bytes as f64 / 1_048_576.0, + }) +} + +#[tauri::command] +pub async fn cleanup_orphaned_thumbnails( + app: AppHandle, + db: State<'_, DbState>, +) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let thumb_dir = app_dir.join("thumbnails"); + + let conn = db.get().map_err(|e| e.to_string())?; + let db_filenames = collect_db_thumbnail_filenames(&conn)?; + drop(conn); + + let mut deleted_count = 0u64; + let mut freed_bytes = 0u64; + + if thumb_dir.exists() { + for entry in std::fs::read_dir(&thumb_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let fname = entry.file_name(); + if !db_filenames.contains(fname.to_string_lossy().as_ref()) { + let size = entry.metadata().map(|m| m.len()).unwrap_or(0); + if std::fs::remove_file(entry.path()).is_ok() { + deleted_count += 1; + freed_bytes += size; + } + } + } + } + + Ok(CleanupOrphanedThumbnailsResult { + deleted_count, + freed_mb: freed_bytes as f64 / 1_048_576.0, + }) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 01c05b8..00ba11a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -146,6 +146,8 @@ pub fn run() { commands::open_app_data_folder, commands::get_database_info, commands::vacuum_database, + commands::get_orphaned_thumbnails_info, + commands::cleanup_orphaned_thumbnails, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 9ec06a4..a00accc 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from "react"; -import { DatabaseInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; +import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; type SettingsSection = "workspace" | "general"; @@ -120,6 +120,9 @@ export function SettingsModal() { const [dbInfo, setDbInfo] = useState(null); const [vacuuming, setVacuuming] = useState(false); const [vacuumResult, setVacuumResult] = useState(null); + const [thumbnailInfo, setThumbnailInfo] = useState(null); + const [cleaningThumbnails, setCleaningThumbnails] = useState(false); + const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState(null); const thresholdErrorTimerRef = useRef | null>(null); const batchSizeErrorTimerRef = useRef | null>(null); @@ -161,6 +164,8 @@ export function SettingsModal() { const openAppDataFolder = useGalleryStore((state) => state.openAppDataFolder); const getDatabaseInfo = useGalleryStore((state) => state.getDatabaseInfo); const vacuumDatabase = useGalleryStore((state) => state.vacuumDatabase); + const getOrphanedThumbnailsInfo = useGalleryStore((state) => state.getOrphanedThumbnailsInfo); + const cleanupOrphanedThumbnails = useGalleryStore((state) => state.cleanupOrphanedThumbnails); useEffect(() => { if (!settingsOpen) return; @@ -181,8 +186,10 @@ export function SettingsModal() { useEffect(() => { if (!settingsOpen || activeSection !== "general") return; setVacuumResult(null); + setThumbnailCleanupResult(null); void getDatabaseInfo().then(setDbInfo).catch(() => {}); - }, [settingsOpen, activeSection, getDatabaseInfo]); + void getOrphanedThumbnailsInfo().then(setThumbnailInfo).catch(() => {}); + }, [settingsOpen, activeSection, getDatabaseInfo, getOrphanedThumbnailsInfo]); // Clean up error timers on unmount useEffect(() => { @@ -649,6 +656,61 @@ export function SettingsModal() { + + +
+
+
+

Orphaned files

+

+ {thumbnailCleanupResult + ? thumbnailCleanupResult.deleted_count.toLocaleString() + : thumbnailInfo + ? thumbnailInfo.count.toLocaleString() + : "—"} +

+
+
+

Reclaimable

+

+ {thumbnailCleanupResult + ? `−${thumbnailCleanupResult.freed_mb.toFixed(1)} MB freed` + : thumbnailInfo + ? `${thumbnailInfo.size_mb.toFixed(1)} MB` + : "—"} +

+
+
+
+

+ {thumbnailCleanupResult + ? `Removed ${thumbnailCleanupResult.deleted_count.toLocaleString()} orphaned thumbnail${thumbnailCleanupResult.deleted_count === 1 ? "" : "s"}.` + : thumbnailInfo && thumbnailInfo.count === 0 + ? "No orphaned thumbnails found." + : "Remove thumbnails no longer associated with any indexed image."} +

+ +
+
+
)} diff --git a/src/store.ts b/src/store.ts index 298655d..b4a0090 100644 --- a/src/store.ts +++ b/src/store.ts @@ -82,6 +82,16 @@ export interface VacuumResult { freed_mb: number; } +export interface OrphanedThumbnailsInfo { + count: number; + size_mb: number; +} + +export interface CleanupOrphanedThumbnailsResult { + deleted_count: number; + freed_mb: number; +} + export interface TaggerModelStatus { model_id: string; model_name: string; @@ -353,6 +363,8 @@ interface GalleryState { openAppDataFolder: () => Promise; getDatabaseInfo: () => Promise; vacuumDatabase: () => Promise; + getOrphanedThumbnailsInfo: () => Promise; + cleanupOrphanedThumbnails: () => Promise; retryFailedEmbeddings: (folderId: number) => Promise; updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise; setCacheDir: (dir: string) => void; @@ -1367,6 +1379,10 @@ export const useGalleryStore = create((set, get) => ({ vacuumDatabase: () => invoke("vacuum_database"), + getOrphanedThumbnailsInfo: () => invoke("get_orphaned_thumbnails_info"), + + cleanupOrphanedThumbnails: () => invoke("cleanup_orphaned_thumbnails"), + loadTaggerModelStatus: async () => { try { const taggerModelStatus = await invoke("get_tagger_model_status");