Author SHA1 Message Date
sneak ec07fd1dc9 quak files COLLECTION fetches that collection's files alone (closes #217)
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 first. 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.
Once the stored cursor is past 0, it only updates a collection the store
holds. For a new one, `quak files COLLECTION` waits for a refresh. A
refresh from a cursor of 0 replaces all the store holds, so collections
then sit in the server's order. `open()` awaits the first refresh while
the cursor is 0. The store lists what did not decrypt in a fixed order.

Model: opus-5-5
2026-10-26 05:46:57 +00:00
clawbot 657266a60e A file's JSON holds keywords in place of its CLIP embedding (closes #218)
check / check (push) Canceled after 0s
`quak backup` and `quak backup-metadata` write each file's ML data without its
CLIP embedding, and `keywords` in its place: the titles of the Discover
categories of Ente's photos app that the file falls in, highest score first. A
file falls in a category when the cosine similarity of its CLIP embedding and
the text embedding of the category's query reaches the category's minimum
score; as in Ente's app, five categories leave out videos.

The 16 text embeddings ship in `src/clip-keyword-vectors.ts`, written by
`bin/clip-keyword-vectors` from Ente's list and CLIP text model. quak runs no
model and gains no dependency.

Disclosure: the text embeddings are committed model output, against
REPO_POLICIES' rule on generated files.

