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.
This commit is contained in:
@@ -1747,3 +1747,100 @@ pub async fn vacuum_database(app: AppHandle, db: State<'_, DbState>) -> Result<V
|
||||
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<std::collections::HashSet<String>, 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<OrphanedThumbnailsInfo, String> {
|
||||
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<CleanupOrphanedThumbnailsResult, String> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<DatabaseInfo | null>(null);
|
||||
const [vacuuming, setVacuuming] = useState(false);
|
||||
const [vacuumResult, setVacuumResult] = useState<VacuumResult | null>(null);
|
||||
const [thumbnailInfo, setThumbnailInfo] = useState<OrphanedThumbnailsInfo | null>(null);
|
||||
const [cleaningThumbnails, setCleaningThumbnails] = useState(false);
|
||||
const [thumbnailCleanupResult, setThumbnailCleanupResult] = useState<CleanupOrphanedThumbnailsResult | null>(null);
|
||||
|
||||
const thresholdErrorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const batchSizeErrorTimerRef = useRef<ReturnType<typeof setTimeout> | 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() {
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Thumbnail cache"
|
||||
description="Thumbnails left behind when folders or images are removed. Safe to delete — they will be regenerated if the original files are re-indexed."
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
||||
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Orphaned files</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-white">
|
||||
{thumbnailCleanupResult
|
||||
? thumbnailCleanupResult.deleted_count.toLocaleString()
|
||||
: thumbnailInfo
|
||||
? thumbnailInfo.count.toLocaleString()
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
||||
<p className="text-[11px] uppercase tracking-[0.14em] text-gray-600">Reclaimable</p>
|
||||
<p className={`mt-2 text-2xl font-semibold ${thumbnailCleanupResult ? "text-emerald-300" : "text-white"}`}>
|
||||
{thumbnailCleanupResult
|
||||
? `−${thumbnailCleanupResult.freed_mb.toFixed(1)} MB freed`
|
||||
: thumbnailInfo
|
||||
? `${thumbnailInfo.size_mb.toFixed(1)} MB`
|
||||
: "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-white/[0.07] bg-black/20 p-4">
|
||||
<p className="text-sm text-gray-400">
|
||||
{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."}
|
||||
</p>
|
||||
<button
|
||||
className="shrink-0 rounded-lg bg-white/[0.07] px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-white/[0.11] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => {
|
||||
setCleaningThumbnails(true);
|
||||
cleanupOrphanedThumbnails()
|
||||
.then((result) => {
|
||||
setThumbnailCleanupResult(result);
|
||||
setThumbnailInfo(null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setCleaningThumbnails(false));
|
||||
}}
|
||||
disabled={cleaningThumbnails || thumbnailCleanupResult !== null || (thumbnailInfo !== null && thumbnailInfo.count === 0)}
|
||||
>
|
||||
{cleaningThumbnails ? "Cleaning..." : "Clean up"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SectionShell>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<void>;
|
||||
getDatabaseInfo: () => Promise<DatabaseInfo>;
|
||||
vacuumDatabase: () => Promise<VacuumResult>;
|
||||
getOrphanedThumbnailsInfo: () => Promise<OrphanedThumbnailsInfo>;
|
||||
cleanupOrphanedThumbnails: () => Promise<CleanupOrphanedThumbnailsResult>;
|
||||
retryFailedEmbeddings: (folderId: number) => Promise<void>;
|
||||
updateImageDetails: (imageId: number, updates: { favorite?: boolean; rating?: number }) => Promise<void>;
|
||||
setCacheDir: (dir: string) => void;
|
||||
@@ -1367,6 +1379,10 @@ export const useGalleryStore = create<GalleryState>((set, get) => ({
|
||||
|
||||
vacuumDatabase: () => invoke<VacuumResult>("vacuum_database"),
|
||||
|
||||
getOrphanedThumbnailsInfo: () => invoke<OrphanedThumbnailsInfo>("get_orphaned_thumbnails_info"),
|
||||
|
||||
cleanupOrphanedThumbnails: () => invoke<CleanupOrphanedThumbnailsResult>("cleanup_orphaned_thumbnails"),
|
||||
|
||||
loadTaggerModelStatus: async () => {
|
||||
try {
|
||||
const taggerModelStatus = await invoke<TaggerModelStatus>("get_tagger_model_status");
|
||||
|
||||
Reference in New Issue
Block a user