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
+51 -2
View File
@@ -54,11 +54,35 @@ impl RemoteError {
RemoteError::Network(_) => true,
RemoteError::Server { status, .. } => {
// 5xx and 429 are worth retrying; other 4xx are not.
*status >= 500 || *status == 429
//
// 423 Locked is the exception, and it is not hypothetical:
// Nextcloud's file locking returns it on a plain *read* under
// concurrency, and the same range re-read seconds later
// succeeds. Treating it as permanent marks an image
// permanently undated over a lock that lasted moments.
*status >= 500 || *status == 429 || *status == 423
}
_ => false,
}
}
/// Whether this failure means *the server could not be reached*, as
/// opposed to the server answering and refusing.
///
/// The distinction is the whole basis of offline mode (FR-CAT-9). A 403
/// and a dead connection are both "the operation failed", but only one of
/// them is fixed by waiting, and only one of them should put the whole app
/// into a degraded mode. Signing the user out — or showing "you are
/// offline" — because a single file was forbidden would be a much worse
/// error than the one it reported.
///
/// A 5xx is deliberately **not** offline: the server is up and talking, it
/// is just failing, and a retry is the right response rather than a
/// mode change. 429 and 423 likewise — those are the server working
/// correctly under load.
pub fn indicates_offline(&self) -> bool {
matches!(self, RemoteError::Network(_))
}
}
#[cfg(test)]
@@ -107,4 +131,29 @@ mod tests {
"the message must point at permissions, not the login: {denied}"
);
}
}
#[test]
fn a_lock_is_transient() {
// Observed against a real server: 12 concurrent range reads produced
// 423 on some files, and the identical request succeeded moments
// later. Classing it with the permanent 4xx left those images
// undated for good.
assert!(RemoteError::Server {
status: 423,
detail: String::new()
}
.is_transient());
// Still permanent, so the exception stays narrow.
assert!(!RemoteError::Server {
status: 404,
detail: String::new()
}
.is_transient());
assert!(!RemoteError::Server {
status: 400,
detail: String::new()
}
.is_transient());
}
}