diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1eabb3b..7be9948 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1614,3 +1614,233 @@ pub async fn remove_tag(db: State<'_, DbState>, params: RemoveTagParams) -> Resu let conn = db.get().map_err(|e| e.to_string())?; db::remove_tag(&conn, params.tag_id).map_err(|e| e.to_string()) } + +// --------------------------------------------------------------------------- +// Queue scope / folder-id persistence +// --------------------------------------------------------------------------- + +const TAGGING_QUEUE_SCOPE_FILE: &str = "settings/tagging_queue_scope.txt"; +const TAGGING_QUEUE_FOLDER_IDS_FILE: &str = "settings/tagging_queue_folder_ids.txt"; + +#[derive(Deserialize)] +pub struct SetTaggingQueueScopeParams { + pub scope: String, +} + +#[derive(Deserialize)] +pub struct SetTaggingQueueFolderIdsParams { + pub folder_ids: Vec, +} + +#[tauri::command] +pub async fn get_tagging_queue_scope(app: AppHandle) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = app_dir.join(TAGGING_QUEUE_SCOPE_FILE); + let value = std::fs::read_to_string(path).unwrap_or_default(); + Ok(if value.trim() == "selected" { "selected".to_string() } else { "all".to_string() }) +} + +#[tauri::command] +pub async fn set_tagging_queue_scope( + app: AppHandle, + params: SetTaggingQueueScopeParams, +) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = app_dir.join(TAGGING_QUEUE_SCOPE_FILE); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let value = if params.scope == "selected" { "selected" } else { "all" }; + std::fs::write(path, value).map_err(|e| e.to_string())?; + Ok(value.to_string()) +} + +#[tauri::command] +pub async fn get_tagging_queue_folder_ids(app: AppHandle) -> Result, String> { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = app_dir.join(TAGGING_QUEUE_FOLDER_IDS_FILE); + let Ok(content) = std::fs::read_to_string(path) else { + return Ok(vec![]); + }; + let ids = content + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .collect(); + Ok(ids) +} + +#[tauri::command] +pub async fn set_tagging_queue_folder_ids( + app: AppHandle, + params: SetTaggingQueueFolderIdsParams, +) -> Result<(), String> { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let path = app_dir.join(TAGGING_QUEUE_FOLDER_IDS_FILE); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let content: Vec = params.folder_ids.iter().map(|id| id.to_string()).collect(); + std::fs::write(path, content.join(",")).map_err(|e| e.to_string())?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// App data folder +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn open_app_data_folder(app: AppHandle) -> Result<(), String> { + use tauri_plugin_opener::OpenerExt; + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + app.opener() + .open_path(app_dir.to_string_lossy().as_ref(), None::<&str>) + .map_err(|e| e.to_string()) +} + +// --------------------------------------------------------------------------- +// Database maintenance +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct DatabaseInfo { + pub size_mb: f64, + pub reclaimable_mb: f64, +} + +#[derive(Serialize)] +pub struct VacuumResult { + pub before_mb: f64, + pub after_mb: f64, + pub freed_mb: f64, +} + +#[tauri::command] +pub async fn get_database_info(app: AppHandle, db: State<'_, DbState>) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let db_path = app_dir.join("gallery.db"); + let size_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + + let conn = db.get().map_err(|e| e.to_string())?; + let page_size: i64 = conn.query_row("PRAGMA page_size", [], |r| r.get(0)).map_err(|e| e.to_string())?; + let freelist_count: i64 = conn.query_row("PRAGMA freelist_count", [], |r| r.get(0)).map_err(|e| e.to_string())?; + + Ok(DatabaseInfo { + size_mb: size_bytes as f64 / 1_048_576.0, + reclaimable_mb: (freelist_count * page_size) as f64 / 1_048_576.0, + }) +} + +#[tauri::command] +pub async fn vacuum_database(app: AppHandle, db: State<'_, DbState>) -> Result { + let app_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let db_path = app_dir.join("gallery.db"); + let before_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + + let conn = db.get().map_err(|e| e.to_string())?; + conn.execute_batch("PRAGMA wal_checkpoint(FULL); VACUUM;").map_err(|e| e.to_string())?; + drop(conn); + + let after_bytes = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0); + Ok(VacuumResult { + before_mb: before_bytes as f64 / 1_048_576.0, + after_mb: after_bytes as f64 / 1_048_576.0, + freed_mb: before_bytes.saturating_sub(after_bytes) as f64 / 1_048_576.0, + }) +} + +#[derive(serde::Serialize)] +pub struct OrphanedThumbnailsInfo { + pub count: u64, + pub size_mb: f64, +} + +#[derive(serde::Serialize)] +pub struct CleanupOrphanedThumbnailsResult { + pub deleted_count: u64, + pub freed_mb: f64, +} + +fn collect_db_thumbnail_filenames(conn: &rusqlite::Connection) -> 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 bb8a736..00ba11a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -139,6 +139,15 @@ pub fn run() { commands::delete_images_from_disk, commands::rename_folder, commands::update_folder_path, + commands::get_tagging_queue_scope, + commands::set_tagging_queue_scope, + commands::get_tagging_queue_folder_ids, + commands::set_tagging_queue_folder_ids, + 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 d328ac6..a00accc 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,11 +1,11 @@ -import { useEffect, useMemo, useState } from "react"; -import { TaggerAcceleration, TaggingQueueScope, useGalleryStore } from "../store"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { CleanupOrphanedThumbnailsResult, DatabaseInfo, OrphanedThumbnailsInfo, TaggerAcceleration, TaggingQueueScope, VacuumResult, useGalleryStore } from "../store"; -type SettingsSection = "workspace" | "workers"; +type SettingsSection = "workspace" | "general"; const SECTIONS: { id: SettingsSection; label: string; detail: string }[] = [ { id: "workspace", label: "AI Workspace", detail: "Tagging models and queue targets" }, - { id: "workers", label: "Workers", detail: "Queue activity and background processing" }, + { id: "general", label: "General", detail: "App data and diagnostics" }, ]; function StatusPill({ children, tone }: { children: React.ReactNode; tone: "ready" | "muted" | "busy" }) { @@ -109,19 +109,35 @@ export function SettingsModal() { const [taggerQueueing, setTaggerQueueing] = useState(false); const [taggerClearing, setTaggerClearing] = useState(false); const [taggerAccelerationSaving, setTaggerAccelerationSaving] = useState(false); + const [taggerAccelerationError, setTaggerAccelerationError] = useState(null); const [taggerThresholdDraft, setTaggerThresholdDraft] = useState(null); const [taggerThresholdSaving, setTaggerThresholdSaving] = useState(false); + const [taggerThresholdError, setTaggerThresholdError] = useState(null); const [taggerBatchSizeDraft, setTaggerBatchSizeDraft] = useState(null); const [taggerBatchSizeSaving, setTaggerBatchSizeSaving] = useState(false); + const [taggerBatchSizeError, setTaggerBatchSizeError] = useState(null); + const [openingDataFolder, setOpeningDataFolder] = useState(false); + 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); + const settingsOpen = useGalleryStore((state) => state.settingsOpen); const setSettingsOpen = useGalleryStore((state) => state.setSettingsOpen); const folders = useGalleryStore((state) => state.folders); const mediaJobProgress = useGalleryStore((state) => state.mediaJobProgress); const taggingQueueScope = useGalleryStore((state) => state.taggingQueueScope); const taggingQueueFolderIds = useGalleryStore((state) => state.taggingQueueFolderIds); + const loadTaggingQueueScope = useGalleryStore((state) => state.loadTaggingQueueScope); const setTaggingQueueScope = useGalleryStore((state) => state.setTaggingQueueScope); const toggleTaggingQueueFolder = useGalleryStore((state) => state.toggleTaggingQueueFolder); const setTaggingQueueFolderIds = useGalleryStore((state) => state.setTaggingQueueFolderIds); + const loadTaggingQueueFolderIds = useGalleryStore((state) => state.loadTaggingQueueFolderIds); const taggerModelStatus = useGalleryStore((state) => state.taggerModelStatus); const taggerModelPreparing = useGalleryStore((state) => state.taggerModelPreparing); const taggerModelProgress = useGalleryStore((state) => state.taggerModelProgress); @@ -145,6 +161,11 @@ export function SettingsModal() { const queueTaggingJobsForFolders = useGalleryStore((state) => state.queueTaggingJobsForFolders); const clearTaggingJobs = useGalleryStore((state) => state.clearTaggingJobs); const clearTaggingJobsForFolders = useGalleryStore((state) => state.clearTaggingJobsForFolders); + 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; @@ -152,24 +173,37 @@ export function SettingsModal() { void loadTaggerAcceleration(); void loadTaggerThreshold(); void loadTaggerBatchSize(); + void loadTaggingQueueScope(); + void loadTaggingQueueFolderIds(); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setSettingsOpen(false); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, setSettingsOpen]); + }, [settingsOpen, loadTaggerModelStatus, loadTaggerAcceleration, loadTaggerThreshold, loadTaggerBatchSize, loadTaggingQueueScope, loadTaggingQueueFolderIds, setSettingsOpen]); + + useEffect(() => { + if (!settingsOpen || activeSection !== "general") return; + setVacuumResult(null); + setThumbnailCleanupResult(null); + void getDatabaseInfo().then(setDbInfo).catch(() => {}); + void getOrphanedThumbnailsInfo().then(setThumbnailInfo).catch(() => {}); + }, [settingsOpen, activeSection, getDatabaseInfo, getOrphanedThumbnailsInfo]); + + // Clean up error timers on unmount + useEffect(() => { + return () => { + if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current); + if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current); + }; + }, []); const selectedFolders = useMemo( () => folders.filter((folder) => taggingQueueFolderIds.includes(folder.id)), [folders, taggingQueueFolderIds], ); - const totalQueuedJobs = useMemo( - () => Object.values(mediaJobProgress).reduce((sum, progress) => sum + (progress?.tagging_pending ?? 0), 0), - [mediaJobProgress], - ); - if (!settingsOpen) return null; const taggerReady = taggerModelStatus?.ready ?? false; @@ -268,8 +302,8 @@ export function SettingsModal() {
-