Model: opus-5-5
2026-10-22 23:32:25 +02:00
27 changed files with 3749 additions and 436 deletions
+295 -145
View File
@@ -2,20 +2,31 @@
quak is a WTFPL-licensed TypeScript client library and CLI by quak is a WTFPL-licensed TypeScript client library and CLI by
[@sneak](https://sneak.berlin) for the [Ente](https://ente.io) end-to-end [@sneak](https://sneak.berlin) for the [Ente](https://ente.io) end-to-end
encrypted photo hosting service. It logs in, enumerates collections and files, encrypted photo hosting service. It logs in, lists collections and their files,
and downloads individual images while decrypting them on the way to disk. and downloads and decrypts single files.
quak also includes a resilient backup command that downloads every file in the `quak backup` downloads every file in the account into a local directory tree.
account into a deduplicated local directory tree, skipping files that already It downloads and stores each file once, however many collections hold it. It
exist on disk and continuing past individual download failures instead of skips files already on disk. It logs a failed download and moves on to the next
crashing. For each file it persists the basic metadata fields quak keeps (title, file. Beside each file it writes a JSON file that holds:
file type, creation and modification time, latitude, longitude, content hash),
its update time, the private and public magic metadata in full, Ente's ML data - the file's title and file type
for it when there is any, and, for an image (for a live photo, its image), its - its creation and modification times
original's EXIF and XMP, with its dimensions for a JPEG only; for a video it - its latitude and longitude
keeps none of these three. It runs unattended from cron (see "Running the backup - its content hash
from cron"). A helper subcommand can detect and regenerate missing thumbnails, - its update time
encrypting and uploading them back to the server. - its private and public magic metadata, in full
- the ML data that Ente has for it, without the CLIP embedding
- its keywords in place of that embedding (see "Backup layout")
- for an image, the EXIF and XMP of its original, and its dimensions if it is a
JPEG
- for a live photo, the same for its image
The backup runs unattended from cron (see "Running the backup from cron").
`quak helper list-missing-thumbnails` finds files whose thumbnail is missing.
`quak helper fix-missing-thumbnails` makes a new thumbnail for such a file, then
encrypts it and uploads it to the server.
## Getting Started ## Getting Started
@@ -59,10 +70,11 @@ const client = await Client.login({
password: "your-password", password: "your-password",
}); });
// Open a cache-backed library. On an empty cache this awaits one server // Open a cache-backed library. On an empty cache, or one that only
// refresh; on an existing cache it returns immediately and refreshes in the // `lib.refreshCollection()` wrote, this awaits one server refresh. Otherwise
// background. Later refreshes start `refreshIntervalSeconds` (default 3) after // it returns immediately and refreshes in the background. A background timer
// the previous one ends. // then refreshes again `refreshIntervalSeconds` (default 3) after its previous
// refresh ends.
const lib = await Library.open({ client }); const lib = await Library.open({ client });
// Default reads answer synchronously from the local cache — no network. // Default reads answer synchronously from the local cache — no network.
@@ -371,6 +383,11 @@ quak/
backup-metadata: the metadata quak keeps, as JSON backup-metadata: the metadata quak keeps, as JSON
exif.ts EXIF read from an image's bytes with exifreader exif.ts EXIF read from an image's bytes with exifreader
mldata-fetch.ts fetch + decrypt per-file ML data mldata-fetch.ts fetch + decrypt per-file ML data
clip-keywords.ts
a file's keywords from its CLIP embedding
clip-keyword-vectors.ts
Ente's Discover categories with their text
embeddings, written by bin/clip-keyword-vectors
filename.ts safe file names from server metadata filename.ts safe file names from server metadata
errors.ts error types shared across layers, and how an error errors.ts error types shared across layers, and how an error
is described is described
@@ -387,6 +404,9 @@ quak/
index.ts public library exports index.ts public library exports
bin/ bin/
quak.ts CLI entrypoint (commander.js) quak.ts CLI entrypoint (commander.js)
clip-keyword-vectors, clip-keyword-vectors.py
write src/clip-keyword-vectors.ts and a test
fixture (see Source attribution)
examples/ examples/
download-albums.ts download-albums.ts
download every album's photos and metadata download every album's photos and metadata
@@ -631,17 +651,17 @@ The CLI stores the snapshot at the platform-appropriate data directory via
`0600`. The key material is stored in cleartext in the JSON; treat this file as `0600`. The key material is stored in cleartext in the JSON; treat this file as
you would treat the password itself. A missing file is reported as "not logged you would treat the password itself. A missing file is reported as "not logged
in"; a file that exists but is corrupt is reported as such, naming the bad in"; a file that exists but is corrupt is reported as such, naming the bad
field. When the refresh a command starts with gets HTTP 401 from the server, field. The server answers HTTP 401 when it no longer accepts the saved session's
because it no longer accepts the saved session's token, the command prints one token. A command that gets that answer prints one line,
line, `quak: the saved session is no longer valid; run "quak login"`, with no `quak: the saved session is no longer valid; run "quak login"`, with no stack
stack trace. All three exit with status 3, which means the user must run trace. All three exit with status 3, which means the user must run `quak login`
`quak login` again. `quak logout` is the exception: with no file it says there again. `quak logout` is the exception: with no file it says there is no session
is no session and exits 0, and it handles a corrupt file or a failed server call and exits 0, and it handles a corrupt file or a failed server call as described
as described below. `quak backup` meets an expired session on the refresh that below. `quak backup` meets an expired session on the refresh that starts every
starts every run, before it touches any file but its lock (see "Backup layout"). run, before it touches any file but its lock (see "Backup layout"). A session
A session that stops working partway through a backup instead fails each that stops working partway through a backup instead fails each remaining file
remaining file into `failures.json`, so that run exits 1 and the next one stops into `failures.json`, so that run exits 1 and the next one stops at its refresh
at its refresh with status 3. No command but `quak login` ever prompts. with status 3. No command but `quak login` ever prompts.
`quak logout` ends the session on the server, so the token in `session.json` `quak logout` ends the session on the server, so the token in `session.json`
stops working even in a copy of the file, and then deletes the file. If the stops working even in a copy of the file, and then deletes the file. If the
@@ -671,22 +691,40 @@ quak helper fix-missing-thumbnails [--file ids] [--json] generate + upload miss
``` ```
Every command except `login`, `whoami` and `logout` runs on the cache-backed Every command except `login`, `whoami` and `logout` runs on the cache-backed
library. The read commands — `collections`, `files`, `get`, `get-thumb`, library. Each read command makes a fresh server round-trip before it answers. It
`backup-metadata`, `helper list-missing-thumbnails` and then reports the account's current state, not what the cache last held. The read
`helper fix-missing-thumbnails` — force a fresh server round-trip before they commands are:
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 - `collections`
request, and neither fetches any collection's files nor changes the cache. The - `files`
others wait for a refresh. With an empty cache, as on a first run, that refresh - `get`
fetches the files of every collection, one collection after another, and stores - `get-thumb`
nothing until it has them all, which on a large account takes a while. Meanwhile - `backup-metadata`
the command logs a line on stderr for each collection whose files it has - `helper list-missing-thumbnails`
fetched, such as `Fetched the files of 3 of 120 collections`, with `--json` too; - `helper fix-missing-thumbnails`
`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 `collections` asks the server for the collection list alone, in one request. It
one line and exits 1, or 3 when the server no longer accepts the saved session fetches no collection's files and leaves the cache as it is. `files` with a
(see "Session handling"). An album or file quak cannot decrypt does not fail it; collection ID asks for the collection list the same way. It then asks for the
the command answers from what did decrypt (see "Default reads vs. fresh reads"). files of that collection alone, and stores them in the cache. It asks for no
files when the cache's copy of that collection is current. The other read
commands, and `files` without a collection ID, wait for a refresh. So does
`files` with the ID of a collection that is new since the last refresh.
The first refresh fetches the files of every collection, one collection after
another. That includes any collection that `files` with a collection ID stored
before it. The refresh stores nothing until it has them all, and on a large
account that 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`. It logs these lines with `--json`
too. `backup` logs them during the refresh it starts with. stdout holds only the
command's output.
If the server round-trip fails, the command prints the error on one line. It
exits 1, or 3 when the server no longer accepts the saved session (see "Session
handling"). An album or file that quak cannot decrypt does not fail the command.
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 `--cache-dir` overrides where the cache lives; without it each account gets its
own directory under the per-user cache path. own directory under the per-user cache path.
@@ -741,9 +779,10 @@ its `Failed files:` list. Every other line is logged as it is without `--json`.
exit status 1. exit status 1.
`files <collectionID>` lists the files in that collection. `files` with no `files <collectionID>` lists the files in that collection. `files` with no
argument lists every file in the account, collection by collection, each file argument lists every file in the account once, collection by collection. The
once: a file in several collections is listed with the first of them, which is collections come in the order the server first sent them in a refresh. A file in
its `collectionID` in `--json`. several collections is listed with the first of them, which is its
`collectionID` in `--json`.
An ID given to `files`, `get`, `get-thumb` or An ID given to `files`, `get`, `get-thumb` or
`helper fix-missing-thumbnails --file` is written in decimal digits, no larger `helper fix-missing-thumbnails --file` is written in decimal digits, no larger
@@ -783,9 +822,11 @@ as it was and goes into `failures.json` (see "Backup layout").
`backup-metadata <dir>` writes `account.json` and, for each collection, a `backup-metadata <dir>` writes `account.json` and, for each collection, a
directory `collections/<id>-<name>/` holding `_collection.json` and a directory `collections/<id>-<name>/` holding `_collection.json` and a
`<fileID>.json` for each of its files. The collection's name has unsafe `<fileID>.json` for each of its files. Each `<fileID>.json` holds the file's ML
characters replaced and is cut short between two characters so that the data and keywords as `quak backup` writes them (see "Backup layout"). The
directory's name fits in 255 bytes; the ID keeps the directories apart. collection's name has unsafe characters replaced and is cut short between two
characters so that the directory's name fits in 255 bytes; the ID keeps the
directories apart.
`backup-metadata` fetches ML data in requests of up to 200 files. When a request `backup-metadata` fetches ML data in requests of up to 200 files. When a request
fails, the error is logged, each of its files is written with the reason in an fails, the error is logged, each of its files is written with the reason in an
@@ -818,11 +859,11 @@ the smallest does not.
below) below)
YYYY-MM-DD.<fileID>.json the file's basic metadata fields quak YYYY-MM-DD.<fileID>.json the file's basic metadata fields quak
keeps, its update time, its private and keeps, its update time, its private and
public magic metadata, its ML data, and, public magic metadata, its ML data and
for an image (for a live photo, its keywords, and, for an image (for a live
image), its original's EXIF and XMP, with photo, its image), its original's EXIF
dimensions for a JPEG only; none of these and XMP, with dimensions for a JPEG
three for a video only; none of these three for a video
YYYY-MM-DD.<fileID>.livephoto.json YYYY-MM-DD.<fileID>.livephoto.json
which of a live photo's two files is which which of a live photo's two files is which
collections/ collections/
@@ -853,20 +894,51 @@ JSON holds its `id`, `collectionID`, `ownerID`, `metadata` and `updationTime`,
and its `magicMetadata` and `pubMagicMetadata` when it has them. Update times and its `magicMetadata` and `pubMagicMetadata` when it has them. Update times
are in microseconds, as Ente records them. are in microseconds, as Ente records them.
A file's JSON holds Ente's ML data for it (its faces and its CLIP embedding) as A file's JSON holds Ente's ML data for it as `mlData`, such as the faces Ente
`mlData`, the same payload `backup-metadata` writes; a file Ente has no ML data found, without its CLIP embedding. It holds `keywords` in place of the
for has no `mlData`. The backup waits for the library's ML data fetch to finish embedding.
before it writes the JSON files. If that fetch fails, each file whose ML data is
not in the cache gets the reason in `mlDataError` instead and counts as failed, The Discover section of Ente's photos app has 16 categories:
and the next run fetches it again. The same goes for a file with no ML data in
the cache whose ML data the server sent but quak could not decrypt, gunzip or - Identity (no videos)
parse. Its `mlDataError` says why, and `failures.json` records it with an error - Screenshots (no videos)
starting `ML data: `. quak keeps that reason in memory only and caches nothing - QR Codes (no videos)
for the file, so the next run asks for its ML data again. An ML data fetch that - Receipts
fails, such as one the library's refresh starts while the backup writes the JSON - Notes
files, leaves the reasons as the last fetch that succeeded left them. The JSON - Memes
files are rewritten on every run, so ML data that arrived since the last run - Visiting Cards (no videos)
appears. - Babies
- Pets
- Selfies
- Wallpapers (no videos)
- Food
- Celebrations
- Sunset
- Hills
- Greenery
A file's keywords are the titles of the categories it falls in. A file's score
for a category is the cosine similarity of its CLIP embedding and the text
embedding of the category's query. As in Ente's app, the file falls in the
category when that score is at least the category's minimum score. A video never
falls in a category marked "no videos". The keywords are listed highest score
first.
A file with no ML data has neither `mlData` nor `keywords`. A file whose ML data
holds no usable CLIP embedding has no `keywords`. `backup-metadata` writes both
fields the same way.
The backup waits for the library's ML data fetch to finish before it writes the
JSON files. If that fetch fails, each file whose ML data is not in the cache
gets the reason in `mlDataError` instead and counts as failed, and the next run
fetches it again. The same goes for a file with no ML data in the cache whose ML
data the server sent but quak could not decrypt, gunzip or parse. Its
`mlDataError` says why, and `failures.json` records it with an error starting
`ML data: `. quak keeps that reason in memory only and caches nothing for the
file, so the next run asks for its ML data again. An ML data fetch that fails,
such as one the library's refresh starts while the backup writes the JSON files,
leaves the reasons as the last fetch that succeeded left them. The JSON files
are rewritten on every run, so ML data that arrived since the last run appears.
A file's JSON holds, as `imageMetadata`, what `backup-metadata --exif` records A file's JSON holds, as `imageMetadata`, what `backup-metadata --exif` records
from its original, or from a live photo's image: `format`, `width` and `height` from its original, or from a live photo's image: `format`, `width` and `height`
@@ -1050,15 +1122,17 @@ against a fake of the Ente HTTP API, and `yarn test` verifies them.
### Opening a library ### Opening a library
`Library.open(options)` loads the on-disk cache, starts the background refresh `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 loop, and resolves to a `Library`. On an empty cache, or one that only
refresh, so it opens onto the account's data whenever the server is reachable; `lib.refreshCollection()` wrote, it awaits one refresh. It then opens onto the
if that refresh fails, it opens with no data and records the error in account's data whenever the server is reachable. If that refresh fails, it opens
`lib.status()`. On an existing cache it returns immediately and refreshes in the onto what the cache holds, and records the error in `lib.status()`. On any other
background, so an unreachable server does not block opening. With cache it returns immediately and refreshes in the background. An unreachable
`refreshOnOpen: false` it starts no refresh and returns at once onto what the server then does not block opening.
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 With `refreshOnOpen: false`, `open()` starts no refresh. It returns at once onto
it so (see "CLI surface"). what the cache holds, even when the cache is empty. A refresh then starts only
when `fresh()` or `lib.backup()` asks, or the background timer fires. The CLI's
read commands open the library this way (see "CLI surface").
`LibraryOptions`: `LibraryOptions`:
@@ -1085,22 +1159,35 @@ when the library opens; `lib.downloadDirectory` holds the result.
`onProgress` receives a `RefreshEvent` with status `"started"` as a refresh, an `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. 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 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 fetched. That event's `fetched` is the number of collections whose files the
far, and `total`, how many it will. A refresh fetches the files of each refresh has fetched so far. Its `total` is the number of collections whose files
collection that is new or changed since the cache's copy, so on an empty cache it will fetch in all. A refresh fetches the files of each collection that is new
it fetches those of every collection. or changed since the cache's copy. On the first refresh, that is every
collection.
Concurrency is set through `pools`: construct Concurrency is set through `pools`: construct
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })` `new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
and pass it. The three pools default to 10 / 5 / 25 (see Request pools below). and pass it. The three pools default to 10 / 5 / 25 (see Request pools below).
`lib.status()` returns a `LibraryStatus` (collection/file counts, last `lib.status()` returns a `LibraryStatus`, which holds:
refresh/ML times and errors, originals usage and effective limit, precache
progress, and `closed`). `lib.close()` stops the background timer; it is - the collection and file counts
idempotent, and an in-flight refresh is left to finish. The promise it returns - the time of the last refresh that succeeded, and the error of the latest
resolves once that refresh (including its cache write), the ML data fetch and refresh if it failed
the precache fetches already running have all finished, so the cache directory - the same two for the ML data fetch
can then be removed. - the originals cache's usage and its effective limit
- the precache progress
- `closed`
`lib.close()` stops the background timer, and is idempotent. A refresh or
`lib.refreshCollection()` in flight is left to finish. `close()` returns a
promise that resolves once all of these have finished:
- that refresh or `lib.refreshCollection()`, with its cache write
- the ML data fetch
- the precache fetches already running
The cache directory can then be removed.
`lib.status().mlDataErrors` lists, as `{ fileID, error }`, each file whose ML `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 data the last ML data fetch that succeeded got but could not decrypt, gunzip or
@@ -1114,39 +1201,72 @@ cache.
Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer
synchronously from the copy held in RAM and never touch the network. A refresh 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 changes that copy only once all its server requests have succeeded.
background timer starts the next refresh `refreshIntervalSeconds` after the `lib.refreshCollection()` changes it the same way for one collection. So a
previous one ends. So a default read is immediate, but only as current as the default read is immediate, but only as current as the last refresh or
last refresh whose requests all succeeded; right after opening an existing `lib.refreshCollection()` whose requests all succeeded. Right after `open()`
cache, it is the copy on disk. returns without awaiting a refresh, that copy is the one loaded from disk.
`await lib.fresh()` waits for a refresh to complete and persist, and returns the The background timer takes a turn `refreshIntervalSeconds` after its last turn
same `{ albums, photos, timeline }` namespaces, which then reflect a completed ends. Each turn starts a refresh, unless a refresh or `lib.refreshCollection()`
server round-trip. When a refresh is already running, background or not, is already running.
`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 `await lib.fresh()` waits for a refresh to complete and persist. It then returns
the same `{ albums, photos, timeline }` namespaces, which reflect a completed
server round-trip. If a `lib.refreshCollection()` is running, `fresh()` first
waits for it 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 rejects the caller (default reads stay silent and keep serving the last good
copy). The CLI's read commands use fresh reads (issue copy). Every CLI read command calls `fresh()`, except `quak collections`.
https://git.eeqj.de/sneak/quak/issues/75). `quak files` with a collection ID calls it only when `lib.refreshCollection()`
resolves to `false`.
`await lib.freshCollections()` is the fresh read of the collection list alone: `await lib.freshCollections()` is the fresh read of the collection list alone.
one collections request, answered with every live collection that decrypts, as It sends one collections request. It answers with every live collection that
the `Collection` objects `listCollections()` returns, in the server's order. It decrypts, in the server's order, as the `Collection` objects that
fetches no collection's files and stores nothing, not even the list: a refresh `listCollections()` returns. It fetches no collection's files and stores
fetches a collection's files only when the collection is new or has changed nothing, not even the list. A refresh from a cursor past 0 fetches a
since the stored copy, so a collection stored without its files would never get collection's files only when the collection is new or has changed since the
them. `quak collections` uses it (issue stored copy. So does `lib.refreshCollection()`. Neither would fetch the files of
https://git.eeqj.de/sneak/quak/issues/210). a collection stored without them. `quak collections` uses `freshCollections()`.
An album or file in the server's answer that quak cannot decrypt, or whose `await lib.refreshCollection(collection)` brings one collection up to date in
metadata is not JSON, does not fail the refresh. The refresh leaves it out, an the cache, given its `Collection` from `freshCollections()`. It does for that
album with all its files, and drops any older copy the cache held, so reads collection what a refresh does for a changed one, and fetches no other
answer from what did decrypt. The library keeps a list of what it left out, with collection's files. It fetches the collection's files changed since the cache's
the reason, in `metadata.json`: the next refresh asks the server only for what copy, or all of them when the cache has no copy. It then stores and saves them.
changed, so it would not see the record again. A record stays on that list until It sends no request when the cache's copy has the same `updationTime` or a later
a later refresh gets a copy that decrypts or learns that the server deleted it. one. It resolves to `true` when the cache then holds the collection.
`lib.backup()` reports each such album, and each such file in its scope that has
no copy that decrypted (see "Backup layout"). `lib.refreshCollection()` adds a collection the cache does not hold only while
the refresh cursor is 0. The cursor moves past 0 once a refresh receives the
account's collections. After that, for a collection the cache does not hold, it
sends no request and resolves to `false`. Such a collection is new since that
refresh, or the refresh dropped it. Only a refresh may then add it, after the
collections the cache holds.
A refresh from a cursor of 0 replaces everything the cache holds. It fetches the
files of every collection, even those `lib.refreshCollection()` stored. It
stores the collections in the server's order. The cache then holds what it would
had `lib.refreshCollection()` never run.
`lib.refreshCollection()` leaves the refresh cursor 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 or
`lib.refreshCollection()` to end. While it runs, a `fresh()` or `lib.backup()`
waits for it to end. `quak files` with a collection ID uses it.
A refresh does not fail on an album or file that quak cannot decrypt, or whose
metadata is not JSON. The refresh leaves out such a file, and such an album
together with all its files. It also drops any older copy that 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 changed, so it would not see them again. An entry stays on that list
until 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 ### Read surface
@@ -1240,10 +1360,17 @@ The GUI-facing records hold no key material and no binary, so they survive
- `LibrarySnapshot`: `{ albums, photos, takenAt }`. - `LibrarySnapshot`: `{ albums, photos, takenAt }`.
`lib.snapshot()` returns a `LibrarySnapshot` (albums newest-updated first, `lib.snapshot()` returns a `LibrarySnapshot` (albums newest-updated first,
photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange` photos newest first). `lib.subscribe({ onChange })` returns `{ unsubscribe }`.
(`albumsChanged`, `photosChanged`, `fileIDsRemoved`, `albumIDsRemoved`, It calls `onChange` with a `LibraryChange` whenever a refresh or
`refreshedAt`) whenever a refresh alters the projection, and returns `lib.refreshCollection()` changes these records. A refresh or
`{ unsubscribe }`; a refresh that changes nothing delivers nothing. `lib.refreshCollection()` that changes nothing does not call it. A
`LibraryChange` holds:
- `albumsChanged`
- `photosChanged`
- `fileIDsRemoved`
- `albumIDsRemoved`
- `refreshedAt`
### Thumbnails, ML search, and backup ### Thumbnails, ML search, and backup
@@ -1259,29 +1386,44 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
`SimilarResult[]` (`{ fileID, score }`, cosine similarity, most similar first, `SimilarResult[]` (`{ fileID, score }`, cosine similarity, most similar first,
default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes
a query vector the caller produced elsewhere. a query vector the caller produced elsewhere.
- `await lib.backup(opts?)` → `BackupResult`. It takes the lock in the download - `await lib.backup(opts?)` → `BackupResult`. It takes the lock, `backup.lock`
directory, and fails at once with an error whose `code` is `ELOCKED` while in the download directory. It fails at once with an error whose `code` is
another backup of it runs. A caller can instead take the lock itself, before `ELOCKED` while another backup of that directory runs. A caller can instead
it opens its library, as `quak backup` does: `await lockBackupDirectory(dir)` take the lock itself before it opens its library, as `quak backup` does.
takes it, failing the same way, and returns a `BackupLock`, whose `release()` `await lockBackupDirectory(dir)` takes it, failing the same way, and returns a
removes the lock unless it was lost and whose `lost` holds why it was lost. `BackupLock`. Its `release()` removes the lock unless it was lost, and its
The caller passes it to the backup as `lock`, and releases it once the backup `lost` holds why it was lost. The caller passes it to the backup as `lock`,
ends. It waits for a refresh as `fresh()` does, puts every in-scope original and releases it once the backup ends.
not already at its save path there as `photo.download()` does (and, with
`includeThumbnails`, fetches thumbnails) through the content cache, waits for The backup waits for a refresh as `fresh()` does. It then fetches each
an ML data fetch, and rebuilds the on-disk backup tree with a durable failure in-scope original that is not yet at its save path, as `photo.download()`
ledger. Each file's JSON holds its ML data and, for an image (for a live does, through the content cache. With `includeThumbnails` it also fetches
photo, its image), its original's EXIF and XMP, with its dimensions for a JPEG thumbnails. It waits for an ML data fetch, then rebuilds the backup tree. It
only; a video's JSON holds none of these three. A fetched original is written records each file that failed in `failures.json`.
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`: Each file's JSON holds its ML data and keywords. An image's JSON also holds
`downloadDirectory` (falls back to the library's), `includeOriginals` (default the EXIF and XMP of its original, and its dimensions if it is a JPEG. A live
`true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify` photo's JSON holds these for its image. A video's JSON holds no EXIF, XMP or
(default `false`), `onProgress`, and `lock`. See Backup layout above for the dimensions.
tree it writes, and for the albums `BackupResult` lists in
`undecryptableAlbums` and `failedAlbums`. Whether the backup took the lock or A fetched original is written straight to its save path, not into the cache.
its caller did, once the lock is lost the backup rejects with The cache then counts it as present. An original that the cache already held
`lost the lock on <dir>: <reason>` before it starts another file. is copied from there.
`BackupOptions` holds:
- `downloadDirectory`, which falls back to the library's
- `includeOriginals`, `true` by default
- `includeThumbnails`, `false` by default
- `onlyAlbumNames`
- `verify`, `false` by default
- `onProgress`
- `lock`
"Backup layout" above describes the tree that the backup writes. It also
describes the albums that `BackupResult` lists in `undecryptableAlbums` and
`failedAlbums`. Once the lock is lost, the backup rejects with
`lost the lock on <dir>: <reason>` before it starts another file. This holds
whether the backup or its caller took the lock.
### Request pools ### Request pools
@@ -1364,6 +1506,14 @@ is rewritten in TypeScript in this repository. Protocol fidelity is verified
against the upstream implementations in `web/packages/base/`, against the upstream implementations in `web/packages/base/`,
`mobile/apps/photos/lib/`, and `cli/`. `mobile/apps/photos/lib/`, and `cli/`.
The keywords come from Ente too. `src/clip-keyword-vectors.ts` holds the
Discover categories of Ente's photos app, from
`mobile/apps/photos/assets/discover.json`, each with its query's text embedding
from Ente's CLIP text model. `bin/clip-keyword-vectors` writes that file and the
CLIP embedding of a public-domain photo that the tests use. It runs Ente's
models in Docker, with every download pinned by sha256. It is run by hand only,
followed by `make fmt`.
## For LLMs ## For LLMs
If you are an LLM agent working on this repository, read and follow these If you are an LLM agent working on this repository, read and follow these
+27
View File
@@ -26,6 +26,33 @@ declares one.
# Completed Steps # Completed Steps
- 2026-10-25: `quak files COLLECTION` fetches the files of that collection alone
(https://git.eeqj.de/sneak/quak/issues/217). It used to wait for a refresh,
which on an empty cache fetched the files of every collection first. It now
reads the collection list through `lib.freshCollections()`, then brings that
one collection up to date through the new `lib.refreshCollection()`. That
stores and saves its files as a refresh would, and leaves the refresh cursor
where it was. When the stored copy is current, it asks for none of the files.
It never runs at the same time as a refresh. Once a refresh has received the
account's collections, it stores no collection the cache does not hold, and
`quak files COLLECTION` waits for a refresh instead. A refresh from a cursor
of 0 replaces all the cache holds, so collections keep the server's order. A
library opened on a cache that only `lib.refreshCollection()` wrote awaits its
first refresh. `quak files` with no argument still waits for a refresh. The
store lists what did not decrypt by collection ID and file ID.
- 2026-10-22: The JSON `quak backup` and `quak backup-metadata` write for a file
holds `keywords` in place of the CLIP embedding in its ML data
(https://git.eeqj.de/sneak/quak/issues/218). The keywords are the titles of
the Discover categories of Ente's photos app that the file falls in, by the
app's rule: the cosine similarity of the file's CLIP embedding and the text
embedding of a category's query is at least the category's minimum score, and
videos stay out of the categories that leave them out. `src/clip-keywords.ts`
applies the rule. `src/clip-keyword-vectors.ts` holds the 16 categories with
their text embeddings. `bin/clip-keyword-vectors` writes it with Ente's CLIP
text model, and writes the CLIP embedding of a public-domain photo of a sunset
that the tests use.
- 2026-10-18: `-v`/`--verbose` raises the CLI's log level once for each repeat, - 2026-10-18: `-v`/`--verbose` raises the CLI's log level once for each repeat,
and `-q`/`--quiet` prints only fatal errors and `-q`/`--quiet` prints only fatal errors
(https://git.eeqj.de/sneak/quak/issues/212). The levels are fatal, error, (https://git.eeqj.de/sneak/quak/issues/212). The levels are fatal, error,
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# Writes src/clip-keyword-vectors.ts and test/clip-keywords-sunset.json by
# running bin/clip-keyword-vectors.py in a Python image pinned by digest, with
# its packages pinned by hash. Run it by hand from a checkout, then `make fmt`;
# no gate runs it. Besides the Python image, it downloads about 265 MB, most of
# it Ente's two CLIP models.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# python:3.13-slim (Python 3.13.17 on Debian 13.7), 2026-10-21. The wheels
# below are the ones for this image on linux/amd64.
IMAGE=python@sha256:57b29a7c050c4e679e4bea572e14b3965cf4b5839de2da49d55e49ffc4024f39
main() {
cd "$ROOT"
docker run --rm -i --platform linux/amd64 \
--user "$(id -u):$(id -g)" -e HOME=/tmp \
-v "$ROOT:/repo" -w /repo "$IMAGE" sh -c '
python -m venv /tmp/venv &&
/tmp/venv/bin/pip install --quiet --disable-pip-version-check \
--no-cache-dir --no-deps --require-hashes -r /dev/stdin &&
/tmp/venv/bin/python bin/clip-keyword-vectors.py' <<'EOF'
numpy==2.5.3 --hash=sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321
onnxruntime==1.30.0 --hash=sha256:86f940afc801ea9681a4da8af84fbe95e1d9ea7d80903952cc1bfad54faad38f
pillow==12.3.0 --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3
tokenizers==0.23.2 --hash=sha256:41c2f84d172449b4dadb9cdc508e3e364076613c35b16e76ecfe47a60d1e3305
EOF
}
main "$@"
+158
View File
@@ -0,0 +1,158 @@
"""Write src/clip-keyword-vectors.ts and test/clip-keywords-sunset.json.
bin/clip-keyword-vectors runs this in a pinned Python image. It downloads
Ente's Discover list, Ente's CLIP text and image models, the CLIP tokenizer
and a public-domain photo of a sunset, checks each against its sha256, and
writes:
- src/clip-keyword-vectors.ts: each Discover category of Ente's photos app,
with the text embedding Ente's CLIP text model gives its query;
- test/clip-keywords-sunset.json: the CLIP embedding Ente's image model gives
the photo, prepared as Ente's apps prepare a photo.
Both embeddings are scaled to length 1, as Ente's apps scale them.
"""
import hashlib
import io
import json
import urllib.request
import numpy as np
import onnxruntime
from PIL import Image, ImageOps
from tokenizers import Tokenizer
ENTE_COMMIT = "f5ad70a860ed25d0c8057b0efb4bd6eaefe4b0c5"
DISCOVER_PATH = "mobile/apps/photos/assets/discover.json"
DISCOVER_URL = (
f"https://raw.githubusercontent.com/ente-io/ente/{ENTE_COMMIT}/{DISCOVER_PATH}"
)
DISCOVER_SHA256 = "cf70ac7116b592f7921ca4cf67cdbb421a8e6b287fbe44b2be678b8d198e5c24"
TEXT_MODEL = "mobileclip_s2_text_opset18_quant.onnx"
TEXT_MODEL_SHA256 = "d92f33dfcff83077fc2e0d3414250710efbb51795dfd89767bdbefb5fdc47322"
IMAGE_MODEL = "mobileclip_s2_image_gelu_opset20.onnx"
IMAGE_MODEL_SHA256 = "205a430af825e501c5138e5bb9abea942482a7a4fd4a680e98e47cf0830dce7e"
# The CLIP tokenizer Hugging Face publishes for OpenAI's CLIP. Its vocabulary
# and merges are those of bpe_simple_vocab_16e6.txt, the vocabulary Ente's
# apps tokenize with.
TOKENIZER_URL = (
"https://huggingface.co/openai/clip-vit-base-patch32/resolve/"
"3d74acf9a28c67741b2f4f2ea7635f0aaf6f0268/tokenizer.json"
)
TOKENIZER_SHA256 = "b556ac8c99757ffb677208af34bc8c6721572114111a6e0aaf5fa69ff0b8d842"
# "Sunset off the Massachusetts coast", a NOAA photo in the public domain:
# https://commons.wikimedia.org/wiki/File:Sunset_off_the_Massachusetts_coast_-_NOAA.jpg
PHOTO_URL = (
"https://upload.wikimedia.org/wikipedia/commons/d/db/"
"Sunset_off_the_Massachusetts_coast_-_NOAA.jpg"
)
PHOTO_SHA256 = "be62f3289c9bf7b75af68cd6978021d54e8a531d5d4cc493f6e9fbf5e1ab74d3"
# Ente's text model takes 77 token ids, padded with 0; its image model takes
# a 256x256 RGB image.
TOKEN_COUNT = 77
IMAGE_SIZE = 256
def download(url, sha256):
# Wikimedia refuses requests without a User-Agent.
request = urllib.request.Request(url, headers={"User-Agent": "quak"})
with urllib.request.urlopen(request) as response:
data = response.read()
digest = hashlib.sha256(data).hexdigest()
if digest != sha256:
raise SystemExit(f"{url} has sha256 {digest}, not {sha256}")
return data
def ente_model(name, sha256):
return onnxruntime.InferenceSession(
download(f"https://models.ente.com/{name}", sha256),
providers=["CPUExecutionProvider"],
)
def run(session, tensor):
(output,) = session.run(None, {session.get_inputs()[0].name: tensor})
embedding = output[0]
return embedding / np.linalg.norm(embedding)
def text_embedding(session, tokenizer, query):
ids = tokenizer.encode(query).ids
ids += [0] * (TOKEN_COUNT - len(ids))
return run(session, np.array([ids], dtype=np.int32))
def image_embedding(session, photo):
image = ImageOps.exif_transpose(Image.open(io.BytesIO(photo))).convert("RGB")
# Scale to cover IMAGE_SIZE x IMAGE_SIZE and crop the centre.
square = ImageOps.fit(image, (IMAGE_SIZE, IMAGE_SIZE), Image.Resampling.BILINEAR)
pixels = np.asarray(square, dtype=np.float32) / 255
return run(session, pixels.transpose(2, 0, 1)[np.newaxis])
def numbers(embedding):
return ", ".join(str(value) for value in embedding)
def write_categories(prompts, embeddings):
lines = [
"// Written by bin/clip-keyword-vectors; do not edit by hand.",
"//",
"// The Discover categories of Ente's photos app, in their order, from",
f"// {DISCOVER_PATH} in https://github.com/ente-io/ente",
f"// at commit {ENTE_COMMIT}. Each has the text",
f"// embedding Ente's CLIP text model ({TEXT_MODEL})",
"// gives its query, scaled to length 1. `showVideo` is false for a category",
"// that leaves out videos.",
"",
"export const DISCOVER_CATEGORIES = [",
]
for prompt, embedding in zip(prompts, embeddings):
lines += [
" {",
f" title: {json.dumps(prompt['title'])},",
f" query: {json.dumps(prompt['query'])},",
f" minScore: {json.dumps(prompt['minScore'])},",
f" showVideo: {json.dumps(prompt.get('showVideo', True))},",
f" textEmbedding: [{numbers(embedding)}],",
" },",
]
lines.append("];")
with open("src/clip-keyword-vectors.ts", "w", encoding="utf-8") as file:
file.write("\n".join(lines) + "\n")
def write_fixture(embedding):
with open("test/clip-keywords-sunset.json", "w", encoding="utf-8") as file:
file.write(
"{\n"
f' "photo": {json.dumps(PHOTO_URL)},\n'
f' "sha256": {json.dumps(PHOTO_SHA256)},\n'
f' "embedding": [{numbers(embedding)}]\n'
"}\n"
)
def main():
prompts = json.loads(download(DISCOVER_URL, DISCOVER_SHA256))["prompts"]
tokenizer = Tokenizer.from_str(
download(TOKENIZER_URL, TOKENIZER_SHA256).decode("utf-8")
)
text_model = ente_model(TEXT_MODEL, TEXT_MODEL_SHA256)
embeddings = [
text_embedding(text_model, tokenizer, prompt["query"]) for prompt in prompts
]
write_categories(prompts, embeddings)
image_model = ente_model(IMAGE_MODEL, IMAGE_MODEL_SHA256)
write_fixture(image_embedding(image_model, download(PHOTO_URL, PHOTO_SHA256)))
if __name__ == "__main__":
main()
+14 -6
View File
@@ -14,9 +14,9 @@
// YYYY/YYYY-MM/YYYY-MM-DD/ // YYYY/YYYY-MM/YYYY-MM-DD/
// YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path) // YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path)
// YYYY-MM-DD.<fileID>.json per-file metadata sidecar, with // YYYY-MM-DD.<fileID>.json per-file metadata sidecar, with
// the file's ML data and its // the file's ML data, its
// original's EXIF, XMP and // keywords, and its original's
// dimensions // EXIF, XMP and dimensions
// collections/<name>/<title> symlink to the original // collections/<name>/<title> symlink to the original
// collections/<name>.json per-collection metadata // collections/<name>.json per-collection metadata
// account.json the account's email and user ID // account.json the account's email and user ID
@@ -77,6 +77,7 @@ import { readFile } from "node:fs/promises";
import { dirname, extname, join, relative, resolve } from "node:path"; import { dirname, extname, join, relative, resolve } from "node:path";
import lockfile from "proper-lockfile"; import lockfile from "proper-lockfile";
import { clipKeywords } from "./clip-keywords.js";
import { removeLeftoverTempFiles } from "./download/index.js"; import { removeLeftoverTempFiles } from "./download/index.js";
import { describeError } from "./errors.js"; import { describeError } from "./errors.js";
import { import {
@@ -497,8 +498,9 @@ const imageMetadataFor = async (
} }
}; };
// The file's JSON: its basic fields, its magic metadata, its ML data or the // The file's JSON: its basic fields, its magic metadata, its ML data without
// reason the ML data is missing, and its image metadata. // the CLIP embedding and the keywords that embedding gives, or the reason the
// ML data is missing, and its image metadata.
const writeSidecar = ( const writeSidecar = (
path: string, path: string,
file: EnteFile, file: EnteFile,
@@ -514,7 +516,13 @@ const writeSidecar = (
}; };
if (file.magicMetadata) meta.magicMetadata = file.magicMetadata; if (file.magicMetadata) meta.magicMetadata = file.magicMetadata;
if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata; if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata;
if (ml.mlData) meta.mlData = ml.mlData; if (ml.mlData) {
const { clip: _clip, ...mlDataWithoutClip } = ml.mlData;
meta.mlData = mlDataWithoutClip;
const isVideo = file.metadata.fileType === "video";
const keywords = clipKeywords(ml.mlData, isVideo);
if (keywords) meta.keywords = keywords;
}
if (ml.mlDataError) meta.mlDataError = ml.mlDataError; if (ml.mlDataError) meta.mlDataError = ml.mlDataError;
if (image.imageMetadata) meta.imageMetadata = image.imageMetadata; if (image.imageMetadata) meta.imageMetadata = image.imageMetadata;
if (image.imageMetadataError) { if (image.imageMetadataError) {
+8 -6
View File
@@ -128,12 +128,14 @@ const readLibraryClient = (client: Client): LibraryClient => ({
contentSource: () => client.contentSource(), contentSource: () => client.contentSource(),
}); });
// Open a library for a single point command: the aggressive background precache // Open a library for a single point command. The aggressive background
// (issue #48) is off — a one-shot `collections` or `get` must not start // precache (issue #48) is off, because a one-shot `collections` or `get` must
// downloading the whole account — and no refresh starts as it opens or on a // not start downloading the whole account. No refresh starts as it opens or on
// timer: each command asks for the one it needs, and `collections` needs none // a timer. Each command asks for the refresh it needs. `collections` needs none
// (issue #210). While a refresh runs, each collection whose files it has // (issue #210). `files COLLECTION` needs one only when `refreshCollection()`
// fetched is logged, so a large account shows progress rather than nothing. // resolves to `false` (issue #217). While a refresh runs,
// each collection whose files it has fetched is logged, so a large account
// shows progress rather than nothing.
const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> => const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
Library.open({ Library.open({
client: readLibraryClient(client), client: readLibraryClient(client),
+44 -27
View File
@@ -2,33 +2,42 @@
// //
// `collections`, `files`, `get`, and `get-thumb` must answer for // `collections`, `files`, `get`, and `get-thumb` must answer for
// the account's state at the moment the command runs, not for whatever the // the account's state at the moment the command runs, not for whatever the
// local cache last happened to hold (owner amendment, issue #36). `files`, // local cache last happened to hold (owner amendment, issue #36). `files`
// `get` and `get-thumb` therefore force a server round-trip through // without a collection ID, `get` and `get-thumb` therefore force a server
// `Library.fresh()` and only then read — so a collection, file, or metadata // round-trip through `Library.fresh()`, and only then read. A collection, file,
// change made elsewhere is visible. `collections` asks the library for the // or metadata change made elsewhere is then visible. `collections` asks the
// collection list alone through `Library.freshCollections()` (issue #210): a // library for the collection list alone through `Library.freshCollections()`
// refresh would also fetch the files of every collection, which on a large // (issue #210). A refresh would also fetch the files of every collection, which
// account takes long and which `collections` does not print. // `collections` does not print, and on a large account that takes long.
// `files COLLECTION` reads the collection list the same way, for the same
// reason. It then brings that one collection up to date through
// `Library.refreshCollection()` (issue #217), and fetches no other collection's
// files. Once a refresh has received the account's collections,
// `refreshCollection()` stores no collection the library does not hold.
// `files COLLECTION` then forces a refresh instead.
// //
// `collections` lists in the server's order and `files` in the library's own // `collections` lists in the server's order. `files` lists in the library's
// enumeration order (`listFiles()`), the orders the pre-library CLI printed, // own enumeration order: `listCollections()`, then `listFiles()` for each.
// rather than the `albums`/`photos` projection's newest-first order, which // Those are the orders the pre-library CLI printed. The `albums`/`photos`
// re-sorts the rows. The field values still come from each record's raw // projection would re-sort the rows newest first. After a refresh, the
// metadata via `cli-output.ts`. `files` without a collection ID lists every // library holds collections in the order the server first sent them. The
// file in the account once (issue #211): the library keeps a record of a file // field values still come from each record's raw metadata via
// for each collection that holds it, and only the first collection's record is // `cli-output.ts`. `files` without a collection ID lists every file in the
// listed. // account once (issue #211).
// The library keeps a record of a file for each collection that holds it, and
// only the first collection's record is listed.
import type { Collection, EnteFile } from "./model/types.js"; import type { Collection, EnteFile } from "./model/types.js";
import type { Photo, PhotosAPI } from "./library/index.js"; import type { Photo, PhotosAPI } from "./library/index.js";
// The slice of `Library` these helpers read. `Library` satisfies it // The slice of `Library` these helpers read. `Library` satisfies it
// structurally; a test can drive them with a stand-in that records the // structurally. A test can drive them with a stand-in that records the
// `fresh()` and `freshCollections()` calls and serves records in a known // `fresh()`, `freshCollections()` and `refreshCollection()` calls. The
// enumeration order. // stand-in serves records in a known enumeration order.
export interface FreshReadLibrary { export interface FreshReadLibrary {
fresh(): Promise<unknown>; fresh(): Promise<unknown>;
freshCollections(): Promise<Collection[]>; freshCollections(): Promise<Collection[]>;
refreshCollection(collection: Collection): Promise<boolean>;
getCollection(id: number): Collection | undefined; getCollection(id: number): Collection | undefined;
listCollections(): Collection[]; listCollections(): Collection[];
listFiles(collectionID: number): EnteFile[]; listFiles(collectionID: number): EnteFile[];
@@ -42,21 +51,29 @@ export const freshCollections = (
lib: FreshReadLibrary, lib: FreshReadLibrary,
): Promise<Collection[]> => lib.freshCollections(); ): Promise<Collection[]> => lib.freshCollections();
// The files of one collection, current as of a forced refresh, in enumeration // The files of one collection, in enumeration order. `undefined` (not an empty
// order. `undefined` (not an empty list) when the collection does not exist, so // list) when the collection does not exist, so the caller can tell "no such
// the caller can tell "no such collection" from "an empty collection". Without // collection" from "an empty collection". They are current as of one
// a collection ID, every file in the account, each once, as held by the first // collections request and the requests that bring that collection alone up to
// collection that holds it. // date. When `refreshCollection()` resolves to `false`, they are current as of
// a forced refresh instead. Without a collection ID, every file in the account,
// current as of a forced refresh. Each file is listed once, as held by the
// first collection that holds it.
export const freshFiles = async ( export const freshFiles = async (
lib: FreshReadLibrary, lib: FreshReadLibrary,
collectionID?: number, collectionID?: number,
): Promise<EnteFile[] | undefined> => { ): Promise<EnteFile[] | undefined> => {
await lib.fresh();
if (collectionID !== undefined) { if (collectionID !== undefined) {
return lib.getCollection(collectionID) const collections = await lib.freshCollections();
? lib.listFiles(collectionID) const collection = collections.find((c) => c.id === collectionID);
: undefined; if (!collection) return undefined;
if (!(await lib.refreshCollection(collection))) {
await lib.fresh();
if (!lib.getCollection(collectionID)) return undefined;
} }
return lib.listFiles(collectionID);
}
await lib.fresh();
const listed = new Set<number>(); const listed = new Set<number>();
const files: EnteFile[] = []; const files: EnteFile[] = [];
for (const collection of lib.listCollections()) { for (const collection of lib.listCollections()) {
File diff suppressed because it is too large Load Diff
+57
View File
@@ -0,0 +1,57 @@
// Keywords for a file from its CLIP embedding.
//
// Ente stores no keywords. A file's ML data holds a CLIP embedding, the 512
// numbers Ente's image model gave the photo, and Ente's photos app sorts files
// into the categories of its Discover section with it: a file falls in a
// category when the cosine similarity of its CLIP embedding and the text
// embedding of the category's query is at least the category's `minScore`,
// unless the file is a video and the category leaves out videos. A file's
// keywords are the titles of the categories it falls in. The categories and
// their text embeddings are in `clip-keyword-vectors.ts`.
import { DISCOVER_CATEGORIES } from "./clip-keyword-vectors.js";
import { clipEmbedding } from "./library/mldata.js";
import type { MLData } from "./mldata-fetch.js";
// How many numbers a CLIP embedding from Ente's image model has.
const EMBEDDING_LENGTH = 512;
const norm = (vector: number[]): number => Math.hypot(...vector);
const cosineSimilarity = (a: number[], b: number[]): number => {
let dot = 0;
a.forEach((value, i) => {
dot += value * (b[i] ?? 0);
});
return dot / (norm(a) * norm(b));
};
// The titles of the Discover categories a file falls in, highest score first,
// or undefined when its ML data has no CLIP embedding of 512 finite numbers
// that are not all zero. Tests pass their own `categories`.
export const clipKeywords = (
mlData: MLData,
isVideo: boolean,
categories = DISCOVER_CATEGORIES,
): string[] | undefined => {
const embedding = clipEmbedding(mlData);
if (
!embedding ||
embedding.length !== EMBEDDING_LENGTH ||
norm(embedding) === 0
) {
return undefined;
}
const matches: { title: string; score: number }[] = [];
for (const category of categories) {
if (isVideo && !category.showVideo) continue;
const score = cosineSimilarity(embedding, category.textEmbedding);
if (score >= category.minScore) {
matches.push({ title: category.title, score });
}
}
return matches
.sort((a, b) => b.score - a.score)
.map((match) => match.title);
};
+286 -159
View File
@@ -1,28 +1,37 @@
// The library surface over the local cache. // The library surface over the local cache.
// //
// `Library.open()` loads the on-disk metadata store (issue #41), then starts // `Library.open()` loads the on-disk metadata store (issue #41), then starts
// the refresh loop. When the cache loaded empty it awaits the first refresh, // the refresh loop. When the stored cursor is 0, `open()` awaits the first
// so the library never opens onto an empty store it could have filled; when an // refresh. No refresh has then stored any collection: the cache is empty, or
// existing copy loaded, that first refresh runs in the background and `open()` // holds only what `refreshCollection()` stored. The library must not serve
// returns as soon as the cached data is ready to serve — a slow or unreachable // that as the whole account. Otherwise that first refresh runs in the
// server no longer stalls opening. A background timer then refreshes every // background. `open()` returns as soon as the cached data is ready to serve,
// `refreshIntervalSeconds`. Every default read is answered from RAM — no // so a slow or unreachable server does not stall opening. A background timer
// default read touches the network. There is deliberately no `sync()`, no // then takes a turn `refreshIntervalSeconds` after its last turn ends. Each
// `refresh()`, no `serverReachable` flag, and no "before each read" mode // turn starts a refresh, unless a refresh or `refreshCollection()` is already
// (design #36). // running. Every default read is answered from RAM — no default read touches
// the network. There is deliberately no `sync()`, no `refresh()`, no
// `serverReachable` flag, and no "before each read" mode (design #36).
// //
// `fresh()` is the one exception (issue #75, an owner amendment to #36): it // `fresh()` is an exception (issue #75, an owner amendment to #36). It awaits
// forces a refresh, awaits it, and only then hands back the read namespaces, so // a refresh, and only then hands back the read namespaces. A caller that needs
// a caller that needs server-current data can ask for it. Concurrent `fresh()` // server-current data uses it. Concurrent `fresh()` calls coalesce onto one
// calls coalesce onto one in-flight refresh, and a refresh that fails rejects // in-flight refresh. A refresh that fails rejects the caller. The default
// the caller (the default reads stay silent and serve the last good copy). The // reads stay silent and serve the last good copy. The default methods and the
// default methods and the background loop are unchanged. // background loop are unchanged.
// //
// `freshCollections()` is the fresh read of the collection list alone (issue // `freshCollections()` is the fresh read of the collection list alone (issue
// #210): one collections request, no collection's files, and nothing stored, // #210). It sends one collections request and stores nothing.
// for a caller such as `quak collections` that needs no more. A caller that // `quak collections` uses it. `refreshCollection()` brings one collection
// asks for each refresh it needs can also open the library with // from that list up to date in the store (issue #217). It fetches that
// `refreshOnOpen: false`, so that `open()` starts none. // collection's files alone, as a refresh would. `quak files COLLECTION` uses
// it. It never runs at the same time as a refresh. Once the stored cursor is
// past 0, it only updates a collection the store holds, and only a refresh
// adds one. A refresh from a stored cursor of 0 replaces all the store holds.
// So after a refresh, the store holds collections in the order the server
// first sent them. A caller that asks for
// each refresh it needs can also open the library with `refreshOnOpen: false`,
// so that `open()` starts none.
// //
// A refresh reports through `onProgress` each collection whose files it has // A refresh reports through `onProgress` each collection whose files it has
// fetched, so a large account, whose first refresh fetches the files of every // fetched, so a large account, whose first refresh fetches the files of every
@@ -31,10 +40,12 @@
// A refresh stages all of its network work first and only mutates the store // A refresh stages all of its network work first and only mutates the store
// once every fetch has succeeded. A refresh that fails partway therefore never // once every fetch has succeeded. A refresh that fails partway therefore never
// becomes visible to reads: the last good snapshot stays in place, and the // becomes visible to reads: the last good snapshot stays in place, and the
// failure surfaces through `onProgress` and `status()` instead. A commit that // failure surfaces through `onProgress` and `status()` instead. A save can
// mutates RAM but then fails to persist keeps `status().lastError` set and the // still fail after the store has changed in RAM. The store then stays marked
// store marked unsaved until a later save actually lands, so a stuck disk is // unsaved until a later save lands. Until then, every `commit()` retries the
// never masked by a subsequent empty refresh. // save. A refresh whose save fails sets `status().lastError`, which stays set
// until a later refresh succeeds. `refreshCollection()` never sets or clears
// it. So a stuck disk is never hidden by a later refresh that changes nothing.
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
@@ -159,21 +170,23 @@ export interface LibraryClient {
payloads: Map<number, MLData>; payloads: Map<number, MLData>;
errors: Map<number, unknown>; errors: Map<number, unknown>;
}>; }>;
// The byte source for the on-disk content cache. Optional so a mock client // The byte source for the on-disk content cache. Optional, so a mock
// that only serves metadata still satisfies the interface; when absent (and // client that only serves metadata still satisfies the interface. Without
// no explicit `contentSource` is passed to `open`) the content cache is // it, and with no `contentSource` passed to `open`, the content cache is
// disabled and `Photo.original`/`thumbnail` and `thumbnails.ensure` throw. // disabled. `Photo.original`/`thumbnail` and `thumbnails.ensure` then
// throw.
contentSource?(): ContentSource; contentSource?(): ContentSource;
} }
// A progress event for one unit of background work. A metadata "refresh" or an // A progress event for one unit of background work. A metadata "refresh" and
// ML "fetchMLData" pass each fire "started" before their network work and then // an ML "fetchMLData" pass each fire "started" before their network work. Each
// exactly one of "done" or "failed"; "failed" carries the error message and // then fires exactly one of "done" or "failed". A "failed" event carries the
// an ML "done" reports how many payloads it stored and, in `mlDataErrors`, // error message. An ML "done" reports in `fetched` how many payloads it
// each file whose ML data did not decrypt, gunzip or parse, with the reason. // stored. Its `mlDataErrors` lists each file whose ML data did not decrypt,
// In between, a refresh fires "progress" after each collection whose files it // gunzip or parse, with the reason. In between, a refresh fires "progress"
// fetched: `fetched` is how many collections it has fetched the files of so // after each collection whose files it fetched. Its `fetched` is how many
// far, `total` how many it will. The precache fills // collections it has fetched the files of so far, and `total` how many it
// will. The precache fills
// ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around // ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around
// each sweep that has work, "done" reporting the count newly cached. // each sweep that has work, "done" reporting the count newly cached.
export interface RefreshEvent { export interface RefreshEvent {
@@ -201,9 +214,10 @@ export interface LibraryOptions {
// directory at open. The content cache treats an original already stored // directory at open. The content cache treats an original already stored
// at its save path as present. // at its save path as present.
downloadDirectory?: string; downloadDirectory?: string;
// How long after one refresh ends the background timer starts the next. // How long after its last turn ends the background timer takes the next.
// `Infinity` starts none: the library then refreshes only as it opens and // A turn starts a refresh unless a refresh or `refreshCollection()` is
// when `fresh()` or `backup()` asks. // running. With `Infinity` the timer takes no turn: the library then
// refreshes only as it opens and when `fresh()` or `backup()` asks.
refreshIntervalSeconds?: number; refreshIntervalSeconds?: number;
// Whether `open()` starts a refresh. Defaults to true. With false, `open()` // Whether `open()` starts a refresh. Defaults to true. With false, `open()`
// returns at once onto what the cache holds, empty or not, and a refresh // returns at once onto what the cache holds, empty or not, and a refresh
@@ -242,8 +256,9 @@ export interface LibraryStatus {
// Wall-clock ms of the last refresh that succeeded, or undefined if none // Wall-clock ms of the last refresh that succeeded, or undefined if none
// has yet. // has yet.
lastRefreshAt?: number; lastRefreshAt?: number;
// The message from the most recent refresh, set only while that refresh // The error of the most recent refresh, set only when it failed. The next
// failed; cleared by the next success. // refresh that succeeds clears it. `refreshCollection()` never sets or
// clears it.
lastError?: string; lastError?: string;
// Wall-clock ms of the last ML fetch pass that succeeded, or undefined if // Wall-clock ms of the last ML fetch pass that succeeded, or undefined if
// none has yet (or ML fetching is disabled). // none has yet (or ML fetching is disabled).
@@ -306,12 +321,17 @@ export class Library {
private readonly precache?: Precache; private readonly precache?: Precache;
private timer?: ReturnType<typeof setTimeout>; private timer?: ReturnType<typeof setTimeout>;
// The in-flight refresh cycle, or undefined when none runs. One slot serves // The in-flight refresh cycle or `refreshCollection()`, or undefined when
// both paths: the background loop skips when it is set, and a fresh read // neither runs. One slot holds both, so they never run at the same time.
// (issue #75) coalesces onto it or starts one. The promise carries the // The background loop skips while it is set. A fresh read (issue #75)
// cycle's real outcome (it rejects on failure); the background loop ignores // coalesces onto a running refresh. It waits for a running
// that, a fresh read propagates it. // `refreshCollection()` to end, and then joins or starts a refresh. The
// promise carries the real outcome: it rejects on failure. The background
// loop ignores a failure. A fresh read passes on the failure of the
// refresh it joins or starts, and `refreshCollection()` passes on its own.
private cycle?: Promise<void>; private cycle?: Promise<void>;
// Whether `cycle` is a refresh cycle rather than a `refreshCollection()`.
private cycleIsRefresh = false;
// Guards the ML fetch pass so a slow backfill never runs twice at once; a // Guards the ML fetch pass so a slow backfill never runs twice at once; a
// refresh whose pass is still running kicks nothing new. Holds the running // refresh whose pass is still running kicks nothing new. Holds the running
// pass, so `close()` and `backup()` can wait for it. It rejects when the // pass, so `close()` and `backup()` can wait for it. It rejects when the
@@ -324,19 +344,20 @@ export class Library {
private lastMLError?: string; private lastMLError?: string;
// For each file whose ML data the last ML fetch pass that succeeded got // For each file whose ML data the last ML fetch pass that succeeded got
// but could not decrypt, gunzip or parse, why not, by fileID. A pass // but could not decrypt, gunzip or parse, why not, by fileID. A pass
// gathers its own and puts them here when it ends without failing, so // gathers its own and puts them here only when it ends without failing.
// `status()` and `backup()` never see the list of a pass that is still // So `status()` and `backup()` never see the list of a pass still running
// running or that failed. Kept in RAM only, never in the cache. // or one that failed. Kept in RAM only, never in the cache.
private mlDataErrors = new Map<number, unknown>(); private mlDataErrors = new Map<number, unknown>();
// The plain-record projection as of the last refresh, and the GUI change // The plain-record projection as of the last store change, and the GUI
// subscribers. A refresh that alters the projection notifies each with the // change subscribers. A refresh or `refreshCollection()` that alters the
// delta; `lastRecords` is kept current every refresh so a subscriber that // projection notifies each subscriber with the delta. `lastRecords`
// joins later diffs against the state its own `snapshot()` already returned. // advances on each `commit()` that changes the store. A subscriber that
// joins later then diffs against the state its own `snapshot()` returned.
private readonly subscribers = new Set<(change: LibraryChange) => void>(); private readonly subscribers = new Set<(change: LibraryChange) => void>();
private lastRecords: DerivedRecords; private lastRecords: DerivedRecords;
// RAM holds changes disk has not yet accepted (an earlier save failed). // RAM holds changes disk has not yet accepted (an earlier save failed).
// Cleared only when a save actually succeeds; keeps the store trying to // Cleared only when a save succeeds. Until then, every `commit()` retries
// persist and the failure visible in `status()` until then. // the save.
private unsaved = false; private unsaved = false;
private constructor(args: { private constructor(args: {
@@ -366,7 +387,7 @@ export class Library {
this.lastRecords = this.deriveNow(); this.lastRecords = this.deriveNow();
// The read namespaces derive fresh from the store on each call, so they // The read namespaces derive fresh from the store on each call, so they
// always reflect the latest refresh. // always reflect what it holds now.
const derive = (): DerivedRecords => this.deriveNow(); const derive = (): DerivedRecords => this.deriveNow();
const root = this.downloadDirectory; const root = this.downloadDirectory;
const saves: SavePathLookup = { const saves: SavePathLookup = {
@@ -393,14 +414,16 @@ export class Library {
this.mldata = makeMLDataAPI(() => this.mlStore); this.mldata = makeMLDataAPI(() => this.mlStore);
} }
// Load the cache and start the refresh loop. With an empty cache the first // Load the cache and start the refresh loop. When the stored cursor is 0,
// refresh is awaited, so `open()` resolves onto populated data whenever the // the first refresh is awaited. Such a cache is empty, or holds only what
// server is reachable; that awaited refresh may still fail, and the library // `refreshCollection()` stored. `open()` then resolves onto the whole
// then opens empty with the failure recorded in `status()`. With an // account whenever the server is reachable. If that refresh fails, the
// existing cache the first refresh runs in the background and `open()` // library opens onto what the cache holds, with the failure recorded in
// returns as soon as the cached data is ready — an unreachable server does // `status()`. With any other cache the first refresh runs in the
// not block opening. With `refreshOnOpen: false` there is no first // background, and `open()` returns as soon as the cached data is ready.
// refresh: `open()` returns onto the cache as it is. // An unreachable server then does not block opening. With
// `refreshOnOpen: false` there is no first refresh: `open()` returns onto
// the cache as it is.
static async open(opts: LibraryOptions): Promise<Library> { static async open(opts: LibraryOptions): Promise<Library> {
const { userID } = opts.client.whoami(); const { userID } = opts.client.whoami();
const cacheDirectory = const cacheDirectory =
@@ -416,8 +439,8 @@ export class Library {
let store = await MetadataStore.load(metadataPath); let store = await MetadataStore.load(metadataPath);
// A cache directory given explicitly can hold another account's cache. // A cache directory given explicitly can hold another account's cache.
// Its records and cursor are not this account's, so delete it and the // Its records and cursor are not this account's, so delete it and the
// ML data beside it and start empty. A user ID of 0 means the cache // ML data beside it and start empty. A user ID of 0 means nothing was
// was never refreshed and so holds nothing to discard. // ever stored there, so there is nothing to discard.
if (store.userID !== 0 && store.userID !== userID) { if (store.userID !== 0 && store.userID !== userID) {
await rm(metadataPath, { force: true }); await rm(metadataPath, { force: true });
await rm(join(cacheDirectory, "mldata"), { await rm(join(cacheDirectory, "mldata"), {
@@ -440,10 +463,11 @@ export class Library {
? await MLDataStore.open(join(cacheDirectory, "mldata")) ? await MLDataStore.open(join(cacheDirectory, "mldata"))
: undefined; : undefined;
// Build the content cache from an explicit source or the client's own, // Build the content cache from an explicit source or the client's own.
// and take its record of what is already cached (and reap orphan temp // Take its record of what is already cached, and reap orphan temp
// files) before the first projection, so cached paths are present from // files, before the first projection. Cached paths are then present
// the start and the first refresh raises no spurious path-change diff. // from the start, and the first `commit()` raises no spurious
// path-change diff.
const source = opts.contentSource ?? opts.client.contentSource?.(); const source = opts.contentSource ?? opts.client.contentSource?.();
let cache: ContentCache | undefined; let cache: ContentCache | undefined;
let precache: Precache | undefined; let precache: Precache | undefined;
@@ -497,22 +521,26 @@ export class Library {
precache, precache,
}); });
// Start filling from whatever the loaded store already holds; each // Start filling from whatever the loaded store already holds. Each
// refresh below re-kicks with the new files (and retries any that // later `commit()` re-kicks the fills with its new files, and retries
// failed). An empty store starts empty here and fills after its first // any that failed. An empty store starts empty here, and fills once a
// refresh. // `commit()` stores files.
precache?.start(); precache?.start();
if (opts.refreshOnOpen === false) { if (opts.refreshOnOpen === false) {
// The caller asks for each refresh it needs. // The caller asks for each refresh it needs.
lib.scheduleNext(); lib.scheduleNext();
} else if (store.loadedFromDisk) { } else if (store.collectionsSinceTime > 0) {
// An existing copy already answers reads; refresh in the background // The stored cursor has moved, so a refresh has stored the
// and start the interval once that first cycle settles. // account's collections and the cache's copy answers reads.
// Refresh in the background, and start the interval once that
// first cycle settles.
void lib.runRefresh().then(() => lib.scheduleNext()); void lib.runRefresh().then(() => lib.scheduleNext());
} else { } else {
// Nothing was cached: wait for the first refresh to fill the store // The stored cursor is still 0, so no refresh has stored any
// (or fail) rather than resolve onto an empty library. // collection: the cache is empty, or holds only what
// `refreshCollection()` stored. Wait for the first refresh to fill
// the store (or fail) rather than resolve onto part of the account.
await lib.runRefresh(); await lib.runRefresh();
lib.scheduleNext(); lib.scheduleNext();
} }
@@ -550,9 +578,9 @@ export class Library {
return snapshotFrom(this.deriveNow(), Date.now()); return snapshotFrom(this.deriveNow(), Date.now());
} }
// Deliver a `LibraryChange` whenever a refresh alters the projection. A // Deliver a `LibraryChange` whenever a refresh or `refreshCollection()`
// refresh that changes nothing delivers nothing. The returned handle's // alters the projection. One that changes nothing delivers nothing. The
// `unsubscribe` stops delivery. // returned handle's `unsubscribe` stops delivery.
subscribe(args: { onChange: (change: LibraryChange) => void }): { subscribe(args: { onChange: (change: LibraryChange) => void }): {
unsubscribe: () => void; unsubscribe: () => void;
} { } {
@@ -595,12 +623,13 @@ export class Library {
}; };
} }
// Fresh reads (issue #75, owner amendment to design #36). Force a refresh, // Fresh reads (issue #75, owner amendment to design #36). Wait for a
// wait for it to complete and persist, then hand back the same // refresh to complete and persist, then hand back the same
// `albums`/`photos`/`timeline` namespaces — now guaranteed to reflect a // `albums`/`photos`/`timeline` namespaces. They then reflect a completed
// completed server round-trip. Concurrent calls coalesce onto one refresh; // server round-trip. A running `refreshCollection()` is waited for first.
// a refresh that fails rejects here, where the default namespaces would // Concurrent calls coalesce onto one refresh. A refresh that fails rejects
// instead stay silent and serve the last good copy. // here, where the default namespaces would stay silent and serve the last
// good copy.
async fresh(): Promise<FreshReads> { async fresh(): Promise<FreshReads> {
await this.refreshNow(); await this.refreshNow();
return { return {
@@ -613,10 +642,10 @@ export class Library {
// The fresh read of the collection list alone (issue #210): every live // The fresh read of the collection list alone (issue #210): every live
// collection that decrypts, in the server's order, from one collections // collection that decrypts, in the server's order, from one collections
// request. Unlike a refresh, it fetches no collection's files, which on a // request. Unlike a refresh, it fetches no collection's files, which on a
// large account take long. Nothing is stored: a refresh fetches a // large account take long. It stores nothing. A refresh from a stored
// collection's files only when the collection is new or has changed since // cursor past 0 fetches a collection's files only when the collection is
// the stored copy, so a collection stored here without its files would // new or has changed since the stored copy. So does `refreshCollection()`.
// never have them fetched. // Neither would fetch the files of a collection stored here without them.
async freshCollections(): Promise<Collection[]> { async freshCollections(): Promise<Collection[]> {
if (this.closed) throw new Error("the library is closed"); if (this.closed) throw new Error("the library is closed");
const { collections } = await this.client.collectionsSince({ const { collections } = await this.client.collectionsSince({
@@ -625,19 +654,44 @@ export class Library {
return collections; return collections;
} }
// Bring one collection up to date in the store (issue #217), given its
// record from `freshCollections()`. It does for that collection what a
// refresh does for a changed one. It fetches no other collection's files.
// It resolves to whether the store then holds the collection.
//
// It adds a collection the store does not hold only while the stored
// cursor is 0, before any refresh has received a collection. After that,
// it stores nothing for such a collection and resolves to false. Only a
// refresh may then add it, after the collections the store holds.
//
// It runs in the same slot as a refresh, so the two never run at the same
// time. It first waits for a running refresh or `refreshCollection()` to
// end. While its own work runs, a fresh read or `backup()` waits for it,
// and the background loop skips its turn.
async refreshCollection(collection: Collection): Promise<boolean> {
while (this.cycle) await this.cycle.catch(() => {});
if (this.closed) throw new Error("the library is closed");
let held = false;
await this.startCycle(false, async () => {
held = await this.refreshCollectionOnce(collection);
});
return held;
}
// Back up every in-scope file to `opts.downloadDirectory`, or else the // Back up every in-scope file to `opts.downloadDirectory`, or else the
// library's, each original at its save path, with a durable failure // library's, each original at its save path, with a durable failure
// ledger (issue #51). Takes the lock in that directory first, unless the // ledger (issue #51). It first takes the lock in that directory, unless
// caller passes the lock it took as `opts.lock`, failing at once while // the caller passes the lock it took as `opts.lock`. Taking it fails at
// another backup of it runs, and before it starts another file once the // once while another backup of that directory runs. Once the lock is lost,
// lock is lost (see `lockBackupDirectory`). Waits for a // the backup fails before it starts another file (see
// completed refresh, as `fresh()` does, joining one already running, and // `lockBackupDirectory`). It waits for a completed refresh as `fresh()`
// rejects before touching any file but the lock when it fails. Then puts // does, joining one already running. When that refresh fails, it rejects
// pending originals at their save paths as `Photo.download()` does (and // before touching any file but the lock. It then puts pending originals
// optional thumbnails) through the content cache and pools, waits for an // at their save paths as `Photo.download()` does, through the content
// ML data fetch, and rebuilds the derived symlink/JSON views from the // cache and pools. With `includeThumbnails` it also fetches thumbnails. It
// model, each file's JSON with its ML data, beside an `account.json` with // waits for an ML data fetch. It rebuilds the derived symlink/JSON views
// the account's email and user ID. // from the model, each file's JSON with its ML data. An `account.json`
// beside them holds the account's email and user ID.
// Throws before any network work when no content cache backs the // Throws before any network work when no content cache backs the
// originals it must fetch. // originals it must fetch.
backup(opts?: BackupOptions): Promise<BackupResult> { backup(opts?: BackupOptions): Promise<BackupResult> {
@@ -674,12 +728,15 @@ export class Library {
); );
} }
// Stop the background timer. Idempotent. An in-flight refresh is left to // Stop the background timer. Idempotent. A refresh or `refreshCollection()`
// finish; it will not schedule another cycle once closed. The returned // in flight is left to finish, and nothing starts after it. The returned
// promise resolves once that refresh (including its cache write), the ML // promise resolves once all of these have finished:
// fetch pass and the precache fetches already running have all finished, // - that refresh or `refreshCollection()`, with its cache write;
// so a caller can then remove the cache directory. A refresh failure is // - the ML fetch pass;
// reported through `status()`, not thrown here. // - the precache fetches already running.
// A caller can then remove the cache directory. A refresh failure is
// reported through `status()`, and a `refreshCollection()` failure to its
// own caller. Neither is thrown here.
async close(): Promise<void> { async close(): Promise<void> {
this.closed = true; this.closed = true;
const precacheClosed = this.precache?.close(); const precacheClosed = this.precache?.close();
@@ -702,33 +759,41 @@ export class Library {
this.timer.unref?.(); this.timer.unref?.();
} }
// The background loop's refresh: run a cycle unless one is already in flight // The background loop's refresh: run a cycle unless a refresh or
// (or the library is closed), and never let a failure escape — the // `refreshCollection()` is in flight (or the library is closed), and never
// background path reports errors through `status()`/`onProgress`, it does // let a failure escape. The background path reports errors through
// not throw. Resolves once the cycle it started (or skipped past) settles. // `status()`/`onProgress`; it does not throw. It resolves once the cycle
// it started settles, or at once when it starts none.
private runRefresh(): Promise<void> { private runRefresh(): Promise<void> {
if (this.closed || this.cycle) return Promise.resolve(); if (this.closed || this.cycle) return Promise.resolve();
return this.startCycle().catch(() => {}); return this.startCycle(true, () => this.refreshCycle()).catch(() => {});
} }
// A fresh read's refresh (issue #75): force a cycle and await it, rejecting // A fresh read's refresh (issue #75): join the running refresh or start
// if it fails. Concurrent fresh reads coalesce onto the one in-flight cycle // one, and await it, rejecting if it fails. Concurrent fresh reads
// — the background loop's included — so they never fan out into redundant // coalesce onto the one in-flight cycle — the background loop's included —
// server round-trips. // so they never fan out into redundant server round-trips. A running
private refreshNow(): Promise<void> { // `refreshCollection()` is waited for first.
if (this.closed) { private async refreshNow(): Promise<void> {
return Promise.reject(new Error("the library is closed")); while (this.cycle && !this.cycleIsRefresh) {
await this.cycle.catch(() => {});
} }
return this.cycle ?? this.startCycle(); if (this.closed) throw new Error("the library is closed");
return this.cycle ?? this.startCycle(true, () => this.refreshCycle());
} }
// Start one refresh cycle and record it as the in-flight cycle so every // Start `work` and record it in the in-flight slot, so every other caller
// caller coalesces onto it. The returned promise carries the cycle's real // sees it. `isRefresh` says whether it is a refresh cycle, which a fresh
// outcome; each caller attaches the handling its own path needs, and the // read can coalesce onto. The returned promise carries the real outcome.
// slot is cleared once the cycle settles. // Each caller attaches the handling its own path needs, and the slot is
private startCycle(): Promise<void> { // cleared once the work settles.
const cycle = this.refreshCycle(); private startCycle(
isRefresh: boolean,
work: () => Promise<void>,
): Promise<void> {
const cycle = work();
this.cycle = cycle; this.cycle = cycle;
this.cycleIsRefresh = isRefresh;
void cycle.then( void cycle.then(
() => { () => {
if (this.cycle === cycle) this.cycle = undefined; if (this.cycle === cycle) this.cycle = undefined;
@@ -751,9 +816,9 @@ export class Library {
this.lastError = undefined; this.lastError = undefined;
this.emit({ operation: "refresh", status: "done" }); this.emit({ operation: "refresh", status: "done" });
// Backfill ML data for the files this refresh knows about. It runs // Backfill ML data for the files this refresh knows about. It runs
// outside the refresh's success/failure so a fetch or disk problem // outside the refresh's success or failure, so a fetch or disk
// there never marks the metadata refresh failed, and it is not // problem there never marks the metadata refresh failed. It is not
// awaited so it never stalls the refresh interval. Its failure is // awaited, so it never stalls the refresh interval. Its failure is
// reported through `status()` and `onProgress`. // reported through `status()` and `onProgress`.
void this.fetchMLDataNow().catch(() => {}); void this.fetchMLDataNow().catch(() => {});
} catch (err) { } catch (err) {
@@ -766,11 +831,14 @@ export class Library {
// Fetch every change since the stored cursor, then commit. All network // Fetch every change since the stored cursor, then commit. All network
// reads happen before any store mutation, so a fetch that throws leaves the // reads happen before any store mutation, so a fetch that throws leaves the
// store untouched and the previous snapshot intact. A collection or file // store untouched and the previous snapshot intact.
// that does not decrypt is left out instead, as if deleted, with the reason //
// kept in the store: the cursor moves past it, so no later refresh sees it // From a stored cursor of 0, the server lists every live collection. The
// again until the server's copy changes. // refresh then replaces all the store holds, and counts every collection
// as new. Only `refreshCollection()` can have stored anything before it.
// So the store ends as it would had `refreshCollection()` never run.
private async refreshOnce(): Promise<void> { private async refreshOnce(): Promise<void> {
const replace = this.store.collectionsSinceTime === 0;
const page = await this.client.collectionsSince({ const page = await this.client.collectionsSince({
sinceTime: this.store.collectionsSinceTime, sinceTime: this.store.collectionsSinceTime,
}); });
@@ -778,16 +846,19 @@ export class Library {
// Stage per-collection file diffs. A collection's files are // Stage per-collection file diffs. A collection's files are
// re-enumerated only when its updationTime has advanced past the cached // re-enumerated only when its updationTime has advanced past the cached
// copy; an unchanged album's file list cannot have changed. New // copy; an unchanged album's file list cannot have changed. New
// collections enumerate from the beginning of time. Each collection // collections enumerate from the beginning of time. When the refresh
// replaces the store, every collection counts as new. Each collection
// whose files are fetched is reported, with how many there are to // whose files are fetched is reported, with how many there are to
// fetch. // fetch.
const storedCopy = (id: number): Collection | undefined =>
replace ? undefined : this.store.getCollection(id);
const changedCollections = page.collections.filter((collection) => { const changedCollections = page.collections.filter((collection) => {
const known = this.store.getCollection(collection.id); const known = storedCopy(collection.id);
return !known || collection.updationTime > known.updationTime; return !known || collection.updationTime > known.updationTime;
}); });
const filePages: { collectionID: number; page: FilesPage }[] = []; const filePages: { collectionID: number; page: FilesPage }[] = [];
for (const collection of changedCollections) { for (const collection of changedCollections) {
const known = this.store.getCollection(collection.id); const known = storedCopy(collection.id);
const filePage = await this.client.filesSince({ const filePage = await this.client.filesSince({
collectionID: collection.id, collectionID: collection.id,
collectionKey: collection.key, collectionKey: collection.key,
@@ -802,8 +873,56 @@ export class Library {
}); });
} }
// Network work done; commit to the store and persist only if something await this.commit(page, filePages, replace);
// actually changed. }
// `refreshCollection()`'s work, run in the slot: fetch the files of one
// collection changed since the stored copy, then commit. It resolves to
// whether the store then holds the collection.
private async refreshCollectionOnce(
collection: Collection,
): Promise<boolean> {
const known = this.store.getCollection(collection.id);
// A refresh has received the account's collections, and this one is
// not stored. It is new since then, or a refresh dropped it. Leave it
// to the next refresh, which adds new collections after the stored
// ones, in the server's order.
if (!known && this.store.collectionsSinceTime > 0) return false;
// The stored copy is as new or newer, so there is nothing to fetch.
if (known && collection.updationTime <= known.updationTime) return true;
const filePage = await this.client.filesSince({
collectionID: collection.id,
collectionKey: collection.key,
sinceTime: known ? known.updationTime : 0,
});
// The stored cursor stays where it is, so the next refresh still
// fetches the files of every other collection that changed.
await this.commit(
{
collections: [collection],
deleted: [],
cursor: this.store.collectionsSinceTime,
},
[{ collectionID: collection.id, page: filePage }],
false,
);
return true;
}
// Commit a collections page and the file pages fetched for its collections
// to the store. With `replace`, the page lists every live collection, so
// the commit first deletes every stored collection. A collection or file
// that does not decrypt is left out, as if deleted. The reason is kept in
// the store. Until the server's copy changes, only a refresh from a stored
// cursor of 0 fetches it again. Last, save the store when it holds changes
// not yet saved.
private async commit(
page: CollectionsPage,
filePages: { collectionID: number; page: FilesPage }[],
replace: boolean,
): Promise<void> {
// The network work is done, so change the store now. `changed` says
// whether this commit changed anything.
let changed = false; let changed = false;
if (this.store.userID !== this.userID) { if (this.store.userID !== this.userID) {
@@ -811,6 +930,13 @@ export class Library {
changed = true; changed = true;
} }
if (replace) {
for (const { id } of this.store.listCollections()) {
this.store.deleteCollection(id);
changed = true;
}
}
for (const id of page.deleted) { for (const id of page.deleted) {
if (this.store.getCollection(id)) { if (this.store.getCollection(id)) {
this.store.deleteCollection(id); this.store.deleteCollection(id);
@@ -864,10 +990,10 @@ export class Library {
if (changed) this.unsaved = true; if (changed) this.unsaved = true;
// Reproject and notify subscribers of the delta. This tracks RAM (what // Reproject and notify subscribers of the delta. This tracks RAM (what
// reads see), so it fires whether or not the save below succeeds; a // reads see), so it fires whether or not the save below succeeds. A
// save failure surfaces separately through `status().lastError`. // failed save rejects the caller, and a refresh also records it in
// `lastRecords` advances every changed refresh so the next diff is // `status().lastError`. `lastRecords` advances on every commit that
// against current state. // changes something, so the next diff is against current state.
if (changed) { if (changed) {
const next = this.deriveNow(); const next = this.deriveNow();
if (this.subscribers.size > 0) { if (this.subscribers.size > 0) {
@@ -878,16 +1004,16 @@ export class Library {
// Recompute the fill orders and pinned set against the new library. // Recompute the fill orders and pinned set against the new library.
this.precache?.update(next); this.precache?.update(next);
} }
// Re-kick the fills every cycle: a finished sweep starts afresh to pick // Re-kick the fills on every commit. A finished sweep starts afresh to
// up new files and retry any that failed, and a running one is left be. // pick up new files and retry any that failed. A running one is left
// be.
this.precache?.start(); this.precache?.start();
// Persist whenever RAM holds changes disk has not accepted — including // Persist whenever RAM holds changes disk has not accepted — including
// changes an earlier cycle staged whose save failed. `unsaved` clears // changes an earlier cycle staged whose save failed. `unsaved` clears
// only once a save lands, so a save failure both stays visible through // only once a save lands, so a later commit retries a failed save even
// `status().lastError` (the throw below records it) and keeps being // when it changes nothing. A failed save rejects the caller, and a
// retried, instead of a later empty refresh silently clearing it while // refresh also records it in `status().lastError`.
// the on-disk cache is still behind RAM.
if (this.unsaved) { if (this.unsaved) {
await this.store.save(); await this.store.save();
this.unsaved = false; this.unsaved = false;
@@ -903,13 +1029,14 @@ export class Library {
return this.mlFetch; return this.mlFetch;
} }
// One ML fetch pass: fetch, decrypt and store the ML data for every file // One ML fetch pass. It fetches, decrypts and stores the ML data of every
// the store knows about that is not cached (or whose `updationTime` has // file the store knows about that is not cached, or whose `updationTime`
// advanced), through the metadata pool, and update the CLIP index. For a // has advanced. It fetches through the metadata pool, and updates the CLIP
// file whose ML data does not decrypt, gunzip or parse, it gathers the // index. For a file whose ML data does not decrypt, gunzip or parse, it
// reason instead, and puts the reasons it gathered in `mlDataErrors` when // gathers the reason instead. It puts the reasons it gathered in
// it ends without failing. A failure leaves `mlDataErrors` as it was and // `mlDataErrors` when it ends without failing. A failure leaves
// is reported through `status()` and `onProgress`, then thrown. // `mlDataErrors` as it was and is reported through `status()` and
// `onProgress`, then thrown.
private async runMLFetch(): Promise<void> { private async runMLFetch(): Promise<void> {
const mldata = this.mlStore; const mldata = this.mlStore;
// Bind so the call keeps the client as its receiver when invoked // Bind so the call keeps the client as its receiver when invoked
@@ -977,9 +1104,9 @@ export class Library {
})); }));
} }
// The distinct files the store holds, one entry per fileID (a file in // The distinct files the store holds, one entry per fileID, since a file
// several collections shares its ML data), each carrying the key and the // in several collections shares its ML data. Each entry carries the key
// newest `updationTime` seen across its memberships. // and the newest `updationTime` seen across the file's memberships.
private uniqueFiles(): { private uniqueFiles(): {
id: number; id: number;
key: Uint8Array; key: Uint8Array;
+1 -1
View File
@@ -61,7 +61,7 @@ export interface MLIndex {
// Pull the CLIP embedding out of a payload, or undefined when it is absent or // Pull the CLIP embedding out of a payload, or undefined when it is absent or
// misshapen. Kept strict so a bad payload is skipped rather than corrupting the // misshapen. Kept strict so a bad payload is skipped rather than corrupting the
// packed index. // packed index.
const clipEmbedding = (payload: MLData): number[] | undefined => { export const clipEmbedding = (payload: MLData): number[] | undefined => {
const clip = payload.clip; const clip = payload.clip;
if (typeof clip !== "object" || clip === null) return undefined; if (typeof clip !== "object" || clip === null) return undefined;
const embedding = (clip as { embedding?: unknown }).embedding; const embedding = (clip as { embedding?: unknown }).embedding;
+1 -1
View File
@@ -122,7 +122,7 @@ export class Precache {
} }
// Recompute the fill orders and the pinned set from the current projection. // Recompute the fill orders and the pinned set from the current projection.
// Called at open and after every refresh that changes the library. // Called at open and after every change to the store.
update(records: DerivedRecords): void { update(records: DerivedRecords): void {
const photos = [...records.photos.values()].sort( const photos = [...records.photos.values()].sort(
(a, b) => b.takenAt - a.takenAt || b.fileID - a.fileID, (a, b) => b.takenAt - a.takenAt || b.fileID - a.fileID,
+3 -2
View File
@@ -71,13 +71,14 @@ export interface LibrarySnapshot {
takenAt: number; takenAt: number;
} }
// What one refresh or `refreshCollection()` changed in the records.
export interface LibraryChange { export interface LibraryChange {
// Full records for albums/photos added or changed by the refresh. // Full records for albums/photos added or changed.
albumsChanged: AlbumRecord[]; albumsChanged: AlbumRecord[];
photosChanged: PhotoRecord[]; photosChanged: PhotoRecord[];
fileIDsRemoved: number[]; fileIDsRemoved: number[];
albumIDsRemoved: number[]; albumIDsRemoved: number[];
// Wall-clock milliseconds of the refresh that produced this change. // Wall-clock milliseconds when the store changed.
refreshedAt: number; refreshedAt: number;
} }
+13 -12
View File
@@ -3,7 +3,7 @@
// The store keeps one `metadata.json` file holding the account's server // The store keeps one `metadata.json` file holding the account's server
// state: the user id, a schema version, the cursor for the incremental // state: the user id, a schema version, the cursor for the incremental
// collections listing, the decrypted collection and file records, and the // collections listing, the decrypted collection and file records, and the
// collections and files the refresh could not decrypt. The whole file is read // collections and files that did not decrypt. The whole file is read
// into RAM on load and rewritten as a whole on save; there is no partial // into RAM on load and rewritten as a whole on save; there is no partial
// update and no lock file. A separate refresh unit populates the store from // update and no lock file. A separate refresh unit populates the store from
// the server — this module only stores what it is given. // the server — this module only stores what it is given.
@@ -34,9 +34,11 @@ const FILE_MODE = 0o600;
type StoredCollection = Omit<Collection, "key"> & { key: string }; type StoredCollection = Omit<Collection, "key"> & { key: string };
type StoredFile = Omit<EnteFile, "key"> & { key: string }; type StoredFile = Omit<EnteFile, "key"> & { key: string };
// A collection, or with `fileID` a file in it, that the last refresh to fetch // A collection, or with `fileID` a file in it, that did not decrypt or parse,
// it could not decrypt or parse, and why. It is kept until a later refresh // and why. A refresh or `refreshCollection()` records it on fetching such a
// fetches it again, because a refresh fetches only what changed. // copy. A later fetch drops it on getting a copy that decrypts, or on learning
// that the server deleted it. Each fetch asks only for what changed, so the
// store keeps the record between fetches.
export interface Undecryptable { export interface Undecryptable {
collectionID: number; collectionID: number;
fileID?: number; fileID?: number;
@@ -70,12 +72,6 @@ export class MetadataStore {
userID = 0; userID = 0;
collectionsSinceTime: Microseconds = 0; collectionsSinceTime: Microseconds = 0;
// True when `load` populated this store from a valid existing file; false
// on a first run or a missing/corrupt/wrong-version file that loaded empty.
// `Library.open` reads it to decide whether the first refresh may run in
// the background (an existing copy already serves reads) or must be awaited.
loadedFromDisk = false;
private readonly collections = new Map<number, Collection>(); private readonly collections = new Map<number, Collection>();
private readonly files = new Map<string, EnteFile>(); private readonly files = new Map<string, EnteFile>();
private undecryptable: Undecryptable[] = []; private undecryptable: Undecryptable[] = [];
@@ -102,7 +98,6 @@ export class MetadataStore {
if (parsed.schemaVersion !== METADATA_SCHEMA_VERSION) { if (parsed.schemaVersion !== METADATA_SCHEMA_VERSION) {
return store; return store;
} }
store.loadedFromDisk = true;
store.userID = parsed.userID ?? 0; store.userID = parsed.userID ?? 0;
store.collectionsSinceTime = parsed.collectionsSinceTime ?? 0; store.collectionsSinceTime = parsed.collectionsSinceTime ?? 0;
for (const stored of parsed.collections ?? []) { for (const stored of parsed.collections ?? []) {
@@ -229,8 +224,14 @@ export class MetadataStore {
this.files.delete(fileKey(collectionID, fileID)); this.files.delete(fileKey(collectionID, fileID));
} }
// Sorted by collection ID, then file ID, so the order does not depend on
// when each record was fetched.
listUndecryptable(): Undecryptable[] { listUndecryptable(): Undecryptable[] {
return [...this.undecryptable]; return [...this.undecryptable].sort(
(a, b) =>
a.collectionID - b.collectionID ||
(a.fileID ?? 0) - (b.fileID ?? 0),
);
} }
// Replaces any earlier record of the same collection or file. // Replaces any earlier record of the same collection or file.
+12 -2
View File
@@ -1,6 +1,7 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import * as jpeg from "jpeg-js"; import * as jpeg from "jpeg-js";
import { clipKeywords } from "./clip-keywords.js";
import type { Client } from "./client.js"; import type { Client } from "./client.js";
import { describeError } from "./errors.js"; import { describeError } from "./errors.js";
import { readExifTags } from "./exif.js"; import { readExifTags } from "./exif.js";
@@ -97,7 +98,8 @@ const extractExif = async (
// Dump every decrypted metadata layer the account holds into a directory tree // Dump every decrypted metadata layer the account holds into a directory tree
// of plain JSON: account, per-collection, and per-file records including the // of plain JSON: account, per-collection, and per-file records including the
// private and public magic metadata and (by default) the ML data. Collections // private and public magic metadata and the ML data, with the keywords of its
// CLIP embedding in place of the embedding (see clip-keywords.ts). Collections
// and files are enumerated from the library's cache, which the caller refreshes // and files are enumerated from the library's cache, which the caller refreshes
// first. Returns how many files have no ML data because their ML data request // first. Returns how many files have no ML data because their ML data request
// failed or their ML data did not decrypt, gunzip or parse; they are still // failed or their ML data did not decrypt, gunzip or parse; they are still
@@ -230,8 +232,16 @@ export const runMetadataBackup = async (
if (file.pubMagicMetadata) if (file.pubMagicMetadata)
fileMeta.pubMagicMetadata = file.pubMagicMetadata; fileMeta.pubMagicMetadata = file.pubMagicMetadata;
// The ML data without the CLIP embedding, and the keywords that
// embedding gives.
const ml = mlDataMap.get(file.id); const ml = mlDataMap.get(file.id);
if (ml) fileMeta.mlData = ml; if (ml) {
const { clip: _clip, ...mlDataWithoutClip } = ml;
fileMeta.mlData = mlDataWithoutClip;
const isVideo = file.metadata.fileType === "video";
const keywords = clipKeywords(ml, isVideo);
if (keywords) fileMeta.keywords = keywords;
}
const mlError = mlDataErrors.get(file.id); const mlError = mlDataErrors.get(file.id);
if (mlError) fileMeta.mlDataError = mlError; if (mlError) fileMeta.mlDataError = mlError;
+14 -3
View File
@@ -86,10 +86,17 @@ const DAY = "2024/2024-03/2024-03-01";
const TOAST = new TextEncoder().encode("a few seconds of the wedding toast"); const TOAST = new TextEncoder().encode("a few seconds of the wedding toast");
// Ente's ML data for beach.jpg: a face it found, and the photo's CLIP // Ente's ML data for beach.jpg: a face it found, and the photo's CLIP
// embedding. // embedding, here that of a photo of a sunset (see clip-keywords.test.ts).
const ML_DATA = { const ML_DATA = {
face: { faces: [{ faceID: "100_1", score: 0.98 }] }, face: { faces: [{ faceID: "100_1", score: 0.98 }] },
clip: { embedding: [0.5, 0.6, 0.7] }, clip: {
embedding: JSON.parse(
readFileSync(
new URL("../clip-keywords-sunset.json", import.meta.url),
"utf-8",
),
).embedding,
},
}; };
// Each file's ID, its owner, its original as uploaded, and the metadata its // Each file's ID, its owner, its original as uploaded, and the metadata its
@@ -486,7 +493,10 @@ describe("the first backup of the account", () => {
}, },
updationTime: TAKEN, updationTime: TAKEN,
}); });
expect(beach.mlData).toEqual(ML_DATA); // The JSON holds the ML data without its CLIP embedding, and the
// keywords the embedding gives in its place.
expect(beach.mlData).toEqual({ face: ML_DATA.face });
expect(beach.keywords).toEqual(["Sunset"]);
expect(beach.imageMetadata.exif.Make.description).toBe("Canon"); expect(beach.imageMetadata.exif.Make.description).toBe("Canon");
expect(beach.imageMetadata.exif.Model.description).toBe("EOS R5"); expect(beach.imageMetadata.exif.Model.description).toBe("EOS R5");
@@ -497,6 +507,7 @@ describe("the first backup of the account", () => {
metadata: { title: "toast.mp4", fileType: "video" }, metadata: { title: "toast.mp4", fileType: "video" },
}); });
expect(toast).not.toHaveProperty("mlData"); expect(toast).not.toHaveProperty("mlData");
expect(toast).not.toHaveProperty("keywords");
expect(toast).not.toHaveProperty("imageMetadata"); expect(toast).not.toHaveProperty("imageMetadata");
expect(toast).not.toHaveProperty("imageMetadataError"); expect(toast).not.toHaveProperty("imageMetadataError");
+101 -7
View File
@@ -52,6 +52,7 @@ import { tmpdir } from "node:os";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { runBackup, type BackupLibrary } from "../../src/backup.js"; import { runBackup, type BackupLibrary } from "../../src/backup.js";
import { DISCOVER_CATEGORIES } from "../../src/clip-keyword-vectors.js";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
import type { ContentSource } from "../../src/library/content.js"; import type { ContentSource } from "../../src/library/content.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js";
@@ -946,9 +947,19 @@ describe("the backup lock", () => {
}); });
}); });
// Also serves ML data: file 100 has `ML_PAYLOAD`, the other two have none. // Also serves ML data: file 100 has `ML_PAYLOAD`, whose CLIP embedding is that
// While `mlError` is set, every ML data request fails with it. // of a photo of a sunset (see clip-keywords.test.ts), and the other two have
const ML_PAYLOAD = { face: { faces: [] }, clip: { embedding: [0.5, 0.25] } }; // none. While `mlError` is set, every ML data request fails with it.
const SUNSET_EMBEDDING: number[] = JSON.parse(
readFileSync(
new URL("../clip-keywords-sunset.json", import.meta.url),
"utf-8",
),
).embedding;
const ML_PAYLOAD = {
face: { faces: [] },
clip: { embedding: SUNSET_EMBEDDING },
};
class MLClient extends MockClient { class MLClient extends MockClient {
mlError?: string; mlError?: string;
@@ -968,16 +979,99 @@ const fileJSON = (outDir: string, fileID: number): Record<string, unknown> =>
JSON.parse(readFileSync(saved(outDir, `${fileID}.json`), "utf-8")); JSON.parse(readFileSync(saved(outDir, `${fileID}.json`), "utf-8"));
describe("ML data in each file's JSON", () => { describe("ML data in each file's JSON", () => {
it("writes a file's ML data, and no ML field for a file that has none", async () => { // The JSON holds the ML data without its CLIP embedding, and in its place
// the keywords the embedding gives.
it("writes a file's ML data and keywords, and no ML field for a file that has none", async () => {
const lib = await openLibrary(stubSource(), new MLClient()); const lib = await openLibrary(stubSource(), new MLClient());
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir }); const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD); expect(fileJSON(outDir, 100).mlData).toEqual({ face: { faces: [] } });
expect(fileJSON(outDir, 100).keywords).toEqual(["Sunset"]);
expect(fileJSON(outDir, 101)).not.toHaveProperty("mlData"); expect(fileJSON(outDir, 101)).not.toHaveProperty("mlData");
expect(fileJSON(outDir, 101)).not.toHaveProperty("mlDataError"); expect(fileJSON(outDir, 101)).not.toHaveProperty("mlDataError");
expect(fileJSON(outDir, 101)).not.toHaveProperty("keywords");
await lib.close();
});
// A file's ML data can hold faces and no CLIP embedding. Its JSON then has
// the ML data and no keywords.
it("writes no keywords for ML data without a CLIP embedding", async () => {
const faces = { face: { faces: [{ faceID: "101_1", score: 0.9 }] } };
// Gives file 101 ML data holding only faces.
class FacesClient extends MockClient {
async fetchMLData(args: { fileIDs: number[] }): Promise<{
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}> {
const payloads = new Map<number, MLData>();
if (args.fileIDs.includes(101)) payloads.set(101, faces);
return { payloads, errors: new Map() };
}
}
const lib = await openLibrary(stubSource(), new FacesClient());
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(fileJSON(outDir, 101).mlData).toEqual(faces);
expect(fileJSON(outDir, 101)).not.toHaveProperty("keywords");
await lib.close();
});
// Ente's photos app keeps videos out of some Discover categories, Identity
// among them. Here a photo and a video share one CLIP embedding, Identity's
// own text embedding, so the photo gets the keyword Identity and the video
// does not.
it("keeps a video out of a category that leaves out videos", async () => {
const identity = DISCOVER_CATEGORIES.find(
(c) => c.title === "Identity",
)!;
expect(identity.showVideo).toBe(false);
const toast = file(102, 1, "toast.mp4");
const video = {
...toast,
metadata: { ...toast.metadata, fileType: "video" as const },
};
// Serves beach.jpg and the video in the Vacation album, and gives
// each ML data holding only that embedding.
class VideoClient extends MockClient {
override async filesSince(args: {
collectionID: number;
}): Promise<FilesPage> {
const files =
args.collectionID === 1
? [file(100, 1, "beach.jpg"), video]
: [];
return { files, deleted: [], cursor: 1 };
}
async fetchMLData(args: { fileIDs: number[] }): Promise<{
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}> {
const payloads = new Map<number, MLData>();
for (const id of args.fileIDs) {
payloads.set(id, {
clip: { embedding: identity.textEmbedding },
});
}
return { payloads, errors: new Map() };
}
}
const lib = await openLibrary(stubSource(), new VideoClient());
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(fileJSON(outDir, 100).keywords).toContain("Identity");
expect(fileJSON(outDir, 102)).toHaveProperty("keywords");
expect(fileJSON(outDir, 102).keywords).not.toContain("Identity");
await lib.close(); await lib.close();
}); });
@@ -993,7 +1087,7 @@ describe("ML data in each file's JSON", () => {
const result = await lib.backup({ downloadDirectory: outDir }); const result = await lib.backup({ downloadDirectory: outDir });
// File 100's ML data was cached by the first run and is kept. // File 100's ML data was cached by the first run and is kept.
expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD); expect(fileJSON(outDir, 100).mlData).toEqual({ face: { faces: [] } });
expect(fileJSON(outDir, 100)).not.toHaveProperty("mlDataError"); expect(fileJSON(outDir, 100)).not.toHaveProperty("mlDataError");
for (const fileID of [101, 200]) { for (const fileID of [101, 200]) {
expect(fileJSON(outDir, fileID).mlDataError).toBe( expect(fileJSON(outDir, fileID).mlDataError).toBe(
@@ -1026,7 +1120,7 @@ describe("ML data in each file's JSON", () => {
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(result.skipped).toBe(3); expect(result.skipped).toBe(3);
expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD); expect(fileJSON(outDir, 100).mlData).toEqual({ face: { faces: [] } });
for (const fileID of [100, 101, 200]) { for (const fileID of [100, 101, 200]) {
expect(fileJSON(outDir, fileID)).not.toHaveProperty("mlDataError"); expect(fileJSON(outDir, fileID)).not.toHaveProperty("mlDataError");
} }
+117 -24
View File
@@ -124,20 +124,35 @@ const FILES: Record<number, EnteFile[]> = {
// thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing // thumbnail as empty. `withNewFile` adds new.jpg (102) to Vacation, advancing
// the collection's updationTime as the server does. `beachInWork` adds // the collection's updationTime as the server does. `beachInWork` adds
// beach.jpg (100) to Work as well, so that one file is in both albums. // beach.jpg (100) to Work as well, so that one file is in both albums.
// `refreshError` makes listing collections fail with that message. // `newAlbums` adds the albums A (3) and B (4), in that order, which both hold
// shared.jpg (300). `refreshError` makes listing collections fail with that
// message.
const fakeClient = ( const fakeClient = (
opts: { opts: {
failID?: number; failID?: number;
emptyThumbID?: number; emptyThumbID?: number;
withNewFile?: boolean; withNewFile?: boolean;
beachInWork?: boolean; beachInWork?: boolean;
newAlbums?: boolean;
refreshError?: string; refreshError?: string;
} = {}, } = {},
) => { ) => {
const collections = opts.withNewFile let collections = opts.withNewFile
? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]] ? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]]
: COLLECTIONS; : COLLECTIONS;
let files = FILES; let files = FILES;
if (opts.newAlbums) {
collections = [
...collections,
{ ...collection(3, "A"), updationTime: 2 },
{ ...collection(4, "B"), updationTime: 2 },
];
files = {
...files,
3: [file(300, 3, "shared.jpg")],
4: [file(300, 4, "shared.jpg")],
};
}
if (opts.withNewFile) { if (opts.withNewFile) {
files = { files = {
...files, ...files,
@@ -535,9 +550,7 @@ describe("files", () => {
it("exits 1 for an unknown collection", async () => { it("exits 1 for an unknown collection", async () => {
expect(await filesCommand(context(), "9", {})).toBe(1); expect(await filesCommand(context(), "9", {})).toBe(1);
expect(stderr.text).toBe( expect(stderr.text).toBe("Collection 9 not found\n");
FETCHED_BOTH_ALBUMS + "Collection 9 not found\n",
);
}); });
// Without a collection ID it lists every file in the account once (issue // Without a collection ID it lists every file in the account once (issue
@@ -571,6 +584,57 @@ describe("files", () => {
]); ]);
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS); expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS);
}); });
// `files 2` on an empty cache stores Work alone. The refresh that `files`
// then waits for must still store Vacation first, as the server lists it.
it("without a collection ID lists in the server's order after files 2 on an empty cache", async () => {
const ctx = context(fakeClient({ beachInWork: true }));
expect(await filesCommand(ctx, "2", {})).toBe(0);
stdout.text = "";
stderr.text = "";
expect(await filesCommand(ctx, undefined, { json: true })).toBe(0);
expect(
JSON.parse(stdout.text).map(
(row: { id: number; collectionID: number }) => [
row.id,
row.collectionID,
],
),
).toEqual([
[100, 1],
[101, 1],
[200, 2],
]);
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS);
});
// After a refresh, the server adds A and then B. `files 4` must not store B
// ahead of A, so it waits for a refresh, which stores both in that order.
it("without a collection ID lists new albums in the server's order after files with the second one's ID", async () => {
expect(await filesCommand(context(), undefined, {})).toBe(0);
const ctx = context(fakeClient({ newAlbums: true }));
stdout.text = "";
expect(await filesCommand(ctx, "4", {})).toBe(0);
expect(stdout.text).toBe("300\timage\tshared.jpg\n");
stdout.text = "";
expect(await filesCommand(ctx, undefined, { json: true })).toBe(0);
expect(
JSON.parse(stdout.text).map(
(row: { id: number; collectionID: number }) => [
row.id,
row.collectionID,
],
),
).toEqual([
[100, 1],
[101, 1],
[200, 2],
[300, 3],
]);
});
}); });
describe("get and get-thumb", () => { describe("get and get-thumb", () => {
@@ -1394,7 +1458,7 @@ describe("backup-metadata --exif", () => {
// collection list alone and stores nothing.) // collection list alone and stores nothing.)
describe("backup-metadata and the thumbnail helpers refresh first", () => { describe("backup-metadata and the thumbnail helpers refresh first", () => {
beforeEach(async () => { beforeEach(async () => {
expect(await filesCommand(context(), "1", {})).toBe(0); expect(await filesCommand(context(), undefined, {})).toBe(0);
stdout.text = ""; stdout.text = "";
stderr.text = ""; stderr.text = "";
}); });
@@ -1579,11 +1643,13 @@ describe("an ID given on the command line", () => {
// An account with many albums (issue #210). On an empty cache `collections` // An account with many albums (issue #210). On an empty cache `collections`
// used to wait for a refresh that fetched and decrypted the files of every // used to wait for a refresh that fetched and decrypted the files of every
// album, one album after another, before it printed anything, so on such an // album, one album after another. It printed nothing until then, so on such an
// account it seemed to hang. The account here has 40 albums of one photo each, // account it seemed to hang. `files` with an album's ID did the same before it
// encrypted as Ente's clients encrypt them, on the fake Ente server of // listed that album (issue #217). The account here has 40 albums of one photo
// `test/ente-server.ts`, which also records the path of each request quak // each, encrypted as Ente's clients encrypt them. The fake Ente server of
// sends. The session is the one logging in to that server saves. // `test/ente-server.ts` serves them. A wrapper around it records the path and
// query of each request quak sends. The session is the one logging in to that
// server saves.
describe("an account with many albums", () => { describe("an account with many albums", () => {
const ALBUMS = 40; const ALBUMS = 40;
const PASSWORD = "many-albums"; const PASSWORD = "many-albums";
@@ -1664,7 +1730,8 @@ describe("an account with many albums", () => {
: input instanceof URL : input instanceof URL
? input.href ? input.href
: input.url; : input.url;
paths.push(new URL(url).pathname); const { pathname, search } = new URL(url);
paths.push(pathname + search);
return ente(input, init); return ente(input, init);
}) as typeof globalThis.fetch; }) as typeof globalThis.fetch;
const client = await Client.login({ const client = await Client.login({
@@ -1698,42 +1765,68 @@ describe("an account with many albums", () => {
albums += `${id}\talbum\tAlbum ${id}\n`; albums += `${id}\talbum\tAlbum ${id}\n`;
expect(stdout.text).toBe(albums); expect(stdout.text).toBe(albums);
expect(stderr.text).toBe(""); expect(stderr.text).toBe("");
expect(paths).toEqual(["/collections/v2"]); expect(paths).toEqual(["/collections/v2?sinceTime=0"]);
// It stored nothing that would make a refresh skip an album's files: // It stored nothing that would make `refreshCollection()` skip an
// `files` after it still finds the album's photo. // album's files: `files` after it still finds the album's photo.
stdout.text = ""; stdout.text = "";
expect(await filesCommand(ctx, "7", {})).toBe(0); expect(await filesCommand(ctx, "7", {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n"); expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
}); });
it("files counts on stderr each album whose files its refresh fetched", async () => { it("files fetches the files of the album it lists and of no other", async () => {
expect(await filesCommand(accountContext(), "7", {})).toBe(0); expect(await filesCommand(accountContext(), "7", {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n"); expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
expect(stderr.text).toBe("");
expect(paths).toEqual([
"/collections/v2?sinceTime=0",
"/collections/v2/diff?collectionID=7&sinceTime=0",
]);
});
// The first listing stored the album, so the second finds its stored copy
// current and asks for no album's files.
it("files run again for the same album sends no request for its files", async () => {
const ctx = accountContext();
expect(await filesCommand(ctx, "7", {})).toBe(0);
stdout.text = "";
paths.length = 0;
expect(await filesCommand(ctx, "7", {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
expect(paths).toEqual(["/collections/v2?sinceTime=0"]);
});
it("files with no album's ID counts on stderr each album whose files its refresh fetched", async () => {
expect(await filesCommand(accountContext(), undefined, {})).toBe(0);
let photos = "";
let progress = ""; let progress = "";
for (let n = 1; n <= ALBUMS; n++) { for (let n = 1; n <= ALBUMS; n++) {
photos += `${n * 100}\timage\tphoto-${n}.jpg\n`;
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`; progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
} }
expect(stdout.text).toBe(photos);
expect(stderr.text).toBe(progress); expect(stderr.text).toBe(progress);
}); });
// `--json` shapes stdout only, so the refresh's progress is still counted // `--json` shapes stdout only, so the refresh's progress is still counted
// on stderr. // on stderr.
it("files --json counts on stderr each album whose files its refresh fetched", async () => { it("files --json with no album's ID counts on stderr each album whose files its refresh fetched", async () => {
expect(await filesCommand(accountContext(), "7", { json: true })).toBe( expect(
0, await filesCommand(accountContext(), undefined, { json: true }),
); ).toBe(0);
expect(JSON.parse(stdout.text)).toEqual([ const rows = JSON.parse(stdout.text);
{ expect(rows).toHaveLength(ALBUMS);
expect(rows[6]).toEqual({
id: 700, id: 700,
title: "photo-7.jpg", title: "photo-7.jpg",
fileType: "image", fileType: "image",
creationTime: 1000, creationTime: 1000,
collectionID: 7, collectionID: 7,
}, });
]);
let progress = ""; let progress = "";
for (let n = 1; n <= ALBUMS; n++) { for (let n = 1; n <= ALBUMS; n++) {
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`; progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
+2 -2
View File
@@ -234,8 +234,8 @@ describe("-q", () => {
}); });
}); });
// The refresh's progress is left out; the line saying why the command // The line saying why the command failed is the fatal one, and it is
// failed is the fatal one, and it is printed. // printed.
it("prints only the line a failing command exits with", async () => { it("prints only the line a failing command exits with", async () => {
expect(await quak("-q", "files", "9")).toEqual({ expect(await quak("-q", "files", "9")).toEqual({
code: 1, code: 1,
+130 -9
View File
@@ -41,6 +41,7 @@ import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { init, toBase64, encryptBlob } from "../../src/crypto/index.js"; import { init, toBase64, encryptBlob } from "../../src/crypto/index.js";
import * as jpegJs from "jpeg-js"; import * as jpegJs from "jpeg-js";
import { Client } from "../../src/client.js"; import { Client } from "../../src/client.js";
import { DISCOVER_CATEGORIES } from "../../src/clip-keyword-vectors.js";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
import { import {
runMetadataBackup, runMetadataBackup,
@@ -56,8 +57,8 @@ import {
type MetaMockState, type MetaMockState,
} from "../ente-server.js"; } from "../ente-server.js";
// One file per ML data request, so the two files of the mock account are // One file per ML data request, so each file of the mock account is fetched in
// fetched in two requests and one of them can fail on its own. // a request of its own and one of them can fail on its own.
vi.mock("../../src/mldata-fetch.js", async (importOriginal) => ({ vi.mock("../../src/mldata-fetch.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../src/mldata-fetch.js")>()), ...(await importOriginal<typeof import("../../src/mldata-fetch.js")>()),
MLDATA_BATCH_SIZE: 1, MLDATA_BATCH_SIZE: 1,
@@ -66,6 +67,21 @@ vi.mock("../../src/mldata-fetch.js", async (importOriginal) => ({
const TEST_EMAIL = "metabackup@example.com"; const TEST_EMAIL = "metabackup@example.com";
const TEST_PASSWORD = "metapass"; const TEST_PASSWORD = "metapass";
// The CLIP embedding of a photo of a sunset (see clip-keywords.test.ts), which
// the ML data of beach.jpg holds.
const SUNSET_EMBEDDING: number[] = JSON.parse(
readFileSync(
new URL("../clip-keywords-sunset.json", import.meta.url),
"utf-8",
),
).embedding;
// Identity, a Discover category of Ente's photos app that leaves out videos.
const IDENTITY = DISCOVER_CATEGORIES.find((c) => c.title === "Identity")!;
// ML data holding faces and no CLIP embedding, which file 302 has.
const FACES_ONLY = { face: { faces: [{ faceID: "face-def", score: 0.9 }] } };
let mock: MetaMockState; let mock: MetaMockState;
let testDir: string; let testDir: string;
@@ -204,6 +220,65 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
updationTime: 1710000000000000, updationTime: 1710000000000000,
}; };
// A file in coll 10 with the ML data given. `fileType` is Ente's number: 0
// an image, 1 a video.
const vacationFile = (
id: number,
title: string,
fileType: number,
mlData: object,
) => {
const fileKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { ciphertext: encKey, nonce } = encryptSecretbox(fileKey, ck1);
const meta = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title,
fileType,
creationTime: 1700000000000000,
modificationTime: 1700000000000000,
}),
),
fileKey,
);
const ml = encryptBlob(
new Uint8Array(gzipSync(Buffer.from(JSON.stringify(mlData)))),
fileKey,
);
return {
raw: {
id,
collectionID: 10,
ownerID: 42,
encryptedKey: toBase64(encKey),
keyDecryptionNonce: toBase64(nonce),
metadata: {
encryptedData: toBase64(meta.ciphertext),
decryptionHeader: toBase64(meta.header),
},
file: {
decryptionHeader: toBase64(sodium.randombytes_buf(24)),
},
thumbnail: {
decryptionHeader: toBase64(sodium.randombytes_buf(24)),
},
updationTime: 1700000000000000,
},
mlData: {
encryptedData: toBase64(ml.ciphertext),
decryptionHeader: toBase64(ml.header),
},
};
};
// Files 300 and 301 in coll 10: a photo and a video whose ML data holds
// only a CLIP embedding, Identity's own text embedding.
const identityML = { clip: { embedding: IDENTITY.textEmbedding } };
const identityPhoto = vacationFile(300, "passport.jpg", 0, identityML);
const identityVideo = vacationFile(301, "passport.mp4", 1, identityML);
// File 302 in coll 10: a photo whose ML data holds faces and no CLIP
// embedding.
const facesPhoto = vacationFile(302, "party.jpg", 0, FACES_ONLY);
// Encrypt ML data for file 100 (gzipped JSON, encrypted with file key) // Encrypt ML data for file 100 (gzipped JSON, encrypted with file key)
const mlPayload = JSON.stringify({ const mlPayload = JSON.stringify({
face: { face: {
@@ -230,7 +305,7 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
clip: { clip: {
version: 1, version: 1,
client: "test", client: "test",
embedding: [0.5, 0.6, 0.7], embedding: SUNSET_EMBEDDING,
}, },
}); });
const gzipped = gzipSync(Buffer.from(mlPayload)); const gzipped = gzipSync(Buffer.from(mlPayload));
@@ -270,12 +345,23 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
keyAttributes: account.keyAttributes, keyAttributes: account.keyAttributes,
encryptedToken: account.encryptedToken, encryptedToken: account.encryptedToken,
collections: [rawColl1, rawColl2], collections: [rawColl1, rawColl2],
filesByCollection: { 10: [rawFile1], 20: [rawFile2] }, filesByCollection: {
10: [
rawFile1,
identityPhoto.raw,
identityVideo.raw,
facesPhoto.raw,
],
20: [rawFile2],
},
encryptedMLData: { encryptedMLData: {
100: { 100: {
encryptedData: toBase64(mlCiphertext), encryptedData: toBase64(mlCiphertext),
decryptionHeader: toBase64(mlHeader), decryptionHeader: toBase64(mlHeader),
}, },
300: identityPhoto.mlData,
301: identityVideo.mlData,
302: facesPhoto.mlData,
}, },
fileCiphertexts: { 100: encFileBody1 }, fileCiphertexts: { 100: encFileBody1 },
}; };
@@ -432,7 +518,9 @@ describe("quak backup-metadata", () => {
expect(account.email).toBe(TEST_EMAIL); expect(account.email).toBe(TEST_EMAIL);
}); });
it("fetches and decrypts ML data by default", async () => { // The CLIP embedding is left out of the ML data, and the keywords it gives
// are written in its place.
it("fetches and decrypts ML data by default, with keywords for the CLIP embedding", async () => {
const outDir = join(testDir, "ml-data"); const outDir = join(testDir, "ml-data");
await runBackup(outDir); await runBackup(outDir);
@@ -452,8 +540,41 @@ describe("quak backup-metadata", () => {
expect(fileMeta.mlData.face.faces[0].faceID).toBe("face-abc"); expect(fileMeta.mlData.face.faces[0].faceID).toBe("face-abc");
expect(fileMeta.mlData.face.faces[0].score).toBeCloseTo(0.98); expect(fileMeta.mlData.face.faces[0].score).toBeCloseTo(0.98);
expect(fileMeta.mlData.face.faces[0].detection.box.x).toBeCloseTo(0.1); expect(fileMeta.mlData.face.faces[0].detection.box.x).toBeCloseTo(0.1);
expect(fileMeta.mlData.clip).toBeDefined(); expect(fileMeta.mlData).not.toHaveProperty("clip");
expect(fileMeta.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]); expect(fileMeta.keywords).toEqual(["Sunset"]);
});
// Ente's photos app keeps videos out of some Discover categories, Identity
// among them. Files 300 and 301, a photo and a video, share one CLIP
// embedding, Identity's own text embedding, so the photo gets the keyword
// Identity and the video does not.
it("keeps a video out of a category that leaves out videos", async () => {
expect(IDENTITY.showVideo).toBe(false);
const outDir = join(testDir, "video-keywords");
await runBackup(outDir);
const vacation = join(outDir, "collections", "10-Vacation");
const fileJSON = (fileID: number) =>
JSON.parse(readFileSync(join(vacation, `${fileID}.json`), "utf-8"));
expect(fileJSON(300).keywords).toContain("Identity");
expect(fileJSON(301)).toHaveProperty("keywords");
expect(fileJSON(301).keywords).not.toContain("Identity");
});
// A file's ML data can hold faces and no CLIP embedding. File 302's JSON
// then has the ML data and no keywords.
it("writes no keywords for ML data without a CLIP embedding", async () => {
const outDir = join(testDir, "no-clip");
await runBackup(outDir);
const fileMeta = JSON.parse(
readFileSync(
join(outDir, "collections", "10-Vacation", "302.json"),
"utf-8",
),
);
expect(fileMeta.mlData).toEqual(FACES_ONLY);
expect(fileMeta).not.toHaveProperty("keywords");
}); });
it("extracts EXIF from downloaded files when --exif is set", async () => { it("extracts EXIF from downloaded files when --exif is set", async () => {
@@ -541,7 +662,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
"utf-8", "utf-8",
), ),
); );
expect(ok.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]); expect(ok.keywords).toEqual(["Sunset"]);
expect(ok.mlDataError).toBeUndefined(); expect(ok.mlDataError).toBeUndefined();
const failed = JSON.parse( const failed = JSON.parse(
@@ -599,7 +720,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
"utf-8", "utf-8",
), ),
); );
expect(ok.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]); expect(ok.keywords).toEqual(["Sunset"]);
}); });
it("exits 0 when every ML data request succeeds", async () => { it("exits 0 when every ML data request succeeds", async () => {
+58 -16
View File
@@ -3,13 +3,18 @@
* issue #36, issue #52). * issue #36, issue #52).
* *
* The `collections`, `files`, `get`, and `get-thumb` commands must answer for * The `collections`, `files`, `get`, and `get-thumb` commands must answer for
* current server state, not the local cache. `files`, `get` and `get-thumb` * current server state, not the local cache. `files` without a collection ID,
* force a `Library.fresh()` round-trip before they read; the stand-in library * `get` and `get-thumb` force a `Library.fresh()` round-trip before they read.
* below serves nothing until `fresh()` has been awaited, so a helper that read * The stand-in library below serves nothing until `fresh()` has been awaited.
* without refreshing would come back empty and fail here. `collections` asks * A helper that read without refreshing would come back empty and fail here.
* the library for the collection list alone (`freshCollections()`, issue * `collections` asks the library for the collection list alone
* #210), and must not wait for a refresh, which would fetch the files of every * (`freshCollections()`, issue #210). It must not wait for a refresh, which
* collection. * would fetch the files of every collection. `files COLLECTION` reads that list
* too, then brings that one collection up to date (`refreshCollection()`,
* issue #217). The stand-in serves a collection's files once `fresh()` has run,
* or once `refreshCollection()` has resolved to `true` for that collection.
* When `refreshCollection()` resolves to `false`, `files COLLECTION` forces a
* refresh instead.
* *
* `collections` and `files` also list in the library's enumeration order * `collections` and `files` also list in the library's enumeration order
* (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not * (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not
@@ -64,12 +69,18 @@ const file = (
// A library that reveals its records only after `fresh()` has been awaited, and // A library that reveals its records only after `fresh()` has been awaited, and
// serves them in the enumeration order it was given. `freshCollections()` // serves them in the enumeration order it was given. `freshCollections()`
// answers the collection list in that order without a refresh. `photos.byID` // answers the collection list in that order without a refresh. When
// returns a stand-in `Photo` carrying just the fileID the helper passes // `refreshCollection()` resolves to true, it reveals the files of that one
// through. // collection.
// `photos.byID` returns a stand-in `Photo` carrying just the fileID the helper
// passes through.
class FakeLibrary implements FreshReadLibrary { class FakeLibrary implements FreshReadLibrary {
freshCalls = 0; freshCalls = 0;
freshCollectionsCalls = 0; freshCollectionsCalls = 0;
refreshCollectionIDs: number[] = [];
// What `refreshCollection()` resolves to. With false it reveals nothing,
// as a library that has had a refresh does for a collection it lacks.
refreshCollectionHolds = true;
private refreshed = false; private refreshed = false;
constructor( constructor(
@@ -88,6 +99,11 @@ class FakeLibrary implements FreshReadLibrary {
return this.collections; return this.collections;
} }
async refreshCollection(collection: Collection): Promise<boolean> {
this.refreshCollectionIDs.push(collection.id);
return this.refreshCollectionHolds;
}
getCollection(id: number): Collection | undefined { getCollection(id: number): Collection | undefined {
if (!this.refreshed) return undefined; if (!this.refreshed) return undefined;
return this.collections.find((c) => c.id === id); return this.collections.find((c) => c.id === id);
@@ -98,7 +114,11 @@ class FakeLibrary implements FreshReadLibrary {
} }
listFiles(collectionID: number): EnteFile[] { listFiles(collectionID: number): EnteFile[] {
return this.refreshed const current =
this.refreshed ||
(this.refreshCollectionHolds &&
this.refreshCollectionIDs.includes(collectionID));
return current
? this.files.filter((f) => f.collectionID === collectionID) ? this.files.filter((f) => f.collectionID === collectionID)
: []; : [];
} }
@@ -138,18 +158,25 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
expect(rows.map((c) => c.id)).not.toEqual(newestFirst); expect(rows.map((c) => c.id)).not.toEqual(newestFirst);
}); });
it("freshFiles refreshes first, lists in enumeration order, keeps raw fields", async () => { it("freshFiles brings the collection alone up to date, lists in enumeration order, keeps raw fields", async () => {
// Enumeration order by id 10, 11, 12; creationTimes ascending, so a // Enumeration order by id 10, 11, 12; creationTimes ascending, so a
// newest-first sort would reverse them. // newest-first sort would reverse them. Collection 2's file is not
// listed.
const files = [ const files = [
file(10, 1, 1_700_000_000_000_000), file(10, 1, 1_700_000_000_000_000),
file(11, 1, 1_700_000_000_000_001), file(11, 1, 1_700_000_000_000_001),
file(12, 1, 1_700_000_000_000_002), file(12, 1, 1_700_000_000_000_002),
file(20, 2, 1_700_000_000_000_003),
]; ];
const lib = new FakeLibrary([collection(1, 100)], files); const lib = new FakeLibrary(
[collection(1, 100), collection(2, 100)],
files,
);
const rows = await freshFiles(lib, 1); const rows = await freshFiles(lib, 1);
expect(lib.freshCalls).toBe(1); expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.refreshCollectionIDs).toEqual([1]);
expect(lib.freshCalls).toBe(0);
expect(rows?.map((f) => f.id)).toEqual([10, 11, 12]); expect(rows?.map((f) => f.id)).toEqual([10, 11, 12]);
// Field values come from raw metadata: microsecond creationTime and the // Field values come from raw metadata: microsecond creationTime and the
@@ -203,10 +230,25 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
]); ]);
}); });
it("freshFiles forces a refresh when refreshCollection resolves to false", async () => {
const lib = new FakeLibrary(
[collection(1, 100)],
[file(10, 1, 1_700_000_000_000_000)],
);
lib.refreshCollectionHolds = false;
const rows = await freshFiles(lib, 1);
expect(lib.refreshCollectionIDs).toEqual([1]);
expect(lib.freshCalls).toBe(1);
expect(rows?.map((f) => f.id)).toEqual([10]);
});
it("freshFiles returns undefined for an unknown collection", async () => { it("freshFiles returns undefined for an unknown collection", async () => {
const lib = new FakeLibrary([collection(1, 100)], []); const lib = new FakeLibrary([collection(1, 100)], []);
const rows = await freshFiles(lib, 999); const rows = await freshFiles(lib, 999);
expect(lib.freshCalls).toBe(1); expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.refreshCollectionIDs).toEqual([]);
expect(lib.freshCalls).toBe(0);
expect(rows).toBeUndefined(); expect(rows).toBeUndefined();
}); });
+109
View File
@@ -0,0 +1,109 @@
{
"photo": "https://upload.wikimedia.org/wikipedia/commons/d/db/Sunset_off_the_Massachusetts_coast_-_NOAA.jpg",
"sha256": "be62f3289c9bf7b75af68cd6978021d54e8a531d5d4cc493f6e9fbf5e1ab74d3",
"embedding": [
-0.0021123015, -0.04437615, -0.062354308, 0.006249992, 0.022141185,
0.011159019, 0.01227392, -0.00030532264, 0.03186437, 0.09196922,
0.039111234, -0.037084445, -0.032962628, -0.019820206, -0.013141825,
0.0047183544, 0.017137334, 0.046581306, 0.0047622235, 0.04982521,
0.023128154, 0.032080866, 0.037709303, 0.019241406, 0.0032100384,
-0.014275843, 0.049337693, 0.025953151, 0.014263995, -0.0062328726,
-0.040504098, -0.015083303, -0.008123875, -0.006233639, -0.05352683,
-0.0048083956, 0.0763486, -0.013155067, 0.025114346, 0.022016674,
0.0001588023, -0.010341572, -0.0037518279, -0.019181008, 0.04549337,
-0.0010725886, 0.004636866, 0.017177915, -0.0117317885, 0.0026097358,
0.030738998, 0.013158572, -0.08856403, -0.0121391015, 0.011477229,
-0.0033104012, 0.035524756, -0.018607879, -0.0071739843, -0.031906743,
0.0056713386, -0.0044195866, 0.00292471, 0.00061686034, -0.043075223,
0.09040427, 0.001522537, -0.011445652, 0.009609886, -0.0053201183,
-0.0057452377, 0.00972853, -0.0024397348, -0.021255083, 0.015893811,
-0.006020548, -0.09428023, 0.014277583, -0.017975299, -0.011878315,
0.011825521, 0.00014203975, -0.0020437292, -0.05305343, -0.024840152,
-0.011336872, -0.002796541, -0.00036171873, 0.004530499, 0.044486605,
0.021602718, -0.01292857, 0.024530916, 0.019559948, -0.039928272,
-0.0681174, 0.049101, 0.02461532, 0.0365531, -0.04070358, -0.011011939,
-0.0027188526, -0.020370876, 0.012904856, 0.043157287, -0.051658224,
-0.3726012, 0.019549074, -0.0499252, -0.04800559, -0.011493859,
0.02893256, 0.060341954, 0.0013929688, -0.051283866, -0.024286803,
0.044124287, -0.005995806, -0.062817656, 0.007369614, -0.021470634,
-0.028147545, 0.00411659, 0.04234773, 0.03787647, 0.008579682,
-0.038568925, -0.027498025, -0.028416378, 0.0022012168, 0.010906191,
-0.013745787, 0.018267838, 0.013531097, -0.061559845, 0.004682517,
-0.005993303, 0.0028113858, -0.0028286583, 0.0026029332, -0.06693403,
-0.0072381413, -0.02419584, 0.0037009937, -0.0011296495, -0.030310456,
0.0005999609, 0.009945354, 0.024915686, 0.038672045, 0.023009432,
-0.05093051, -0.09101409, -0.06686167, -0.06211481, -0.022600474,
0.0024902367, -0.0067171026, -0.016769778, -0.0032803628, 0.008623083,
-0.0016110836, 0.014925073, 0.033811048, 0.012829449, -0.052575592,
0.03728281, -0.0051201633, 0.021238064, -0.031086605, -0.0043402985,
0.25054494, -0.028396713, -0.010725523, -0.023445267, 0.021751683,
0.003130971, 0.012270548, -0.014836475, 0.014466885, 0.0021478906,
-0.088153616, 0.015356315, 0.039639276, -0.040283255, 0.014923924,
-0.029303031, -0.004475201, 0.047299225, -0.06726505, -0.012491676,
-0.002080582, 0.03506341, 0.04146718, 0.0074114986, -0.009582633,
0.006623749, -0.05454898, -0.0034387247, -0.014780821, 0.03698328,
-0.034441486, 0.22032379, -0.021792699, 0.008001907, 0.0341019,
0.0025784625, -0.0071689896, -0.014151225, -0.050547987, -0.030372301,
-0.03288388, -0.042987447, -0.0033656165, 0.021502906, -0.0030374376,
-0.024413, -0.03506603, 0.030891743, 0.0069994032, 0.004887366,
0.030707076, 0.0065348223, 0.0008240555, 0.016809713, -0.024151647,
-0.0040458064, 0.044567548, 0.03908212, -0.061782435, 0.0026002831,
0.02003242, -0.0115451, -0.045135964, -0.0663542, 0.005240975,
0.0010777109, -0.009276002, -0.011919081, 0.027654119, -0.013634551,
0.0129154315, -0.013195512, 0.040571272, -0.099290505, -0.04656693,
-0.20979854, 0.04181004, -0.06769166, -0.04121946, 0.018260444,
0.005617506, -0.15694222, 0.032472793, 0.014189598, 0.008345103,
-0.041118417, 0.0074527003, -0.011494104, 0.004906474, -0.0027100458,
0.0009994594, -0.041061398, -0.045274835, 0.015187793, 0.034275036,
-0.02813289, 0.02098019, -0.015043198, 0.03716667, 0.013542642,
-0.008104835, 0.014965486, -0.027978558, 0.021163663, -0.006942737,
-0.034969814, -0.038961593, 0.016802894, -0.012318175, -0.047821593,
-0.01933216, 0.0064432374, 0.0322358, -0.020437784, 0.02659447,
-0.03022022, -0.019443465, -0.094178624, -0.017009107, 0.03860671,
-0.00023695534, -0.033089854, -0.014677462, 0.012356399, 0.018040499,
-0.19360113, 0.011710817, -0.019871691, 0.020124774, 0.024948105,
-0.024573864, -0.028349072, -0.041731454, 0.032646254, 0.015098165,
0.0018447012, -0.01129152, -0.02481687, 0.036048, 0.20797811,
0.04317757, -0.017239755, 0.033431605, -0.011384422, 0.0099174455,
-0.19739223, -0.008901062, -0.056426458, 0.014591864, 0.0044474266,
0.008209326, -0.012241819, 0.042553645, -0.021552453, 0.008611734,
0.048944127, -0.013872049, 0.019179989, 0.008183264, -0.053223096,
-0.027327595, -0.0033542586, -0.012353858, -0.010941209, 0.010212546,
0.029714134, -0.011385593, -0.0013529479, 0.014505385, 0.01148663,
0.012613636, -0.0074430504, -0.014008101, -0.028146038, -0.042778917,
0.02143165, 0.016812252, -0.06286215, 0.02085532, -0.035128277,
0.0241282, 0.01397908, -0.034992814, 0.013153766, 0.0068236147,
0.013594184, -0.06859393, 0.051849406, 0.029675119, -0.0037699796,
-0.00013216575, 0.02916647, 0.031859048, -0.015006051, 0.0001439966,
0.0026584554, -0.0046948595, 0.039754897, 0.034873154, 0.0012870439,
-0.011516082, -0.074089095, 0.019241294, 0.00076195505, 0.0027334464,
-0.036206458, -0.0011109262, -0.048838835, -0.00204685, 0.01179991,
0.0068136845, 0.009852495, 0.017308988, 0.03159545, 0.058803353,
0.038116634, 0.008836143, -0.010460637, -0.010994099, -0.0012926942,
-0.048370842, 0.037696566, -0.08093239, -0.0041242186, -0.005866373,
0.02048607, -0.12549639, 0.011705739, -0.0021425558, 0.018078592,
-0.038228087, 0.004245971, -0.11560507, 0.01381203, -0.02734893,
0.029595908, 0.101082794, -0.042842254, -0.08397301, -0.1546237,
-0.037624564, 0.0069606556, -0.008020479, 0.0043268185, 0.016504597,
0.044554908, -0.05240209, 0.020519335, -0.050994243, -0.019861093,
0.022066997, -0.0188911, 0.012919696, -0.022872692, -0.012961374,
-0.020071112, 0.0074553066, 0.06402014, 0.04065211, 0.014139608,
0.06221546, -0.02574805, 0.010114545, 0.020619567, 0.032264773,
-0.009148828, 0.022849955, -0.016593235, -0.032456633, -0.006737303,
-0.0037493357, -0.065912865, -0.0024181998, -0.0037048983, -0.019304488,
0.009085037, 0.057529982, -0.0378338, 0.01792042, 0.01885012,
-0.0043600523, 0.017382368, -0.027792772, 0.006749118, -0.0053297766,
-0.011628587, 0.015736576, 0.024801968, 0.011543913, -0.01159873,
-0.000759976, 0.042183932, 0.05764386, -0.06422416, 0.010577374,
-0.02261249, 0.02282793, -0.05141075, -0.11287078, -0.020670144,
-0.020953743, 0.030885734, -0.012597732, -0.121472664, 0.028447151,
-0.0415664, -0.010061708, 0.06441338, 0.0271207, -0.0016693748,
-0.0045694243, 0.020342957, 0.030528478, 0.007129517, -0.0029600926,
-0.007061397, 0.06036486, -0.004736018, -0.0090786135, -0.07601116,
-0.039556272, -0.051269546, -0.019473685, 0.029858151, 0.028464556,
0.012424476, 0.013742254, 0.008742927, 0.006065751, -0.009892663,
0.07699999, -0.003443197, 0.0027863425, 0.029197995, 0.0211013,
0.01098379, 0.016684106, -0.028917195, 0.008122037, 0.0265241,
-0.04461223
]
}
+129
View File
@@ -0,0 +1,129 @@
// `clipKeywords`: the keywords quak writes for a file in place of its CLIP
// embedding.
//
// Ente's ML data for a file holds a CLIP embedding, 512 numbers its image
// model gave the photo. Ente's photos app puts a file in a category of its
// Discover section when the cosine similarity of that embedding and the text
// embedding of the category's query is at least the category's minimum score,
// and keeps videos out of some categories. `clipKeywords` applies that rule
// and returns the titles of the categories a file falls in, highest score
// first; `quak backup` and `quak backup-metadata` write them as `keywords`.
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { DISCOVER_CATEGORIES } from "../src/clip-keyword-vectors.js";
import { clipKeywords } from "../src/clip-keywords.js";
// 512 numbers: the ones given, then zeros.
const vector = (...start: number[]): number[] => [
...start,
...new Array<number>(512 - start.length).fill(0),
];
const withEmbedding = (embedding: unknown) => ({
face: { faces: [] },
clip: { embedding },
});
// Two made-up categories, each with a text embedding along one axis. The
// embedding [3, 4] has a cosine similarity of 3/5 with "Across" and 4/5 with
// "Up". Videos are kept out of "Across".
const ACROSS = {
title: "Across",
query: "across",
minScore: 0.6,
showVideo: false,
textEmbedding: vector(1),
};
const UP = {
title: "Up",
query: "up",
minScore: 0.5,
showVideo: true,
textEmbedding: vector(0, 1),
};
describe("clipKeywords", () => {
it("gives each category whose minimum score the similarity reaches, highest score first", () => {
expect(
clipKeywords(withEmbedding(vector(3, 4)), false, [ACROSS, UP]),
).toEqual(["Up", "Across"]);
});
it("leaves out a category when the similarity is just below its minimum score", () => {
const stricter = { ...ACROSS, minScore: 0.6 + 1e-9 };
expect(
clipKeywords(withEmbedding(vector(3, 4)), false, [stricter, UP]),
).toEqual(["Up"]);
});
it("keeps a video out of a category that leaves out videos", () => {
expect(
clipKeywords(withEmbedding(vector(3, 4)), true, [ACROSS, UP]),
).toEqual(["Up"]);
});
it("gives an empty list for an embedding that falls in no category", () => {
expect(
clipKeywords(withEmbedding(vector(0, 0, 1)), false, [ACROSS, UP]),
).toEqual([]);
});
it.each([
["no CLIP embedding", { face: { faces: [] } }],
["a CLIP part that is not an object", { clip: null }],
["a CLIP part without an embedding", { clip: {} }],
["an embedding of 511 numbers", withEmbedding(vector(3, 4).slice(1))],
["an embedding with NaN in it", withEmbedding(vector(3, NaN))],
["an embedding with Infinity in it", withEmbedding(vector(Infinity))],
["an embedding of strings", withEmbedding(vector(3, 4).map(String))],
["an embedding of zeros", withEmbedding(vector())],
])("gives no keywords for ML data with %s", (_, mlData) => {
expect(clipKeywords(mlData, false, [ACROSS, UP])).toBeUndefined();
});
});
// The CLIP embedding Ente's image model gives a public-domain NOAA photo of a
// sunset, made by bin/clip-keyword-vectors from the photo at `photo`.
const SUNSET: { photo: string; sha256: string; embedding: number[] } =
JSON.parse(
readFileSync(
new URL("clip-keywords-sunset.json", import.meta.url),
"utf-8",
),
);
describe("Ente's Discover categories", () => {
it("are the 16 of Ente's photos app, each with a text embedding of norm 1", () => {
expect(DISCOVER_CATEGORIES.map((c) => c.title)).toEqual([
"Identity",
"Screenshots",
"QR Codes",
"Receipts",
"Notes",
"Memes",
"Visiting Cards",
"Babies",
"Pets",
"Selfies",
"Wallpapers",
"Food",
"Celebrations",
"Sunset",
"Hills",
"Greenery",
]);
for (const category of DISCOVER_CATEGORIES) {
expect(category.textEmbedding).toHaveLength(512);
expect(Math.hypot(...category.textEmbedding)).toBeCloseTo(1, 5);
}
});
it("give a photo of a sunset the keyword Sunset and no other", () => {
expect(
clipKeywords({ clip: { embedding: SUNSET.embedding } }, false),
).toEqual(["Sunset"]);
});
});
+273 -4
View File
@@ -17,6 +17,14 @@
* 4. `freshCollections()` reads the collection list alone, with one request * 4. `freshCollections()` reads the collection list alone, with one request
* and no collection's files, and stores nothing, so the next refresh still * and no collection's files, and stores nothing, so the next refresh still
* fetches the files of every collection (issue #210). * fetches the files of every collection (issue #210).
* 5. `refreshCollection()` brings one collection up to date in the store
* (issue #217). It leaves the stored cursor 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. After a refresh, it stores
* no collection the store does not hold: not a new one, and not one a
* refresh deleted. `close()` waits for it. A library opened on a cache that
* only it wrote awaits its first refresh. That refresh replaces what it
* stored, in the server's order.
* *
* The client is the same metadata-only mock the background-refresh tests use: * The client is the same metadata-only mock the background-refresh tests use:
* no crypto, no network, scripted pages, and a record of each call. A long * no crypto, no network, scripted pages, and a record of each call. A long
@@ -30,6 +38,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
import { MetadataStore } from "../../src/library/store.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
@@ -144,6 +153,26 @@ const openSeeded = async (
return { client, lib }; return { client, lib };
}; };
// Hold the client's next files request until `release()` is called. `started`
// resolves once that request has been sent.
const holdNextFilesRequest = (
client: MockClient,
): { started: Promise<void>; release: () => void } => {
let release!: () => void;
const held = new Promise<void>((r) => (release = r));
let sent!: () => void;
const started = new Promise<void>((r) => (sent = r));
const realFilesSince = client.filesSince.bind(client);
client.filesSince = async (args) => {
client.filesSince = realFilesSince;
const page = await realFilesSince(args);
sent();
await held;
return page;
};
return { started, release };
};
describe("Library.fresh", () => { describe("Library.fresh", () => {
let dir: string; let dir: string;
let cacheDirectory: string; let cacheDirectory: string;
@@ -287,10 +316,10 @@ describe("Library.freshCollections", () => {
rmSync(dir, { recursive: true, force: true }); rmSync(dir, { recursive: true, force: true });
}); });
// A refresh fetches a collection's files only when the collection is new // A refresh from a stored cursor past 0 fetches a collection's files only
// or its updationTime has advanced past the stored copy's. Had the // when the collection is new or its updationTime has advanced past the
// collection list been stored, the next refresh would take every // stored copy's. So does `refreshCollection()`. Neither would fetch the
// collection for one it already holds the files of, and fetch none. // files of a collection stored here without them.
it("asks for the collection list alone and stores nothing", async () => { it("asks for the collection list alone and stores nothing", async () => {
const client = new MockClient(); const client = new MockClient();
const page = { const page = {
@@ -340,3 +369,243 @@ describe("Library.freshCollections", () => {
} }
}); });
}); });
describe("Library.refreshCollection", () => {
let dir: string;
let cacheDirectory: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "quak-refresh-collection-"));
cacheDirectory = join(dir, "cache");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
// Collections 1 and 2 are stored at updationTime 100, with a stored cursor
// of 100, and then both change on the server.
it("fetches the files of one changed collection and leaves the others to the next refresh", async () => {
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 100), collection(2, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
client.filesFor(2, {
files: [file(2001, 2, 90)],
deleted: [],
cursor: 90,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: SLOW_INTERVAL,
});
try {
client.filesCalls.length = 0;
client.filesFor(1, {
files: [file(1002, 1, 110)],
deleted: [],
cursor: 110,
});
client.filesFor(2, {
files: [file(2002, 2, 120)],
deleted: [],
cursor: 120,
});
// Collection 1's files alone, since the stored copy.
expect(await lib.refreshCollection(collection(1, 110))).toBe(true);
expect(client.filesCalls).toEqual([
{ collectionID: 1, sinceTime: 100 },
]);
expect(lib.listFiles(1).map((f) => f.id)).toEqual([1001, 1002]);
expect(lib.listFiles(2).map((f) => f.id)).toEqual([2001]);
// The stored copy is now current, so no request.
await lib.refreshCollection(collection(1, 110));
expect(client.filesCalls).toHaveLength(1);
// The next refresh asks from the stored cursor, 100, so it still
// sees collection 2 changed and fetches its files.
client.collectionsQueue.push({
collections: [collection(1, 110), collection(2, 120)],
deleted: [],
cursor: 120,
});
await lib.fresh();
expect(client.collectionsSinceTimes).toEqual([0, 100]);
expect(client.filesCalls).toEqual([
{ collectionID: 1, sinceTime: 100 },
{ collectionID: 2, sinceTime: 100 },
]);
expect(lib.listFiles(2).map((f) => f.id)).toEqual([2001, 2002]);
} finally {
await lib.close();
}
});
// Collection 1 is stored at 100. `refreshCollection()` asks for it at 110,
// which adds file 1002. While that files request waits, a fresh read
// starts a refresh. The server tells that refresh that collection 1 was
// deleted at 130.
it("holds off a refresh that starts during the files request, which then deletes the collection", async () => {
const { client, lib } = await openSeeded(cacheDirectory);
const files = holdNextFilesRequest(client);
try {
client.filesFor(1, {
files: [file(1002, 1, 110)],
deleted: [],
cursor: 110,
});
client.collectionsQueue.push({
collections: [],
deleted: [1],
cursor: 130,
});
const refreshing = lib.refreshCollection(collection(1, 110));
await files.started;
const reading = lib.fresh();
// The refresh has not sent its collections request yet.
await new Promise((r) => setImmediate(r));
expect(client.collectionsSinceTimes).toEqual([0]);
files.release();
await refreshing;
await reading;
expect(client.collectionsSinceTimes).toEqual([0, 100]);
expect(lib.getCollection(1)).toBeUndefined();
expect(lib.listFiles(1)).toEqual([]);
} finally {
files.release();
await lib.close();
}
});
// `freshCollections()` lists collection 1 at 110. Before
// `refreshCollection()` is called, a refresh learns that it was deleted at
// 130.
it("stores nothing for a collection that a refresh deleted before the call", async () => {
const { client, lib } = await openSeeded(cacheDirectory);
try {
client.collectionsQueue.push(
{ collections: [collection(1, 110)], deleted: [], cursor: 110 },
{ collections: [], deleted: [1], cursor: 130 },
);
const [listed] = await lib.freshCollections();
await lib.fresh();
client.filesCalls.length = 0;
expect(await lib.refreshCollection(listed)).toBe(false);
expect(client.filesCalls).toEqual([]);
expect(lib.getCollection(1)).toBeUndefined();
} finally {
await lib.close();
}
});
// A refresh has stored collection 1. Collection 2 is new on the server
// since then. Only the next refresh may add it, after collection 1.
it("stores nothing for a collection that is new since the last refresh", async () => {
const { client, lib } = await openSeeded(cacheDirectory);
try {
client.filesCalls.length = 0;
expect(await lib.refreshCollection(collection(2, 110))).toBe(false);
expect(client.filesCalls).toEqual([]);
expect(lib.getCollection(2)).toBeUndefined();
} finally {
await lib.close();
}
});
// The caller may remove the cache directory once `close()` resolves, so
// the cache write must have landed by then.
it("close() waits for a refreshCollection() in flight", async () => {
const { client, lib } = await openSeeded(cacheDirectory);
const files = holdNextFilesRequest(client);
client.filesFor(1, {
files: [file(1002, 1, 110)],
deleted: [],
cursor: 110,
});
const refreshing = lib.refreshCollection(collection(1, 110));
await files.started;
let closed = false;
const closing = lib.close().then(() => {
closed = true;
});
await new Promise((r) => setImmediate(r));
expect(closed).toBe(false);
files.release();
await closing;
const saved = await MetadataStore.load(
join(cacheDirectory, "metadata.json"),
);
expect(saved.listFiles(1).map((f) => f.id)).toEqual([1001, 1002]);
await refreshing;
});
// `quak files 2` on an empty cache stores collection 2 alone. A library
// opened later on that cache must not serve it as the whole account. Its
// first refresh replaces what the cache holds, in the server's order.
it("open() awaits the first refresh on a cache that only refreshCollection() wrote", async () => {
const client = new MockClient();
client.filesFor(2, {
files: [file(2001, 2, 90)],
deleted: [],
cursor: 90,
});
const first = await Library.open({
client,
cacheDirectory,
refreshOnOpen: false,
refreshIntervalSeconds: SLOW_INTERVAL,
});
expect(await first.refreshCollection(collection(2, 100))).toBe(true);
await first.close();
client.collectionsQueue.push({
collections: [collection(1, 100), collection(2, 100)],
deleted: [],
cursor: 100,
});
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
client.filesFor(2, {
files: [file(2001, 2, 90)],
deleted: [],
cursor: 90,
});
client.filesCalls.length = 0;
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: SLOW_INTERVAL,
});
try {
expect(lib.status().lastRefreshAt).toBeDefined();
expect(lib.listCollections().map((c) => c.id)).toEqual([1, 2]);
// It fetches collection 2's files again, from the beginning.
expect(client.filesCalls).toEqual([
{ collectionID: 1, sinceTime: 0 },
{ collectionID: 2, sinceTime: 0 },
]);
expect(lib.listFiles(2).map((f) => f.id)).toEqual([2001]);
} finally {
await lib.close();
}
});
});
+3 -3
View File
@@ -20,9 +20,9 @@
* 5. `close()` stops the timer and is idempotent, and its promise resolves * 5. `close()` stops the timer and is idempotent, and its promise resolves
* only once an in-flight refresh has written the cache file. * only once an in-flight refresh has written the cache file.
* 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id. * 6. `cacheDirectory` defaults to the env-paths cache dir plus the user id.
* 7. `open()` branches on the cache: an empty cache awaits the first refresh * 7. `open()` branches on the stored cursor. At 0, as on an empty cache, it
* (it has nothing to serve yet); an existing cache serves its copy at once * awaits the first refresh. Past 0 it serves the cache's copy at once and
* and refreshes in the background, so a slow or dead server never stalls * refreshes in the background, so a slow or dead server never stalls
* opening. * opening.
* 8. A save failure that leaves RAM ahead of disk keeps `status().lastError` * 8. A save failure that leaves RAM ahead of disk keeps `status().lastError`
* set and keeps retrying the write; a later empty refresh does not clear it. * set and keeps retrying the write; a later empty refresh does not clear it.
+2 -1
View File
@@ -4,7 +4,8 @@
* These are the surface the GUI consumes across Electron IPC. `snapshot()` is * These are the surface the GUI consumes across Electron IPC. `snapshot()` is
* synchronous — it reads the in-RAM store and projects it into plain records * synchronous — it reads the in-RAM store and projects it into plain records
* (no keys) — and `subscribe({ onChange })` delivers a `LibraryChange` whenever * (no keys) — and `subscribe({ onChange })` delivers a `LibraryChange` whenever
* a background refresh actually changes the derived records. The contracts: * a refresh or `refreshCollection()` changes the derived records. The
* contracts:
* *
* 1. `snapshot()` deduplicates a file across memberships into one record with * 1. `snapshot()` deduplicates a file across memberships into one record with
* every album id, orders photos newest first, and carries no key material. * every album id, orders photos newest first, and carries no key material.
+20 -1
View File
@@ -172,7 +172,7 @@ describe("MetadataStore", () => {
expect(store.listCollections()).toEqual([]); expect(store.listCollections()).toEqual([]);
}); });
it("loads an empty store when the list of what the refresh could not decrypt is damaged", async () => { it("loads an empty store when the list of what did not decrypt is damaged", async () => {
// A backup reads that list on every run, so a damaged one that loaded // A backup reads that list on every run, so a damaged one that loaded
// would fail every backup until the cache was deleted. A string is // would fail every backup until the cache was deleted. A string is
// damaged too, though JavaScript can loop over its characters. // damaged too, though JavaScript can loop over its characters.
@@ -266,4 +266,23 @@ describe("MetadataStore", () => {
expect(store.getFile(12345, 67890)).toBeUndefined(); expect(store.getFile(12345, 67890)).toBeUndefined();
expect(store.getFile(55555, 67890)?.key).toEqual(inB.key); expect(store.getFile(55555, 67890)?.key).toEqual(inB.key);
}); });
// A backup reports these in this order. It must not depend on whether a
// refresh or `Library.refreshCollection()` fetched each one first.
it("lists what did not decrypt by collection ID, then file ID", async () => {
const store = await MetadataStore.load(path);
store.putUndecryptable({ collectionID: 2, fileID: 7, error: "a" });
store.putUndecryptable({ collectionID: 3, error: "b" });
store.putUndecryptable({ collectionID: 1, fileID: 9, error: "c" });
store.putUndecryptable({ collectionID: 2, fileID: 5, error: "d" });
expect(
store.listUndecryptable().map((r) => [r.collectionID, r.fileID]),
).toEqual([
[1, 9],
[2, 5],
[2, 7],
[3, undefined],
]);
});
}); });