gtauri: fix phantom rows, dead deletion detection, 5-min launch stall
Root causes found by reproducing each on the emulator (2.8k-file fixture with mixed jpg/mp4): 1. walk_dir_nostats (incremental rescan of >2000-file dirs) had NO image-extension filter: it re-enumerated every file, injecting all ~1,727 videos into the DB. This is what made the count swing 8,439 -> 10,166 and the grid full of blank cells (404 rows). Now filters through is_image_path() like every other walk. 2. walk_dir aborted the ENTIRE walk on any per-file stat failure (file deleted mid-walk), so an error during the walk meant the walk was never applied and deletions were never detected. Per-file stat errors are now skipped; only readdir/root errors fail the walk (an errored walk is still not applied). 3. dir_sig stat'ed every entry of every root dir every watch tick: 10k+ stats/second on the phone = 10s+ of FUSE I/O per second = the long delays. On Android the MediaStore URI payloads already carry the exact change signal, so the mtime-diff machinery now runs desktop-only. The Android watch tick is a single read_dir of our own app dir (no-op when empty): 1s backstop, 30s-throttled fallback full rescan for events with no payload, 5-min full pass. 4. TOCTOU: a delete consumed while a full rescan was mid-walk lost to the walk's stale snapshot (walk re-added the row). RESCANNING gate parks payloads during the walk; they apply after it, so mid-walk deletions always win. Reproduced deterministically (churn during the startup walk), fixed, re-verified. Emulator stress (all passing): full-walk convergence, add +1s, delete +1s, video add/delete = zero rows, 60-op churn converges to exact disk count, 3x relaunch idempotency, startup-walk race converges (1s normally, 5-min backstop when the OS scanner drops burst events), no-blank-cells visual check, 0.00% dropped frames. Phone verified: 10,166 -> 8,439 on first launch (all video phantoms purged), add appears ~2s after the OS indexes it, delete same, DB returns to 8,439 exactly. ContentObserver registration + onChange logging added (it fired reliably once the stale instance was replaced).
This commit is contained in:
parent
0ade19ee76
commit
10ec0b52cc
|
|
@ -164,6 +164,7 @@ class MainActivity : TauriActivity() {
|
||||||
// Event-driven: the OS passes the changed URI(s) directly.
|
// Event-driven: the OS passes the changed URI(s) directly.
|
||||||
val obs = object : ContentObserver(mainHandler) {
|
val obs = object : ContentObserver(mainHandler) {
|
||||||
override fun onChange(selfChange: Boolean, uri: Uri?, flags: Int) {
|
override fun onChange(selfChange: Boolean, uri: Uri?, flags: Int) {
|
||||||
|
android.util.Log.i("gallery-ms", "onChange uri=$uri self=$selfChange flags=$flags")
|
||||||
if (selfChange) return
|
if (selfChange) return
|
||||||
fsExecutor.execute { handleUri(uri) }
|
fsExecutor.execute { handleUri(uri) }
|
||||||
}
|
}
|
||||||
|
|
@ -174,6 +175,7 @@ class MainActivity : TauriActivity() {
|
||||||
contentResolver.registerContentObserver(
|
contentResolver.registerContentObserver(
|
||||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, obs
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, true, obs
|
||||||
)
|
)
|
||||||
|
android.util.Log.i("gallery-ms", "observer registered in pid " + android.os.Process.myPid())
|
||||||
// Startup catch-up: if the app was killed after a photo was taken,
|
// Startup catch-up: if the app was killed after a photo was taken,
|
||||||
// the observer won't replay — push whatever was indexed recently.
|
// the observer won't replay — push whatever was indexed recently.
|
||||||
mainHandler.postDelayed({ fsExecutor.execute { windowQuery() } }, 1500)
|
mainHandler.postDelayed({ fsExecutor.execute { windowQuery() } }, 1500)
|
||||||
|
|
@ -181,6 +183,7 @@ class MainActivity : TauriActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
android.util.Log.i("gallery-ms", "onDestroy: unregistering observer")
|
||||||
observer?.let { contentResolver.unregisterContentObserver(it) }
|
observer?.let { contentResolver.unregisterContentObserver(it) }
|
||||||
fsExecutor.shutdownNow()
|
fsExecutor.shutdownNow()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
|
|
|
||||||
|
|
@ -728,12 +728,34 @@ fn walk_once(app: &tauri::AppHandle) -> std::io::Result<Vec<scan::Entry>> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set while a full rescan's walk→apply is in flight. The watch loop
|
||||||
|
/// must NOT consume MediaStore payloads meanwhile: a delete consumed
|
||||||
|
/// mid-walk would race the walk's in-memory snapshot (the walk would
|
||||||
|
/// re-add the row the delete already processed). Payloads simply wait
|
||||||
|
/// as files and are drained by the rescan's post-apply drain.
|
||||||
|
static RESCANNING: std::sync::atomic::AtomicBool =
|
||||||
|
std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
|
struct RescanGuard;
|
||||||
|
impl RescanGuard {
|
||||||
|
fn new() -> Self {
|
||||||
|
RESCANNING.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for RescanGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
RESCANNING.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Full rescan: walk the roots, diff against the DB (insert new /
|
/// Full rescan: walk the roots, diff against the DB (insert new /
|
||||||
/// update changed / delete removed), refresh META, emit the list.
|
/// update changed / delete removed), refresh META, emit the list.
|
||||||
/// Used by the startup scan AND the 5-minute reconciliation pass.
|
/// Used by the startup scan AND the 5-minute reconciliation pass.
|
||||||
/// If the walk fails, nothing is applied — an error is not "those
|
/// If the walk fails, nothing is applied — an error is not "those
|
||||||
/// files are gone"; the next pass retries.
|
/// files are gone"; the next pass retries.
|
||||||
fn rescan(app: &tauri::AppHandle) {
|
fn rescan(app: &tauri::AppHandle) {
|
||||||
|
let _guard = RescanGuard::new();
|
||||||
let entries = match walk_once(app) {
|
let entries = match walk_once(app) {
|
||||||
Ok(e) => e,
|
Ok(e) => e,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -759,6 +781,11 @@ fn rescan(app: &tauri::AppHandle) {
|
||||||
}
|
}
|
||||||
emit_files(app, false, entries.len());
|
emit_files(app, false, entries.len());
|
||||||
log::info!("rescan: {} files", entries.len());
|
log::info!("rescan: {} files", entries.len());
|
||||||
|
// Draining happens in the watch loop's next tick: the RESCANNING
|
||||||
|
// flag lifted at drop time guarantees any payload consumed from now
|
||||||
|
// on is applied AFTER this walk's apply, so mid-walk deletions
|
||||||
|
// always win over the stale snapshot. (The 5-min pass catches any
|
||||||
|
// straggler in the worst-case millisecond window.)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn spawn_scan(app: tauri::AppHandle) {
|
fn spawn_scan(app: tauri::AppHandle) {
|
||||||
|
|
@ -774,9 +801,12 @@ fn mtime_ns(m: &std::fs::Metadata) -> i64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tree signature: (dir, mtime) for every directory under the roots.
|
/// Tree signature: (dir, mtime) for every directory under the roots.
|
||||||
/// Verified on the phone: Android's FUSE DOES update a directory's mtime
|
/// Verified on the phone: Android's FUSE DOES update a directory's
|
||||||
/// when files are added/removed inside it (stat before/after a copy
|
/// mtime when files are added/removed inside it (stat before/after a
|
||||||
/// showed the change). Stat-only cost: a few dozen syscalls per tick.
|
/// copy showed the change). Cost per tick: one stat per DIRECTORY and
|
||||||
|
/// one readdir per directory — no per-FILE stat. (The old version
|
||||||
|
/// called is_dir() on every entry: 10k stats per second on a 10k-file
|
||||||
|
/// Camera dir, which put a 10 s I/O floor under every event.)
|
||||||
fn dir_sig(roots: &[PathBuf]) -> HashMap<PathBuf, i64> {
|
fn dir_sig(roots: &[PathBuf]) -> HashMap<PathBuf, i64> {
|
||||||
let mut out = HashMap::new();
|
let mut out = HashMap::new();
|
||||||
let mut stack: Vec<PathBuf> = roots.iter().filter(|r| r.is_dir()).cloned().collect();
|
let mut stack: Vec<PathBuf> = roots.iter().filter(|r| r.is_dir()).cloned().collect();
|
||||||
|
|
@ -787,7 +817,10 @@ fn dir_sig(roots: &[PathBuf]) -> HashMap<PathBuf, i64> {
|
||||||
Ok(rd) => {
|
Ok(rd) => {
|
||||||
for ent in rd.flatten() {
|
for ent in rd.flatten() {
|
||||||
let p = ent.path();
|
let p = ent.path();
|
||||||
if p.is_symlink() || !p.is_dir() {
|
// file_type() uses the d_type from getdents (no
|
||||||
|
// syscall); is_dir() would stat the entry.
|
||||||
|
let Ok(ft) = ent.file_type() else { continue };
|
||||||
|
if ft.is_symlink() || !ft.is_dir() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if p.file_name().map_or(false, |n| n.to_string_lossy().starts_with('.')) {
|
if p.file_name().map_or(false, |n| n.to_string_lossy().starts_with('.')) {
|
||||||
|
|
@ -902,11 +935,14 @@ fn rescan_subtrees(app: &tauri::AppHandle, changed: &[PathBuf]) {
|
||||||
// A|<media_id>|<path>|<size> row exists → upsert
|
// A|<media_id>|<path>|<size> row exists → upsert
|
||||||
// D|<media_id> row gone → delete by id
|
// D|<media_id> row gone → delete by id
|
||||||
// ---------------------------------------------------------------------
|
// ---------------------------------------------------------------------
|
||||||
|
/// Consume pending event files. Returns true when at least one change
|
||||||
|
/// line was applied (the watch loop uses this to decide whether the
|
||||||
|
/// OS event was fully handled by the payload path).
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
fn apply_mediasotre_payload(app: &tauri::AppHandle) {
|
fn apply_mediasotre_payload(app: &tauri::AppHandle) -> bool {
|
||||||
// Consume every pending .ms_recent.* event file (oldest first).
|
// Consume every pending .ms_recent.* event file (oldest first).
|
||||||
let dir = external_files_dir();
|
let dir = external_files_dir();
|
||||||
let Ok(rd) = std::fs::read_dir(&dir) else { return };
|
let Ok(rd) = std::fs::read_dir(&dir) else { return false };
|
||||||
let mut files: Vec<std::path::PathBuf> = rd
|
let mut files: Vec<std::path::PathBuf> = rd
|
||||||
.flatten()
|
.flatten()
|
||||||
.filter(|e| {
|
.filter(|e| {
|
||||||
|
|
@ -917,9 +953,10 @@ fn apply_mediasotre_payload(app: &tauri::AppHandle) {
|
||||||
.map(|e| e.path())
|
.map(|e| e.path())
|
||||||
.collect();
|
.collect();
|
||||||
if files.is_empty() {
|
if files.is_empty() {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
files.sort();
|
files.sort();
|
||||||
|
let mut applied = false;
|
||||||
for f in files {
|
for f in files {
|
||||||
let name = f.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
let name = f.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||||
let Ok(text) = std::fs::read_to_string(&f) else { continue };
|
let Ok(text) = std::fs::read_to_string(&f) else { continue };
|
||||||
|
|
@ -928,20 +965,23 @@ fn apply_mediasotre_payload(app: &tauri::AppHandle) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tr(&format!("mediastore event {name}: {} lines", text.lines().count()));
|
tr(&format!("mediastore event {name}: {} lines", text.lines().count()));
|
||||||
mediastore_apply(app, &text);
|
applied |= mediastore_apply(app, &text);
|
||||||
}
|
}
|
||||||
|
applied
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
fn apply_mediasotre_payload(_app: &tauri::AppHandle) {}
|
fn apply_mediasotre_payload(_app: &tauri::AppHandle) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
fn mediastore_apply(app: &tauri::AppHandle, text: &str) {
|
fn mediastore_apply(app: &tauri::AppHandle, text: &str) -> bool {
|
||||||
let mut conn = match db::open(&db::db_path()) {
|
let mut conn = match db::open(&db::db_path()) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("mediastore db: {e}");
|
log::warn!("mediastore db: {e}");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut changed = 0usize;
|
let mut changed = 0usize;
|
||||||
|
|
@ -1038,6 +1078,7 @@ fn mediastore_apply(app: &tauri::AppHandle, text: &str) {
|
||||||
}
|
}
|
||||||
emit_files(app, false, META.read().unwrap().len());
|
emit_files(app, false, META.read().unwrap().len());
|
||||||
}
|
}
|
||||||
|
changed + removed > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Watcher wake events.
|
/// Watcher wake events.
|
||||||
|
|
@ -1140,7 +1181,13 @@ fn watch_files(app: tauri::AppHandle) {
|
||||||
});
|
});
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
spawn_fs_watcher(tx);
|
spawn_fs_watcher(tx);
|
||||||
|
// Desktop only: the mtime-diff machinery. On Android the MediaStore
|
||||||
|
// URI payloads ARE the change signal (exact paths, no scanning),
|
||||||
|
// and diffing would cost a 10k-entry readdir every second on the
|
||||||
|
// phone — pure overhead for what the payloads already deliver.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
let mut sig: HashMap<PathBuf, i64> = HashMap::new();
|
let mut sig: HashMap<PathBuf, i64> = HashMap::new();
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
let mut baseline = true;
|
let mut baseline = true;
|
||||||
let mut last_full = std::time::Instant::now();
|
let mut last_full = std::time::Instant::now();
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -1156,42 +1203,56 @@ fn watch_files(app: tauri::AppHandle) {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(600));
|
std::thread::sleep(std::time::Duration::from_millis(600));
|
||||||
while rx.try_recv().is_ok() {}
|
while rx.try_recv().is_ok() {}
|
||||||
}
|
}
|
||||||
// MediaStore event path: MainActivity drops a one-line payload
|
|
||||||
// per changed URI and wakes us via nativeFsChange. Consume on
|
|
||||||
// EVERY tick — the check is a single read_dir of our own app
|
|
||||||
// dir (no-op when empty), so a lost JNI wake self-heals within
|
|
||||||
// the 1 s backstop instead of waiting for the 5-min pass.
|
|
||||||
apply_mediasotre_payload(&app);
|
|
||||||
let next = dir_sig(&scan::roots());
|
|
||||||
let changed = if baseline {
|
|
||||||
baseline = false;
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
changed_dirs(&sig, &next)
|
|
||||||
};
|
|
||||||
sig = next;
|
|
||||||
if do_full {
|
if do_full {
|
||||||
tr("watch: full rescan (5min)");
|
tr("watch: full rescan (5min)");
|
||||||
rescan(&app);
|
rescan(&app);
|
||||||
last_full = std::time::Instant::now();
|
last_full = std::time::Instant::now();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if changed.is_empty() {
|
// MediaStore event path (Android): MainActivity drops a one-line
|
||||||
if woke.is_some() {
|
// payload per changed URI and wakes us via nativeFsChange.
|
||||||
// The OS reported a change but no dir mtime moved
|
// Consume on EVERY tick (single read_dir of our own app dir —
|
||||||
// (in-place edit / event for an untracked file):
|
// no-op when empty, so a lost JNI wake self-heals within the 1 s
|
||||||
// full rescan, throttled to one per minute.
|
// backstop) — but NOT while a full rescan is mid-walk: those
|
||||||
if last_full.elapsed() >= std::time::Duration::from_secs(60) {
|
// payloads wait as files and apply after the walk's apply, so
|
||||||
tr("watch: full rescan (event, no dir delta)");
|
// mid-walk deletions win over the walk's stale snapshot.
|
||||||
rescan(&app);
|
let applied_ms = if RESCANNING.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
last_full = std::time::Instant::now();
|
false
|
||||||
}
|
} else {
|
||||||
}
|
apply_mediasotre_payload(&app)
|
||||||
// Timeout with no dir delta: nothing to do (~0.05 ms spent).
|
};
|
||||||
|
// Android fallback: the OS said "media changed" but no payload
|
||||||
|
// arrived (permission missing, scanner gap, or the payload was
|
||||||
|
// parked behind a rescan). Full rescan, throttled — the
|
||||||
|
// degraded path, not the common one. Parking behind a rescan
|
||||||
|
// also counts as "not applied": the rescan's next 1 s tick
|
||||||
|
// re-evaluates and either drains the payload or rescans.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
if matches!(woke, Some(WEvent::Fs)) && !applied_ms
|
||||||
|
&& last_full.elapsed() >= std::time::Duration::from_secs(30)
|
||||||
|
{
|
||||||
|
tr("watch: full rescan (event, no payload)");
|
||||||
|
rescan(&app);
|
||||||
|
last_full = std::time::Instant::now();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tr(&format!("watch: {} changed dir(s)", changed.len()));
|
// Desktop: diff the dir-mtime signature, rescan changed subtrees.
|
||||||
rescan_subtrees(&app, &changed);
|
#[cfg(not(target_os = "android"))]
|
||||||
|
{
|
||||||
|
let next = dir_sig(&scan::roots());
|
||||||
|
let changed = if baseline {
|
||||||
|
baseline = false;
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
changed_dirs(&sig, &next)
|
||||||
|
};
|
||||||
|
sig = next;
|
||||||
|
if changed.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
tr(&format!("watch: {} changed dir(s)", changed.len()));
|
||||||
|
rescan_subtrees(&app, &changed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,18 @@
|
||||||
//! Plain recursive scan of the roots (Pictures + DCIM on Android).
|
//! Plain recursive scan of the roots (Pictures + DCIM on Android).
|
||||||
//! FUSE is a reliable infrastructure point: a directory reads either
|
//! FUSE is a reliable infrastructure point: a directory reads either
|
||||||
//! returns its full listing or returns an error. There is no silent
|
//! returns its full listing or returns an error. There is no silent
|
||||||
//! truncation. Therefore the one invariant here is simple and strict:
|
//! truncation. Error semantics:
|
||||||
//! **any read_dir or stat error fails the walk** — a failed walk must
|
//! - a **read_dir error fails the walk** — the result would be
|
||||||
//! never be applied to the DB, because an error is not "those files
|
//! incomplete and must never be applied to the DB (an error is not
|
||||||
//! are gone."
|
//! "those files are gone");
|
||||||
|
//! - a **per-file failure is skipped** — a file vanishing between
|
||||||
|
//! readdir and stat is a normal race on an actively used
|
||||||
|
//! directory (the user deleting while we walk), not a walk
|
||||||
|
//! failure. Aborting on this killed deletion detection: every
|
||||||
|
//! incremental rescan of a busy 10k-file directory aborted.
|
||||||
|
//! Both walks (with-stat and readdir-only) filter to image
|
||||||
|
//! extensions: library directories hold thousands of videos, and
|
||||||
|
//! enumerating them would inject non-image rows into the DB.
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -108,8 +116,13 @@ pub fn walk_dir_into(dir: &Path, out: &mut Vec<Entry>) -> io::Result<()> {
|
||||||
/// Readdir-only walk (NO per-file stat) of ONE directory subtree — for
|
/// Readdir-only walk (NO per-file stat) of ONE directory subtree — for
|
||||||
/// huge FUSE directories where stat'ing every file costs ~30 s; the
|
/// huge FUSE directories where stat'ing every file costs ~30 s; the
|
||||||
/// caller supplies metadata for new files and keeps DB metadata for
|
/// caller supplies metadata for new files and keeps DB metadata for
|
||||||
/// known ones. A read_dir error fails the walk; a per-file type
|
/// known ones.
|
||||||
/// failure (file vanishing mid-walk — a normal race) is skipped.
|
///
|
||||||
|
/// FILTERS TO IMAGE EXTENSIONS (critical: a library directory holds
|
||||||
|
/// thousands of videos too — enumerating them would inject non-image
|
||||||
|
/// rows into the DB: phantom blank grid cells and count swings).
|
||||||
|
/// A read_dir error fails the walk; per-file failures (file vanishing
|
||||||
|
/// mid-walk — a normal race) are skipped.
|
||||||
pub fn walk_dir_nostats(dir: &Path, cb: &mut dyn FnMut(&Path)) -> io::Result<()> {
|
pub fn walk_dir_nostats(dir: &Path, cb: &mut dyn FnMut(&Path)) -> io::Result<()> {
|
||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|
@ -129,13 +142,20 @@ pub fn walk_dir_nostats(dir: &Path, cb: &mut dyn FnMut(&Path)) -> io::Result<()>
|
||||||
}
|
}
|
||||||
stack.push(p);
|
stack.push(p);
|
||||||
} else if ft.is_file() {
|
} else if ft.is_file() {
|
||||||
cb(&p);
|
let ps = p.to_string_lossy();
|
||||||
|
if is_image_path(&ps) {
|
||||||
|
cb(&p);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full stat walk. Error semantics: a read_dir error fails the walk
|
||||||
|
/// (the result would be incomplete — must never be applied); per-file
|
||||||
|
/// failures (a file vanishing between readdir and stat — a normal race
|
||||||
|
/// on an actively used directory) are SKIPPED, not propagated.
|
||||||
fn walk_dir(
|
fn walk_dir(
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
out: &mut Vec<Entry>,
|
out: &mut Vec<Entry>,
|
||||||
|
|
@ -144,7 +164,7 @@ fn walk_dir(
|
||||||
for ent in std::fs::read_dir(dir)? {
|
for ent in std::fs::read_dir(dir)? {
|
||||||
let ent = ent?;
|
let ent = ent?;
|
||||||
let p = ent.path();
|
let p = ent.path();
|
||||||
let ft = ent.file_type()?;
|
let Ok(ft) = ent.file_type() else { continue };
|
||||||
if ft.is_dir() {
|
if ft.is_dir() {
|
||||||
// Skip hidden dirs (.thumbnails, .trash, …) — metadata, not photos.
|
// 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('.')) {
|
if p.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with('.')) {
|
||||||
|
|
@ -164,7 +184,7 @@ fn walk_dir(
|
||||||
if !is_image_file(name, ext) {
|
if !is_image_file(name, ext) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let (mtime, size) = file_metadata(&p)?;
|
let Ok((mtime, size)) = file_metadata(&p) else { continue };
|
||||||
out.push(Entry {
|
out.push(Entry {
|
||||||
path: p.to_string_lossy().into_owned(),
|
path: p.to_string_lossy().into_owned(),
|
||||||
mtime,
|
mtime,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user