{activeSection === "workspace" ? "AI Workspace" : "Workers"}

-

{activeSection === "workspace" ? "Model setup and queue targets" : "Background processing status"}

+

{activeSection === "workspace" ? "AI Workspace" : "General"}

+

{activeSection === "workspace" ? "Model setup and queue targets" : "App data and diagnostics"}

-

{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}

+ {taggerAccelerationError ? ( +

{taggerAccelerationError}

+ ) : ( +

{taggerAccelerationSaving ? "Saving..." : `Current: ${taggerAcceleration}`}

+ )} @@ -370,19 +409,27 @@ export function SettingsModal() { onBlur={() => { const value = parseFloat(thresholdDisplay); if (!isNaN(value) && value >= 0.05 && value <= 0.99) { + setTaggerThresholdError(null); setTaggerThresholdSaving(true); void setTaggerThreshold(value) - .catch((error) => setTaggerQueueStatus(String(error))) + .catch((error: unknown) => setTaggerThresholdError(String(error))) .finally(() => { setTaggerThresholdDraft(null); setTaggerThresholdSaving(false); }); } else { setTaggerThresholdDraft(null); + setTaggerThresholdError("Must be 0.05 – 0.99"); + if (thresholdErrorTimerRef.current) clearTimeout(thresholdErrorTimerRef.current); + thresholdErrorTimerRef.current = setTimeout(() => setTaggerThresholdError(null), 2000); } }} /> -

{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}

+ {taggerThresholdError ? ( +

{taggerThresholdError}

+ ) : ( +

{taggerThresholdSaving ? "Saving..." : "Default: 0.35"}

+ )} @@ -399,6 +446,7 @@ export function SettingsModal() { onBlur={() => { const value = parseInt(batchSizeDisplay, 10); if (!isNaN(value) && value >= 1 && value <= 100) { + setTaggerBatchSizeError(null); setTaggerBatchSizeSaving(true); void setTaggerBatchSize(value) .catch((error: unknown) => setTaggerQueueStatus(String(error))) @@ -408,10 +456,17 @@ export function SettingsModal() { }); } else { setTaggerBatchSizeDraft(null); + setTaggerBatchSizeError("Must be 1 – 100"); + if (batchSizeErrorTimerRef.current) clearTimeout(batchSizeErrorTimerRef.current); + batchSizeErrorTimerRef.current = setTimeout(() => setTaggerBatchSizeError(null), 2000); } }} /> -

{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}

+ {taggerBatchSizeError ? ( +

{taggerBatchSizeError}

+ ) : ( +

{taggerBatchSizeSaving ? "Saving..." : "Default: 8"}

+ )} @@ -453,16 +508,16 @@ export function SettingsModal() {
@@ -477,12 +532,13 @@ export function SettingsModal() { +
+ + + +
+
+
+

Database size

+

+ {vacuumResult + ? `${vacuumResult.after_mb.toFixed(1)} MB` + : dbInfo + ? `${dbInfo.size_mb.toFixed(1)} MB` + : "—"} +

+
+
+

Reclaimable

+

+ {vacuumResult + ? `−${vacuumResult.freed_mb.toFixed(1)} MB freed` + : dbInfo + ? `${dbInfo.reclaimable_mb.toFixed(1)} MB` + : "—"} +

+
-
-

Selected folders

-

{selectedFolders.length.toLocaleString()}

-
-
-

Library folders

-

{folders.length.toLocaleString()}

+
+

+ {vacuumResult + ? `Compacted from ${vacuumResult.before_mb.toFixed(1)} MB to ${vacuumResult.after_mb.toFixed(1)} MB.` + : dbInfo && dbInfo.reclaimable_mb < 0.5 + ? "Database is already compact." + : "Run this after removing folders or bulk-deleting images."} +

+
- -
-

Workers are running in the background.

- Live + +
+
+
+

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 c82494d..b4a0090 100644 --- a/src/store.ts +++ b/src/store.ts @@ -71,6 +71,27 @@ export interface ImageTag { created_at: string; } +export interface DatabaseInfo { + size_mb: number; + reclaimable_mb: number; +} + +export interface VacuumResult { + before_mb: number; + after_mb: number; + 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; @@ -334,9 +355,16 @@ interface GalleryState { setCaptionDetail: (detail: CaptionDetail) => Promise; setAiCaptionsEnabled: (enabled: boolean) => void; setSettingsOpen: (open: boolean) => void; + loadTaggingQueueScope: () => Promise; setTaggingQueueScope: (scope: TaggingQueueScope) => void; + loadTaggingQueueFolderIds: () => Promise; toggleTaggingQueueFolder: (folderId: number) => void; setTaggingQueueFolderIds: (folderIds: number[]) => void; + 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; @@ -1299,6 +1327,15 @@ export const useGalleryStore = create((set, get) => ({ setSettingsOpen: (settingsOpen) => set({ settingsOpen }), + loadTaggingQueueScope: async () => { + try { + const scope = await invoke("get_tagging_queue_scope"); + set({ taggingQueueScope: scope }); + } catch { + // silently fall back to in-memory default + } + }, + setTaggingQueueScope: (taggingQueueScope) => { set((state) => ({ taggingQueueScope, @@ -1307,6 +1344,16 @@ export const useGalleryStore = create((set, get) => ({ ? [state.folders[0].id] : state.taggingQueueFolderIds, })); + void invoke("set_tagging_queue_scope", { scope: taggingQueueScope }).catch(() => {}); + }, + + loadTaggingQueueFolderIds: async () => { + try { + const folderIds = await invoke("get_tagging_queue_folder_ids"); + set({ taggingQueueFolderIds: folderIds }); + } catch { + // silently fall back to in-memory default + } }, toggleTaggingQueueFolder: (folderId) => { @@ -1314,11 +1361,27 @@ export const useGalleryStore = create((set, get) => ({ const next = state.taggingQueueFolderIds.includes(folderId) ? state.taggingQueueFolderIds.filter((id) => id !== folderId) : [...state.taggingQueueFolderIds, folderId].sort((a, b) => a - b); + void invoke("set_tagging_queue_folder_ids", { folder_ids: next }).catch(() => {}); return { taggingQueueFolderIds: next }; }); }, - setTaggingQueueFolderIds: (taggingQueueFolderIds) => set({ taggingQueueFolderIds }), + setTaggingQueueFolderIds: (taggingQueueFolderIds) => { + set({ taggingQueueFolderIds }); + void invoke("set_tagging_queue_folder_ids", { folder_ids: taggingQueueFolderIds }).catch(() => {}); + }, + + openAppDataFolder: async () => { + await invoke("open_app_data_folder"); + }, + + getDatabaseInfo: () => invoke("get_database_info"), + + vacuumDatabase: () => invoke("vacuum_database"), + + getOrphanedThumbnailsInfo: () => invoke("get_orphaned_thumbnails_info"), + + cleanupOrphanedThumbnails: () => invoke("cleanup_orphaned_thumbnails"), loadTaggerModelStatus: async () => { try {