style(backend): apply rustfmt across the backend
Mechanical cargo fmt pass so the CI rustfmt gate starts green; no semantic changes (verified token-level + cargo check).
This commit is contained in:
+6
-2
@@ -15,13 +15,17 @@ fn main() {
|
||||
if let Ok(path) = &cuda_path {
|
||||
println!("cargo:warning=CUDA Toolkit found at {path} — GPU acceleration enabled.");
|
||||
} else {
|
||||
println!("cargo:warning=CUDA Toolkit (nvcc) found in PATH — GPU acceleration enabled.");
|
||||
println!(
|
||||
"cargo:warning=CUDA Toolkit (nvcc) found in PATH — GPU acceleration enabled."
|
||||
);
|
||||
}
|
||||
} else {
|
||||
println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
println!("cargo:warning= candle-cuda feature is enabled but no CUDA Toolkit found.");
|
||||
println!("cargo:warning= Install CUDA Toolkit from https://developer.nvidia.com/cuda-downloads");
|
||||
println!("cargo:warning= Or build without GPU support: cargo build --no-default-features");
|
||||
println!(
|
||||
"cargo:warning= Or build without GPU support: cargo build --no-default-features"
|
||||
);
|
||||
println!("cargo:warning=━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
|
||||
}
|
||||
}
|
||||
|
||||
+157
-91
@@ -445,7 +445,7 @@ pub async fn find_similar_images(
|
||||
offset,
|
||||
limit + 1,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
.map_err(|e| e.to_string())?;
|
||||
let has_more = matches.len() > limit;
|
||||
let image_ids = matches
|
||||
.into_iter()
|
||||
@@ -499,8 +499,9 @@ pub async fn find_similar_by_region(
|
||||
None => {
|
||||
// Fetch one extra candidate to compensate for the source image that
|
||||
// will be removed, so has_more is accurate and results span multiple pages.
|
||||
let mut ids = vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut ids =
|
||||
vector::search_image_ids_by_embedding(&conn, &embedding, offset + limit + 2)
|
||||
.map_err(|e| e.to_string())?;
|
||||
ids.retain(|&id| id != params.image_id);
|
||||
ids
|
||||
}
|
||||
@@ -639,7 +640,12 @@ pub async fn search_images_by_tag(
|
||||
offset,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(TagSearchPage { images, total, offset, limit })
|
||||
Ok(TagSearchPage {
|
||||
images,
|
||||
total,
|
||||
offset,
|
||||
limit,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -835,7 +841,6 @@ pub struct TagCloudEntry {
|
||||
pub image_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
|
||||
/// Clusters the library's image embeddings with k-means and returns one representative
|
||||
/// image per cluster — the member whose embedding is closest to its cluster centroid.
|
||||
/// Results are cached in SQLite keyed by a hash of the embedded image IDs, so repeated
|
||||
@@ -957,7 +962,8 @@ pub async fn get_explore_tags(
|
||||
params: GetExploreTagsParams,
|
||||
) -> Result<Vec<ExploreTagEntry>, String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(48)).map_err(|e| e.to_string())
|
||||
db::get_explore_tags(&conn, params.folder_id, params.limit.unwrap_or(48))
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -969,8 +975,13 @@ pub async fn search_tags_autocomplete(
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
db::search_tags_autocomplete(&conn, ¶ms.query, params.folder_id, params.limit.unwrap_or(10))
|
||||
.map_err(|e| e.to_string())
|
||||
db::search_tags_autocomplete(
|
||||
&conn,
|
||||
¶ms.query,
|
||||
params.folder_id,
|
||||
params.limit.unwrap_or(10),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -985,12 +996,15 @@ pub async fn find_duplicates(
|
||||
};
|
||||
|
||||
let total = records.len();
|
||||
let _ = app.emit("duplicate_scan_progress", DuplicateScanProgress {
|
||||
phase: "checking".to_string(),
|
||||
processed: 0,
|
||||
total,
|
||||
skipped: 0,
|
||||
});
|
||||
let _ = app.emit(
|
||||
"duplicate_scan_progress",
|
||||
DuplicateScanProgress {
|
||||
phase: "checking".to_string(),
|
||||
processed: 0,
|
||||
total,
|
||||
skipped: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// Three-phase detection.
|
||||
//
|
||||
@@ -1007,8 +1021,11 @@ pub async fn find_duplicates(
|
||||
// For sample-hash groups that contain files > 64 KB, compute a full-file
|
||||
// hash to confirm the match before presenting them as deletable duplicates.
|
||||
let app_hash = app.clone();
|
||||
let (pairs, skipped_before_confirm, candidate_total):
|
||||
(Vec<(u64, u64, i64, String)>, usize, usize) = tokio::task::spawn_blocking(move || {
|
||||
let (pairs, skipped_before_confirm, candidate_total): (
|
||||
Vec<(u64, u64, i64, String)>,
|
||||
usize,
|
||||
usize,
|
||||
) = tokio::task::spawn_blocking(move || {
|
||||
use memmap2::Mmap;
|
||||
use rayon::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
@@ -1016,7 +1033,7 @@ pub async fn find_duplicates(
|
||||
use xxhash_rust::xxh3::{xxh3_64, Xxh3};
|
||||
|
||||
const WINDOW: usize = 16 * 1024; // 16 KB per sample window
|
||||
const N: usize = 4; // windows at 0%, 33%, 66%, 100%
|
||||
const N: usize = 4; // windows at 0%, 33%, 66%, 100%
|
||||
const COVERED: usize = WINDOW * N; // 64 KB total; also full-coverage threshold
|
||||
|
||||
// Hash four evenly-spaced 16 KB windows. For files ≤ 64 KB this is
|
||||
@@ -1048,12 +1065,15 @@ pub async fn find_duplicates(
|
||||
};
|
||||
let done = stat_counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if done % 100 == 0 || done == total {
|
||||
let _ = app_hash.emit("duplicate_scan_progress", DuplicateScanProgress {
|
||||
phase: "checking".to_string(),
|
||||
processed: done,
|
||||
total,
|
||||
skipped: stat_skipped.load(Ordering::Relaxed),
|
||||
});
|
||||
let _ = app_hash.emit(
|
||||
"duplicate_scan_progress",
|
||||
DuplicateScanProgress {
|
||||
phase: "checking".to_string(),
|
||||
processed: done,
|
||||
total,
|
||||
skipped: stat_skipped.load(Ordering::Relaxed),
|
||||
},
|
||||
);
|
||||
}
|
||||
result
|
||||
})
|
||||
@@ -1076,12 +1096,15 @@ pub async fn find_duplicates(
|
||||
|
||||
// ── Phase 2: sample hash ──────────────────────────────────────────
|
||||
let c_total = candidates.len();
|
||||
let _ = app_hash.emit("duplicate_scan_progress", DuplicateScanProgress {
|
||||
phase: "hashing".to_string(),
|
||||
processed: 0,
|
||||
total: c_total,
|
||||
skipped: stat_skipped,
|
||||
});
|
||||
let _ = app_hash.emit(
|
||||
"duplicate_scan_progress",
|
||||
DuplicateScanProgress {
|
||||
phase: "hashing".to_string(),
|
||||
processed: 0,
|
||||
total: c_total,
|
||||
skipped: stat_skipped,
|
||||
},
|
||||
);
|
||||
let counter = AtomicUsize::new(0);
|
||||
let hash_skipped = AtomicUsize::new(0);
|
||||
|
||||
@@ -1103,12 +1126,15 @@ pub async fn find_duplicates(
|
||||
}
|
||||
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if done % 100 == 0 || done == c_total {
|
||||
let _ = app_hash.emit("duplicate_scan_progress", DuplicateScanProgress {
|
||||
phase: "hashing".to_string(),
|
||||
processed: done,
|
||||
total: c_total,
|
||||
skipped: stat_skipped + hash_skipped.load(Ordering::Relaxed),
|
||||
});
|
||||
let _ = app_hash.emit(
|
||||
"duplicate_scan_progress",
|
||||
DuplicateScanProgress {
|
||||
phase: "hashing".to_string(),
|
||||
processed: done,
|
||||
total: c_total,
|
||||
skipped: stat_skipped + hash_skipped.load(Ordering::Relaxed),
|
||||
},
|
||||
);
|
||||
}
|
||||
result
|
||||
})
|
||||
@@ -1126,7 +1152,10 @@ pub async fn find_duplicates(
|
||||
let mut size_hash_map: std::collections::HashMap<(u64, u64), Vec<(i64, String)>> =
|
||||
std::collections::HashMap::new();
|
||||
for (hash, file_size, id, path) in pairs {
|
||||
size_hash_map.entry((hash, file_size)).or_default().push((id, path));
|
||||
size_hash_map
|
||||
.entry((hash, file_size))
|
||||
.or_default()
|
||||
.push((id, path));
|
||||
}
|
||||
|
||||
// Phase 3: for large-file groups (> 64 KB) the sample hash is not exact;
|
||||
@@ -1140,12 +1169,15 @@ pub async fn find_duplicates(
|
||||
.map(|(_, entries)| entries.len())
|
||||
.sum();
|
||||
if confirming_total > 0 {
|
||||
let _ = app.emit("duplicate_scan_progress", DuplicateScanProgress {
|
||||
phase: "confirming".to_string(),
|
||||
processed: 0,
|
||||
total: confirming_total,
|
||||
skipped: skipped_before_confirm,
|
||||
});
|
||||
let _ = app.emit(
|
||||
"duplicate_scan_progress",
|
||||
DuplicateScanProgress {
|
||||
phase: "confirming".to_string(),
|
||||
processed: 0,
|
||||
total: confirming_total,
|
||||
skipped: skipped_before_confirm,
|
||||
},
|
||||
);
|
||||
}
|
||||
let app_confirm = app.clone();
|
||||
let (confirmed, skipped_files): (Vec<(u64, u64, Vec<i64>)>, usize) =
|
||||
@@ -1163,7 +1195,11 @@ pub async fn find_duplicates(
|
||||
.flat_map(|((sample_hash, file_size), entries)| {
|
||||
if file_size <= COVERED {
|
||||
// Sample was already a full hash — one group, no re-read needed.
|
||||
return vec![(sample_hash, file_size, entries.into_iter().map(|(id, _)| id).collect())];
|
||||
return vec![(
|
||||
sample_hash,
|
||||
file_size,
|
||||
entries.into_iter().map(|(id, _)| id).collect(),
|
||||
)];
|
||||
}
|
||||
// Full-file hash pass. Group by full hash so that two sets of
|
||||
// files that collide only in the sample produce separate groups.
|
||||
@@ -1179,13 +1215,16 @@ pub async fn find_duplicates(
|
||||
}
|
||||
let done = counter.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if done % 100 == 0 || done == confirming_total {
|
||||
let _ = app_confirm.emit("duplicate_scan_progress", DuplicateScanProgress {
|
||||
phase: "confirming".to_string(),
|
||||
processed: done,
|
||||
total: confirming_total,
|
||||
skipped: skipped_before_confirm
|
||||
+ confirm_skipped.load(Ordering::Relaxed),
|
||||
});
|
||||
let _ = app_confirm.emit(
|
||||
"duplicate_scan_progress",
|
||||
DuplicateScanProgress {
|
||||
phase: "confirming".to_string(),
|
||||
processed: done,
|
||||
total: confirming_total,
|
||||
skipped: skipped_before_confirm
|
||||
+ confirm_skipped.load(Ordering::Relaxed),
|
||||
},
|
||||
);
|
||||
}
|
||||
(*id, hash)
|
||||
})
|
||||
@@ -1470,16 +1509,17 @@ pub async fn get_worker_states(folder_ids: Vec<i64>) -> Result<Vec<FolderWorkerS
|
||||
Ok(folder_ids
|
||||
.into_iter()
|
||||
.map(|folder_id| {
|
||||
let state = states
|
||||
.get(&folder_id)
|
||||
.copied()
|
||||
.unwrap_or(indexer::FolderWorkerPausedState {
|
||||
thumbnail: false,
|
||||
metadata: false,
|
||||
embedding: false,
|
||||
caption: false,
|
||||
tagging: false,
|
||||
});
|
||||
let state =
|
||||
states
|
||||
.get(&folder_id)
|
||||
.copied()
|
||||
.unwrap_or(indexer::FolderWorkerPausedState {
|
||||
thumbnail: false,
|
||||
metadata: false,
|
||||
embedding: false,
|
||||
caption: false,
|
||||
tagging: false,
|
||||
});
|
||||
FolderWorkerStates {
|
||||
folder_id,
|
||||
thumbnail_paused: state.thumbnail,
|
||||
@@ -1631,7 +1671,11 @@ pub async fn queue_tagging_jobs(
|
||||
) -> Result<usize, String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
let requested_folder_ids = params.folder_ids.unwrap_or_default();
|
||||
let (total, folder_ids) = match (params.folder_id, params.image_id, requested_folder_ids.is_empty()) {
|
||||
let (total, folder_ids) = match (
|
||||
params.folder_id,
|
||||
params.image_id,
|
||||
requested_folder_ids.is_empty(),
|
||||
) {
|
||||
(_, Some(image_id), _) => {
|
||||
db::enqueue_tagging_job(&conn, image_id).map_err(|e| e.to_string())?;
|
||||
// Look up just this image's folder_id rather than fetching all folders
|
||||
@@ -1675,27 +1719,29 @@ pub async fn clear_tagging_jobs(
|
||||
) -> Result<usize, String> {
|
||||
let conn = db.get().map_err(|e| e.to_string())?;
|
||||
let requested_folder_ids = params.folder_ids.unwrap_or_default();
|
||||
let (n, folder_ids): (usize, Vec<i64>) = match (params.folder_id, requested_folder_ids.is_empty()) {
|
||||
(Some(id), _) => (
|
||||
db::clear_tagging_jobs(&conn, Some(id)).map_err(|e| e.to_string())?,
|
||||
vec![id],
|
||||
),
|
||||
(None, false) => {
|
||||
let mut total = 0usize;
|
||||
for &folder_id in &requested_folder_ids {
|
||||
total += db::clear_tagging_jobs(&conn, Some(folder_id)).map_err(|e| e.to_string())?;
|
||||
let (n, folder_ids): (usize, Vec<i64>) =
|
||||
match (params.folder_id, requested_folder_ids.is_empty()) {
|
||||
(Some(id), _) => (
|
||||
db::clear_tagging_jobs(&conn, Some(id)).map_err(|e| e.to_string())?,
|
||||
vec![id],
|
||||
),
|
||||
(None, false) => {
|
||||
let mut total = 0usize;
|
||||
for &folder_id in &requested_folder_ids {
|
||||
total += db::clear_tagging_jobs(&conn, Some(folder_id))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
(total, requested_folder_ids)
|
||||
}
|
||||
(total, requested_folder_ids)
|
||||
}
|
||||
(None, true) => (
|
||||
db::clear_tagging_jobs(&conn, None).map_err(|e| e.to_string())?,
|
||||
db::get_folders(&conn)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|f| f.id)
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
(None, true) => (
|
||||
db::clear_tagging_jobs(&conn, None).map_err(|e| e.to_string())?,
|
||||
db::get_folders(&conn)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|f| f.id)
|
||||
.collect(),
|
||||
),
|
||||
};
|
||||
drop(conn);
|
||||
indexer::emit_folder_job_progress(&app, db.inner(), &folder_ids, true);
|
||||
Ok(n)
|
||||
@@ -1747,7 +1793,11 @@ pub async fn get_tagging_queue_scope(app: AppHandle) -> Result<String, String> {
|
||||
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() })
|
||||
Ok(if value.trim() == "selected" {
|
||||
"selected".to_string()
|
||||
} else {
|
||||
"all".to_string()
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1760,7 +1810,11 @@ pub async fn set_tagging_queue_scope(
|
||||
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" };
|
||||
let value = if params.scope == "selected" {
|
||||
"selected"
|
||||
} else {
|
||||
"all"
|
||||
};
|
||||
std::fs::write(path, value).map_err(|e| e.to_string())?;
|
||||
Ok(value.to_string())
|
||||
}
|
||||
@@ -1825,14 +1879,21 @@ pub struct VacuumResult {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_database_info(app: AppHandle, db: State<'_, DbState>) -> Result<DatabaseInfo, String> {
|
||||
pub async fn get_database_info(
|
||||
app: AppHandle,
|
||||
db: State<'_, DbState>,
|
||||
) -> Result<DatabaseInfo, String> {
|
||||
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())?;
|
||||
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,
|
||||
@@ -1841,13 +1902,17 @@ pub async fn get_database_info(app: AppHandle, db: State<'_, DbState>) -> Result
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn vacuum_database(app: AppHandle, db: State<'_, DbState>) -> Result<VacuumResult, String> {
|
||||
pub async fn vacuum_database(
|
||||
app: AppHandle,
|
||||
db: State<'_, DbState>,
|
||||
) -> Result<VacuumResult, String> {
|
||||
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())?;
|
||||
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);
|
||||
@@ -1870,7 +1935,9 @@ pub struct CleanupOrphanedThumbnailsResult {
|
||||
pub freed_mb: f64,
|
||||
}
|
||||
|
||||
fn collect_db_thumbnail_filenames(conn: &rusqlite::Connection) -> Result<std::collections::HashSet<String>, String> {
|
||||
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())?;
|
||||
@@ -1993,8 +2060,7 @@ pub async fn set_muted_folder_ids(
|
||||
.map(|id| id.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
std::fs::write(settings_dir.join("muted_folder_ids.txt"), content)
|
||||
.map_err(|e| e.to_string())
|
||||
std::fs::write(settings_dir.join("muted_folder_ids.txt"), content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
+30
-18
@@ -318,9 +318,7 @@ pub fn migrate(conn: &Connection) -> Result<()> {
|
||||
// Index must be created after ensure_column adds the column; it cannot live
|
||||
// in the execute_batch above because that batch runs before the column exists
|
||||
// on databases that predate Phase 1.
|
||||
conn.execute_batch(
|
||||
"CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);",
|
||||
)?;
|
||||
conn.execute_batch("CREATE INDEX IF NOT EXISTS idx_images_taken_at ON images(taken_at);")?;
|
||||
ensure_column(conn, "folders", "scan_error", "TEXT")?;
|
||||
|
||||
vector::migrate(conn)?;
|
||||
@@ -1410,7 +1408,10 @@ pub fn update_image_details(
|
||||
|
||||
/// Look up the lightweight indexed-media entry for a single path.
|
||||
/// Used by the filesystem watcher to run change-detection before upserting.
|
||||
pub fn get_indexed_entry_by_path(conn: &Connection, path: &str) -> Result<Option<IndexedMediaEntry>> {
|
||||
pub fn get_indexed_entry_by_path(
|
||||
conn: &Connection,
|
||||
path: &str,
|
||||
) -> Result<Option<IndexedMediaEntry>> {
|
||||
let result = conn.query_row(
|
||||
"SELECT id, path, modified_at, file_size, media_kind FROM images WHERE path = ?1",
|
||||
[path],
|
||||
@@ -1434,11 +1435,9 @@ pub fn get_indexed_entry_by_path(conn: &Connection, path: &str) -> Result<Option
|
||||
/// Look up just the image id for a path. Used by the filesystem watcher
|
||||
/// to find the DB row to delete when a file is removed from disk.
|
||||
pub fn get_image_id_by_path(conn: &Connection, path: &str) -> Result<Option<i64>> {
|
||||
let result = conn.query_row(
|
||||
"SELECT id FROM images WHERE path = ?1",
|
||||
[path],
|
||||
|row| row.get(0),
|
||||
);
|
||||
let result = conn.query_row("SELECT id FROM images WHERE path = ?1", [path], |row| {
|
||||
row.get(0)
|
||||
});
|
||||
match result {
|
||||
Ok(id) => Ok(Some(id)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
@@ -1474,8 +1473,9 @@ pub fn get_images_by_ids(conn: &Connection, image_ids: &[i64]) -> Result<Vec<Ima
|
||||
}
|
||||
|
||||
pub fn get_folders(conn: &Connection) -> Result<Vec<Folder>> {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, path, name, image_count, indexed_at, scan_error FROM folders ORDER BY name")?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, path, name, image_count, indexed_at, scan_error FROM folders ORDER BY name",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(Folder {
|
||||
id: row.get(0)?,
|
||||
@@ -1497,7 +1497,13 @@ pub fn rename_folder(conn: &Connection, folder_id: i64, new_name: &str) -> Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_folder_path(conn: &Connection, folder_id: i64, old_path: &str, new_path: &str, new_name: &str) -> Result<()> {
|
||||
pub fn update_folder_path(
|
||||
conn: &Connection,
|
||||
folder_id: i64,
|
||||
old_path: &str,
|
||||
new_path: &str,
|
||||
new_name: &str,
|
||||
) -> Result<()> {
|
||||
// Both updates must be atomic: if the image path rewrite fails (e.g. a
|
||||
// uniqueness collision) the folder row must not remain at the new location.
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
@@ -1659,7 +1665,13 @@ pub fn search_images_by_tag(
|
||||
AND (?3 = 0 OR i.favorite = 1)
|
||||
AND i.rating >= ?4
|
||||
AND LOWER(TRIM(t.tag)) = ?5",
|
||||
params![folder_id, media_kind, favorites_flag, rating_min, normalized_query],
|
||||
params![
|
||||
folder_id,
|
||||
media_kind,
|
||||
favorites_flag,
|
||||
rating_min,
|
||||
normalized_query
|
||||
],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? as usize;
|
||||
|
||||
@@ -1769,10 +1781,7 @@ pub fn get_image_id_and_thumbnail_by_path(
|
||||
|
||||
/// 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>> {
|
||||
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",
|
||||
)?;
|
||||
@@ -2054,7 +2063,10 @@ pub fn clear_tagging_jobs(conn: &Connection, folder_id: Option<i64>) -> Result<u
|
||||
WHERE status = 'processing'",
|
||||
[],
|
||||
)?;
|
||||
let n = conn.execute("DELETE FROM tagging_jobs WHERE status NOT IN ('processing', 'cancelled')", [])?;
|
||||
let n = conn.execute(
|
||||
"DELETE FROM tagging_jobs WHERE status NOT IN ('processing', 'cancelled')",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE images SET ai_tagger_error = NULL WHERE ai_tagged_at IS NULL",
|
||||
[],
|
||||
|
||||
+46
-13
@@ -7,9 +7,9 @@ use crate::tagger::{self, WdTagger};
|
||||
use crate::thumbnail;
|
||||
use crate::vector;
|
||||
use anyhow::Result;
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use rayon::prelude::*;
|
||||
use serde::Serialize;
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
@@ -1401,7 +1401,11 @@ impl WatcherHandle {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.inner.folder_map.lock().unwrap().insert(path, folder_id);
|
||||
self.inner
|
||||
.folder_map
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(path, folder_id);
|
||||
}
|
||||
|
||||
pub fn remove_folder(&self, path: &Path) {
|
||||
@@ -1446,7 +1450,9 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
|
||||
// Seed the folder map from the DB so existing folders are watched on startup.
|
||||
let folder_map: Arc<Mutex<HashMap<PathBuf, i64>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
{
|
||||
let conn = pool.get().expect("Watcher: failed to get DB connection for init");
|
||||
let conn = pool
|
||||
.get()
|
||||
.expect("Watcher: failed to get DB connection for init");
|
||||
let folders = db::get_folders(&conn).unwrap_or_default();
|
||||
let mut map = folder_map.lock().unwrap();
|
||||
for f in folders {
|
||||
@@ -1505,7 +1511,10 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
|
||||
|
||||
// Absorb incoming event — coalesces rapid writes into one deadline.
|
||||
if let Some(Ok(event)) = received {
|
||||
use notify::{EventKind, event::{ModifyKind, RenameMode}};
|
||||
use notify::{
|
||||
event::{ModifyKind, RenameMode},
|
||||
EventKind,
|
||||
};
|
||||
if !matches!(event.kind, EventKind::Access(_)) {
|
||||
// Intercept atomic rename events (old + new path in one event).
|
||||
// Handle these separately to preserve embeddings and thumbnails.
|
||||
@@ -1536,7 +1545,14 @@ pub fn start_watcher(app: AppHandle, pool: DbPool, thumb_dir: PathBuf) -> Watche
|
||||
// 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_watcher_rename(
|
||||
&app,
|
||||
&pool,
|
||||
&folder_map_thread,
|
||||
&thumb_dir,
|
||||
&old_path,
|
||||
&new_path,
|
||||
);
|
||||
}
|
||||
|
||||
// Process all paths whose debounce window has expired.
|
||||
@@ -1594,7 +1610,13 @@ fn process_watcher_path(
|
||||
match commit_batch(pool, &[record]) {
|
||||
Ok(committed) if !committed.is_empty() => {
|
||||
// Always emit the images — they are committed to the DB.
|
||||
emit_images(app, &IndexedImagesBatch { folder_id, images: committed });
|
||||
emit_images(
|
||||
app,
|
||||
&IndexedImagesBatch {
|
||||
folder_id,
|
||||
images: committed,
|
||||
},
|
||||
);
|
||||
emit_folder_job_progress(app, pool, &[folder_id], false);
|
||||
// Update the sidebar count only if we successfully write the new
|
||||
// count; skip the frontend notification on pool/DB failure to
|
||||
@@ -1643,7 +1665,9 @@ fn process_watcher_rename(
|
||||
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()))
|
||||
.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 };
|
||||
@@ -1684,19 +1708,28 @@ fn process_watcher_rename(
|
||||
// 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("");
|
||||
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) {
|
||||
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] }),
|
||||
Ok(record) => emit_images(
|
||||
app,
|
||||
&IndexedImagesBatch {
|
||||
folder_id,
|
||||
images: vec![record],
|
||||
},
|
||||
),
|
||||
Err(e) => eprintln!("Watcher rename: post-update fetch error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,11 +200,7 @@ pub fn tagger_batch_size(app_data_dir: &Path) -> usize {
|
||||
let Ok(value) = std::fs::read_to_string(path) else {
|
||||
return 8;
|
||||
};
|
||||
value
|
||||
.trim()
|
||||
.parse::<usize>()
|
||||
.unwrap_or(8)
|
||||
.clamp(1, 100)
|
||||
value.trim().parse::<usize>().unwrap_or(8).clamp(1, 100)
|
||||
}
|
||||
|
||||
pub fn set_tagger_batch_size(app_data_dir: &Path, batch_size: usize) -> Result<usize> {
|
||||
|
||||
@@ -289,8 +289,8 @@ mod tests {
|
||||
assert_eq!((decoded.width(), decoded.height()), (400, 300));
|
||||
|
||||
// Cover mode: shortest side (1200) must stay >= 224 -> numerator 2
|
||||
let covered = decode_jpeg_scaled(&src_path, 224, true)
|
||||
.expect("fast path should handle plain JPEG");
|
||||
let covered =
|
||||
decode_jpeg_scaled(&src_path, 224, true).expect("fast path should handle plain JPEG");
|
||||
assert_eq!((covered.width(), covered.height()), (400, 300));
|
||||
|
||||
let cache = dir.join("cache");
|
||||
|
||||
Reference in New Issue
Block a user