gallery/harnesses/gtauri/src/scan.rs
Greg Pomerantz bd27a7ccc1 gtauri: fix live watching (incremental subtree rescans)
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.
2026-11-01 18:55:31 -05:00

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
);
}
}