quak files COLLECTION fetches that collection's files alone (closes #217)
check / check (push) Waiting to run
check / check (push) Waiting to run
`quak files COLLECTION` used to wait for a full refresh, which on an empty cache fetched the files of every collection before it listed one. It now reads the collection list, then calls the new `Library.refreshCollection()`. That brings one collection up to date in the store as a refresh would, and saves it. It runs in the same slot as a refresh, so the two never run at the same time, and `close()` waits for it. It never stores a collection that a refresh has already dropped. It leaves the refresh cursor alone. `open()` therefore awaits the first refresh on a cache whose cursor is still 0. `quak files` with no argument still waits for a refresh. Model: opus-5-5
This commit is contained in:
@@ -685,17 +685,21 @@ library. The read commands — `collections`, `files`, `get`, `get-thumb`,
|
||||
`helper fix-missing-thumbnails` — force a fresh server round-trip before they
|
||||
answer, so they report current account state rather than whatever the cache last
|
||||
held. `collections` asks the server for the collection list alone, in one
|
||||
request, and neither fetches any collection's files nor changes the cache. The
|
||||
others wait for a refresh. With an empty cache, as on a first run, that refresh
|
||||
fetches the files of every collection, one collection after another, and stores
|
||||
nothing until it has them all, which on a large account takes a while. Meanwhile
|
||||
the command logs a line on stderr for each collection whose files it has
|
||||
fetched, such as `Fetched the files of 3 of 120 collections`, with `--json` too;
|
||||
`backup` does the same while the refresh it starts with runs. stdout holds only
|
||||
the command's output. If that round-trip fails, the command prints the error on
|
||||
one line and exits 1, or 3 when the server no longer accepts the saved session
|
||||
(see "Session handling"). An album or file quak cannot decrypt does not fail it;
|
||||
the command answers from what did decrypt (see "Default reads vs. fresh reads").
|
||||
request, and neither fetches any collection's files nor changes the cache.
|
||||
`files` with a collection ID asks for the collection list the same way, then for
|
||||
the files of that collection alone, and stores them in the cache. It asks for
|
||||
none of them when the cache's copy of that collection is current. `files`
|
||||
without a collection ID and the other read commands wait for a refresh. With an
|
||||
empty cache, as on a first run, that refresh fetches the files of every
|
||||
collection, one collection after another, and stores nothing until it has them
|
||||
all, which on a large account takes a while. Meanwhile the command logs a line
|
||||
on stderr for each collection whose files it has fetched, such as
|
||||
`Fetched the files of 3 of 120 collections`, with `--json` too; `backup` does
|
||||
the same while the refresh it starts with runs. stdout holds only the command's
|
||||
output. If that round-trip fails, the command prints the error on one line and
|
||||
exits 1, or 3 when the server no longer accepts the saved session (see "Session
|
||||
handling"). An album or file quak cannot decrypt does not fail it; the command
|
||||
answers from what did decrypt (see "Default reads vs. fresh reads").
|
||||
`--cache-dir` overrides where the cache lives; without it each account gets its
|
||||
own directory under the per-user cache path.
|
||||
|
||||
@@ -1093,12 +1097,13 @@ against a fake of the Ente HTTP API, and `yarn test` verifies them.
|
||||
|
||||
`Library.open(options)` loads the on-disk cache, starts the background refresh
|
||||
loop, and resolves to a `Library`. On an empty cache it awaits the first
|
||||
refresh, so it opens onto the account's data whenever the server is reachable;
|
||||
if that refresh fails, it opens with no data and records the error in
|
||||
`lib.status()`. On an existing cache it returns immediately and refreshes in the
|
||||
background, so an unreachable server does not block opening. With
|
||||
`refreshOnOpen: false` it starts no refresh and returns at once onto what the
|
||||
cache holds, empty or not; a refresh then starts only when `fresh()` or
|
||||
refresh, so it opens onto the account's data whenever the server is reachable.
|
||||
It also awaits it on a cache that holds only what `lib.refreshCollection()`
|
||||
stored. If that refresh fails, it opens onto what the cache holds and records
|
||||
the error in `lib.status()`. On any other cache it returns immediately and
|
||||
refreshes in the background, so an unreachable server does not block opening.
|
||||
With `refreshOnOpen: false` it starts no refresh, and returns at once onto what
|
||||
the cache holds, empty or not. A refresh then starts only when `fresh()` or
|
||||
`lib.backup()` asks, or the background timer fires. The CLI's read commands open
|
||||
it so (see "CLI surface").
|
||||
|
||||
@@ -1127,8 +1132,8 @@ when the library opens; `lib.downloadDirectory` holds the result.
|
||||
`onProgress` receives a `RefreshEvent` with status `"started"` as a refresh, an
|
||||
ML data fetch or a precache sweep starts, and `"done"` or `"failed"` as it ends.
|
||||
In between, a refresh sends `"progress"` after each collection whose files it
|
||||
fetched, with `fetched`, how many collections it has fetched the files of so
|
||||
far, and `total`, how many it will. A refresh fetches the files of each
|
||||
fetched. That event's `fetched` is how many collections it has fetched the files
|
||||
of so far, and its `total` how many it will. A refresh fetches the files of each
|
||||
collection that is new or changed since the cache's copy, so on an empty cache
|
||||
it fetches those of every collection.
|
||||
|
||||
@@ -1138,11 +1143,16 @@ and pass it. The three pools default to 10 / 5 / 25 (see Request pools below).
|
||||
|
||||
`lib.status()` returns a `LibraryStatus` (collection/file counts, last
|
||||
refresh/ML times and errors, originals usage and effective limit, precache
|
||||
progress, and `closed`). `lib.close()` stops the background timer; it is
|
||||
idempotent, and an in-flight refresh is left to finish. The promise it returns
|
||||
resolves once that refresh (including its cache write), the ML data fetch and
|
||||
the precache fetches already running have all finished, so the cache directory
|
||||
can then be removed.
|
||||
progress, and `closed`).
|
||||
|
||||
`lib.close()` stops the background timer, and is idempotent. A refresh or
|
||||
`lib.refreshCollection()` in flight is left to finish. The promise `close()`
|
||||
returns resolves once all of these have finished, so the cache directory can
|
||||
then be removed:
|
||||
|
||||
- that refresh or `lib.refreshCollection()`, with its cache write
|
||||
- the ML data fetch
|
||||
- the precache fetches already running
|
||||
|
||||
`lib.status().mlDataErrors` lists, as `{ fileID, error }`, each file whose ML
|
||||
data the last ML data fetch that succeeded got but could not decrypt, gunzip or
|
||||
@@ -1156,39 +1166,58 @@ cache.
|
||||
|
||||
Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer
|
||||
synchronously from the copy held in RAM and never touch the network. A refresh
|
||||
changes that copy only once all its server requests have succeeded, and the
|
||||
background timer starts the next refresh `refreshIntervalSeconds` after the
|
||||
previous one ends. So a default read is immediate, but only as current as the
|
||||
last refresh whose requests all succeeded; right after opening an existing
|
||||
cache, it is the copy on disk.
|
||||
changes that copy only once all its server requests have succeeded.
|
||||
`lib.refreshCollection()` changes it the same way for one collection. So a
|
||||
default read is immediate, but only as current as the last refresh or
|
||||
`lib.refreshCollection()` whose requests all succeeded. Right after opening an
|
||||
existing cache, it is the copy on disk.
|
||||
|
||||
The background timer takes a turn `refreshIntervalSeconds` after its last turn
|
||||
ends. Each turn starts a refresh, unless a refresh or `lib.refreshCollection()`
|
||||
is already running.
|
||||
|
||||
`await lib.fresh()` waits for a refresh to complete and persist, and returns the
|
||||
same `{ albums, photos, timeline }` namespaces, which then reflect a completed
|
||||
server round-trip. When a refresh is already running, background or not,
|
||||
`fresh()` waits for that one, so its answer can come from requests made before
|
||||
the call; only when none is running does it start one. A refresh that fails
|
||||
rejects the caller (default reads stay silent and keep serving the last good
|
||||
copy). The CLI's read commands use fresh reads (issue
|
||||
https://git.eeqj.de/sneak/quak/issues/75).
|
||||
server round-trip. It first waits for a running `lib.refreshCollection()` to
|
||||
end. When a refresh is already running, background or not, `fresh()` waits for
|
||||
that one. Its answer can then come from requests made before the call. Only when
|
||||
none is running does it start one. A refresh that fails rejects the caller
|
||||
(default reads stay silent and keep serving the last good copy). The CLI's read
|
||||
commands use fresh reads (issue https://git.eeqj.de/sneak/quak/issues/75).
|
||||
|
||||
`await lib.freshCollections()` is the fresh read of the collection list alone:
|
||||
one collections request, answered with every live collection that decrypts, as
|
||||
the `Collection` objects `listCollections()` returns, in the server's order. It
|
||||
fetches no collection's files and stores nothing, not even the list: a refresh
|
||||
fetches no collection's files and stores nothing, not even the list. A refresh
|
||||
fetches a collection's files only when the collection is new or has changed
|
||||
since the stored copy, so a collection stored without its files would never get
|
||||
since the stored copy. A collection stored without its files would never get
|
||||
them. `quak collections` uses it (issue
|
||||
https://git.eeqj.de/sneak/quak/issues/210).
|
||||
|
||||
`await lib.refreshCollection(collection)` brings one collection up to date in
|
||||
the cache, given its `Collection` from `freshCollections()`. It does for that
|
||||
collection what a refresh does for a changed one, and fetches no other
|
||||
collection's files. It sends no request when the cache's copy has the same
|
||||
`updationTime` or a later one. It also sends none when a refresh has already
|
||||
dropped the collection from the cache, because the server deleted it or it did
|
||||
not decrypt. Otherwise it fetches the collection's files changed since the
|
||||
cache's copy, or all of them when there is none, then stores and saves them. The
|
||||
refresh cursor stays where it was, so the next refresh still fetches the files
|
||||
of every other collection that changed. It never runs at the same time as a
|
||||
refresh. It first waits for a running refresh to end. A `fresh()` or
|
||||
`lib.backup()` called after that waits for it. `quak files` with a collection ID
|
||||
uses it.
|
||||
|
||||
An album or file in the server's answer that quak cannot decrypt, or whose
|
||||
metadata is not JSON, does not fail the refresh. The refresh leaves it out, an
|
||||
album with all its files, and drops any older copy the cache held, so reads
|
||||
answer from what did decrypt. The library keeps a list of what it left out, with
|
||||
the reason, in `metadata.json`: the next refresh asks the server only for what
|
||||
the reason, in `metadata.json`. The next refresh asks the server only for what
|
||||
changed, so it would not see the record again. A record stays on that list until
|
||||
a later refresh gets a copy that decrypts or learns that the server deleted it.
|
||||
`lib.backup()` reports each such album, and each such file in its scope that has
|
||||
no copy that decrypted (see "Backup layout").
|
||||
a later refresh or `lib.refreshCollection()` stores a copy that decrypts, or
|
||||
learns that the server deleted it. `lib.backup()` reports each such album, and
|
||||
each such file in its scope that has no copy that decrypted (see "Backup
|
||||
layout").
|
||||
|
||||
### Read surface
|
||||
|
||||
@@ -1282,10 +1311,11 @@ The GUI-facing records hold no key material and no binary, so they survive
|
||||
- `LibrarySnapshot`: `{ albums, photos, takenAt }`.
|
||||
|
||||
`lib.snapshot()` returns a `LibrarySnapshot` (albums newest-updated first,
|
||||
photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
|
||||
(`albumsChanged`, `photosChanged`, `fileIDsRemoved`, `albumIDsRemoved`,
|
||||
`refreshedAt`) whenever a refresh alters the projection, and returns
|
||||
`{ unsubscribe }`; a refresh that changes nothing delivers nothing.
|
||||
photos newest first). `lib.subscribe({ onChange })` returns `{ unsubscribe }`.
|
||||
It delivers a `LibraryChange` (`albumsChanged`, `photosChanged`,
|
||||
`fileIDsRemoved`, `albumIDsRemoved`, `refreshedAt`) whenever a refresh or
|
||||
`lib.refreshCollection()` alters the projection. One that changes nothing
|
||||
delivers nothing.
|
||||
|
||||
### Thumbnails, ML search, and backup
|
||||
|
||||
@@ -1304,26 +1334,27 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
|
||||
- `await lib.backup(opts?)` → `BackupResult`. It takes the lock in the download
|
||||
directory, and fails at once with an error whose `code` is `ELOCKED` while
|
||||
another backup of it runs. A caller can instead take the lock itself, before
|
||||
it opens its library, as `quak backup` does: `await lockBackupDirectory(dir)`
|
||||
takes it, failing the same way, and returns a `BackupLock`, whose `release()`
|
||||
removes the lock unless it was lost and whose `lost` holds why it was lost.
|
||||
The caller passes it to the backup as `lock`, and releases it once the backup
|
||||
ends. It waits for a refresh as `fresh()` does, puts every in-scope original
|
||||
not already at its save path there as `photo.download()` does (and, with
|
||||
`includeThumbnails`, fetches thumbnails) through the content cache, waits for
|
||||
an ML data fetch, and rebuilds the on-disk backup tree with a durable failure
|
||||
ledger. Each file's JSON holds its ML data and keywords. An image's JSON also
|
||||
holds the EXIF and XMP of its original, and its dimensions if it is a JPEG. A
|
||||
live photo's JSON holds these for its image. A video's JSON holds no EXIF, XMP
|
||||
or dimensions. A fetched original is written straight to its save path and not
|
||||
into the cache, which then counts it as present; one the cache already held is
|
||||
copied from there. `BackupOptions`: `downloadDirectory` (falls back to the
|
||||
library's), `includeOriginals` (default `true`), `includeThumbnails` (default
|
||||
`false`), `onlyAlbumNames`, `verify` (default `false`), `onProgress`, and
|
||||
`lock`. See Backup layout above for the tree it writes, and for the albums
|
||||
`BackupResult` lists in `undecryptableAlbums` and `failedAlbums`. Whether the
|
||||
backup took the lock or its caller did, once the lock is lost the backup
|
||||
rejects with `lost the lock on <dir>: <reason>` before it starts another file.
|
||||
it opens its library, as `quak backup` does. `await lockBackupDirectory(dir)`
|
||||
takes it, failing the same way, and returns a `BackupLock`. Its `release()`
|
||||
removes the lock unless it was lost, and its `lost` holds why it was lost. The
|
||||
caller passes it to the backup as `lock`, and releases it once the backup
|
||||
ends. The backup waits for a refresh as `fresh()` does. It then puts every
|
||||
in-scope original not already at its save path there, as `photo.download()`
|
||||
does, through the content cache. With `includeThumbnails` it also fetches
|
||||
thumbnails. It waits for an ML data fetch, and rebuilds the on-disk backup
|
||||
tree with a durable failure ledger. Each file's JSON holds its ML data and
|
||||
keywords. An image's JSON also holds the EXIF and XMP of its original, and its
|
||||
dimensions if it is a JPEG. A live photo's JSON holds these for its image. A
|
||||
video's JSON holds no EXIF, XMP or dimensions. A fetched original is written
|
||||
straight to its save path and not into the cache, which then counts it as
|
||||
present; one the cache already held is copied from there. `BackupOptions`:
|
||||
`downloadDirectory` (falls back to the library's), `includeOriginals` (default
|
||||
`true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify`
|
||||
(default `false`), `onProgress`, and `lock`. See Backup layout above for the
|
||||
tree it writes, and for the albums `BackupResult` lists in
|
||||
`undecryptableAlbums` and `failedAlbums`. Whether the backup took the lock or
|
||||
its caller did, once the lock is lost the backup rejects with
|
||||
`lost the lock on <dir>: <reason>` before it starts another file.
|
||||
|
||||
### Request pools
|
||||
|
||||
|
||||
Reference in New Issue
Block a user