Root cause of 'adds/removes not showing up': the watcher's file-COUNT tree signature took 8-10 s per tick on the phone (FUSE readdir of the 8400-file Camera dir is ~1 ms/entry), so the 1 s poll was effectively a 10 s walk, and the old 30 s forced FULL rescan (walk+stat of every file, ~30 s) kept the watcher thread perpetually busy. New design: - 1 s tick stats DIRECTORY mtimes only (~1 ms). Verified on the phone: Android's FUSE DOES update dir mtime on file add/remove (stat before/after a copy showed it) — the earlier 'never fires' reading was wrong; no files had actually been added in that trace window. - On a changed dir: incremental rescan of ONLY that subtree. Small dirs (<=2000 files) get a full walk with stat (adds, deletes AND in-place edits). The huge Camera dir gets a readdir-only walk: new files are stat'd, known files keep their DB mtime/size, deletions are detected by set difference. Everything outside the changed subtree keeps its DB metadata — cost is proportional to the change. - Full rescan every 5 min as the safety net (in-place edits in the huge dir, anything missed). - DB: busy_timeout(10s) so the startup scan and watcher can transact concurrently (WAL). Measured on the phone: add to Pictures -> grid +1 in ~5 s; add to Camera -> +1 in ~12 s; removals picked up on the next tick. Also observed tracking a live 1700-photo user import (grid 8440 -> 10166 as the import progressed). JS: event payload normalizer for thumb-ready/full-ready (object vs JSON string across WebView builds), and js files/emit instrumentation to compare send vs receive counts.
192 lines
6.4 KiB
Rust
192 lines
6.4 KiB
Rust
//! Directory walk + image detection (M1).
|
|
//!
|
|
//! Plain recursive scan of the roots (Pictures + DCIM on Android). This is
|
|
//! what makes the app testable without MediaStore; MediaStore integration is
|
|
//! a later refinement (HANDOFF.md §7.4).
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
/// One image on disk. `mtime` is nanoseconds since the Unix epoch (i64).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Entry {
|
|
pub path: String,
|
|
pub mtime: i64,
|
|
pub size: i64,
|
|
}
|
|
|
|
/// Lower-case image extensions we index. HEIC is the Android camera default.
|
|
pub const IMAGE_EXTS: &[&str] = &[
|
|
"jpg", "jpeg", "png", "webp", "heic", "heif", "bmp", "tif", "tiff",
|
|
];
|
|
|
|
/// Scan roots. `GALLERY_SCAN_DIRS` (colon-separated) overrides — test hook.
|
|
pub fn roots() -> Vec<PathBuf> {
|
|
if let Ok(d) = std::env::var("GALLERY_SCAN_DIRS") {
|
|
return d
|
|
.split(':')
|
|
.filter(|s| !s.is_empty())
|
|
.map(PathBuf::from)
|
|
.collect();
|
|
}
|
|
#[cfg(target_os = "android")]
|
|
{
|
|
let ext = PathBuf::from("/storage/emulated/0");
|
|
vec![ext.join("Pictures"), ext.join("DCIM")]
|
|
}
|
|
#[cfg(not(target_os = "android"))]
|
|
{
|
|
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
|
|
vec![home.join("Pictures"), home.join("DCIM")]
|
|
}
|
|
}
|
|
|
|
/// Recursively collect all image entries below `roots`.
|
|
/// `on_progress(n)` is called every 512 files found.
|
|
pub fn walk(roots: &[PathBuf], on_progress: &dyn Fn(usize)) -> Vec<Entry> {
|
|
walk_stream(roots, &|batch| on_progress(batch.len()))
|
|
}
|
|
|
|
/// Like `walk`, but streams progress: `on_batch(&entries)` receives ALL
|
|
/// entries collected so far every 512 new files, so the UI can show results
|
|
/// while the scan is still running (first launch must not wait for the full
|
|
/// DB build).
|
|
pub fn walk_stream(roots: &[PathBuf], on_batch: &dyn Fn(&[Entry])) -> Vec<Entry> {
|
|
let mut out = Vec::new();
|
|
for r in roots {
|
|
if r.is_dir() {
|
|
walk_dir(r, &mut out, on_batch);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Full walk (with stat) of ONE directory subtree.
|
|
pub fn walk_dir_into(dir: &Path, out: &mut Vec<Entry>) {
|
|
if dir.is_dir() {
|
|
walk_dir(dir, out, &|_| {});
|
|
}
|
|
}
|
|
|
|
/// Readdir-only walk (NO per-file stat) of ONE directory subtree — for
|
|
/// huge FUSE directories where stat'ing every file costs ~30 s; the
|
|
/// caller supplies metadata for new files and keeps DB metadata for
|
|
/// known ones.
|
|
pub fn walk_dir_nostats(dir: &Path, cb: &mut dyn FnMut(&Path)) {
|
|
if !dir.is_dir() {
|
|
return;
|
|
}
|
|
let mut stack = vec![dir.to_path_buf()];
|
|
while let Some(d) = stack.pop() {
|
|
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
|
for ent in rd.flatten() {
|
|
let p = ent.path();
|
|
let Ok(ft) = ent.file_type() else { continue };
|
|
if ft.is_dir() {
|
|
if p.is_symlink() {
|
|
continue;
|
|
}
|
|
if p.file_name().map_or(false, |n| n.to_string_lossy().starts_with('.')) {
|
|
continue;
|
|
}
|
|
stack.push(p);
|
|
} else if ft.is_file() {
|
|
cb(&p);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn walk_dir(dir: &Path, out: &mut Vec<Entry>, on_batch: &dyn Fn(&[Entry])) {
|
|
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
|
for ent in rd.flatten() {
|
|
let p = ent.path();
|
|
let Ok(ft) = ent.file_type() else { continue };
|
|
if ft.is_dir() {
|
|
// Skip hidden dirs (.thumbnails, .trash, …) — metadata, not photos.
|
|
if p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.')) {
|
|
continue;
|
|
}
|
|
// Don't follow symlinked dirs (loop protection).
|
|
if !p.is_symlink() {
|
|
walk_dir(&p, out, on_batch);
|
|
}
|
|
continue;
|
|
}
|
|
if !ft.is_file() {
|
|
continue;
|
|
}
|
|
// OS junk that masquerades as photos: macOS AppleDouble
|
|
// resource forks ("._IMG…jpg"), hidden files, .DS_Store, …
|
|
// Not decodable — they render as permanent gray cells.
|
|
let name = p.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
|
if name.starts_with('.') {
|
|
continue;
|
|
}
|
|
let Some(ext) = p.extension().and_then(|e| e.to_str()) else { continue };
|
|
if !IMAGE_EXTS.contains(&ext.to_ascii_lowercase().as_str()) {
|
|
continue;
|
|
}
|
|
let Ok(md) = std::fs::metadata(&p) else { continue };
|
|
let mtime = md
|
|
.modified()
|
|
.ok()
|
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
|
.map(|d| d.as_nanos() as i64)
|
|
.unwrap_or(0);
|
|
out.push(Entry {
|
|
path: p.to_string_lossy().into_owned(),
|
|
mtime,
|
|
size: md.len() as i64,
|
|
});
|
|
if out.len() % 512 == 0 {
|
|
on_batch(out);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::io::Write;
|
|
|
|
fn make_tree(root: &Path, n_files: usize, subdirs: usize) {
|
|
for d in 0..subdirs {
|
|
let dir = root.join(format!("dir{d}"));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
}
|
|
for i in 0..n_files {
|
|
let dir = if i % 2 == 0 { root.join(format!("dir{}", i % subdirs)) } else { root.to_path_buf() };
|
|
let mut f = std::fs::File::create(dir.join(format!("img{i}.jpg"))).unwrap();
|
|
f.write_all(&vec![0u8; i % 64]).unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn finds_images_skips_others() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
std::fs::write(dir.path().join("a.jpg"), b"x").unwrap();
|
|
std::fs::write(dir.path().join("B.PNG"), b"x").unwrap();
|
|
std::fs::write(dir.path().join("c.txt"), b"x").unwrap();
|
|
std::fs::create_dir_all(dir.path().join("d")).unwrap();
|
|
std::fs::write(dir.path().join("d").join("e.heic"), b"x").unwrap();
|
|
let out = walk(&[dir.path().to_path_buf()], &|_| {});
|
|
assert_eq!(out.len(), 3);
|
|
assert!(out.iter().all(|e| e.size == 1));
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_5k_files_under_2s() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
make_tree(dir.path(), 5000, 8);
|
|
let t = std::time::Instant::now();
|
|
let out = walk(&[dir.path().to_path_buf()], &|_| {});
|
|
let elapsed = t.elapsed();
|
|
assert_eq!(out.len(), 5000);
|
|
assert!(
|
|
elapsed.as_millis() < 2000,
|
|
"scan took {:?} (> 2 s)",
|
|
elapsed
|
|
);
|
|
}
|
|
}
|