Run the library from local data when the server is unreachable

Also carries in-flight work that shared these files: the zoom structure-key
fix in the adjust pipeline, nearest-neighbour filtering past 1:1, the
timeline scrub marker correction, the 423-Locked retry in the metadata
sweep, and the thumbnail size-class migration.

# Offline mode (FR-CAT-9)

The app previously assumed the server was reachable and treated its absence
as a series of unrelated per-operation failures. A launch without a
connection produced an empty grid, even with a complete catalog on disk and
every thumbnail already in the shards.

Reachability is now inferred from traffic the app was already making, rather
than probed for. `RemoteError::indicates_offline` draws the line that makes
this possible: a dead connection is offline, a 403 or a 500 is not — the
server answered, so blanking the library over one forbidden file would be a
worse error than the one being reported. `Reachability` turns those outcomes
into a state, so a library browsing happily never issues a probe at all.

Going offline takes one failure, because the user is already experiencing it.
Coming back requires evidence — a completed scan or a fetched thumbnail —
with a capped exponential backoff behind the manual retry, so twelve sweep
lanes failing together do not schedule twelve immediate probes.

What keeps working: the catalog opens even when the scan that normally
provides it failed, so the grid fills from the last successful scan.
Thumbnails come from the shards. Rating, flagging and collecting are catalog
writes that never touched the network. What stops is opening an original that
was never stored locally, and it now says so in those words instead of
reporting "network error: connection refused" over a photograph.

Work that is pure network is refused rather than left to fail slowly: the
metadata sweep, derived sync, and sidecar writes. The sweep would otherwise
spend a timeout per image across the whole library while the progress bar
implied something was happening. Deferring sidecars is a real gap rather than
a hidden one — a rating made offline reaches its sidecar only when that image
is judged again while connected — and it is recorded as such at the call site.

# The "On this device" filter

A chip beside the rating filters, narrowing the grid to images whose original
is held locally. It composes with the rating terms rather than replacing them,
so "five-star frames I can actually edit on this train" is one filter. The
predicate is SQL, like the rating terms and for the same reason: the count in
the header has to agree with the cells drawn.

It reads `image_cache.tier_actual`, which nothing writes yet — the next
commit fills it. Until then the chip honestly reports zero.

`Tier` gains an explicit on-disk encoding. The variants are ordered by
generosity and the derived `Ord` invites reordering them, which would
silently reinterpret every cached row; the round-trip test is what holds the
two in agreement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 20:36:50 +02:00
co-authored by Claude Opus 5
parent f6a100863e
commit cd75e5a4c6
18 changed files with 2604 additions and 109 deletions
@@ -27,6 +27,45 @@ async fn main() {
Err(e) => println!("READ FAILED: {e}"),
}
// What is actually in the derived folder on the server?
{
let dir = RemotePath::new(format!("{}/.darkroom-derived", session.root));
match backend.list(&dir, None).await {
Ok(entries) => {
println!("\n[derived] {} entry/entries on the server:", entries.len());
for e in &entries {
println!(" {:<28} {:>10} bytes", e.path.name(), e.size);
}
}
Err(e) => println!("\n[derived] listing failed: {e}"),
}
}
// Probe a file the sweep reported as 423 Locked: is it the file, the
// range request, or the folder?
{
let c = dr_sync_nextcloud::http_client("DarkRoom").unwrap();
let base = format!("{}/remote.php/dav/files/{}",
creds.server.trim_end_matches('/'), session.user_id);
let f = "PhotosRaw/Darktable/20230629_no_name/20230629_0030.jpeg";
let enc: String = f.split('/').map(|seg| {
seg.bytes().map(|b| match b {
b'A'..=b'Z'|b'a'..=b'z'|b'0'..=b'9'|b'-'|b'_'|b'.'|b'~' => (b as char).to_string(),
_ => format!("%{b:02X}"),
}).collect::<String>()
}).collect::<Vec<_>>().join("/");
let url = format!("{base}/{enc}");
for (what, range) in [("ranged 0-256k", Some("bytes=0-262143")), ("whole file", None)] {
let mut rq = c.get(&url).basic_auth(&creds.login_name, Some(&creds.app_password));
if let Some(r) = range { rq = rq.header("Range", r); }
match rq.send().await {
Ok(r) => println!("GET {what}: {}", r.status()),
Err(e) => println!("GET {what}: transport {e}"),
}
}
}
// Raw HTTP, to see the status the connector maps away.
{
let url = format!("{}/remote.php/dav/files/{}/{}/.darkroom-write-test",