Author SHA1 Message Date
sneak d372283900 Names quak builds from server data fit in 255 bytes (closes #205)
check / check (push) Canceled after 0s
The default names of `quak get`, `quak get-thumb`, `downloadFile` and
`downloadThumbnail`, and the album folders of `backup-metadata`, could be
over 255 bytes, which a file system refuses. `shortenKeepingExtension` in
`src/filename.ts` cuts a name short, keeping its extension when it fits,
and can put text right before the extension; the backup's `withID` now
calls it instead of doing the same itself. The `get` defaults use it with
`thumb_` counted. A `backup-metadata` folder keeps its `<id>-` and has the
name after it cut short. `safeExtension` gives `.bin` for an extension of
more than 16 letters and digits, so a save path's name always fits.

Model: opus-5-5
2026-10-18 16:26:46 +00:00
44 changed files with 689 additions and 5691 deletions
+151 -403
View File
@@ -2,31 +2,20 @@
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, lists collections and their files, encrypted photo hosting service. It logs in, enumerates collections and files,
and downloads and decrypts single files. and downloads individual images while decrypting them on the way to disk.
`quak backup` downloads every file in the account into a local directory tree. quak also includes a resilient backup command that downloads every file in the
It downloads and stores each file once, however many collections hold it. It account into a deduplicated local directory tree, skipping files that already
skips files already on disk. It logs a failed download and moves on to the next exist on disk and continuing past individual download failures instead of
file. Beside each file it writes a JSON file that holds: crashing. For each file it persists the basic metadata fields quak keeps (title,
file type, creation and modification time, latitude, longitude, content hash),
- the file's title and file type its update time, the private and public magic metadata in full, Ente's ML data
- its creation and modification times for it when there is any, and, for an image (for a live photo, its image), its
- its latitude and longitude original's EXIF and XMP, with its dimensions for a JPEG only; for a video it
- its content hash keeps none of these three. It runs unattended from cron (see "Running the backup
- its update time from cron"). A helper subcommand can detect and regenerate missing thumbnails,
- its private and public magic metadata, in full encrypting and uploading them back to the server.
- 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
@@ -46,11 +35,8 @@ yarn quak login
# List the user's collections (albums). # List the user's collections (albums).
yarn quak collections yarn quak collections
# List every file in the account. # List files in a collection.
yarn quak files yarn quak files --collection 12345
# List the files in a collection.
yarn quak files 12345
# Download and decrypt a single file. # Download and decrypt a single file.
yarn quak get 67890 --out ./photo.jpg yarn quak get 67890 --out ./photo.jpg
@@ -70,11 +56,10 @@ const client = await Client.login({
password: "your-password", password: "your-password",
}); });
// Open a cache-backed library. On an empty cache, or one that only // Open a cache-backed library. On an empty cache this awaits one server
// `lib.refreshCollection()` wrote, this awaits one server refresh. Otherwise // refresh; on an existing cache it returns immediately and refreshes in the
// it returns immediately and refreshes in the background. A background timer // background. Later refreshes start `refreshIntervalSeconds` (default 3) after
// then refreshes again `refreshIntervalSeconds` (default 3) after its previous // the previous one ends.
// 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.
@@ -149,12 +134,12 @@ matters.
### Exit codes ### Exit codes
| Code | Meaning | | Code | Meaning |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `0` | The backup is complete: every original is at its save path, and no file failed. | | `0` | The backup is complete: every original is at its save path, and no file failed. |
| `1` | The run finished with files in `failures.json` or albums it could not decrypt or write (see "Backup layout"), and its last line says how many, or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. | | `1` | The run finished with files in `failures.json` or albums it could not decrypt or write (see "Backup layout"), or it stopped on another error, printed as one line `quak: <message>`. The next run tries again. |
| `2` | Another backup of the same directory is running. This run sent no request and changed nothing. | | `2` | Another backup of the same directory is running. This run sent no request and changed nothing. |
| `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. | | `3` | There is no usable session: none is saved, the saved one is corrupt, or the server no longer accepts it. Run `quak login` as the user the cron job runs as. |
`<message>` is the error's message, followed by the message of each error that `<message>` is the error's message, followed by the message of each error that
caused it. Under Node, as `yarn quak` runs quak, `fetch` reports every failure caused it. Under Node, as `yarn quak` runs quak, `fetch` reports every failure
@@ -383,11 +368,6 @@ 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
@@ -395,8 +375,6 @@ quak/
thumbnails.ts detect + regenerate missing thumbnails thumbnails.ts detect + regenerate missing thumbnails
client.ts high-level Client class assembled from the above client.ts high-level Client class assembled from the above
cli-commands.ts the CLI's commands as functions returning exit codes cli-commands.ts the CLI's commands as functions returning exit codes
cli-log.ts the CLI's log level, and the one function the
commands write stderr lines through
cli-output.ts how the CLI prints a file's title and time cli-output.ts how the CLI prints a file's title and time
cli-read.ts fresh reads for the CLI's read commands cli-read.ts fresh reads for the CLI's read commands
cli-run.ts run a command, print its error, exit with its code cli-run.ts run a command, print its error, exit with its code
@@ -404,9 +382,6 @@ 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
@@ -478,12 +453,6 @@ A custom API endpoint is configurable for self-hosted servers via the
constructor option `apiOrigin`. When set, file downloads route through constructor option `apiOrigin`. When set, file downloads route through
`<apiOrigin>/files/download/<id>` instead of the dedicated CDN host. `<apiOrigin>/files/download/<id>` instead of the dedicated CDN host.
The constructor option `onRequest` is called once for each request sent, a
retried request once for each attempt, with its method, its URL and the status
of the answer or the error the request failed with. The URL of a presigned
upload is given without its query, which holds the upload's signature. The CLI
logs each request with `-vv` (see "CLI surface").
Required request headers on every authenticated call: Required request headers on every authenticated call:
- `X-Auth-Token`: the decrypted auth token from login. - `X-Auth-Token`: the decrypted auth token from login.
@@ -560,9 +529,7 @@ each wait is drawn at random below its ceiling. Every other command uses the
defaults. A library user gets the same budget by passing defaults. A library user gets the same budget by passing
`UNATTENDED_RETRY_OPTIONS` as `ApiClientOptions.retry`. `sleep` and `random` are `UNATTENDED_RETRY_OPTIONS` as `ApiClientOptions.retry`. `sleep` and `random` are
injectable through the same option, which is how the test suite exercises the injectable through the same option, which is how the test suite exercises the
whole policy without waiting. `onRetry`, given there too, is called before each whole policy without waiting.
wait with the error the attempt failed with, the attempt's number and the wait;
the CLI logs it with `-v` (see "CLI surface").
Two deadlines, renewed for each attempt: Two deadlines, renewed for each attempt:
@@ -651,17 +618,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. The server answers HTTP 401 when it no longer accepts the saved session's field. When the refresh a command starts with gets HTTP 401 from the server,
token. A command that gets that answer prints one line, because it no longer accepts the saved session's token, the command prints one
`quak: the saved session is no longer valid; run "quak login"`, with no stack line, `quak: the saved session is no longer valid; run "quak login"`, with no
trace. All three exit with status 3, which means the user must run `quak login` stack trace. All three exit with status 3, which means the user must run
again. `quak logout` is the exception: with no file it says there is no session `quak login` again. `quak logout` is the exception: with no file it says there
and exits 0, and it handles a corrupt file or a failed server call as described is no session and exits 0, and it handles a corrupt file or a failed server call
below. `quak backup` meets an expired session on the refresh that starts every as described below. `quak backup` meets an expired session on the refresh that
run, before it touches any file but its lock (see "Backup layout"). A session starts every run, before it touches any file but its lock (see "Backup layout").
that stops working partway through a backup instead fails each remaining file A session that stops working partway through a backup instead fails each
into `failures.json`, so that run exits 1 and the next one stops at its refresh remaining file into `failures.json`, so that run exits 1 and the next one stops
with status 3. No command but `quak login` ever prompts. at its refresh 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
@@ -676,12 +643,11 @@ thumbnails), for the user to delete if they want it gone.
``` ```
quak [--cache-dir <path>] <command> global: local metadata/content cache location quak [--cache-dir <path>] <command> global: local metadata/content cache location
quak [-v | -vv | -q] <command> global: log more on stderr, or only fatal errors
quak login interactive or QUAK_EMAIL/QUAK_PASSWORD quak login interactive or QUAK_EMAIL/QUAK_PASSWORD
quak whoami print logged-in account as JSON quak whoami print logged-in account as JSON
quak logout end the session, delete it quak logout end the session, delete it
quak collections [--json] list all collections quak collections [--json] list all collections
quak files [collectionID] [--json] list files in a collection, or every file quak files --collection <id> [--json] list files in a collection
quak get <fileID> [--out path] [--collection] download and decrypt a file quak get <fileID> [--out path] [--collection] download and decrypt a file
quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail
quak backup <dir> [--json] [--verify] full incremental backup quak backup <dir> [--json] [--verify] full incremental backup
@@ -691,100 +657,18 @@ 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. Each read command makes a fresh server round-trip before it answers. It library. The read commands — `collections`, `files`, `get`, `get-thumb`,
then reports the account's current state, not what the cache last held. The read `backup-metadata`, `helper list-missing-thumbnails` and
commands are: `helper fix-missing-thumbnails` — force a fresh server round-trip before they
answer, so they report current account state rather than whatever the cache last
- `collections` held. If that round-trip fails, the command prints the error on one line and
- `files`
- `get`
- `get-thumb`
- `backup-metadata`
- `helper list-missing-thumbnails`
- `helper fix-missing-thumbnails`
`collections` asks the server for the collection list alone, in one request. It
fetches no collection's files and leaves the cache as it is. `files` with a
collection ID asks for the collection list the same way. It then asks for the
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 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. handling"). An album or file quak cannot decrypt does not fail it; the command
The command answers from what did decrypt (see "Default reads vs. fresh reads"). 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.
`-v` (`--verbose`) and `-q` (`--quiet`) set how much quak logs on stderr. Like An ID given to `files --collection`, `get`, `get-thumb` or
`--cache-dir`, they go before or after any command's name. Every line quak
prints on stderr is a log line at one of six levels, from the quietest: fatal,
error, warning, info, debug and trace. A run prints the lines at its own level
and at every quieter one. stdout holds only the command's output, at every
level.
- Every command that exits non-zero logs a fatal line saying why, such as
`quak: <message>`, `Not logged in`, an invalid ID or a collection that does
not exist. A command that exits 1 because files or albums failed logs it last,
saying how many failed and where to look: `backup` points to `failures.json`
and its `FAILED` lines, `backup-metadata` to the `mlDataError` in the JSON of
each such file, and `helper fix-missing-thumbnails` to its `Failed files:`
list, or to its JSON with `--json`.
- An error line reports something that failed: the `FAILED` lines that `backup`
and `helper fix-missing-thumbnails` log as they work,
`ML data of file <id> could not be read` and
`ML data request for <n> file(s) failed` from `backup-metadata`,
`Could not check <title>` from both thumbnail helpers, and the `Failed files:`
list at the end of `backup` and of `helper fix-missing-thumbnails`.
- A warning reports something that did not fail but needs your attention: after
`logout`, that the cache directory still holds decrypted data.
- Every other line quak logs by default is info.
With `--json`, a command's result on stdout takes the place of its summary on
stderr: for `backup`, the lines from `--- Backup complete ---` to the end of its
`Failed files:` list; for `helper list-missing-thumbnails`,
`No missing thumbnails found.` or `<n> file(s) with missing thumbnails:`; and
for `helper fix-missing-thumbnails`, the lines from `--- Done ---` to the end of
its `Failed files:` list. Every other line is logged as it is without `--json`.
- With neither option the level is info: the lines quak prints as it works, such
as a refresh's progress, the files a backup fetches, its summary and the files
that failed.
- `-v` raises the level to debug, which adds each retry of a request, with why
the attempt failed and how long quak waits before the next, and the start and
end of each refresh and each ML data fetch, with the error of one that failed.
- `-vv` raises it to trace, which adds each request quak sends: its method, its
URL and the status of the answer, or the error the request failed with. The
URL of an upload of a thumbnail is logged without its query, which holds the
upload's signature. More repeats stay at trace, and `--verbose --verbose` is
`-vv`.
- `-q` lowers it to fatal: a run that exits 0 prints nothing on stderr, and one
that exits non-zero prints only its fatal lines. The prompts of `login` are
not log lines, and `-q` leaves them as they are.
`-q` with `-v` is refused with one line,
`error: option '-q, --quiet' cannot be used with option '-v, --verbose'`, and
exit status 1.
`files <collectionID>` lists the files in that collection. `files` with no
argument lists every file in the account once, collection by collection. The
collections come in the order the server first sent them in a refresh. A file in
several collections is listed with the first of them, which is its
`collectionID` in `--json`.
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
than 9007199254740991. Anything else, such as `0x10`, `1e3` or `-1`, is refused than 9007199254740991. Anything else, such as `0x10`, `1e3` or `-1`, is refused
before any request with one line, `Invalid file ID: <arg>` or before any request with one line, `Invalid file ID: <arg>` or
@@ -795,26 +679,24 @@ Without `--out`, `get` writes the file to the working directory under its title,
and `get-thumb` writes the thumbnail there under `thumb_` followed by the title, and `get-thumb` writes the thumbnail there under `thumb_` followed by the title,
each with unsafe characters replaced. A name over 255 bytes, which most file each with unsafe characters replaced. A name over 255 bytes, which most file
systems refuse, is cut short between two characters before its extension, so the systems refuse, is cut short between two characters before its extension, so the
extension is kept whenever it fits. An extension too long to leave room for the extension is kept whenever it fits.
name's first character is cut short too, so the name never starts with a dot.
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is `get` and `get-thumb` resolve the file by ID directly, so `--collection` is
accepted for backward compatibility but ignored. For a live photo, `get` writes accepted for backward compatibility but ignored. For a live photo, `get` writes
its image and its video, each named after the title with its own extension, as its image and its video, each named after the title with its own extension, as
Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`), and each name is Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With
cut short as above once its own extension is in it. With `--out PATH`, the image `--out PATH`, the image is written to `PATH` and the video beside it, with
is written to `PATH` and the video beside it, with `PATH`'s name and the video's `PATH`'s name and the video's extension; a `PATH` with the video's extension is
extension; a `PATH` with the video's extension is refused. refused. `backup-metadata --exif` (alias `--all`) additionally fetches each
`backup-metadata --exif` (alias `--all`) additionally fetches each file's file's original through the cache and records, from it or a live photo's image,
original through the cache and records, from it or a live photo's image, its XMP its XMP metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is
metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is read with read with [exifreader](https://github.com/mattiasw/ExifReader) from any image
[exifreader](https://github.com/mattiasw/ExifReader) from any image format it format it reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's
reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's `exif` field `exif` field is exifreader's EXIF tag output: each tag by name, with its
is exifreader's EXIF tag output: each tag by name, with its `value`, `value`, `description` and `computed` value. An EXIF block exifreader finds but
`description` and `computed` value. An EXIF block exifreader finds but reads no reads no tag from is recorded, base64, as `exifRaw`, with the reason in
tag from is recorded, base64, as `exifRaw`, with the reason in `exifError`. `exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails`
`collections`, `files`, `backup`, `helper list-missing-thumbnails` and and `helper fix-missing-thumbnails` take `--json` for machine-readable output.
`helper fix-missing-thumbnails` take `--json` for machine-readable output.
`backup --verify` also hashes the originals already in the backup and replaces `backup --verify` also hashes the originals already in the backup and replaces
any that do not match the content hash Ente records; one it cannot replace stays any that do not match the content hash Ente records; one it cannot replace stays
@@ -822,18 +704,16 @@ 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. Each `<fileID>.json` holds the file's ML `<fileID>.json` for each of its files. The collection's name has unsafe
data and keywords as `quak backup` writes them (see "Backup layout"). The characters replaced and is cut short between two characters so that the
collection's name has unsafe characters replaced and is cut short between two directory's name fits in 255 bytes; the ID keeps the directories apart.
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
`mlDataError` field instead of `mlData`, and the dump goes on. A file whose ML `mlDataError` field instead of `mlData`, and the dump goes on. A file whose ML
data does not decrypt, gunzip or parse is logged and written the same way, with data does not decrypt, gunzip or parse is logged and written the same way, with
why in `mlDataError`. The exit code is non-zero if any file was written with why in `mlDataError`. The exit code is non-zero if any file was written with
`mlDataError`, and the last line then says how many were. `mlDataError`.
`helper fix-missing-thumbnails` regenerates thumbnails for JPEG images only, `helper fix-missing-thumbnails` regenerates thumbnails for JPEG images only,
because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG image because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG image
@@ -859,11 +739,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,
keywords, and, for an image (for a live for an image (for a live photo, its
photo, its image), its original's EXIF image), its original's EXIF and XMP, with
and XMP, with dimensions for a JPEG dimensions for a JPEG only; none of these
only; none of these three for a video 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/
@@ -894,51 +774,20 @@ 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 as `mlData`, such as the faces Ente A file's JSON holds Ente's ML data for it (its faces and its CLIP embedding) as
found, without its CLIP embedding. It holds `keywords` in place of the `mlData`, the same payload `backup-metadata` writes; a file Ente has no ML data
embedding. for has no `mlData`. 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
The Discover section of Ente's photos app has 16 categories: 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
- Identity (no videos) the cache whose ML data the server sent but quak could not decrypt, gunzip or
- Screenshots (no videos) parse. Its `mlDataError` says why, and `failures.json` records it with an error
- QR Codes (no videos) starting `ML data: `. quak keeps that reason in memory only and caches nothing
- Receipts for the file, so the next run asks for its ML data again. An ML data fetch that
- Notes fails, such as one the library's refresh starts while the backup writes the JSON
- Memes files, leaves the reasons as the last fetch that succeeded left them. The JSON
- Visiting Cards (no videos) files are rewritten on every run, so ML data that arrived since the last run
- Babies appears.
- 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`
@@ -961,8 +810,8 @@ reads vs. fresh reads") goes into `failures.json` with an error that starts
`could not be decrypted:` and gives the reason. If a copy of it in another album `could not be decrypted:` and gives the reason. If a copy of it in another album
decrypted, it does not: its original is stored from that copy, and the album decrypted, it does not: its original is stored from that copy, and the album
where it does not decrypt neither links nor lists it. An album the library could where it does not decrypt neither links nor lists it. An album the library could
not decrypt has no name quak can read and no files it can list. `quak backup` not decrypt has no name quak can read and no files it can list. A run without
prints one line for it on stderr, `--json` prints one line for it,
`FAILED album <id>: could not be decrypted: <reason>`, and `--json` and the `FAILED album <id>: could not be decrypted: <reason>`, and `--json` and the
`BackupResult` list its ID in `undecryptableAlbums`. Such a file in `BackupResult` list its ID in `undecryptableAlbums`. Such a file in
`failures.json`, or such an album, keeps the exit code at 1, run after run, `failures.json`, or such an album, keeps the exit code at 1, run after run,
@@ -1007,16 +856,15 @@ gets its ID added as well: beside an album `x`, whose JSON is `x.json`, an album
named `x.json` gets the directory `x.json (12)/`. A name too long for a file named `x.json` gets the directory `x.json (12)/`. A name too long for a file
name, which most file systems limit to 255 bytes, gets its ID added too. A name name, which most file systems limit to 255 bytes, gets its ID added too. A name
with its ID added is cut short between two characters in the part before the ID, with its ID added is cut short between two characters in the part before the ID,
just far enough that it fits. When a symlink's extension leaves no room for the just far enough that it fits. When a symlink's extension alone leaves no room
ID and the first character of the name, the extension is cut short, and the part for the ID, the extension is cut short and the part before the ID keeps only the
before the ID keeps that character and any bytes the cut leaves over. A bytes that cut leaves over. A collection's directory name also leaves room for
collection's directory name also leaves room for the `.json` its JSON adds. IDs the `.json` its JSON adds. IDs never change, so a name stays the same from run
never change, so a name stays the same from run to run until such a clash to run until such a clash appears or goes away.
appears or goes away.
A collection whose directory or JSON cannot be written, such as one whose A collection whose directory or JSON cannot be written, such as one whose
directory name is taken by a file you put in `collections/`, does not stop the directory name is taken by a file you put in `collections/`, does not stop the
run. `quak backup` prints `FAILED album <name>: <reason>` for it on stderr, run. A run without `--json` prints `FAILED album <name>: <reason>` for it,
`--json` and the `BackupResult` list its ID in `failedAlbums`, and the exit code `--json` and the `BackupResult` list its ID in `failedAlbums`, and the exit code
is 1. The other collections and `failures.json` are still written. is 1. The other collections and `failures.json` are still written.
@@ -1122,17 +970,11 @@ 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, or one that only loop, and resolves to a `Library`. On an empty cache it awaits the first
`lib.refreshCollection()` wrote, it awaits one refresh. It then opens onto the refresh, so it opens onto the account's data whenever the server is reachable;
account's data whenever the server is reachable. If that refresh fails, it opens if that refresh fails, it opens with no data and records the error in
onto what the cache holds, and records the error in `lib.status()`. On any other `lib.status()`. On an existing cache it returns immediately and refreshes in the
cache it returns immediately and refreshes in the background. An unreachable background, so an unreachable server does not block opening.
server then does not block opening.
With `refreshOnOpen: false`, `open()` starts no refresh. It returns at once onto
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`:
@@ -1142,7 +984,6 @@ read commands open the library this way (see "CLI surface").
| `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live | | `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live |
| `downloadDirectory` | `photos` in the working directory | root of the save paths; an original stored there counts as cached | | `downloadDirectory` | `photos` in the working directory | root of the save paths; an original stored there counts as cached |
| `refreshIntervalSeconds` | `3` | background refresh cadence; `Infinity` for no background timer | | `refreshIntervalSeconds` | `3` | background refresh cadence; `Infinity` for no background timer |
| `refreshOnOpen` | `true` | start a refresh as the library opens |
| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first | | `precacheThumbnails` | `true` | prefetch every thumbnail, newest first |
| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals | | `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals |
| `precacheOriginalsDays` | `7` | length in days of that latest window | | `precacheOriginalsDays` | `7` | length in days of that latest window |
@@ -1156,38 +997,17 @@ read commands open the library this way (see "CLI surface").
The default `downloadDirectory` is resolved against the working directory once, The default `downloadDirectory` is resolved against the working directory once,
when the library opens; `lib.downloadDirectory` holds the result. when the library opens; `lib.downloadDirectory` holds the result.
`onProgress` receives a `RefreshEvent` with status `"started"` as a refresh, an
ML data fetch or a precache sweep starts, and `"done"` or `"failed"` as it ends.
In between, a refresh sends `"progress"` after each collection whose files it
fetched. That event's `fetched` is the number of collections whose files the
refresh has fetched so far. Its `total` is the number of collections whose files
it will fetch in all. A refresh fetches the files of each collection that is new
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`, which holds: `lib.status()` returns a `LibraryStatus` (collection/file counts, last
refresh/ML times and errors, originals usage and effective limit, precache
- the collection and file counts progress, and `closed`). `lib.close()` stops the background timer; it is
- the time of the last refresh that succeeded, and the error of the latest idempotent, and an in-flight refresh is left to finish. The promise it returns
refresh if it failed resolves once that refresh (including its cache write), the ML data fetch and
- the same two for the ML data fetch the precache fetches already running have all finished, so the cache directory
- the originals cache's usage and its effective limit can then be removed.
- 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
@@ -1201,72 +1021,30 @@ 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. changes that copy only once all its server requests have succeeded, and the
`lib.refreshCollection()` changes it the same way for one collection. So a background timer starts the next refresh `refreshIntervalSeconds` after the
default read is immediate, but only as current as the last refresh or previous one ends. So a default read is immediate, but only as current as the
`lib.refreshCollection()` whose requests all succeeded. Right after `open()` last refresh whose requests all succeeded; right after opening an existing
returns without awaiting a refresh, that copy is the one loaded from disk. cache, it is the copy on disk.
The background timer takes a turn `refreshIntervalSeconds` after its last turn `await lib.fresh()` waits for a refresh to complete and persist, and returns the
ends. Each turn starts a refresh, unless a refresh or `lib.refreshCollection()` same `{ albums, photos, timeline }` namespaces, which then reflect a completed
is already running. server round-trip. When a refresh is already running, background or not,
`fresh()` waits for that one, so its answer can come from requests made before
`await lib.fresh()` waits for a refresh to complete and persist. It then returns the call; only when none is running does it start one. A refresh that fails
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). Every CLI read command calls `fresh()`, except `quak collections`. copy). The CLI's read commands use fresh reads (issue
`quak files` with a collection ID calls it only when `lib.refreshCollection()` https://git.eeqj.de/sneak/quak/issues/75).
resolves to `false`.
`await lib.freshCollections()` is the fresh read of the collection list alone. An album or file in the server's answer that quak cannot decrypt, or whose
It sends one collections request. It answers with every live collection that metadata is not JSON, does not fail the refresh. The refresh leaves it out, an
decrypts, in the server's order, as the `Collection` objects that album with all its files, and drops any older copy the cache held, so reads
`listCollections()` returns. It fetches no collection's files and stores answer from what did decrypt. The library keeps a list of what it left out, with
nothing, not even the list. A refresh from a cursor past 0 fetches a the reason, in `metadata.json`: the next refresh asks the server only for what
collection's files only when the collection is new or has changed since the changed, so it would not see the record again. A record stays on that list until
stored copy. So does `lib.refreshCollection()`. Neither would fetch the files of a later refresh gets a copy that decrypts or learns that the server deleted it.
a collection stored without them. `quak collections` uses `freshCollections()`. `lib.backup()` reports each such album, and each such file in its scope that has
no copy that decrypted (see "Backup layout").
`await lib.refreshCollection(collection)` brings one collection up to date in
the cache, given its `Collection` from `freshCollections()`. It does for that
collection what a refresh does for a changed one, and fetches no other
collection's files. It fetches the collection's files changed since the cache's
copy, or all of them when the cache has no copy. It then stores and saves them.
It sends no request when the cache's copy has the same `updationTime` or a later
one. It resolves to `true` when the cache then holds the collection.
`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
@@ -1360,17 +1138,10 @@ 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 })` returns `{ unsubscribe }`. photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
It calls `onChange` with a `LibraryChange` whenever a refresh or (`albumsChanged`, `photosChanged`, `fileIDsRemoved`, `albumIDsRemoved`,
`lib.refreshCollection()` changes these records. A refresh or `refreshedAt`) whenever a refresh alters the projection, and returns
`lib.refreshCollection()` that changes nothing does not call it. A `{ unsubscribe }`; a refresh that changes nothing delivers nothing.
`LibraryChange` holds:
- `albumsChanged`
- `photosChanged`
- `fileIDsRemoved`
- `albumIDsRemoved`
- `refreshedAt`
### Thumbnails, ML search, and backup ### Thumbnails, ML search, and backup
@@ -1386,44 +1157,29 @@ It calls `onChange` with a `LibraryChange` whenever a refresh or
`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, `backup.lock` - `await lib.backup(opts?)` → `BackupResult`. It takes the lock in the download
in the download directory. It fails at once with an error whose `code` is directory, and fails at once with an error whose `code` is `ELOCKED` while
`ELOCKED` while another backup of that directory runs. A caller can instead another backup of it runs. A caller can instead take the lock itself, before
take the lock itself before it opens its library, as `quak backup` does. it opens its library, as `quak backup` does: `await lockBackupDirectory(dir)`
`await lockBackupDirectory(dir)` takes it, failing the same way, and returns a takes it, failing the same way, and returns a `BackupLock`, whose `release()`
`BackupLock`. Its `release()` removes the lock unless it was lost, and its removes the lock unless it was lost and whose `lost` holds why it was lost.
`lost` holds why it was lost. The caller passes it to the backup as `lock`, The caller passes it to the backup as `lock`, and releases it once the backup
and releases it once the backup ends. ends. It waits for a refresh as `fresh()` does, puts every in-scope original
not already at its save path there as `photo.download()` does (and, with
The backup waits for a refresh as `fresh()` does. It then fetches each `includeThumbnails`, fetches thumbnails) through the content cache, waits for
in-scope original that is not yet at its save path, as `photo.download()` an ML data fetch, and rebuilds the on-disk backup tree with a durable failure
does, through the content cache. With `includeThumbnails` it also fetches ledger. Each file's JSON holds its ML data and, for an image (for a live
thumbnails. It waits for an ML data fetch, then rebuilds the backup tree. It photo, its image), its original's EXIF and XMP, with its dimensions for a JPEG
records each file that failed in `failures.json`. only; a video's JSON holds none of these three. A fetched original is written
straight to its save path and not into the cache, which then counts it as
Each file's JSON holds its ML data and keywords. An image's JSON also holds present; one the cache already held is copied from there. `BackupOptions`:
the EXIF and XMP of its original, and its dimensions if it is a JPEG. A live `downloadDirectory` (falls back to the library's), `includeOriginals` (default
photo's JSON holds these for its image. A video's JSON holds no EXIF, XMP or `true`), `includeThumbnails` (default `false`), `onlyAlbumNames`, `verify`
dimensions. (default `false`), `onProgress`, and `lock`. See Backup layout above for the
tree it writes, and for the albums `BackupResult` lists in
A fetched original is written straight to its save path, not into the cache. `undecryptableAlbums` and `failedAlbums`. Whether the backup took the lock or
The cache then counts it as present. An original that the cache already held its caller did, once the lock is lost the backup rejects with
is copied from there. `lost the lock on <dir>: <reason>` before it starts another file.
`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
@@ -1506,14 +1262,6 @@ 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
+10 -74
View File
@@ -17,90 +17,26 @@ pre-1.0
# Next Step # Next Step
No bug is open, and no change sneak asked for is open. The check of quak against No bug is open. The check of quak against a live Ente account,
a live Ente account, https://git.eeqj.de/sneak/quak/issues/187, needs an account https://git.eeqj.de/sneak/quak/issues/187, needs an account only sneak has, so
only sneak has, so it waits on him. it waits on him.
Tagging and releases are decided by sneak alone, and happen only when he Tagging and releases are decided by sneak alone, and happen only when he
declares one. 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,
and `-q`/`--quiet` prints only fatal errors
(https://git.eeqj.de/sneak/quak/issues/212). The levels are fatal, error,
warning, info, debug and trace; the default is info, everything quak printed
on stderr before, `-v` gives debug and `-vv` trace. `src/cli-log.ts` holds the
level and the log the commands write stderr lines through. Debug adds each
retry, which `withRetry` reports through the new `onRetry`, and the start and
end of each refresh and ML data fetch; trace adds each request, which
`ApiClient` reports through the new `onRequest`. `--json` no longer keeps the
progress lines off stderr, so `backup` and `--json` runs log a refresh's
progress too. Every line that reports a failed file or album is at error,
which the progress callbacks of `backup`, `backup-metadata` and the thumbnail
helpers now say, and a command that exits 1 because files or albums failed
ends with one fatal line saying how many and where to look.
- 2026-10-18: `quak files` takes the collection ID as an optional argument
instead of the `--collection` option, which is gone
(https://git.eeqj.de/sneak/quak/issues/211). With no argument it lists every
file in the account once, collection by collection; a file in several
collections is listed with the first of them, which is its `collectionID` in
`--json`. The fresh read in `src/cli-read.ts` does this.
- 2026-10-18: Names quak builds from an album name or a title fit in a file name - 2026-10-18: Names quak builds from an album name or a title fit in a file name
(https://git.eeqj.de/sneak/quak/issues/205). `shortenKeepingExtension` in (issue 205). `shortenKeepingExtension` in `src/filename.ts` cuts a name short
`src/filename.ts` cuts a name short to a number of bytes, keeping its to a number of bytes, keeping its extension when it fits, and can put text
extension when it fits and always its first character, and can put text right right before the extension; the backup's `withID` uses it for an ID. The
before the extension; the backup's `withID` uses it for an ID. default names of `quak get`, `quak get-thumb`, `downloadFile` and
`defaultFileName` there gives the name a file is saved under when the caller `downloadThumbnail` are cut short with it to 255 bytes, `thumb_` included.
gives no path: a prefix such as `thumb_` and the title with unsafe characters `backup-metadata` cuts an album's name short so that its folder,
replaced, cut short with it to 255 bytes, and, for a live photo's image or `<id>-<name>`, fits in 255 bytes. `safeExtension` gives `.bin` for an
video, with that part's own extension before the cut. `quak get`,
`quak get-thumb`, `downloadFile` and `downloadThumbnail` use it without
`--out` or `outPath`. `backup-metadata` cuts an album's name short so that its
folder, `<id>-<name>`, fits in 255 bytes. `safeExtension` gives `.bin` for an
extension of more than 16 letters and digits, so a save path's name always extension of more than 16 letters and digits, so a save path's name always
fits. fits.
- 2026-10-18: `quak collections` no longer waits for the files of every
collection (issue 210). On an empty cache it opened its library with a refresh
that fetched and decrypted the files of each collection in turn, printing
nothing and storing nothing until all were done, so on a large account it
seemed to hang. It now reads the collection list alone through the new
`lib.freshCollections()`, one request that stores nothing. The read commands
open their library with the new `refreshOnOpen: false`, so each makes only the
refresh it asks for. A refresh sends a `"progress"` event after each
collection whose files it fetched, and the commands that wait on one print it
on stderr, except with `--json`.
- 2026-10-16: ML data the server sends that does not decrypt, gunzip or parse is - 2026-10-16: ML data the server sends that does not decrypt, gunzip or parse is
no longer taken for no ML data (issue 198). `fetchMLDataBatch` returns why for no longer taken for no ML data (issue 198). `fetchMLDataBatch` returns why for
each such file beside the payloads; ML data that parses to something other each such file beside the payloads; ML data that parses to something other
-31
View File
@@ -1,31 +0,0 @@
#!/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
@@ -1,158 +0,0 @@
"""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 -47
View File
@@ -2,9 +2,8 @@
import { stdout, stderr } from "node:process"; import { stdout, stderr } from "node:process";
import { input, password } from "@inquirer/prompts"; import { input, password } from "@inquirer/prompts";
import { Command, Option } from "commander"; import { Command } from "commander";
import envPaths from "env-paths"; import envPaths from "env-paths";
import { logLevel, logRequests, stderrLog, type Log } from "../src/cli-log.js";
import { init } from "../src/crypto/index.js"; import { init } from "../src/crypto/index.js";
import { import {
type CliContext, type CliContext,
@@ -30,9 +29,6 @@ const paths = envPaths("quak", { suffix: "" });
const program = new Command(); const program = new Command();
// Program options, so they are taken before or after any command's name.
// `-v` counts its repeats, the way commander's documentation counts a repeated
// option.
program program
.name("quak") .name("quak")
.description("CLI for the Ente end-to-end encrypted photo service") .description("CLI for the Ente end-to-end encrypted photo service")
@@ -41,46 +37,21 @@ program
"--cache-dir <path>", "--cache-dir <path>",
"Directory for the local metadata/content cache " + "Directory for the local metadata/content cache " +
"(default: the per-user cache directory)", "(default: the per-user cache directory)",
) );
.option(
"-v, --verbose",
"Log more on stderr: debug lines with -v, trace lines too with -vv",
(_value: string, previous: number) => previous + 1,
0,
)
.addOption(
new Option(
"-q, --quiet",
"Print nothing on stderr but the error a failing command exits with",
).conflicts("verbose"),
)
.configureHelp({ showGlobalOptions: true });
interface ProgramOptions {
cacheDir?: string;
verbose: number;
quiet?: boolean;
}
// The log for this run, at the level `-v` and `-q` ask for.
const log = (): Log => {
const { verbose, quiet } = program.opts<ProgramOptions>();
return stderrLog(stderr, logLevel(verbose, quiet ?? false));
};
const context = (): CliContext => ({ const context = (): CliContext => ({
stdout, stdout,
log: log(), stderr,
sessionDir: paths.data, sessionDir: paths.data,
cacheDir: program.opts<ProgramOptions>().cacheDir, cacheDir: program.opts<{ cacheDir?: string }>().cacheDir,
loadSession: (path) => loadSession(path, logRequests(log())), loadSession,
login: (opts) => Client.login({ ...opts, apiOptions: logRequests(log()) }), login: (opts) => Client.login(opts),
prompt: (message) => input({ message }), prompt: (message) => input({ message }),
promptSecret: (message) => password({ message, mask: true }), promptSecret: (message) => password({ message, mask: true }),
}); });
const run = (command: Promise<number>): Promise<void> => const run = (command: Promise<number>): Promise<void> =>
runCommand(command, stdout, stderr, log(), (code) => process.exit(code)); runCommand(command, stdout, stderr, (code) => process.exit(code));
program program
.command("login") .command("login")
@@ -107,15 +78,14 @@ program
program program
.command("files") .command("files")
.description("List the files in a collection, or every file in the account") .description("List files in a collection")
.argument( .requiredOption(
"[collectionID]", "--collection <id>",
"Collection ID (from `quak collections`); without it, every file " + "Collection ID (from `quak collections`)",
"is listed once",
) )
.option("--json", "Output as JSON array") .option("--json", "Output as JSON array")
.action((collectionID: string | undefined, opts: { json?: boolean }) => .action((opts: { collection: string; json?: boolean }) =>
run(filesCommand(context(), collectionID, opts)), run(filesCommand(context(), opts)),
); );
program program
@@ -172,10 +142,7 @@ program
{ {
...context(), ...context(),
loadSession: (path) => loadSession: (path) =>
loadSession( loadSession(path, { retry: UNATTENDED_RETRY_OPTIONS }),
path,
logRequests(log(), UNATTENDED_RETRY_OPTIONS),
),
}, },
dir, dir,
opts, opts,
+12 -57
View File
@@ -36,15 +36,6 @@ export interface ApiClientOptions {
retry?: RetryOptions; retry?: RetryOptions;
requestTimeoutMs?: number; requestTimeoutMs?: number;
downloadTimeoutMs?: number; downloadTimeoutMs?: number;
// Called once for each request sent, with the status of its response or
// the error it failed with, for a caller that logs requests. A request
// that is retried is reported once for each attempt.
onRequest?: (request: {
method: string;
url: string;
status?: number;
error?: unknown;
}) => void;
} }
export interface StreamOptions { export interface StreamOptions {
@@ -168,7 +159,6 @@ export class ApiClient {
private readonly retry: ResolvedRetryOptions; private readonly retry: ResolvedRetryOptions;
private readonly requestTimeoutMs: number; private readonly requestTimeoutMs: number;
private readonly downloadTimeoutMs: number; private readonly downloadTimeoutMs: number;
private readonly onRequest: NonNullable<ApiClientOptions["onRequest"]>;
private token: string | undefined; private token: string | undefined;
constructor(opts?: ApiClientOptions) { constructor(opts?: ApiClientOptions) {
@@ -190,7 +180,6 @@ export class ApiClient {
opts?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS; opts?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
this.downloadTimeoutMs = this.downloadTimeoutMs =
opts?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS; opts?.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS;
this.onRequest = opts?.onRequest ?? (() => {});
this.token = opts?.authToken; this.token = opts?.authToken;
} }
@@ -225,33 +214,6 @@ export class ApiClient {
return h; return h;
} }
// Send one request and report it to `onRequest`, with the status of its
// response or the error it failed with. `reportedURL` is the URL given to
// `onRequest`, when that must not be the one sent.
private async send(
url: string,
init: RequestInit & { method: string },
reportedURL = url,
): Promise<Response> {
let resp: Response;
try {
resp = await this._fetch(url, init);
} catch (err) {
this.onRequest({
method: init.method,
url: reportedURL,
error: err,
});
throw err;
}
this.onRequest({
method: init.method,
url: reportedURL,
status: resp.status,
});
return resp;
}
private async throwIfError(resp: Response): Promise<void> { private async throwIfError(resp: Response): Promise<void> {
if (resp.ok) return; if (resp.ok) return;
const requestID = resp.headers.get("x-request-id") ?? undefined; const requestID = resp.headers.get("x-request-id") ?? undefined;
@@ -295,7 +257,7 @@ export class ApiClient {
const url = buildURL(this.apiOrigin, path, query); const url = buildURL(this.apiOrigin, path, query);
// A GET changes nothing, so it is retried under the full policy. // A GET changes nothing, so it is retried under the full policy.
return withRetry(async () => { return withRetry(async () => {
const resp = await this.send(url, { const resp = await this._fetch(url, {
method: "GET", method: "GET",
headers: this.headers(), headers: this.headers(),
signal: AbortSignal.timeout(this.requestTimeoutMs), signal: AbortSignal.timeout(this.requestTimeoutMs),
@@ -318,7 +280,7 @@ export class ApiClient {
// 3xx status, which is not retried. // 3xx status, which is not retried.
return withRetry( return withRetry(
async () => { async () => {
const resp = await this.send(url, { const resp = await this._fetch(url, {
method: "POST", method: "POST",
headers: this.headers({ headers: this.headers({
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -359,23 +321,16 @@ export class ApiClient {
// object at one key in one request, so replaying it either overwrites // object at one key in one request, so replaying it either overwrites
// the same bytes or lands them for the first time. There is no partial // the same bytes or lands them for the first time. There is no partial
// state to protect, hence the full policy rather than the POST rule. // state to protect, hence the full policy rather than the POST rule.
// It is reported without its query, which holds the upload's
// signature: a log keeps what it is given.
const { origin, pathname } = new URL(presignedURL);
await withRetry(async () => { await withRetry(async () => {
const resp = await this.send( const resp = await this._fetch(presignedURL, {
presignedURL, method: "PUT",
{ headers: {
method: "PUT", "Content-Type": "application/octet-stream",
headers: { "Content-Length": String(data.length),
"Content-Type": "application/octet-stream",
"Content-Length": String(data.length),
},
body: data,
signal: AbortSignal.timeout(this.requestTimeoutMs),
}, },
origin + pathname, body: data,
); signal: AbortSignal.timeout(this.requestTimeoutMs),
});
if (!resp.ok) { if (!resp.ok) {
// An ApiError, not a bare Error: without the status on the // An ApiError, not a bare Error: without the status on the
// error the upload path cannot be classified at all, and a // error the upload path cannot be classified at all, and a
@@ -393,7 +348,7 @@ export class ApiClient {
// Same replay and redirect rules as `postJSON`, for the same reasons. // Same replay and redirect rules as `postJSON`, for the same reasons.
return withRetry( return withRetry(
async () => { async () => {
const resp = await this.send(url, { const resp = await this._fetch(url, {
method: "PUT", method: "PUT",
headers: this.headers({ headers: this.headers({
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -439,7 +394,7 @@ export class ApiClient {
// headers, when no bytes have arrived either. // headers, when no bytes have arrived either.
const deadline = idleDeadline(this.downloadTimeoutMs); const deadline = idleDeadline(this.downloadTimeoutMs);
try { try {
const resp = await this.send(url, { const resp = await this._fetch(url, {
method: "GET", method: "GET",
headers: this.headers(), headers: this.headers(),
signal: deadline.signal, signal: deadline.signal,
+14 -33
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, its // the file's ML data and its
// keywords, and its original's // original's EXIF, XMP and
// EXIF, XMP and dimensions // 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,7 +77,6 @@ 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 {
@@ -102,10 +101,7 @@ import type { MLData } from "./mldata-fetch.js";
import type { Collection, EnteFile, FileMetadata } from "./model/types.js"; import type { Collection, EnteFile, FileMetadata } from "./model/types.js";
import { hasTransportCode } from "./retry.js"; import { hasTransportCode } from "./retry.js";
// Reports one line of the backup's progress. `level` is "error" when the line export type ProgressCallback = (message: string) => void;
// reports something that failed, such as a file or an album, and is left out
// for every other line.
export type ProgressCallback = (message: string, level?: "error") => void;
// The lock in a backup directory, as `lockBackupDirectory` returns it. // The lock in a backup directory, as `lockBackupDirectory` returns it.
export interface BackupLock { export interface BackupLock {
@@ -294,10 +290,9 @@ const rebuildSymlink = (linkPath: string, target: string): void => {
// case (two names that differ only in case are one entry on a case-insensitive // case (two names that differ only in case are one entry on a case-insensitive
// file system), or it is too long for a file name; then it gets ` (<id>)`, // file system), or it is too long for a file name; then it gets ` (<id>)`,
// right before the extension when `beforeExtension` is set. To fit, the part // right before the extension when `beforeExtension` is set. To fit, the part
// before the ID is cut short as far as it must be. When the extension leaves // before the ID is cut short as far as it must be. When the extension alone
// no room for the ID and the first character of the name, the extension is // leaves no room for the ID, the extension is cut short and the part before
// cut short, and the part before the ID keeps that character and any bytes // the ID keeps only the bytes that cut leaves over. With `withJSON`, each
// the cut leaves over (see `shortenKeepingExtension`). With `withJSON`, each
// entry also has the JSON file `<name>.json` beside it, as an album does: a // entry also has the JSON file `<name>.json` beside it, as an album does: a
// name then leaves room for `.json`, and one that is another entry's JSON name // name then leaves room for `.json`, and one that is another entry's JSON name
// gets its ID added too. // gets its ID added too.
@@ -498,9 +493,8 @@ const imageMetadataFor = async (
} }
}; };
// The file's JSON: its basic fields, its magic metadata, its ML data without // The file's JSON: its basic fields, its magic metadata, its ML data or the
// the CLIP embedding and the keywords that embedding gives, or the reason the // reason the ML data is missing, and its image metadata.
// ML data is missing, and its image metadata.
const writeSidecar = ( const writeSidecar = (
path: string, path: string,
file: EnteFile, file: EnteFile,
@@ -516,13 +510,7 @@ 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) { if (ml.mlData) meta.mlData = 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) {
@@ -665,7 +653,7 @@ const runLockedBackup = async (
for (const record of lib.undecryptable()) { for (const record of lib.undecryptable()) {
const error = `could not be decrypted: ${record.error}`; const error = `could not be decrypted: ${record.error}`;
if (record.fileID === undefined) { if (record.fileID === undefined) {
log(`FAILED album ${record.collectionID}: ${error}`, "error"); log(`FAILED album ${record.collectionID}: ${error}`);
undecryptableAlbums.push(record.collectionID); undecryptableAlbums.push(record.collectionID);
} else if (scope.has(record.collectionID)) { } else if (scope.has(record.collectionID)) {
const copies = memberships.get(record.fileID); const copies = memberships.get(record.fileID);
@@ -710,7 +698,6 @@ const runLockedBackup = async (
} catch (err) { } catch (err) {
log( log(
`FAILED verifying original ${file.metadata.title}: ${describeError(err)}`, `FAILED verifying original ${file.metadata.title}: ${describeError(err)}`,
"error",
); );
recordFailure( recordFailure(
file.id, file.id,
@@ -744,7 +731,6 @@ const runLockedBackup = async (
} catch (err) { } catch (err) {
log( log(
`FAILED original ${file.metadata.title}: ${describeError(err)}`, `FAILED original ${file.metadata.title}: ${describeError(err)}`,
"error",
); );
recordFailure( recordFailure(
file.id, file.id,
@@ -820,7 +806,7 @@ const runLockedBackup = async (
await lib.fetchMLData(); await lib.fetchMLData();
} catch (err) { } catch (err) {
mlDataFailure = err; mlDataFailure = err;
log(`FAILED ML data: ${describeError(err)}`, "error"); log(`FAILED ML data: ${describeError(err)}`);
} }
for (const file of distinct.values()) { for (const file of distinct.values()) {
if (lock.lost) throw lock.lost; if (lock.lost) throw lock.lost;
@@ -881,10 +867,7 @@ const runLockedBackup = async (
downloadDirectory, downloadDirectory,
); );
} catch (err) { } catch (err) {
log( log(`FAILED removing old album directories: ${describeError(err)}`);
`FAILED removing old album directories: ${describeError(err)}`,
"error",
);
} }
// An album whose folder or JSON cannot be written, such as one whose // An album whose folder or JSON cannot be written, such as one whose
@@ -913,7 +896,6 @@ const runLockedBackup = async (
} catch (err) { } catch (err) {
log( log(
`FAILED removing old links in ${c.name}: ${describeError(err)}`, `FAILED removing old links in ${c.name}: ${describeError(err)}`,
"error",
); );
} }
@@ -932,7 +914,6 @@ const runLockedBackup = async (
} catch (err) { } catch (err) {
log( log(
`FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`, `FAILED symlink ${c.name}/${linkName}: ${describeError(err)}`,
"error",
); );
recordFailure( recordFailure(
link.file.id, link.file.id,
@@ -949,7 +930,7 @@ const runLockedBackup = async (
metaFiles, metaFiles,
); );
} catch (err) { } catch (err) {
log(`FAILED album ${c.name}: ${describeError(err)}`, "error"); log(`FAILED album ${c.name}: ${describeError(err)}`);
failedAlbums.push(c.id); failedAlbums.push(c.id);
} }
} }
+100 -131
View File
@@ -4,10 +4,7 @@
// code; a thrown error is left to the caller. Nothing here calls // code; a thrown error is left to the caller. Nothing here calls
// `process.exit`: `bin/quak.ts` wires these to the command line, and `run` in // `process.exit`: `bin/quak.ts` wires these to the command line, and `run` in
// `cli-run.ts` prints a thrown error as one line and exits once output has // `cli-run.ts` prints a thrown error as one line and exits once output has
// drained. Output must stay byte-identical (see `cli-output.ts`). Each line on // drained. Output must stay byte-identical (see `cli-output.ts`).
// stderr goes through `ctx.log` at its level (see `cli-log.ts`). Every command
// that exits non-zero logs a fatal line saying why; one that exits 1 because
// files or albums failed logs it last, saying how many and where to look.
import { import {
copyFileSync, copyFileSync,
@@ -36,7 +33,6 @@ import {
originalName, originalName,
thumbnailName, thumbnailName,
} from "./cli-output.js"; } from "./cli-output.js";
import { type Log, logRefresh } from "./cli-log.js";
import { freshCollections, freshFiles, freshFile } from "./cli-read.js"; import { freshCollections, freshFiles, freshFile } from "./cli-read.js";
import { withExtension } from "./filename.js"; import { withExtension } from "./filename.js";
import { runMetadataBackup } from "./metadata-backup.js"; import { runMetadataBackup } from "./metadata-backup.js";
@@ -44,8 +40,7 @@ import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js";
export interface CliContext { export interface CliContext {
stdout: { write(text: string): unknown }; stdout: { write(text: string): unknown };
// Writes a line on stderr at a log level, if the run's level prints it. stderr: { write(text: string): unknown };
log: Log;
// Directory holding `session.json`. // Directory holding `session.json`.
sessionDir: string; sessionDir: string;
// The `--cache-dir` global, or undefined to let the library pick its // The `--cache-dir` global, or undefined to let the library pick its
@@ -87,16 +82,16 @@ const requireSession = (ctx: CliContext): Client | undefined => {
try { try {
client = ctx.loadSession(sessionPath(ctx)); client = ctx.loadSession(sessionPath(ctx));
} catch (err) { } catch (err) {
ctx.log("fatal", err instanceof Error ? err.message : String(err)); ctx.stderr.write(
ctx.log( `${err instanceof Error ? err.message : err}\n` +
"fatal", `Run "quak logout" and then "quak login" to replace it.\n`,
`Run "quak logout" and then "quak login" to replace it.`,
); );
return undefined; return undefined;
} }
if (!client) { if (!client) {
ctx.log("fatal", `Not logged in. Run "quak login" first.`); ctx.stderr.write(
ctx.log("fatal", `Session file: ${sessionPath(ctx)}`); `Not logged in. Run "quak login" first.\nSession file: ${sessionPath(ctx)}\n`,
);
return undefined; return undefined;
} }
return client; return client;
@@ -112,7 +107,7 @@ const parseID = (
): number | undefined => { ): number | undefined => {
const id = Number(text); const id = Number(text);
if (/^[0-9]+$/.test(text) && Number.isSafeInteger(id)) return id; if (/^[0-9]+$/.test(text) && Number.isSafeInteger(id)) return id;
ctx.log("fatal", `Invalid ${kind} ID: ${text}`); ctx.stderr.write(`Invalid ${kind} ID: ${text}\n`);
return undefined; return undefined;
}; };
@@ -128,23 +123,17 @@ const readLibraryClient = (client: Client): LibraryClient => ({
contentSource: () => client.contentSource(), contentSource: () => client.contentSource(),
}); });
// Open a library for a single point command. The aggressive background // Open a library for a single point command: the aggressive background precache
// precache (issue #48) is off, because a one-shot `collections` or `get` must // (issue #48) is off — a one-shot `collections` or `get` must not start
// not start downloading the whole account. No refresh starts as it opens or on // downloading the whole account — and the refresh interval is long so no second
// a timer. Each command asks for the refresh it needs. `collections` needs none // refresh fires mid-command.
// (issue #210). `files COLLECTION` needs one only when `refreshCollection()`
// 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),
cacheDirectory: ctx.cacheDir, cacheDirectory: ctx.cacheDir,
refreshOnOpen: false, refreshIntervalSeconds: 3600,
refreshIntervalSeconds: Infinity,
precacheThumbnails: false, precacheThumbnails: false,
precacheOriginals: false, precacheOriginals: false,
onProgress: logRefresh(ctx.log),
}); });
export const loginCommand = async (ctx: CliContext): Promise<number> => { export const loginCommand = async (ctx: CliContext): Promise<number> => {
@@ -153,7 +142,7 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
const password = const password =
process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password")); process.env.QUAK_PASSWORD ?? (await ctx.promptSecret("Password"));
ctx.log("info", "Authenticating..."); ctx.stderr.write("Authenticating...\n");
try { try {
const client = await ctx.login({ const client = await ctx.login({
email, email,
@@ -164,12 +153,11 @@ export const loginCommand = async (ctx: CliContext): Promise<number> => {
saveSession(ctx.sessionDir, client.toJSON()); saveSession(ctx.sessionDir, client.toJSON());
const info = client.whoami(); const info = client.whoami();
ctx.log("info", `Logged in as ${info.email} (user ${info.userID})`); ctx.stderr.write(`Logged in as ${info.email} (user ${info.userID})\n`);
ctx.log("info", `Session saved to ${sessionPath(ctx)}`); ctx.stderr.write(`Session saved to ${sessionPath(ctx)}\n`);
} catch (err) { } catch (err) {
ctx.log( ctx.stderr.write(
"fatal", `Login failed: ${err instanceof Error ? err.message : err}\n`,
`Login failed: ${err instanceof Error ? err.message : err}`,
); );
return 1; return 1;
} }
@@ -191,7 +179,7 @@ export const whoamiCommand = async (ctx: CliContext): Promise<number> => {
export const logoutCommand = async (ctx: CliContext): Promise<number> => { export const logoutCommand = async (ctx: CliContext): Promise<number> => {
const path = sessionPath(ctx); const path = sessionPath(ctx);
if (!existsSync(path)) { if (!existsSync(path)) {
ctx.log("info", "No session found."); ctx.stderr.write("No session found.\n");
return 0; return 0;
} }
await init(); await init();
@@ -209,15 +197,16 @@ export const logoutCommand = async (ctx: CliContext): Promise<number> => {
} }
unlinkSync(path); unlinkSync(path);
if (failure === undefined) { if (failure === undefined) {
ctx.log("info", "Session ended on the server."); ctx.stderr.write("Session ended on the server.\n");
} else { } else {
ctx.log("fatal", `Could not end the session on the server: ${failure}`); ctx.stderr.write(
`Could not end the session on the server: ${failure}\n`,
);
} }
ctx.log("info", "Session deleted."); ctx.stderr.write("Session deleted.\n");
if (cacheDir !== undefined) { if (cacheDir !== undefined) {
ctx.log( ctx.stderr.write(
"warning", `Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.\n`,
`Cache directory ${cacheDir} still holds decrypted data; delete it to remove that data.`,
); );
} }
return failure === undefined ? 0 : 1; return failure === undefined ? 0 : 1;
@@ -232,10 +221,9 @@ export const collectionsCommand = async (
if (!client) return 3; if (!client) return 3;
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Ask the server for the collection list alone and list it in its // Force a server round-trip and list in enumeration order (issue #36
// order (issue #36 amendment, issues #52 and #210): the pre-library // amendment, issue #52): the pre-library CLI printed current state in
// CLI printed current state in this order, not the albums // this order, not the albums projection's newest-first order.
// projection's newest-first order.
const collections = await freshCollections(lib); const collections = await freshCollections(lib);
if (opts.json) { if (opts.json) {
@@ -266,21 +254,15 @@ export const collectionsCommand = async (
} }
}; };
// Lists the files of the collection `collectionIDStr` names, or without it every
// file in the account, each once (issue #211).
export const filesCommand = async ( export const filesCommand = async (
ctx: CliContext, ctx: CliContext,
collectionIDStr: string | undefined, opts: { collection: string; json?: boolean },
opts: { json?: boolean },
): Promise<number> => { ): Promise<number> => {
await init(); await init();
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 3; if (!client) return 3;
const collectionID = const collectionID = parseID(ctx, "collection", opts.collection);
collectionIDStr === undefined if (collectionID === undefined) return 1;
? undefined
: parseID(ctx, "collection", collectionIDStr);
if (collectionIDStr !== undefined && collectionID === undefined) return 1;
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
@@ -291,7 +273,7 @@ export const filesCommand = async (
// newest-first order. // newest-first order.
const files = await freshFiles(lib, collectionID); const files = await freshFiles(lib, collectionID);
if (!files) { if (!files) {
ctx.log("fatal", `Collection ${collectionID} not found`); ctx.stderr.write(`Collection ${collectionID} not found\n`);
return 1; return 1;
} }
@@ -327,7 +309,7 @@ export const getCommand = async (
// (issue #36 amendment, issue #52). // (issue #36 amendment, issue #52).
const resolved = await freshFile(lib, fileID); const resolved = await freshFile(lib, fileID);
if (!resolved) { if (!resolved) {
ctx.log("fatal", `File ${fileID} not found`); ctx.stderr.write(`File ${fileID} not found\n`);
return 1; return 1;
} }
const { photo, file } = resolved; const { photo, file } = resolved;
@@ -335,33 +317,30 @@ export const getCommand = async (
const result = await photo.original(); const result = await photo.original();
// Default name is the file's own title, as the pre-library CLI used // Default name is the file's own title, as the pre-library CLI used
// (not the editedName-preferring projection title) (issue #52). // (not the editedName-preferring projection title) (issue #52).
const outPath = opts.out ?? originalName(file);
if (result.videoPath === undefined) { if (result.videoPath === undefined) {
const outPath = opts.out ?? originalName(file);
copyFileSync(result.path, outPath); copyFileSync(result.path, outPath);
ctx.log("info", `${result.bytes} bytes -> ${outPath}`); ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
return 0; return 0;
} }
// A live photo is written as its image and its video, each named after // A live photo is written as its image and its video, each named after
// the title with its own extension, as Ente's clients name them. With // the title with its own extension, as Ente's clients name them. With
// --out, the image goes there and the video beside it. // --out, the image goes there and the video beside it.
const imageExt = extname(result.path); const imageOut =
const videoExt = extname(result.videoPath); opts.out ?? withExtension(outPath, extname(result.path));
const imageOut = opts.out ?? originalName(file, imageExt); const videoOut = withExtension(outPath, extname(result.videoPath));
const videoOut =
opts.out === undefined
? originalName(file, videoExt)
: withExtension(opts.out, videoExt);
if (imageOut.toLowerCase() === videoOut.toLowerCase()) { if (imageOut.toLowerCase() === videoOut.toLowerCase()) {
ctx.log( ctx.stderr.write(
"fatal", `File ${fileID} is a live photo, and its video would also be written to ${imageOut}\n`,
`File ${fileID} is a live photo, and its video would also be written to ${imageOut}`,
); );
return 1; return 1;
} }
copyFileSync(result.path, imageOut); copyFileSync(result.path, imageOut);
copyFileSync(result.videoPath, videoOut); copyFileSync(result.videoPath, videoOut);
ctx.log("info", `${result.bytes} bytes -> ${imageOut}`); ctx.stderr.write(
ctx.log("info", `${statSync(videoOut).size} bytes -> ${videoOut}`); `${result.bytes} bytes -> ${imageOut}\n` +
`${statSync(videoOut).size} bytes -> ${videoOut}\n`,
);
return 0; return 0;
} finally { } finally {
await lib.close(); await lib.close();
@@ -385,7 +364,7 @@ export const getThumbCommand = async (
// (issue #36 amendment, issue #52). // (issue #36 amendment, issue #52).
const resolved = await freshFile(lib, fileID); const resolved = await freshFile(lib, fileID);
if (!resolved) { if (!resolved) {
ctx.log("fatal", `File ${fileID} not found`); ctx.stderr.write(`File ${fileID} not found\n`);
return 1; return 1;
} }
const { photo, file } = resolved; const { photo, file } = resolved;
@@ -395,7 +374,7 @@ export const getThumbCommand = async (
// used (not the projection title) (issue #52). // used (not the projection title) (issue #52).
const outPath = opts.out ?? thumbnailName(file); const outPath = opts.out ?? thumbnailName(file);
copyFileSync(result.path, outPath); copyFileSync(result.path, outPath);
ctx.log("info", `${result.bytes} bytes -> ${outPath}`); ctx.stderr.write(`${result.bytes} bytes -> ${outPath}\n`);
return 0; return 0;
} finally { } finally {
await lib.close(); await lib.close();
@@ -417,14 +396,9 @@ export const backupMetadataCommand = async (
await lib.fresh(); await lib.fresh();
const result = await runMetadataBackup(lib, client, dir, { const result = await runMetadataBackup(lib, client, dir, {
exif: opts.exif || opts.all, exif: opts.exif || opts.all,
onProgress: (msg, level) => ctx.log(level ?? "info", msg), onProgress: (msg) => ctx.stderr.write(msg + "\n"),
}); });
if (result.filesWithMLDataError === 0) return 0; return result.filesWithMLDataError > 0 ? 1 : 0;
ctx.log(
"fatal",
`quak: the ML data of ${result.filesWithMLDataError} file(s) could not be read; see mlDataError in their JSON in ${dir}`,
);
return 1;
} finally { } finally {
await lib.close(); await lib.close();
} }
@@ -439,7 +413,7 @@ export const backupCommand = async (
const client = requireSession(ctx); const client = requireSession(ctx);
if (!client) return 3; if (!client) return 3;
ctx.log("info", "Starting backup..."); ctx.stderr.write("Starting backup...\n");
// The lock is taken before the library opens and starts its refresh, so a // The lock is taken before the library opens and starts its refresh, so a
// run refused while another backup of `dir` runs sends no request. // run refused while another backup of `dir` runs sends no request.
let lock: BackupLock; let lock: BackupLock;
@@ -447,7 +421,7 @@ export const backupCommand = async (
lock = await lockBackupDirectory(dir); lock = await lockBackupDirectory(dir);
} catch (err) { } catch (err) {
if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err; if ((err as NodeJS.ErrnoException).code !== "ELOCKED") throw err;
ctx.log("fatal", `quak: ${(err as Error).message}`); ctx.stderr.write(`quak: ${(err as Error).message}\n`);
return 2; return 2;
} }
try { try {
@@ -462,48 +436,45 @@ export const backupCommand = async (
refreshIntervalSeconds: Infinity, refreshIntervalSeconds: Infinity,
precacheThumbnails: false, precacheThumbnails: false,
precacheOriginals: false, precacheOriginals: false,
onProgress: logRefresh(ctx.log),
}); });
try { try {
const result = await lib.backup({ const result = await lib.backup({
downloadDirectory: dir, downloadDirectory: dir,
lock, lock,
verify: opts.verify, verify: opts.verify,
onProgress: (msg, level) => ctx.log(level ?? "info", msg), onProgress: (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
},
}); });
if (opts.json) { if (opts.json) {
ctx.stdout.write(JSON.stringify(result, null, 2) + "\n"); ctx.stdout.write(JSON.stringify(result, null, 2) + "\n");
} else { } else {
ctx.log("info", "\n--- Backup complete ---"); ctx.stderr.write("\n--- Backup complete ---\n");
ctx.log("info", ` Total files: ${result.totalFiles}`); ctx.stderr.write(` Total files: ${result.totalFiles}\n`);
ctx.log("info", ` Downloaded: ${result.downloaded}`); ctx.stderr.write(` Downloaded: ${result.downloaded}\n`);
ctx.log("info", ` Skipped: ${result.skipped}`); ctx.stderr.write(` Skipped: ${result.skipped}\n`);
if (opts.verify) { if (opts.verify) {
ctx.log("info", ` Verified: ${result.verified}`); ctx.stderr.write(` Verified: ${result.verified}\n`);
ctx.log("info", ` Mismatched: ${result.mismatched}`); ctx.stderr.write(` Mismatched: ${result.mismatched}\n`);
ctx.log("info", ` Unchecked: ${result.unchecked}`); ctx.stderr.write(` Unchecked: ${result.unchecked}\n`);
} }
ctx.log("info", ` Failed: ${result.failed}`); ctx.stderr.write(` Failed: ${result.failed}\n`);
if (result.errors.length > 0) { if (result.errors.length > 0) {
ctx.log("error", "\nFailed files:"); ctx.stderr.write("\nFailed files:\n");
for (const e of result.errors) { for (const e of result.errors) {
ctx.log( ctx.stderr.write(
"error", ` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}\n`,
` [${e.collection}] ${e.title} (id ${e.fileID}): ${e.error}`,
); );
} }
} }
} }
const albums = return result.failed > 0 ||
result.undecryptableAlbums.length + result.failedAlbums.length; result.undecryptableAlbums.length > 0 ||
if (result.failed === 0 && albums === 0) return 0; result.failedAlbums.length > 0
ctx.log( ? 1
"fatal", : 0;
`quak: ${result.failed} file(s) and ${albums} album(s) failed; see ${join(dir, "failures.json")} and the FAILED lines`,
);
return 1;
} finally { } finally {
await lib.close(); await lib.close();
} }
@@ -524,19 +495,18 @@ export const listMissingThumbnailsCommand = async (
// Refresh first so files added since the cache was written are // Refresh first so files added since the cache was written are
// checked; a failed refresh throws. // checked; a failed refresh throws.
await lib.fresh(); await lib.fresh();
const missing = await listMissingThumbnails(lib, client, (msg, level) => const missing = await listMissingThumbnails(lib, client, (msg) => {
ctx.log(level ?? "info", msg), if (!opts.json) ctx.stderr.write(msg + "\n");
); });
if (opts.json) { if (opts.json) {
ctx.stdout.write(JSON.stringify(missing, null, 2) + "\n"); ctx.stdout.write(JSON.stringify(missing, null, 2) + "\n");
} else { } else {
if (missing.length === 0) { if (missing.length === 0) {
ctx.log("info", "No missing thumbnails found."); ctx.stderr.write("No missing thumbnails found.\n");
} else { } else {
ctx.log( ctx.stderr.write(
"info", `\n${missing.length} file(s) with missing thumbnails:\n`,
`\n${missing.length} file(s) with missing thumbnails:`,
); );
for (const m of missing) { for (const m of missing) {
ctx.stdout.write( ctx.stdout.write(
@@ -571,28 +541,27 @@ export const fixMissingThumbnailsCommand = async (
// a failed refresh throws. // a failed refresh throws.
await lib.fresh(); await lib.fresh();
if (fileIDs.length === 0) { if (fileIDs.length === 0) {
ctx.log("info", "Scanning for missing thumbnails..."); ctx.stderr.write("Scanning for missing thumbnails...\n");
const missing = await listMissingThumbnails( const missing = await listMissingThumbnails(lib, client, (msg) => {
lib, if (!opts.json) ctx.stderr.write(msg + "\n");
client, });
(msg, level) => ctx.log(level ?? "info", msg),
);
fileIDs = missing.map((m) => m.fileID); fileIDs = missing.map((m) => m.fileID);
if (fileIDs.length === 0) { if (fileIDs.length === 0) {
ctx.log("info", "No missing thumbnails found."); ctx.stderr.write("No missing thumbnails found.\n");
return 0; return 0;
} }
ctx.log("info", `Found ${fileIDs.length} file(s) to fix.`); ctx.stderr.write(`Found ${fileIDs.length} file(s) to fix.\n`);
} }
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(
lib, lib,
client, client,
fileIDs, fileIDs,
(msg, level) => ctx.log(level ?? "info", msg), (msg) => {
if (!opts.json) ctx.stderr.write(msg + "\n");
},
); );
const failed = results.filter((r) => r.status === "failed").length;
if (opts.json) { if (opts.json) {
ctx.stdout.write(JSON.stringify(results, null, 2) + "\n"); ctx.stdout.write(JSON.stringify(results, null, 2) + "\n");
} else { } else {
@@ -600,30 +569,30 @@ export const fixMissingThumbnailsCommand = async (
const skipped = results.filter( const skipped = results.filter(
(r) => r.status === "skipped", (r) => r.status === "skipped",
).length; ).length;
ctx.log("info", `\n--- Done ---`); const failed = results.filter((r) => r.status === "failed").length;
ctx.log("info", ` Fixed: ${fixed}`); ctx.stderr.write(`\n--- Done ---\n`);
ctx.log("info", ` Skipped: ${skipped}`); ctx.stderr.write(` Fixed: ${fixed}\n`);
ctx.log("info", ` Failed: ${failed}`); ctx.stderr.write(` Skipped: ${skipped}\n`);
ctx.stderr.write(` Failed: ${failed}\n`);
if (skipped > 0) { if (skipped > 0) {
ctx.log("info", "\nSkipped:"); ctx.stderr.write("\nSkipped:\n");
for (const r of results.filter((r) => r.status === "skipped")) { for (const r of results.filter((r) => r.status === "skipped")) {
ctx.log("info", ` ${r.fileID}\t${r.title}\t${r.reason}`); ctx.stderr.write(
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
);
} }
} }
if (failed > 0) { if (failed > 0) {
ctx.log("error", "\nFailed files:"); ctx.stderr.write("\nFailed files:\n");
for (const r of results.filter((r) => r.status === "failed")) { for (const r of results.filter((r) => r.status === "failed")) {
ctx.log("error", ` ${r.fileID}\t${r.title}\t${r.reason}`); ctx.stderr.write(
` ${r.fileID}\t${r.title}\t${r.reason}\n`,
);
} }
} }
} }
if (failed === 0) return 0; return results.some((r) => r.status === "failed") ? 1 : 0;
ctx.log(
"fatal",
`quak: ${failed} file(s) failed; see the "Failed files:" list, or the JSON with --json`,
);
return 1;
} finally { } finally {
await lib.close(); await lib.close();
} }
-99
View File
@@ -1,99 +0,0 @@
// The CLI's log level, and the one function the commands write stderr lines
// through.
//
// Every line quak prints on stderr is a log line at one of six levels, from
// the quietest: fatal, error, warning, info, debug, trace. A run prints the
// lines at its own level and at every quieter one. Its level is info unless
// `-v` or `-q` asks for another (see "CLI surface" in the README). Every
// command that exits non-zero logs a fatal line saying why, so even `-q`, which
// prints only fatal lines, never leaves a non-zero exit unexplained. An error
// line reports something that failed, such as a file or an album, and a
// warning something that did not fail but needs the user's attention. stdout
// carries only a command's output and is not a log.
//
// The library itself writes no lines: its layers report through callbacks, and
// the CLI logs what they report. `logRequests` gives the API client options
// that log each request and each retry, and `logRefresh` the library's
// `onProgress` that logs what a refresh and an ML data fetch report.
import type { ApiClientOptions } from "./api/client.js";
import { describeError } from "./errors.js";
import type { RefreshEvent } from "./library/index.js";
import type { RetryOptions } from "./retry.js";
const LEVELS = ["fatal", "error", "warning", "info", "debug", "trace"] as const;
export type LogLevel = (typeof LEVELS)[number];
// Writes `line` on stderr when the run's level prints lines at `level`.
export type Log = (level: LogLevel, line: string) => void;
// The level asked for by `-v` given `verbose` times, or by `-q`: info without
// either, debug for `-v`, trace for `-vv` and more, fatal for `-q`.
// `bin/quak.ts` refuses `-q` and `-v` together.
export const logLevel = (verbose: number, quiet: boolean): LogLevel => {
if (quiet) return "fatal";
if (verbose === 0) return "info";
return verbose === 1 ? "debug" : "trace";
};
// The log that writes each line at `level` or quieter to `stderr`, ending it
// with a newline.
export const stderrLog =
(stderr: { write(text: string): unknown }, level: LogLevel): Log =>
(lineLevel, line) => {
if (LEVELS.indexOf(lineLevel) <= LEVELS.indexOf(level)) {
stderr.write(line + "\n");
}
};
// API client options that log each request at trace, with the status of its
// response or the error it failed with, and each retry at debug, with why and
// how long it waits. `retry` is the retry policy, the default when not given.
export const logRequests = (
log: Log,
retry?: RetryOptions,
): ApiClientOptions => ({
retry: {
...retry,
onRetry: (err, attempt, delayMs) =>
log(
"debug",
`Attempt ${attempt} failed, retrying in ${Math.round(delayMs)} ms: ${describeError(err)}`,
),
},
onRequest: ({ method, url, status, error }) =>
log(
"trace",
`${method} ${url}: ${error === undefined ? `HTTP ${status}` : describeError(error)}`,
),
});
// How a log line names each operation the library reports the start and end
// of.
const OPERATION_NAMES: Record<RefreshEvent["operation"], string> = {
refresh: "Refresh",
fetchMLData: "ML data fetch",
precacheThumbnails: "Thumbnail precache",
precacheOriginals: "Original precache",
};
// A library `onProgress` that logs, at info, each collection whose files a
// refresh has fetched, and at debug the start and end of each refresh and ML
// data fetch, with the error of one that failed.
export const logRefresh =
(log: Log) =>
(event: RefreshEvent): void => {
if (event.status === "progress") {
log(
"info",
`Fetched the files of ${event.fetched} of ${event.total} collections`,
);
return;
}
const error = event.error === undefined ? "" : `: ${event.error}`;
log(
"debug",
`${OPERATION_NAMES[event.operation]} ${event.status}${error}`,
);
};
+19 -8
View File
@@ -9,7 +9,11 @@
// `metadata.title`, and issue #52 requires that output stay byte-identical, so // `metadata.title`, and issue #52 requires that output stay byte-identical, so
// the commands shape their output from the raw `EnteFile` through here. // the commands shape their output from the raw `EnteFile` through here.
import { defaultFileName } from "./filename.js"; import {
MAX_NAME_BYTES,
sanitizeFileName,
shortenKeepingExtension,
} from "./filename.js";
import type { EnteFile, FileType, Microseconds } from "./model/types.js"; import type { EnteFile, FileType, Microseconds } from "./model/types.js";
// One row of `quak files --json`. // One row of `quak files --json`.
@@ -33,13 +37,20 @@ export const fileListRow = (file: EnteFile): FileListRow => ({
export const fileListLine = (file: EnteFile): string => export const fileListLine = (file: EnteFile): string =>
`${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`; `${file.id}\t${file.metadata.fileType}\t${file.metadata.title}`;
// Default output path for `quak get` when `--out` is not given, made from the // Default output path for `quak get` when `--out` is not given. The title comes
// title (see `defaultFileName`), with `ext` in place of its extension for a // from the server, so it is sanitized, and cut short to fit in a file name,
// live photo's image or video; `--out` is the user's and is used as is. // keeping its extension; `--out` is the user's and is used as is.
export const originalName = (file: EnteFile, ext?: string): string => export const originalName = (file: EnteFile): string =>
defaultFileName(file.metadata.title, file.id, { ext }); shortenKeepingExtension(
sanitizeFileName(file.metadata.title, `file-${file.id}`),
MAX_NAME_BYTES,
);
// Default output path for `quak get-thumb` when `--out` is not given: `thumb_` // Default output path for `quak get-thumb` when `--out` is not given: `thumb_`
// and the title. // and the title, cut short so that the two fit in a file name.
export const thumbnailName = (file: EnteFile): string => export const thumbnailName = (file: EnteFile): string =>
defaultFileName(file.metadata.title, file.id, { prefix: "thumb_" }); "thumb_" +
shortenKeepingExtension(
sanitizeFileName(file.metadata.title, `file-${file.id}`),
MAX_NAME_BYTES - "thumb_".length,
);
+24 -63
View File
@@ -1,89 +1,50 @@
// How the CLI's read commands obtain current data. // How the CLI's read commands obtain current data.
// //
// `collections`, `files`, `get`, and `get-thumb` must answer for // `collections`, `files --collection`, `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). Each helper
// without a collection ID, `get` and `get-thumb` therefore force a server // therefore forces a server round-trip through `Library.fresh()` and only then
// round-trip through `Library.fresh()`, and only then read. A collection, file, // reads — so a collection, file, or metadata change made elsewhere is visible.
// or metadata change made elsewhere is then visible. `collections` asks the
// library for the collection list alone through `Library.freshCollections()`
// (issue #210). A refresh would also fetch the files of every collection, which
// `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. `files` lists in the library's // `collections` and `files` also list in the library's own enumeration order —
// own enumeration order: `listCollections()`, then `listFiles()` for each. // `listCollections()`/`listFiles()`, the order the pre-library CLI printed —
// Those are the orders the pre-library CLI printed. The `albums`/`photos` // rather than the `albums`/`photos` projection's newest-first order, which
// projection would re-sort the rows newest first. After a refresh, the // re-sorts the rows. The field values still come from each record's raw
// library holds collections in the order the server first sent them. The // metadata via `cli-output.ts`.
// field values still come from each record's raw metadata via
// `cli-output.ts`. `files` without a collection ID lists every file in the
// 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()`, `freshCollections()` and `refreshCollection()` calls. The // `fresh()` call and serves records in a known 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[]>;
refreshCollection(collection: Collection): Promise<boolean>;
getCollection(id: number): Collection | undefined;
listCollections(): Collection[]; listCollections(): Collection[];
getCollection(id: number): Collection | undefined;
listFiles(collectionID: number): EnteFile[]; listFiles(collectionID: number): EnteFile[];
getFileByID(fileID: number): EnteFile | undefined; getFileByID(fileID: number): EnteFile | undefined;
photos: Pick<PhotosAPI, "byID">; photos: Pick<PhotosAPI, "byID">;
} }
// Every live collection, current as of one collections request, in the // Every live collection, current as of a forced refresh, in enumeration order.
// server's order. export const freshCollections = async (
export const freshCollections = (
lib: FreshReadLibrary, lib: FreshReadLibrary,
): Promise<Collection[]> => lib.freshCollections(); ): Promise<Collection[]> => {
await lib.fresh();
return lib.listCollections();
};
// The files of one collection, in enumeration order. `undefined` (not an empty // The files of one collection, current as of a forced refresh, in enumeration
// list) when the collection does not exist, so the caller can tell "no such // order. `undefined` (not an empty list) when the collection does not exist, so
// collection" from "an empty collection". They are current as of one // the caller can tell "no such collection" from "an empty collection".
// collections request and the requests that bring that collection alone up to
// 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> => {
if (collectionID !== undefined) {
const collections = await lib.freshCollections();
const collection = collections.find((c) => c.id === collectionID);
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(); await lib.fresh();
const listed = new Set<number>(); if (!lib.getCollection(collectionID)) return undefined;
const files: EnteFile[] = []; return lib.listFiles(collectionID);
for (const collection of lib.listCollections()) {
for (const file of lib.listFiles(collection.id)) {
if (listed.has(file.id)) continue;
listed.add(file.id);
files.push(file);
}
}
return files;
}; };
// One file, current as of a forced refresh, resolved to both its content // One file, current as of a forced refresh, resolved to both its content
+9 -12
View File
@@ -2,23 +2,21 @@
import type { Writable } from "node:stream"; import type { Writable } from "node:stream";
import { ApiError } from "./api/client.js"; import { ApiError } from "./api/client.js";
import type { Log } from "./cli-log.js";
import { describeError } from "./errors.js"; import { describeError } from "./errors.js";
// Run a command and exit with its code once stdout/stderr have drained. // Run a command and exit with its code once stdout/stderr have drained.
// Exiting before the drain can truncate piped output, and the library can keep // Exiting before the drain can truncate piped output, and the library can keep
// the event loop alive after a command returns, so a plain return could hang. // the event loop alive after a command returns, so a plain return could hang.
// An error the command throws is logged as one fatal `quak: MESSAGE` line, // An error the command throws is printed as one `quak: MESSAGE` line, without
// without the stack trace, and exits 1. MESSAGE is the error as // the stack trace, and exits 1. MESSAGE is the error as `describeError` gives
// `describeError` gives it, so it carries the reason Node's `fetch` puts on // it, so it carries the reason Node's `fetch` puts on `cause`. A 401 from the
// `cause`. A 401 from the server means it no longer accepts the saved session: // server means it no longer accepts the saved session: that prints one line
// that logs one fatal line saying to log in again and exits 3, as a missing or // saying to log in again and exits 3, as a missing or corrupt session file
// corrupt session file does. // does.
export const run = async ( export const run = async (
command: Promise<number>, command: Promise<number>,
stdout: Writable, stdout: Writable,
stderr: Writable, stderr: Writable,
log: Log,
exit: (code: number) => void, exit: (code: number) => void,
): Promise<void> => { ): Promise<void> => {
let code: number; let code: number;
@@ -26,13 +24,12 @@ export const run = async (
code = await command; code = await command;
} catch (err) { } catch (err) {
if (err instanceof ApiError && err.status === 401) { if (err instanceof ApiError && err.status === 401) {
log( stderr.write(
"fatal", `quak: the saved session is no longer valid; run "quak login"\n`,
`quak: the saved session is no longer valid; run "quak login"`,
); );
code = 3; code = 3;
} else { } else {
log("fatal", `quak: ${describeError(err)}`); stderr.write(`quak: ${describeError(err)}\n`);
code = 1; code = 1;
} }
} }
File diff suppressed because it is too large Load Diff
-57
View File
@@ -1,57 +0,0 @@
// 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);
};
+26 -27
View File
@@ -16,7 +16,13 @@ import {
streamTagFinal, streamTagFinal,
} from "../crypto/index.js"; } from "../crypto/index.js";
import { TruncatedStreamError } from "../errors.js"; import { TruncatedStreamError } from "../errors.js";
import { defaultFileName, safeExtension, withExtension } from "../filename.js"; import {
MAX_NAME_BYTES,
safeExtension,
sanitizeFileName,
shortenKeepingExtension,
withExtension,
} from "../filename.js";
import { withRetry } from "../retry.js"; import { withRetry } from "../retry.js";
import type { ApiClient } from "../api/client.js"; import type { ApiClient } from "../api/client.js";
import type { EnteFile } from "../model/types.js"; import type { EnteFile } from "../model/types.js";
@@ -365,14 +371,10 @@ const openPart = async (
// name starts with `image` as the image and the one whose name starts with // name starts with `image` as the image and the one whose name starts with
// `video` as the video, and refuses a ZIP holding a second of either. It is // `video` as the video, and refuses a ZIP holding a second of either. It is
// written unpacked: each part is named `destination` with the extension // written unpacked: each part is named `destination` with the extension
// replaced by its own entry's, and the two must differ ignoring case. When // replaced by its own entry's, and the two must differ ignoring case. When the
// `defaultName` is set, `destination` is the name `defaultFileName` made from // file records a hash, `<imageHash>:<videoHash>` must match it, each over that
// the title rather than a path the caller gave, and each part is named as // part's own bytes. Only then are the image, then the video, renamed into
// `defaultFileName` names it with its own extension, so that the name is cut // place; on any failure neither is stored.
// short to fit with that extension. When the file records a hash,
// `<imageHash>:<videoHash>` must match it, each over that part's own bytes.
// Only then are the image, then the video, renamed into place; on any failure
// neither is stored.
// //
// The ZIP is chosen by its uploader and may expand enormously, so each part is // The ZIP is chosen by its uploader and may expand enormously, so each part is
// written as it decompresses and never held, and the ZIP is refused once the // written as it decompresses and never held, and the ZIP is refused once the
@@ -392,7 +394,6 @@ const decryptLivePhoto = async (
key: Uint8Array, key: Uint8Array,
onProgress: ProgressCallback | undefined, onProgress: ProgressCallback | undefined,
file: EnteFile, file: EnteFile,
defaultName: boolean,
): Promise<DownloadResult> => { ): Promise<DownloadResult> => {
const sliceSize = 4096; const sliceSize = 4096;
const dir = dirname(destination); const dir = dirname(destination);
@@ -491,12 +492,8 @@ const decryptLivePhoto = async (
`live photo's image and video have the same extension, ${video.ext}`, `live photo's image and video have the same extension, ${video.ext}`,
); );
} }
const partPath = (ext: string): string => const path = withExtension(destination, image.ext);
defaultName const videoPath = withExtension(destination, video.ext);
? defaultFileName(file.metadata.title, file.id, { ext })
: withExtension(destination, ext);
const path = partPath(image.ext);
const videoPath = partPath(video.ext);
for (const part of parts) { for (const part of parts) {
await part.handle.sync(); await part.handle.sync();
await part.handle.close(); await part.handle.close();
@@ -540,9 +537,6 @@ const decryptLivePhoto = async (
// and then removes its own temp file, and only the attempt that reaches // and then removes its own temp file, and only the attempt that reaches
// TAG_FINAL renames one into place, so a download that needed three tries still // TAG_FINAL renames one into place, so a download that needed three tries still
// performs exactly one rename over the destination. // performs exactly one rename over the destination.
//
// `defaultName` says `destination` is the name `defaultFileName` made, not a
// path the caller gave (see `decryptLivePhoto`).
const fetchAndDecrypt = async ( const fetchAndDecrypt = async (
api: ApiClient, api: ApiClient,
openStream: () => Promise<ReadableStream<Uint8Array>>, openStream: () => Promise<ReadableStream<Uint8Array>>,
@@ -551,7 +545,6 @@ const fetchAndDecrypt = async (
destination: string, destination: string,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
original?: EnteFile, original?: EnteFile,
defaultName = false,
): Promise<DownloadResult> => ): Promise<DownloadResult> =>
withRetry(async () => { withRetry(async () => {
const stream = await openStream(); const stream = await openStream();
@@ -564,7 +557,6 @@ const fetchAndDecrypt = async (
key, key,
onProgress, onProgress,
original, original,
defaultName,
); );
} }
const bytesWritten = await decryptToTemp( const bytesWritten = await decryptToTemp(
@@ -596,11 +588,15 @@ export const downloadFile = async (
outPath?: string, outPath?: string,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
): Promise<DownloadResult> => { ): Promise<DownloadResult> => {
// `outPath` is the caller's and is used as is. Without it, the file is // `outPath` is the caller's and is used as is; the title is the server's
// written in the current directory under the name `defaultFileName` makes // and is sanitized so it can only name a file in the current directory,
// from the title, which is the server's. // and cut short to fit in a file name, keeping its extension.
const resolvedPath = const resolvedPath =
outPath ?? defaultFileName(file.metadata.title, file.id); outPath ??
shortenKeepingExtension(
sanitizeFileName(file.metadata.title, `file-${file.id}`),
MAX_NAME_BYTES,
);
const header = fromBase64(file.file.decryptionHeader); const header = fromBase64(file.file.decryptionHeader);
return fetchAndDecrypt( return fetchAndDecrypt(
api, api,
@@ -610,7 +606,6 @@ export const downloadFile = async (
resolvedPath, resolvedPath,
onProgress, onProgress,
file, file,
outPath === undefined,
); );
}; };
@@ -623,7 +618,11 @@ export const downloadThumbnail = async (
// As in `downloadFile`, with `thumb_` before the title. // As in `downloadFile`, with `thumb_` before the title.
const resolvedPath = const resolvedPath =
outPath ?? outPath ??
defaultFileName(file.metadata.title, file.id, { prefix: "thumb_" }); "thumb_" +
shortenKeepingExtension(
sanitizeFileName(file.metadata.title, `file-${file.id}`),
MAX_NAME_BYTES - "thumb_".length,
);
const header = fromBase64(file.thumbnail.decryptionHeader); const header = fromBase64(file.thumbnail.decryptionHeader);
return fetchAndDecrypt( return fetchAndDecrypt(
api, api,
+5 -38
View File
@@ -6,8 +6,6 @@
// directory the caller chose: never a path, never `..`, never hidden, never a // directory the caller chose: never a path, never `..`, never hidden, never a
// Windows device name. Such a name can still be too long for a file name, and // Windows device name. Such a name can still be too long for a file name, and
// `shortenToBytes` or `shortenKeepingExtension` cuts one short. // `shortenToBytes` or `shortenKeepingExtension` cuts one short.
// `defaultFileName` does both for the name a file is saved under when the
// caller gives no path.
// //
// A path the user typed (`--out`, `outPath`) is not passed through here: the // A path the user typed (`--out`, `outPath`) is not passed through here: the
// caller is trusted, the server is not. // caller is trusted, the server is not.
@@ -63,12 +61,10 @@ export const shortenToBytes = (name: string, maxBytes: number): string => {
// `name` cut short to fit in `maxBytes` bytes with `insert` put right before // `name` cut short to fit in `maxBytes` bytes with `insert` put right before
// its extension (`IMG_0001 (12).jpg`), each cut made as `shortenToBytes` // its extension (`IMG_0001 (12).jpg`), each cut made as `shortenToBytes`
// makes it. The part before the extension is cut first, so the extension is // makes it. The part before the extension is cut first, so the extension is
// kept whenever it fits, but that part always keeps its first character: // kept whenever it fits. When the extension alone leaves no room for
// when the extension leaves no room for `insert` and that character, the // `insert`, the extension is cut short too, and the part before it keeps only
// extension is cut short too, and the part before it keeps the bytes that cut // the bytes that cut leaves over. `insert` is never cut. With no `insert`, a
// leaves over. So a name that does not start with a dot never comes back // name that fits comes back unchanged.
// starting with one, which would hide it. `insert` is never cut. With no
// `insert`, a name that fits comes back unchanged.
export const shortenKeepingExtension = ( export const shortenKeepingExtension = (
name: string, name: string,
maxBytes: number, maxBytes: number,
@@ -76,36 +72,7 @@ export const shortenKeepingExtension = (
): string => { ): string => {
const fullExt = extname(name); const fullExt = extname(name);
const stem = name.slice(0, name.length - fullExt.length); const stem = name.slice(0, name.length - fullExt.length);
const first = Array.from(stem)[0] ?? ""; const ext = shortenToBytes(fullExt, maxBytes - Buffer.byteLength(insert));
const ext = shortenToBytes(
fullExt,
maxBytes - Buffer.byteLength(insert + first),
);
const room = maxBytes - Buffer.byteLength(insert + ext); const room = maxBytes - Buffer.byteLength(insert + ext);
return shortenToBytes(stem, room) + insert + ext; return shortenToBytes(stem, room) + insert + ext;
}; };
// The name a file is saved under in the working directory when the caller
// gives no path (`quak get` without `--out`, `downloadFile` without
// `outPath`): `prefix` (`thumb_` for a thumbnail), then the file's title with
// unsafe characters replaced, or `file-<id>` when the title is empty, cut
// short so that the whole fits in a file name, keeping the title's extension
// whenever it fits. `ext` (".mov"), when given, takes the place of the
// title's extension before the name is cut short; a live photo's image and
// video are each named with their own. The cut can leave a device name
// (`CONSOLE.<long extension>` becomes `CON.<long extension>`); that name gets
// a leading `_`, as `sanitizeFileName` gives one, and is cut a byte shorter to
// leave room for it.
export const defaultFileName = (
title: string,
fileID: number,
opts: { prefix?: string; ext?: string } = {},
): string => {
const { prefix = "", ext } = opts;
const safe = sanitizeFileName(title, `file-${fileID}`);
const name = ext === undefined ? safe : withExtension(safe, ext);
const room = MAX_NAME_BYTES - Buffer.byteLength(prefix);
const cut = prefix + shortenKeepingExtension(name, room);
if (!RESERVED_DEVICE_NAME.test(cut)) return cut;
return `_${prefix}${shortenKeepingExtension(name, room - 1)}`;
};
+153 -326
View File
@@ -1,51 +1,30 @@
// 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 stored cursor is 0, `open()` awaits the first // the refresh loop. When the cache loaded empty it awaits the first refresh,
// refresh. No refresh has then stored any collection: the cache is empty, or // so the library never opens onto an empty store it could have filled; when an
// holds only what `refreshCollection()` stored. The library must not serve // existing copy loaded, that first refresh runs in the background and `open()`
// that as the whole account. Otherwise that first refresh runs in the // returns as soon as the cached data is ready to serve — a slow or unreachable
// background. `open()` returns as soon as the cached data is ready to serve, // server no longer stalls opening. A background timer then refreshes every
// so a slow or unreachable server does not stall opening. A background timer // `refreshIntervalSeconds`. Every default read is answered from RAM — no
// then takes a turn `refreshIntervalSeconds` after its last turn ends. Each // default read touches the network. There is deliberately no `sync()`, no
// turn starts a refresh, unless a refresh or `refreshCollection()` is already // `refresh()`, no `serverReachable` flag, and no "before each read" mode
// running. Every default read is answered from RAM — no default read touches // (design #36).
// the network. There is deliberately no `sync()`, no `refresh()`, no
// `serverReachable` flag, and no "before each read" mode (design #36).
// //
// `fresh()` is an exception (issue #75, an owner amendment to #36). It awaits // `fresh()` is the one exception (issue #75, an owner amendment to #36): it
// a refresh, and only then hands back the read namespaces. A caller that needs // forces a refresh, awaits it, and only then hands back the read namespaces, so
// server-current data uses it. Concurrent `fresh()` calls coalesce onto one // a caller that needs server-current data can ask for it. Concurrent `fresh()`
// in-flight refresh. A refresh that fails rejects the caller. The default // calls coalesce onto one in-flight refresh, and a refresh that fails rejects
// reads stay silent and serve the last good copy. The default methods and the // the caller (the default reads stay silent and serve the last good copy). The
// background loop are unchanged. // default methods and the background loop are unchanged.
//
// `freshCollections()` is the fresh read of the collection list alone (issue
// #210). It sends one collections request and stores nothing.
// `quak collections` uses it. `refreshCollection()` brings one collection
// from that list up to date in the store (issue #217). It fetches that
// 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
// fetched, so a large account, whose first refresh fetches the files of every
// collection, shows progress.
// //
// 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 save can // failure surfaces through `onProgress` and `status()` instead. A commit that
// still fail after the store has changed in RAM. The store then stays marked // mutates RAM but then fails to persist keeps `status().lastError` set and the
// unsaved until a later save lands. Until then, every `commit()` retries the // store marked unsaved until a later save actually lands, so a stuck disk is
// save. A refresh whose save fails sets `status().lastError`, which stays set // never masked by a subsequent empty refresh.
// 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";
@@ -170,35 +149,30 @@ 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 // The byte source for the on-disk content cache. Optional so a mock client
// client that only serves metadata still satisfies the interface. Without // that only serves metadata still satisfies the interface; when absent (and
// it, and with no `contentSource` passed to `open`, the content cache is // no explicit `contentSource` is passed to `open`) the content cache is
// disabled. `Photo.original`/`thumbnail` and `thumbnails.ensure` then // disabled and `Photo.original`/`thumbnail` and `thumbnails.ensure` throw.
// throw.
contentSource?(): ContentSource; contentSource?(): ContentSource;
} }
// A progress event for one unit of background work. A metadata "refresh" and // A progress event for one unit of background work. A metadata "refresh" or an
// an ML "fetchMLData" pass each fire "started" before their network work. Each // ML "fetchMLData" pass each fire "started" before their network work and then
// then fires exactly one of "done" or "failed". A "failed" event carries the // exactly one of "done" or "failed"; "failed" carries the error message and
// error message. An ML "done" reports in `fetched` how many payloads it // an ML "done" reports how many payloads it stored and, in `mlDataErrors`,
// stored. Its `mlDataErrors` lists each file whose ML data did not decrypt, // each file whose ML data did not decrypt, gunzip or parse, with the reason.
// gunzip or parse, with the reason. In between, a refresh fires "progress" // The precache fills ("precacheThumbnails"/"precacheOriginals", #48) fire
// after each collection whose files it fetched. Its `fetched` is how many // "started"/"done" around each sweep that has work, "done" reporting the count
// collections it has fetched the files of so far, and `total` how many it // newly cached.
// will. The precache fills
// ("precacheThumbnails"/"precacheOriginals", #48) fire "started"/"done" around
// each sweep that has work, "done" reporting the count newly cached.
export interface RefreshEvent { export interface RefreshEvent {
operation: operation:
| "refresh" | "refresh"
| "fetchMLData" | "fetchMLData"
| "precacheThumbnails" | "precacheThumbnails"
| "precacheOriginals"; | "precacheOriginals";
status: "started" | "progress" | "done" | "failed"; status: "started" | "done" | "failed";
error?: string; error?: string;
fetched?: number; fetched?: number;
total?: number;
mlDataErrors?: { fileID: number; error: string }[]; mlDataErrors?: { fileID: number; error: string }[];
} }
@@ -214,15 +188,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 its last turn ends the background timer takes the next. // How long after one refresh ends the background timer starts the next.
// A turn starts a refresh unless a refresh or `refreshCollection()` is // `Infinity` starts none: the library then refreshes only as it opens and
// running. With `Infinity` the timer takes no turn: the library then // when `fresh()` or `backup()` asks.
// 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()`
// returns at once onto what the cache holds, empty or not, and a refresh
// starts only when `fresh()` or `backup()` asks or the timer fires.
refreshOnOpen?: boolean;
onProgress?: RefreshProgressCallback; onProgress?: RefreshProgressCallback;
// The bounded request pools (issue #45), shared by the ML-data fetch (the // The bounded request pools (issue #45), shared by the ML-data fetch (the
// metadata pool) and the content cache. Defaults to a fresh set at the // metadata pool) and the content cache. Defaults to a fresh set at the
@@ -256,9 +225,8 @@ 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 error of the most recent refresh, set only when it failed. The next // The message from the most recent refresh, set only while that refresh
// refresh that succeeds clears it. `refreshCollection()` never sets or // failed; cleared by the next success.
// 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).
@@ -321,17 +289,12 @@ 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 `refreshCollection()`, or undefined when // The in-flight refresh cycle, or undefined when none runs. One slot serves
// neither runs. One slot holds both, so they never run at the same time. // both paths: the background loop skips when it is set, and a fresh read
// The background loop skips while it is set. A fresh read (issue #75) // (issue #75) coalesces onto it or starts one. The promise carries the
// coalesces onto a running refresh. It waits for a running // cycle's real outcome (it rejects on failure); the background loop ignores
// `refreshCollection()` to end, and then joins or starts a refresh. The // that, a fresh read propagates it.
// 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
@@ -344,20 +307,19 @@ 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 only when it ends without failing. // gathers its own and puts them here when it ends without failing, so
// So `status()` and `backup()` never see the list of a pass still running // `status()` and `backup()` never see the list of a pass that is still
// or one that failed. Kept in RAM only, never in the cache. // running or 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 store change, and the GUI // The plain-record projection as of the last refresh, and the GUI change
// change subscribers. A refresh or `refreshCollection()` that alters the // subscribers. A refresh that alters the projection notifies each with the
// projection notifies each subscriber with the delta. `lastRecords` // delta; `lastRecords` is kept current every refresh so a subscriber that
// advances on each `commit()` that changes the store. A subscriber that // joins later diffs against the state its own `snapshot()` already returned.
// 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 succeeds. Until then, every `commit()` retries // Cleared only when a save actually succeeds; keeps the store trying to
// the save. // persist and the failure visible in `status()` until then.
private unsaved = false; private unsaved = false;
private constructor(args: { private constructor(args: {
@@ -387,7 +349,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 what it holds now. // always reflect the latest refresh.
const derive = (): DerivedRecords => this.deriveNow(); const derive = (): DerivedRecords => this.deriveNow();
const root = this.downloadDirectory; const root = this.downloadDirectory;
const saves: SavePathLookup = { const saves: SavePathLookup = {
@@ -414,16 +376,13 @@ export class Library {
this.mldata = makeMLDataAPI(() => this.mlStore); this.mldata = makeMLDataAPI(() => this.mlStore);
} }
// Load the cache and start the refresh loop. When the stored cursor is 0, // Load the cache and start the refresh loop. With an empty cache the first
// the first refresh is awaited. Such a cache is empty, or holds only what // refresh is awaited, so `open()` resolves onto populated data whenever the
// `refreshCollection()` stored. `open()` then resolves onto the whole // server is reachable; that awaited refresh may still fail, and the library
// account whenever the server is reachable. If that refresh fails, the // then opens empty with the failure recorded in `status()`. With an
// library opens onto what the cache holds, with the failure recorded in // existing cache the first refresh runs in the background and `open()`
// `status()`. With any other cache the first refresh runs in the // returns as soon as the cached data is ready — an unreachable server does
// background, and `open()` returns as soon as the cached data is ready. // not block opening.
// 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 =
@@ -439,8 +398,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 nothing was // ML data beside it and start empty. A user ID of 0 means the cache
// ever stored there, so there is nothing to discard. // was never refreshed and so holds 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"), {
@@ -463,11 +422,10 @@ 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,
// Take its record of what is already cached, and reap orphan temp // and take its record of what is already cached (and reap orphan temp
// files, before the first projection. Cached paths are then present // files) before the first projection, so cached paths are present from
// from the start, and the first `commit()` raises no spurious // the start and the first refresh raises no spurious path-change diff.
// 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;
@@ -521,26 +479,19 @@ 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
// later `commit()` re-kicks the fills with its new files, and retries // refresh below re-kicks with the new files (and retries any that
// any that failed. An empty store starts empty here, and fills once a // failed). An empty store starts empty here and fills after its first
// `commit()` stores files. // refresh.
precache?.start(); precache?.start();
if (opts.refreshOnOpen === false) { if (store.loadedFromDisk) {
// The caller asks for each refresh it needs. // An existing copy already answers reads; refresh in the background
lib.scheduleNext(); // and start the interval once that first cycle settles.
} else if (store.collectionsSinceTime > 0) {
// The stored cursor has moved, so a refresh has stored the
// 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 {
// The stored cursor is still 0, so no refresh has stored any // Nothing was cached: wait for the first refresh to fill the store
// collection: the cache is empty, or holds only what // (or fail) rather than resolve onto an empty library.
// `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();
} }
@@ -578,9 +529,9 @@ export class Library {
return snapshotFrom(this.deriveNow(), Date.now()); return snapshotFrom(this.deriveNow(), Date.now());
} }
// Deliver a `LibraryChange` whenever a refresh or `refreshCollection()` // Deliver a `LibraryChange` whenever a refresh alters the projection. A
// alters the projection. One that changes nothing delivers nothing. The // refresh that changes nothing delivers nothing. The returned handle's
// returned handle's `unsubscribe` stops delivery. // `unsubscribe` stops delivery.
subscribe(args: { onChange: (change: LibraryChange) => void }): { subscribe(args: { onChange: (change: LibraryChange) => void }): {
unsubscribe: () => void; unsubscribe: () => void;
} { } {
@@ -623,13 +574,12 @@ export class Library {
}; };
} }
// Fresh reads (issue #75, owner amendment to design #36). Wait for a // Fresh reads (issue #75, owner amendment to design #36). Force a refresh,
// refresh to complete and persist, then hand back the same // wait for it to complete and persist, then hand back the same
// `albums`/`photos`/`timeline` namespaces. They then reflect a completed // `albums`/`photos`/`timeline` namespaces — now guaranteed to reflect a
// server round-trip. A running `refreshCollection()` is waited for first. // completed server round-trip. Concurrent calls coalesce onto one refresh;
// Concurrent calls coalesce onto one refresh. A refresh that fails rejects // a refresh that fails rejects here, where the default namespaces would
// here, where the default namespaces would stay silent and serve the last // instead stay silent and serve the last good copy.
// good copy.
async fresh(): Promise<FreshReads> { async fresh(): Promise<FreshReads> {
await this.refreshNow(); await this.refreshNow();
return { return {
@@ -639,59 +589,19 @@ export class Library {
}; };
} }
// The fresh read of the collection list alone (issue #210): every live
// collection that decrypts, in the server's order, from one collections
// request. Unlike a refresh, it fetches no collection's files, which on a
// large account take long. It stores nothing. A refresh from a stored
// cursor past 0 fetches a collection's files only when the collection is
// new or has changed since the stored copy. So does `refreshCollection()`.
// Neither would fetch the files of a collection stored here without them.
async freshCollections(): Promise<Collection[]> {
if (this.closed) throw new Error("the library is closed");
const { collections } = await this.client.collectionsSince({
sinceTime: 0,
});
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). It first takes the lock in that directory, unless // ledger (issue #51). Takes the lock in that directory first, unless the
// the caller passes the lock it took as `opts.lock`. Taking it fails at // caller passes the lock it took as `opts.lock`, failing at once while
// once while another backup of that directory runs. Once the lock is lost, // another backup of it runs, and before it starts another file once the
// the backup fails before it starts another file (see // lock is lost (see `lockBackupDirectory`). Waits for a
// `lockBackupDirectory`). It waits for a completed refresh as `fresh()` // completed refresh, as `fresh()` does, joining one already running, and
// does, joining one already running. When that refresh fails, it rejects // rejects before touching any file but the lock when it fails. Then puts
// before touching any file but the lock. It then puts pending originals // pending originals at their save paths as `Photo.download()` does (and
// at their save paths as `Photo.download()` does, through the content // optional thumbnails) through the content cache and pools, waits for an
// cache and pools. With `includeThumbnails` it also fetches thumbnails. It // ML data fetch, and rebuilds the derived symlink/JSON views from the
// waits for an ML data fetch. It rebuilds the derived symlink/JSON views // model, each file's JSON with its ML data, beside an `account.json` with
// from the model, each file's JSON with its ML data. An `account.json` // the account's email and user ID.
// 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> {
@@ -728,15 +638,12 @@ export class Library {
); );
} }
// Stop the background timer. Idempotent. A refresh or `refreshCollection()` // Stop the background timer. Idempotent. An in-flight refresh is left to
// in flight is left to finish, and nothing starts after it. The returned // finish; it will not schedule another cycle once closed. The returned
// promise resolves once all of these have finished: // promise resolves once that refresh (including its cache write), the ML
// - that refresh or `refreshCollection()`, with its cache write; // fetch pass and the precache fetches already running have all finished,
// - the ML fetch pass; // so a caller can then remove the cache directory. A refresh failure is
// - the precache fetches already running. // reported through `status()`, not thrown here.
// 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();
@@ -759,41 +666,33 @@ export class Library {
this.timer.unref?.(); this.timer.unref?.();
} }
// The background loop's refresh: run a cycle unless a refresh or // The background loop's refresh: run a cycle unless one is already in flight
// `refreshCollection()` is in flight (or the library is closed), and never // (or the library is closed), and never let a failure escape — the
// let a failure escape. The background path reports errors through // background path reports errors through `status()`/`onProgress`, it does
// `status()`/`onProgress`; it does not throw. It resolves once the cycle // not throw. Resolves once the cycle it started (or skipped past) settles.
// 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(true, () => this.refreshCycle()).catch(() => {}); return this.startCycle().catch(() => {});
} }
// A fresh read's refresh (issue #75): join the running refresh or start // A fresh read's refresh (issue #75): force a cycle and await it, rejecting
// one, and await it, rejecting if it fails. Concurrent fresh reads // if it fails. Concurrent fresh reads coalesce onto the one in-flight cycle
// coalesce onto the one in-flight cycle — the background loop's included — // — the background loop's included — so they never fan out into redundant
// so they never fan out into redundant server round-trips. A running // server round-trips.
// `refreshCollection()` is waited for first. private refreshNow(): Promise<void> {
private async refreshNow(): Promise<void> { if (this.closed) {
while (this.cycle && !this.cycleIsRefresh) { return Promise.reject(new Error("the library is closed"));
await this.cycle.catch(() => {});
} }
if (this.closed) throw new Error("the library is closed"); return this.cycle ?? this.startCycle();
return this.cycle ?? this.startCycle(true, () => this.refreshCycle());
} }
// Start `work` and record it in the in-flight slot, so every other caller // Start one refresh cycle and record it as the in-flight cycle so every
// sees it. `isRefresh` says whether it is a refresh cycle, which a fresh // caller coalesces onto it. The returned promise carries the cycle's real
// read can coalesce onto. The returned promise carries the real outcome. // outcome; each caller attaches the handling its own path needs, and the
// Each caller attaches the handling its own path needs, and the slot is // slot is cleared once the cycle settles.
// cleared once the work settles. private startCycle(): Promise<void> {
private startCycle( const cycle = this.refreshCycle();
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;
@@ -816,9 +715,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 or failure, so a fetch or disk // outside the refresh's success/failure so a fetch or disk problem
// problem there never marks the metadata refresh failed. It is not // there never marks the metadata refresh failed, and 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) {
@@ -831,14 +730,11 @@ 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. // store untouched and the previous snapshot intact. A collection or file
// // that does not decrypt is left out instead, as if deleted, with the reason
// From a stored cursor of 0, the server lists every live collection. The // kept in the store: the cursor moves past it, so no later refresh sees it
// refresh then replaces all the store holds, and counts every collection // again until the server's copy changes.
// 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,
}); });
@@ -846,83 +742,22 @@ 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. When the refresh // collections enumerate from the beginning of time.
// replaces the store, every collection counts as new. Each collection
// whose files are fetched is reported, with how many there are to
// fetch.
const storedCopy = (id: number): Collection | undefined =>
replace ? undefined : this.store.getCollection(id);
const changedCollections = page.collections.filter((collection) => {
const known = storedCopy(collection.id);
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 page.collections) {
const known = storedCopy(collection.id); const known = this.store.getCollection(collection.id);
if (known && collection.updationTime <= known.updationTime)
continue;
const filePage = await this.client.filesSince({ const filePage = await this.client.filesSince({
collectionID: collection.id, collectionID: collection.id,
collectionKey: collection.key, collectionKey: collection.key,
sinceTime: known ? known.updationTime : 0, sinceTime: known ? known.updationTime : 0,
}); });
filePages.push({ collectionID: collection.id, page: filePage }); filePages.push({ collectionID: collection.id, page: filePage });
this.emit({
operation: "refresh",
status: "progress",
fetched: filePages.length,
total: changedCollections.length,
});
} }
await this.commit(page, filePages, replace); // Network work done; commit to the store and persist only if something
} // 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) {
@@ -930,13 +765,6 @@ 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);
@@ -990,10 +818,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
// failed save rejects the caller, and a refresh also records it in // save failure surfaces separately through `status().lastError`.
// `status().lastError`. `lastRecords` advances on every commit that // `lastRecords` advances every changed refresh so the next diff is
// changes something, so the next diff is against current state. // 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) {
@@ -1004,16 +832,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 on every commit. A finished sweep starts afresh to // Re-kick the fills every cycle: a finished sweep starts afresh to pick
// pick up new files and retry any that failed. A running one is left // up new files and retry any that failed, and a running one is left be.
// 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 later commit retries a failed save even // only once a save lands, so a save failure both stays visible through
// when it changes nothing. A failed save rejects the caller, and a // `status().lastError` (the throw below records it) and keeps being
// refresh also records it in `status().lastError`. // retried, instead of a later empty refresh silently clearing it while
// 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;
@@ -1029,14 +857,13 @@ export class Library {
return this.mlFetch; return this.mlFetch;
} }
// One ML fetch pass. It fetches, decrypts and stores the ML data of every // One ML fetch pass: fetch, decrypt and store the ML data for every file
// file the store knows about that is not cached, or whose `updationTime` // the store knows about that is not cached (or whose `updationTime` has
// has advanced. It fetches through the metadata pool, and updates the CLIP // advanced), through the metadata pool, and update the CLIP index. For a
// index. For a file whose ML data does not decrypt, gunzip or parse, it // file whose ML data does not decrypt, gunzip or parse, it gathers the
// gathers the reason instead. It puts the reasons it gathered in // reason instead, and puts the reasons it gathered in `mlDataErrors` when
// `mlDataErrors` when it ends without failing. A failure leaves // it ends without failing. A failure leaves `mlDataErrors` as it was and
// `mlDataErrors` as it was and is reported through `status()` and // is reported through `status()` and `onProgress`, then thrown.
// `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
@@ -1104,9 +931,9 @@ export class Library {
})); }));
} }
// The distinct files the store holds, one entry per fileID, since a file // The distinct files the store holds, one entry per fileID (a file in
// in several collections shares its ML data. Each entry carries the key // several collections shares its ML data), each carrying the key and the
// and the newest `updationTime` seen across the file's memberships. // newest `updationTime` seen across its 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.
export const clipEmbedding = (payload: MLData): number[] | undefined => { 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 change to the store. // Called at open and after every refresh that changes the library.
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,
+2 -3
View File
@@ -71,14 +71,13 @@ 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. // Full records for albums/photos added or changed by the refresh.
albumsChanged: AlbumRecord[]; albumsChanged: AlbumRecord[];
photosChanged: PhotoRecord[]; photosChanged: PhotoRecord[];
fileIDsRemoved: number[]; fileIDsRemoved: number[];
albumIDsRemoved: number[]; albumIDsRemoved: number[];
// Wall-clock milliseconds when the store changed. // Wall-clock milliseconds of the refresh that produced this change.
refreshedAt: number; refreshedAt: number;
} }
+12 -13
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 that did not decrypt. The whole file is read // collections and files the refresh could 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,11 +34,9 @@ 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 did not decrypt or parse, // A collection, or with `fileID` a file in it, that the last refresh to fetch
// and why. A refresh or `refreshCollection()` records it on fetching such a // it could not decrypt or parse, and why. It is kept until a later refresh
// copy. A later fetch drops it on getting a copy that decrypts, or on learning // fetches it again, because a refresh fetches only what changed.
// 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;
@@ -72,6 +70,12 @@ 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[] = [];
@@ -98,6 +102,7 @@ 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 ?? []) {
@@ -224,14 +229,8 @@ 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].sort( return [...this.undecryptable];
(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.
+4 -21
View File
@@ -1,7 +1,6 @@
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";
@@ -18,10 +17,7 @@ import {
} from "./mldata-fetch.js"; } from "./mldata-fetch.js";
import type { EnteFile } from "./model/types.js"; import type { EnteFile } from "./model/types.js";
// Reports one line of the dump's progress. `level` is "error" when the line export type ProgressCallback = (message: string) => void;
// reports ML data that could not be read, and is left out for every other
// line.
export type ProgressCallback = (message: string, level?: "error") => void;
export interface MetadataBackupOptions { export interface MetadataBackupOptions {
exif?: boolean; exif?: boolean;
@@ -98,8 +94,7 @@ 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 the ML data, with the keywords of its // private and public magic metadata and (by default) the ML data. Collections
// 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
@@ -200,17 +195,13 @@ export const runMetadataBackup = async (
for (const [id, payload] of payloads) mlDataMap.set(id, payload); for (const [id, payload] of payloads) mlDataMap.set(id, payload);
for (const [id, err] of errors) { for (const [id, err] of errors) {
const reason = describeError(err); const reason = describeError(err);
log( log(`ML data of file ${id} could not be read: ${reason}`);
`ML data of file ${id} could not be read: ${reason}`,
"error",
);
mlDataErrors.set(id, reason); mlDataErrors.set(id, reason);
} }
} catch (err) { } catch (err) {
const reason = err instanceof Error ? err.message : String(err); const reason = err instanceof Error ? err.message : String(err);
log( log(
`ML data request for ${batch.length} file(s) failed: ${reason}`, `ML data request for ${batch.length} file(s) failed: ${reason}`,
"error",
); );
for (const id of batch) mlDataErrors.set(id, reason); for (const id of batch) mlDataErrors.set(id, reason);
} }
@@ -232,16 +223,8 @@ 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) { if (ml) fileMeta.mlData = 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;
+1 -8
View File
@@ -24,9 +24,6 @@ export interface RetryOptions {
sleep?: (ms: number) => Promise<void>; sleep?: (ms: number) => Promise<void>;
// Injected so the jitter is reproducible under test. // Injected so the jitter is reproducible under test.
random?: () => number; random?: () => number;
// Called before each wait with the error that failed attempt number
// `attempt` and how long the wait is, for a caller that logs retries.
onRetry?: (err: unknown, attempt: number, delayMs: number) => void;
} }
export type ResolvedRetryOptions = Required<RetryOptions>; export type ResolvedRetryOptions = Required<RetryOptions>;
@@ -38,7 +35,6 @@ export const DEFAULT_RETRY_OPTIONS: ResolvedRetryOptions = {
sleep: (ms: number): Promise<void> => sleep: (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms)), new Promise((resolve) => setTimeout(resolve, ms)),
random: Math.random, random: Math.random,
onRetry: () => {},
}; };
// For a run nobody is watching, such as `quak backup` from cron. A request that // For a run nobody is watching, such as `quak backup` from cron. A request that
@@ -59,7 +55,6 @@ export const resolveRetryOptions = (
maxDelayMs: opts?.maxDelayMs ?? DEFAULT_RETRY_OPTIONS.maxDelayMs, maxDelayMs: opts?.maxDelayMs ?? DEFAULT_RETRY_OPTIONS.maxDelayMs,
sleep: opts?.sleep ?? DEFAULT_RETRY_OPTIONS.sleep, sleep: opts?.sleep ?? DEFAULT_RETRY_OPTIONS.sleep,
random: opts?.random ?? DEFAULT_RETRY_OPTIONS.random, random: opts?.random ?? DEFAULT_RETRY_OPTIONS.random,
onRetry: opts?.onRetry ?? DEFAULT_RETRY_OPTIONS.onRetry,
}); });
// Transport failures: the request did not complete, for reasons below HTTP. // Transport failures: the request did not complete, for reasons below HTTP.
@@ -239,9 +234,7 @@ export const withRetry = async <T>(
// and its status intact. // and its status intact.
throw err; throw err;
} }
const delayMs = backoffMs(attempt, policy); await policy.sleep(backoffMs(attempt, policy));
policy.onRetry(err, attempt, delayMs);
await policy.sleep(delayMs);
} }
} }
}; };
+1 -6
View File
@@ -49,10 +49,7 @@ export interface ThumbnailFixResult {
reason?: string; reason?: string;
} }
// Reports one line of progress. `level` is "error" when the line reports a export type ProgressCallback = (message: string) => void;
// file that could not be checked or fixed, and is left out for every other
// line.
export type ProgressCallback = (message: string, level?: "error") => void;
// Enumerate every file the library knows about, newest album first, each file // Enumerate every file the library knows about, newest album first, each file
// once, and report those whose server-side thumbnail is missing. "Missing" is // once, and report those whose server-side thumbnail is missing. "Missing" is
@@ -114,7 +111,6 @@ export const listMissingThumbnails = async (
} else { } else {
log( log(
`[${album.name}] Could not check ${photo.title}: ${err instanceof Error ? err.message : String(err)} (not reported as missing)`, `[${album.name}] Could not check ${photo.title}: ${err instanceof Error ? err.message : String(err)} (not reported as missing)`,
"error",
); );
} }
} }
@@ -363,7 +359,6 @@ export const fixMissingThumbnails = async (
} catch (err) { } catch (err) {
log( log(
`[${collectionName}] FAILED ${title}: ${err instanceof Error ? err.message : err}`, `[${collectionName}] FAILED ${title}: ${err instanceof Error ? err.message : err}`,
"error",
); );
results.push({ results.push({
fileID, fileID,
-62
View File
@@ -1222,65 +1222,3 @@ describe("ApiClient non-idempotent requests", () => {
} }
}); });
}); });
// `onRequest` hears of each request the client sends, as its response arrives
// or as it fails. The CLI logs these with `-vv`. A retried request is reported
// once for each attempt.
describe("ApiClient onRequest", () => {
it("reports each request with its status, or with the error it failed with", async () => {
const refused = new TypeError("fetch failed");
const { fetch } = scriptedFetch(
refused,
textResponse("unavailable", 503),
jsonResponse({ collections: [] }),
jsonResponse({}),
streamResponse(new Uint8Array([1, 2, 3])),
);
const requests: unknown[] = [];
const client = new ApiClient({
fetch,
retry: noWait,
onRequest: (request) => requests.push(request),
});
await client.getJSON("/collections/v2", { sinceTime: 0 });
await client.postJSON("/users/logout", {});
await readAll(await client.getFileStream(7));
const collections = "https://api.ente.io/collections/v2?sinceTime=0";
expect(requests).toEqual([
{ method: "GET", url: collections, error: refused },
{ method: "GET", url: collections, status: 503 },
{ method: "GET", url: collections, status: 200 },
{
method: "POST",
url: "https://api.ente.io/users/logout",
status: 200,
},
{
method: "GET",
url: "https://files.ente.io/?fileID=7",
status: 200,
},
]);
});
it("reports a presigned upload without its query, which holds the upload's signature", async () => {
const { fetch } = scriptedFetch(new Response(null, { status: 200 }));
const requests: unknown[] = [];
const client = new ApiClient({
fetch,
retry: noWait,
onRequest: (request) => requests.push(request),
});
await client.putFile(
"https://s3.example/obj?X-Amz-Signature=abc",
new Uint8Array([1, 2]),
);
expect(requests).toEqual([
{ method: "PUT", url: "https://s3.example/obj", status: 200 },
]);
});
});
+5 -21
View File
@@ -54,7 +54,6 @@ import {
saveSession, saveSession,
type CliContext, type CliContext,
} from "../../src/cli-commands.js"; } from "../../src/cli-commands.js";
import { stderrLog } from "../../src/cli-log.js";
import { run } from "../../src/cli-run.js"; import { run } from "../../src/cli-run.js";
import { loadSession } from "../../src/cli-session.js"; import { loadSession } from "../../src/cli-session.js";
import { Client } from "../../src/client.js"; import { Client } from "../../src/client.js";
@@ -86,17 +85,10 @@ 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, here that of a photo of a sunset (see clip-keywords.test.ts). // embedding.
const ML_DATA = { const ML_DATA = {
face: { faces: [{ faceID: "100_1", score: 0.98 }] }, face: { faces: [{ faceID: "100_1", score: 0.98 }] },
clip: { clip: { embedding: [0.5, 0.6, 0.7] },
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
@@ -311,10 +303,9 @@ const backup = async (
const prompted = async (): Promise<never> => { const prompted = async (): Promise<never> => {
throw new Error("quak backup prompted"); throw new Error("quak backup prompted");
}; };
const log = stderrLog(stderr, "info");
const ctx: CliContext = { const ctx: CliContext = {
stdout, stdout,
log, stderr,
sessionDir, sessionDir,
cacheDir: `${dir}-cache`, cacheDir: `${dir}-cache`,
loadSession: (path) => loadSession: (path) =>
@@ -328,7 +319,7 @@ const backup = async (
promptSecret: prompted, promptSecret: prompted,
}; };
const code = await new Promise<number>((resolve) => { const code = await new Promise<number>((resolve) => {
void run(backupCommand(ctx, dir, opts), stdout, stderr, log, resolve); void run(backupCommand(ctx, dir, opts), stdout, stderr, resolve);
}); });
return { code, stderr: printed, stdout: output }; return { code, stderr: printed, stdout: output };
}; };
@@ -493,10 +484,7 @@ describe("the first backup of the account", () => {
}, },
updationTime: TAKEN, updationTime: TAKEN,
}); });
// The JSON holds the ML data without its CLIP embedding, and the expect(beach.mlData).toEqual(ML_DATA);
// 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");
@@ -507,7 +495,6 @@ 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");
@@ -1117,9 +1104,6 @@ describe("a backup whose lock is lost", () => {
expect(code).toBe(1); expect(code).toBe(1);
expect(stderr).toBe( expect(stderr).toBe(
"Starting backup...\n" + "Starting backup...\n" +
"Fetched the files of 1 of 3 collections\n" +
"Fetched the files of 2 of 3 collections\n" +
"Fetched the files of 3 of 3 collections\n" +
"Refreshing library...\n" + "Refreshing library...\n" +
"Fetching original beach.jpg (100)...\n" + "Fetching original beach.jpg (100)...\n" +
"Fetching original IMG_0002.HEIC (300)...\n" + "Fetching original IMG_0002.HEIC (300)...\n" +
+2 -21
View File
@@ -42,7 +42,6 @@ import {
saveSession, saveSession,
type CliContext, type CliContext,
} from "../../src/cli-commands.js"; } from "../../src/cli-commands.js";
import { stderrLog } from "../../src/cli-log.js";
import { loadSession } from "../../src/cli-session.js"; import { loadSession } from "../../src/cli-session.js";
import { Client, type ClientSnapshot } from "../../src/client.js"; import { Client, type ClientSnapshot } from "../../src/client.js";
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js"; import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
@@ -305,7 +304,7 @@ const context = (ente: FakeEnte): CliContext => {
saveSession(join(root, "session"), ente.snapshot()); saveSession(join(root, "session"), ente.snapshot());
return { return {
stdout, stdout,
log: stderrLog(stderr, "info"), stderr,
sessionDir: join(root, "session"), sessionDir: join(root, "session"),
cacheDir: join(root, "cache"), cacheDir: join(root, "cache"),
loadSession: (path) => loadSession: (path) =>
@@ -371,24 +370,6 @@ describe("quak backup with records it cannot decrypt", () => {
expect(stderr.text).toContain(" Failed: 2\n"); expect(stderr.text).toContain(" Failed: 2\n");
}); });
// The album is an error line. The last line is fatal and counts the files
// and the album, so `-q`, which prints fatal lines only, still says why
// the backup exits 1.
it("logs the album at error, and ends with one fatal line counting the files and the album", async () => {
const ctx = {
...context(brokenAccount()),
log: stderrLog(stderr, "error"),
};
expect(await backupCommand(ctx, backupDir(), {})).toBe(1);
expect(stderr.text).toMatch(
/^FAILED album 2: could not be decrypted: /,
);
expect(stderr.text.split("\n").at(-2)).toBe(
`quak: 2 file(s) and 1 album(s) failed; see ${join(backupDir(), "failures.json")} and the FAILED lines`,
);
});
it("names each album it cannot decrypt in the --json result", async () => { it("names each album it cannot decrypt in the --json result", async () => {
expect(await backup(brokenAccount(), { json: true })).toBe(1); expect(await backup(brokenAccount(), { json: true })).toBe(1);
@@ -567,7 +548,7 @@ describe("the read commands with records quak cannot decrypt", () => {
expect(await collectionsCommand(context(ente), {})).toBe(0); expect(await collectionsCommand(context(ente), {})).toBe(0);
expect(stdout.text).toBe("1\talbum\tTrip\n"); expect(stdout.text).toBe("1\talbum\tTrip\n");
expect(await filesCommand(context(ente), "1", {})).toBe(0); expect(await filesCommand(context(ente), { collection: "1" })).toBe(0);
expect(stdout.text).toBe("10\timage\tphoto-10.jpg\n"); expect(stdout.text).toBe("10\timage\tphoto-10.jpg\n");
}); });
}); });
+18 -111
View File
@@ -52,7 +52,6 @@ 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";
@@ -947,19 +946,9 @@ describe("the backup lock", () => {
}); });
}); });
// Also serves ML data: file 100 has `ML_PAYLOAD`, whose CLIP embedding is that // Also serves ML data: file 100 has `ML_PAYLOAD`, the other two have none.
// of a photo of a sunset (see clip-keywords.test.ts), and the other two have // While `mlError` is set, every ML data request fails with it.
// none. While `mlError` is set, every ML data request fails with it. const ML_PAYLOAD = { face: { faces: [] }, clip: { embedding: [0.5, 0.25] } };
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;
@@ -979,99 +968,16 @@ 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", () => {
// The JSON holds the ML data without its CLIP embedding, and in its place it("writes a file's ML data, and no ML field for a file that has none", async () => {
// 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({ face: { faces: [] } }); expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
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();
}); });
@@ -1087,7 +993,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({ face: { faces: [] } }); expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
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(
@@ -1120,7 +1026,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({ face: { faces: [] } }); expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
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");
} }
@@ -1889,11 +1795,12 @@ describe("backup album folders", () => {
expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 }); expect(readLedger(outDir).files["5"]).toMatchObject({ attempts: 1 });
}); });
// The extensions of these titles leave no room for the ID and the "a" // The extensions of these titles leave no room for the ID, so each is cut
// before them, so each is cut short to 250 bytes, which leaves one, for // short and the name before the ID keeps only the bytes that cut leaves
// the "a". File 1's extension is 303 bytes and file 2's 271. Neither // over. File 1's extension is 303 bytes and is cut to 251, which leaves
// extension is only letters and digits, so both originals are saved with // none. File 2's is 271 bytes and is cut to 250, since "€" takes three
// `.bin`, a name that fits. // bytes, which leaves one, for the "a". Neither extension is only letters
// and digits, so both originals are saved with `.bin`, a name that fits.
it("cuts the extension of a title short when it leaves no room for the ID", async () => { it("cuts the extension of a title short when it leaves no room for the ID", async () => {
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const lib = libraryOf([ const lib = libraryOf([
@@ -1908,7 +1815,7 @@ describe("backup album folders", () => {
const result = await runBackup(lib, { downloadDirectory: outDir }); const result = await runBackup(lib, { downloadDirectory: outDir });
const link = `a (1).${"L".repeat(249)}`; const link = ` (1).${"L".repeat(250)}`;
const link2 = `a (2).${"€".repeat(83)}`; const link2 = `a (2).${"€".repeat(83)}`;
expect(Buffer.byteLength(link)).toBe(255); expect(Buffer.byteLength(link)).toBe(255);
expect(Buffer.byteLength(link2)).toBe(255); expect(Buffer.byteLength(link2)).toBe(255);
@@ -1923,9 +1830,9 @@ describe("backup album folders", () => {
// Files 1 and 3 share a title, so each gets its ID before the // Files 1 and 3 share a title, so each gets its ID before the
// extension. File 2's title is too long, and its extension leaves no // extension. File 2's title is too long, and its extension leaves no
// room for the ID and the "x" before it. Were the ID put at the end of // room for the ID. Were the ID put at the end of file 2's title cut
// file 2's title cut short, its link would have file 1's name, and the // short, its link would have file 1's name, and the link made second
// link made second would replace the first. // would replace the first.
it("keeps names unique when a title's extension leaves no room for the ID", async () => { it("keeps names unique when a title's extension leaves no room for the ID", async () => {
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const shared = `x.${"Q".repeat(245)} (2)`; const shared = `x.${"Q".repeat(245)} (2)`;
@@ -1945,8 +1852,8 @@ describe("backup album folders", () => {
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([ expect(tree(outDir)).toEqual([
"Trip/", "Trip/",
`Trip/ (2).${"Q".repeat(250)} -> ${linkTo("2.bin")}`,
`Trip/x (1).${"Q".repeat(245)} (2) -> ${linkTo("1.bin")}`, `Trip/x (1).${"Q".repeat(245)} (2) -> ${linkTo("1.bin")}`,
`Trip/x (2).${"Q".repeat(249)} -> ${linkTo("2.bin")}`,
`Trip/x (3).${"Q".repeat(245)} (2) -> ${linkTo("3.bin")}`, `Trip/x (3).${"Q".repeat(245)} (2) -> ${linkTo("3.bin")}`,
"Trip.json", "Trip.json",
]); ]);
+8 -34
View File
@@ -1,8 +1,6 @@
/** /**
* Tests for the retry options `bin/quak.ts` loads the saved session with * Tests for the retry options `bin/quak.ts` loads the saved session with:
* (`quak backup` gets the unattended ones, every other command the default), * `quak backup` gets the unattended ones, every other command the default.
* each with the callbacks that log its requests and retries, and for
* `quak files` taking its collection ID as an optional argument.
* *
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader * Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
* replaced by one that records the options it is given and reports no session, * replaced by one that records the options it is given and reports no session,
@@ -28,19 +26,14 @@ vi.mock("../../src/cli-session.js", () => ({
const argv = process.argv; const argv = process.argv;
const dir = mkdtempSync(join(tmpdir(), "quak-bin-test-")); const dir = mkdtempSync(join(tmpdir(), "quak-bin-test-"));
// Run `quak <args>` to completion and return what it wrote on stderr, which is // Run `quak <args>` to completion. The "Not logged in" message and the exit
// kept off the terminal. The exit is swallowed. // are swallowed.
const quak = async (...args: string[]): Promise<string> => { const quak = async (...args: string[]): Promise<void> => {
let written = ""; vi.spyOn(process.stderr, "write").mockImplementation(() => true);
vi.spyOn(process.stderr, "write").mockImplementation((text) => {
written += String(text);
return true;
});
vi.spyOn(process, "exit").mockImplementation(() => undefined as never); vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
process.argv = ["node", "quak", ...args]; process.argv = ["node", "quak", ...args];
vi.resetModules(); vi.resetModules();
await import("../../bin/quak.js"); await import("../../bin/quak.js");
return written;
}; };
afterEach(() => { afterEach(() => {
@@ -66,32 +59,13 @@ describe("bin/quak.ts session loading", () => {
attempts, attempts,
baseDelayMs, baseDelayMs,
maxDelayMs, maxDelayMs,
onRetry: expect.any(Function),
}), }),
onRequest: expect.any(Function),
}, },
]); ]);
}); });
it("loads the session for another command with the default retry options", async () => { it("loads the session for another command with the default options", async () => {
await quak("collections"); await quak("collections");
expect(loaded).toEqual([ expect(loaded).toEqual([undefined]);
{
retry: { onRetry: expect.any(Function) },
onRequest: expect.any(Function),
},
]);
});
});
// The command line takes `files` with or without a collection ID, and the
// command runs as far as its session check either way (issue #211).
describe("bin/quak.ts files", () => {
it("runs without a collection ID", async () => {
expect(await quak("files")).toMatch(/^Not logged in/);
});
it("runs with a collection ID", async () => {
expect(await quak("files", "7")).toMatch(/^Not logged in/);
}); });
}); });
+50 -500
View File
@@ -51,24 +51,13 @@ import {
listMissingThumbnailsCommand, listMissingThumbnailsCommand,
fixMissingThumbnailsCommand, fixMissingThumbnailsCommand,
} from "../../src/cli-commands.js"; } from "../../src/cli-commands.js";
import { stderrLog } from "../../src/cli-log.js";
import { run } from "../../src/cli-run.js"; import { run } from "../../src/cli-run.js";
import { loadSession } from "../../src/cli-session.js"; import { loadSession } from "../../src/cli-session.js";
import { import type { Client, ClientSnapshot, LoginOptions } from "../../src/client.js";
Client,
type ClientSnapshot,
type LoginOptions,
} from "../../src/client.js";
import { savePath, type ContentSource } from "../../src/library/content.js"; import { savePath, type ContentSource } from "../../src/library/content.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js"; import { encryptBlob, init, toBase64 } from "../../src/crypto/index.js";
import { defaultCacheDirectory } from "../../src/library/index.js"; import { defaultCacheDirectory } from "../../src/library/index.js";
import {
buildAccount,
buildMetaFetch,
encryptSecretbox,
encryptStreamBlob,
} from "../ente-server.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js"; import { HEIC_WITH_EXIF } from "../exif-heic.js";
import { import {
asLivePhoto, asLivePhoto,
@@ -122,46 +111,25 @@ const FILES: Record<number, EnteFile[]> = {
// An original is 7 bytes and a thumbnail 3. `failID` makes that file's // An original is 7 bytes and a thumbnail 3. `failID` makes that file's
// original fail; `emptyThumbID` makes the server report that file's // original fail; `emptyThumbID` makes the server report that file's
// 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, and `refreshError` makes
// beach.jpg (100) to Work as well, so that one file is in both albums. // 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;
newAlbums?: boolean;
refreshError?: string; refreshError?: string;
} = {}, } = {},
) => { ) => {
let collections = opts.withNewFile const collections = opts.withNewFile
? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]] ? [{ ...COLLECTIONS[0], updationTime: 2 }, COLLECTIONS[1]]
: COLLECTIONS; : COLLECTIONS;
let files = FILES; const files = opts.withNewFile
if (opts.newAlbums) { ? {
collections = [ ...FILES,
...collections, 1: [...FILES[1], { ...file(102, 1, "new.jpg"), updationTime: 2 }],
{ ...collection(3, "A"), updationTime: 2 }, }
{ ...collection(4, "B"), updationTime: 2 }, : FILES;
];
files = {
...files,
3: [file(300, 3, "shared.jpg")],
4: [file(300, 4, "shared.jpg")],
};
}
if (opts.withNewFile) {
files = {
...files,
1: [...files[1], { ...file(102, 1, "new.jpg"), updationTime: 2 }],
};
}
if (opts.beachInWork) {
files = { ...files, 2: [...files[2], file(100, 2, "beach.jpg")] };
}
const source: ContentSource = { const source: ContentSource = {
original: async ({ file: f, destination }) => { original: async ({ file: f, destination }) => {
if (f.id === opts.failID) throw new Error("HTTP 500 from server"); if (f.id === opts.failID) throw new Error("HTTP 500 from server");
@@ -203,15 +171,6 @@ const fakeClient = (
return fake as unknown as Client; return fake as unknown as Client;
}; };
// What a read command prints on stderr while the refresh it waits for fetches
// the files of each of the fake's two albums, on an empty cache.
const FETCHED_BOTH_ALBUMS =
"Fetched the files of 1 of 2 collections\n" +
"Fetched the files of 2 of 2 collections\n";
// The same for an account with one album.
const FETCHED_ONE_ALBUM = "Fetched the files of 1 of 1 collections\n";
// Collects everything written to it. // Collects everything written to it.
class Output { class Output {
text = ""; text = "";
@@ -224,11 +183,9 @@ let root: string;
let stdout: Output; let stdout: Output;
let stderr: Output; let stderr: Output;
// The commands log at the default level, info, as `quak` without `-v` or `-q`
// does; the levels have their own tests, in `log.test.ts`.
const context = (client: Client | null = fakeClient()): CliContext => ({ const context = (client: Client | null = fakeClient()): CliContext => ({
stdout, stdout,
log: stderrLog(stderr, "info"), stderr,
sessionDir: join(root, "session"), sessionDir: join(root, "session"),
cacheDir: join(root, "cache"), cacheDir: join(root, "cache"),
loadSession: () => client, loadSession: () => client,
@@ -281,7 +238,7 @@ describe("session file", () => {
const dir = join(root, "backup"); const dir = join(root, "backup");
expect(await whoamiCommand(ctx)).toBe(3); expect(await whoamiCommand(ctx)).toBe(3);
expect(await collectionsCommand(ctx, {})).toBe(3); expect(await collectionsCommand(ctx, {})).toBe(3);
expect(await filesCommand(ctx, "1", {})).toBe(3); expect(await filesCommand(ctx, { collection: "1" })).toBe(3);
expect(await getCommand(ctx, "100", {})).toBe(3); expect(await getCommand(ctx, "100", {})).toBe(3);
expect(await getThumbCommand(ctx, "100", {})).toBe(3); expect(await getThumbCommand(ctx, "100", {})).toBe(3);
expect(await backupMetadataCommand(ctx, dir, {})).toBe(3); expect(await backupMetadataCommand(ctx, dir, {})).toBe(3);
@@ -528,14 +485,17 @@ describe("collections", () => {
describe("files", () => { describe("files", () => {
it("prints one tab-separated line per file", async () => { it("prints one tab-separated line per file", async () => {
expect(await filesCommand(context(), "1", {})).toBe(0); expect(await filesCommand(context(), { collection: "1" })).toBe(0);
expect(stdout.text).toBe( expect(stdout.text).toBe(
"100\timage\tbeach.jpg\n" + "101\timage\tsunset.jpg\n", "100\timage\tbeach.jpg\n" + "101\timage\tsunset.jpg\n",
); );
}); });
it("prints a JSON array with --json", async () => { it("prints a JSON array with --json", async () => {
const code = await filesCommand(context(), "2", { json: true }); const code = await filesCommand(context(), {
collection: "2",
json: true,
});
expect(code).toBe(0); expect(code).toBe(0);
expect(JSON.parse(stdout.text)).toEqual([ expect(JSON.parse(stdout.text)).toEqual([
{ {
@@ -549,92 +509,9 @@ 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(), { collection: "9" })).toBe(1);
expect(stderr.text).toBe("Collection 9 not found\n"); expect(stderr.text).toBe("Collection 9 not found\n");
}); });
// Without a collection ID it lists every file in the account once (issue
// #211). beach.jpg is in Vacation and Work, and Vacation comes first, so it
// is listed once, as Vacation's.
it("without a collection ID prints every file once", async () => {
const ctx = context(fakeClient({ beachInWork: true }));
expect(await filesCommand(ctx, undefined, {})).toBe(0);
expect(stdout.text).toBe(
"100\timage\tbeach.jpg\n" +
"101\timage\tsunset.jpg\n" +
"200\timage\tdiagram.png\n",
);
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS);
});
it("without a collection ID gives each file the first album holding it with --json", async () => {
const ctx = context(fakeClient({ beachInWork: true }));
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);
});
// `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", () => {
@@ -642,27 +519,27 @@ describe("get and get-thumb", () => {
const out = join(root, "diagram.png"); const out = join(root, "diagram.png");
expect(await getCommand(context(), "200", { out })).toBe(0); expect(await getCommand(context(), "200", { out })).toBe(0);
expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200)); expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200));
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + `7 bytes -> ${out}\n`); expect(stderr.text).toBe(`7 bytes -> ${out}\n`);
}); });
it("get-thumb finds a file in any album without --collection", async () => { it("get-thumb finds a file in any album without --collection", async () => {
const out = join(root, "thumb.jpg"); const out = join(root, "thumb.jpg");
expect(await getThumbCommand(context(), "200", { out })).toBe(0); expect(await getThumbCommand(context(), "200", { out })).toBe(0);
expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200)); expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200));
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + `3 bytes -> ${out}\n`); expect(stderr.text).toBe(`3 bytes -> ${out}\n`);
}); });
it("get exits 1 when no album has the file", async () => { it("get exits 1 when no album has the file", async () => {
const out = join(root, "x"); const out = join(root, "x");
expect(await getCommand(context(), "999", { out })).toBe(1); expect(await getCommand(context(), "999", { out })).toBe(1);
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "File 999 not found\n"); expect(stderr.text).toBe("File 999 not found\n");
expect(existsSync(out)).toBe(false); expect(existsSync(out)).toBe(false);
}); });
it("get-thumb exits 1 when no album has the file", async () => { it("get-thumb exits 1 when no album has the file", async () => {
const out = join(root, "x"); const out = join(root, "x");
expect(await getThumbCommand(context(), "999", { out })).toBe(1); expect(await getThumbCommand(context(), "999", { out })).toBe(1);
expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "File 999 not found\n"); expect(stderr.text).toBe("File 999 not found\n");
expect(existsSync(out)).toBe(false); expect(existsSync(out)).toBe(false);
}); });
}); });
@@ -710,7 +587,7 @@ describe("names from an album name or a title over 255 bytes", () => {
expect(Buffer.byteLength(name)).toBe(255); expect(Buffer.byteLength(name)).toBe(255);
expect(code).toBe(0); expect(code).toBe(0);
expect(readdirSync(dir)).toEqual([name]); expect(readdirSync(dir)).toEqual([name]);
expect(stderr.text).toBe(FETCHED_ONE_ALBUM + `7 bytes -> ${name}\n`); expect(stderr.text).toBe(`7 bytes -> ${name}\n`);
}); });
it("get-thumb cuts the title short so that thumb_ and it fit in 255 bytes, and exits 0", async () => { it("get-thumb cuts the title short so that thumb_ and it fit in 255 bytes, and exits 0", async () => {
@@ -722,7 +599,7 @@ describe("names from an album name or a title over 255 bytes", () => {
expect(Buffer.byteLength(name)).toBe(255); expect(Buffer.byteLength(name)).toBe(255);
expect(code).toBe(0); expect(code).toBe(0);
expect(readdirSync(dir)).toEqual([name]); expect(readdirSync(dir)).toEqual([name]);
expect(stderr.text).toBe(FETCHED_ONE_ALBUM + `3 bytes -> ${name}\n`); expect(stderr.text).toBe(`3 bytes -> ${name}\n`);
}); });
it("backup-metadata cuts the album name short so that its folder's name fits in 255 bytes, and exits 0", async () => { it("backup-metadata cuts the album name short so that its folder's name fits in 255 bytes, and exits 0", async () => {
@@ -764,12 +641,9 @@ describe("names from an album name or a title over 255 bytes", () => {
// one live photo, 300, downloaded through the real download layer // one live photo, 300, downloaded through the real download layer
// (test/live-photo.ts). // (test/live-photo.ts).
describe("a live photo", () => { describe("a live photo", () => {
const livePhotoClient = async ( const livePhotoClient = async (image = IMAGE): Promise<Client> => {
image = IMAGE,
title = "IMG_0300.HEIC",
): Promise<Client> => {
const { file: live, body } = await asLivePhoto( const { file: live, body } = await asLivePhoto(
file(300, 1, title), file(300, 1, "IMG_0300.HEIC"),
livePhotoZip({ "image.heic": image, "video.mov": VIDEO }), livePhotoZip({ "image.heic": image, "video.mov": VIDEO }),
livePhotoHash(image, VIDEO), livePhotoHash(image, VIDEO),
); );
@@ -811,50 +685,11 @@ describe("a live photo", () => {
Buffer.from(VIDEO), Buffer.from(VIDEO),
); );
expect(stderr.text).toBe( expect(stderr.text).toBe(
FETCHED_ONE_ALBUM + `${IMAGE.length} bytes -> IMG_0300.heic\n` +
`${IMAGE.length} bytes -> IMG_0300.heic\n` +
`${VIDEO.length} bytes -> IMG_0300.mov\n`, `${VIDEO.length} bytes -> IMG_0300.mov\n`,
); );
}); });
// Run `get` without `--out` in a new directory for a live photo titled
// `title`, and return its exit code and the names it wrote.
const getInNewDirectory = async (
title: string,
): Promise<{ code: number; names: string[] }> => {
const ctx = context(await livePhotoClient(IMAGE, title));
const dir = mkdtempSync(join(root, "cwd-"));
const previous = process.cwd();
process.chdir(dir);
try {
const code = await getCommand(ctx, "300", {});
return { code, names: readdirSync(dir).sort() };
} finally {
process.chdir(previous);
}
};
const imageName = `${"t".repeat(250)}.heic`;
const videoName = `${"t".repeat(251)}.mov`;
// The title fits, but ".heic" is one byte longer than ".jpg", so the
// image's name is cut short once its extension is in it.
it("get cuts its image's name short for a 255-byte title ending in .jpg, and exits 0", async () => {
const title = `${"t".repeat(251)}.jpg`;
const result = await getInNewDirectory(title);
expect(Buffer.byteLength(title)).toBe(255);
expect(Buffer.byteLength(imageName)).toBe(255);
expect(Buffer.byteLength(videoName)).toBe(255);
expect(result).toEqual({ code: 0, names: [imageName, videoName] });
});
it("get cuts the names of its image and video short for a 300-byte title with no extension, and exits 0", async () => {
const result = await getInNewDirectory("t".repeat(300));
expect(result).toEqual({ code: 0, names: [imageName, videoName] });
});
it("get --out writes the image there and the video beside it", async () => { it("get --out writes the image there and the video beside it", async () => {
const out = join(root, "photo.jpg"); const out = join(root, "photo.jpg");
const video = join(root, "photo.mov"); const video = join(root, "photo.mov");
@@ -866,8 +701,7 @@ describe("a live photo", () => {
expect(readFileSync(out)).toEqual(Buffer.from(IMAGE)); expect(readFileSync(out)).toEqual(Buffer.from(IMAGE));
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO)); expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
expect(stderr.text).toBe( expect(stderr.text).toBe(
FETCHED_ONE_ALBUM + `${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
`${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
); );
}); });
@@ -881,8 +715,7 @@ describe("a live photo", () => {
expect(existsSync(out)).toBe(false); expect(existsSync(out)).toBe(false);
expect(existsSync(join(root, "photo.mov"))).toBe(false); expect(existsSync(join(root, "photo.mov"))).toBe(false);
expect(stderr.text).toBe( expect(stderr.text).toBe(
FETCHED_ONE_ALBUM + `File 300 is a live photo, and its video would also be written to ${out}\n`,
`File 300 is a live photo, and its video would also be written to ${out}\n`,
); );
}); });
@@ -977,40 +810,6 @@ describe("backup", () => {
); );
}); });
// A failed file is an error line both where the backup fails it and in
// the list at the end. The last line is fatal, so `-q`, which prints
// fatal lines only, still says why the backup exits 1.
it("logs a failed file at error, and ends with one fatal line counting the failures", async () => {
const dir = join(root, "backup");
const last = `quak: 1 file(s) and 0 album(s) failed; see ${join(dir, "failures.json")} and the FAILED lines\n`;
const ctx = context(fakeClient({ failID: 101 }));
expect(
await backupCommand(
{ ...ctx, log: stderrLog(stderr, "error") },
dir,
{},
),
).toBe(1);
expect(stderr.text).toBe(
"FAILED original sunset.jpg: HTTP 500 from server\n" +
"\nFailed files:\n" +
" [Vacation] sunset.jpg (id 101): HTTP 500 from server\n" +
last,
);
stderr.text = "";
expect(
await backupCommand(
{ ...ctx, log: stderrLog(stderr, "fatal") },
dir,
{ json: true },
),
).toBe(1);
expect(stderr.text).toBe(last);
});
// `--json` shapes stdout only: the log lines stay on stderr, and the JSON
// takes the place of the summary.
it("prints the result as JSON with --json, still exiting 1 on a failure", async () => { it("prints the result as JSON with --json, still exiting 1 on a failure", async () => {
const ctx = context(fakeClient({ failID: 101 })); const ctx = context(fakeClient({ failID: 101 }));
const code = await backupCommand(ctx, join(root, "backup"), { const code = await backupCommand(ctx, join(root, "backup"), {
@@ -1025,31 +824,7 @@ describe("backup", () => {
failed: 1, failed: 1,
}); });
expect(result.errors[0].fileID).toBe(101); expect(result.errors[0].fileID).toBe(101);
expect(stderr.text).toContain( expect(stderr.text).toBe("Starting backup...\n");
"Fetching original sunset.jpg (101)...\n",
);
expect(stderr.text).not.toContain("Backup complete");
});
// On an empty cache the library's first refresh fetches the files of
// every album before the backup can start, which on a large account takes
// a while; each album it has fetched the files of is counted on stderr,
// with `--json` too.
it("counts on stderr each album whose files its first refresh fetched, with --json too", async () => {
const start =
"Starting backup...\n" +
FETCHED_BOTH_ALBUMS +
"Refreshing library...\n";
for (const json of [false, true]) {
stderr.text = "";
const dir = mkdtempSync(join(root, "backup-"));
const ctx = {
...context(),
cacheDir: mkdtempSync(join(root, "cache-")),
};
expect(await backupCommand(ctx, dir, { json })).toBe(0);
expect(stderr.text.slice(0, start.length)).toBe(start);
}
}); });
it("--verify downloads again an original that does not match its hash, prints the counts, and exits 0", async () => { it("--verify downloads again an original that does not match its hash, prints the counts, and exits 0", async () => {
@@ -1139,7 +914,6 @@ describe("backup", () => {
backupCommand(ctx, dir, {}), backupCommand(ctx, dir, {}),
new PassThrough(), new PassThrough(),
runStderr, runStderr,
stderrLog(runStderr, "info"),
resolve, resolve,
); );
}); });
@@ -1380,9 +1154,7 @@ describe("helper list-missing-thumbnails", () => {
expect(stderr.text).toContain("No missing thumbnails found.\n"); expect(stderr.text).toContain("No missing thumbnails found.\n");
}); });
// `--json` shapes stdout only: the progress stays on stderr, and the JSON it("prints a JSON array with --json and no progress", async () => {
// takes the place of the count and the list.
it("prints a JSON array with --json, and its progress on stderr", async () => {
const ctx = context(fakeClient({ emptyThumbID: 200 })); const ctx = context(fakeClient({ emptyThumbID: 200 }));
expect(await listMissingThumbnailsCommand(ctx, { json: true })).toBe(0); expect(await listMissingThumbnailsCommand(ctx, { json: true })).toBe(0);
expect(JSON.parse(stdout.text)).toEqual([ expect(JSON.parse(stdout.text)).toEqual([
@@ -1393,37 +1165,7 @@ describe("helper list-missing-thumbnails", () => {
reason: "empty thumbnail (0 bytes)", reason: "empty thumbnail (0 bytes)",
}, },
]); ]);
expect(stderr.text).toContain(FETCHED_BOTH_ALBUMS); expect(stderr.text).toBe("");
expect(stderr.text).toContain("[Work] Checking thumbnails...\n");
expect(stderr.text).not.toContain("with missing thumbnails");
});
});
describe("helper fix-missing-thumbnails", () => {
// File 999 is in no album, so fixing it fails. The list of failed files
// is at error, and the last line is fatal, so `-q`, which prints fatal
// lines only, still says why the command exits 1.
it("lists a failed file at error, and ends with one fatal line counting the failures", async () => {
const last = `quak: 1 file(s) failed; see the "Failed files:" list, or the JSON with --json\n`;
const ctx = { ...context(), log: stderrLog(stderr, "error") };
expect(await fixMissingThumbnailsCommand(ctx, { file: ["999"] })).toBe(
1,
);
expect(stderr.text).toBe(
"\nFailed files:\n" +
" 999\tunknown\tfile not found in any collection\n" +
last,
);
stderr.text = "";
const quiet = { ...context(), log: stderrLog(stderr, "fatal") };
expect(
await fixMissingThumbnailsCommand(quiet, {
file: ["999"],
json: true,
}),
).toBe(1);
expect(stderr.text).toBe(last);
}); });
}); });
@@ -1453,12 +1195,11 @@ describe("backup-metadata --exif", () => {
}); });
}); });
// Each test first runs `files` so the cache holds the account as it was, then // Each test first runs `collections` so the cache holds the account as it was,
// changes the server under it. (`collections` would not do: it reads the // then changes the server under it.
// 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(), undefined, {})).toBe(0); expect(await collectionsCommand(context(), {})).toBe(0);
stdout.text = ""; stdout.text = "";
stderr.text = ""; stderr.text = "";
}); });
@@ -1517,10 +1258,10 @@ describe("backup-metadata and the thumbnail helpers refresh first", () => {
}); });
}); });
// `files`, `get`, `get-thumb` and `helper fix-missing-thumbnails --file` take // `files --collection`, `get`, `get-thumb` and `helper fix-missing-thumbnails
// an ID only when it is written in decimal digits and is a safe integer. // --file` take an ID only when it is written in decimal digits and is a safe
// `Number()` alone reads `0x10` as 16, `1e3` as 1000 and an empty string as 0, // integer. `Number()` alone reads `0x10` as 16, `1e3` as 1000 and an empty
// so the command would act on a file the user never named. // string as 0, so the command would act on a file the user never named.
// Anything else is refused with one line naming it, and exit 1, before any // Anything else is refused with one line naming it, and exit 1, before any
// request. // request.
// //
@@ -1574,18 +1315,21 @@ describe("an ID given on the command line", () => {
it("files looks up a decimal collection ID", async () => { it("files looks up a decimal collection ID", async () => {
const ctx = emptyAccount(); const ctx = emptyAccount();
expect(await filesCommand(ctx, "1", {})).toBe(1); expect(await filesCommand(ctx, { collection: "1" })).toBe(1);
expect(stderr.text).toBe("Collection 1 not found\n"); expect(stderr.text).toBe("Collection 1 not found\n");
expect(requests).not.toHaveLength(0); expect(requests).not.toHaveLength(0);
}); });
it.each(NOT_IDS)("files refuses %j before any request", async (arg) => { it.each(NOT_IDS)(
const ctx = emptyAccount(); "files refuses --collection %j before any request",
expect(await filesCommand(ctx, arg, {})).toBe(1); async (arg) => {
expect(stderr.text).toBe(`Invalid collection ID: ${arg}\n`); const ctx = emptyAccount();
expect(stdout.text).toBe(""); expect(await filesCommand(ctx, { collection: arg })).toBe(1);
expect(requests).toHaveLength(0); expect(stderr.text).toBe(`Invalid collection ID: ${arg}\n`);
}); expect(stdout.text).toBe("");
expect(requests).toHaveLength(0);
},
);
it("get looks up a decimal file ID", async () => { it("get looks up a decimal file ID", async () => {
expect(await getCommand(emptyAccount(), "100", {})).toBe(1); expect(await getCommand(emptyAccount(), "100", {})).toBe(1);
@@ -1640,197 +1384,3 @@ describe("an ID given on the command line", () => {
}, },
); );
}); });
// 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
// album, one album after another. It printed nothing until then, so on such an
// account it seemed to hang. `files` with an album's ID did the same before it
// listed that album (issue #217). The account here has 40 albums of one photo
// each, encrypted as Ente's clients encrypt them. The fake Ente server of
// `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", () => {
const ALBUMS = 40;
const PASSWORD = "many-albums";
const paths: string[] = [];
let server: typeof globalThis.fetch;
let snapshot: ClientSnapshot;
// Album n is named `Album n` and holds one photo, n * 100, titled
// `photo-n.jpg`.
beforeAll(async () => {
await sodium.ready;
const account = await buildAccount(PASSWORD);
const collections: Record<string, unknown>[] = [];
const filesByCollection: Record<number, Record<string, unknown>[]> = {};
for (let id = 1; id <= ALBUMS; id++) {
const albumKey = sodium.crypto_secretbox_keygen();
const albumKeyBox = encryptSecretbox(albumKey, account.masterKey);
const name = encryptSecretbox(
new TextEncoder().encode(`Album ${id}`),
albumKey,
);
collections.push({
id,
owner: { id: USER_ID },
encryptedKey: toBase64(albumKeyBox.ciphertext),
keyDecryptionNonce: toBase64(albumKeyBox.nonce),
encryptedName: toBase64(name.ciphertext),
nameDecryptionNonce: toBase64(name.nonce),
type: "album",
updationTime: 1,
});
const photoKey =
sodium.crypto_secretstream_xchacha20poly1305_keygen();
const photoKeyBox = encryptSecretbox(photoKey, albumKey);
const metadata = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title: `photo-${id}.jpg`,
fileType: 0,
creationTime: 1000,
modificationTime: 1000,
}),
),
photoKey,
);
filesByCollection[id] = [
{
id: id * 100,
collectionID: id,
ownerID: USER_ID,
encryptedKey: toBase64(photoKeyBox.ciphertext),
keyDecryptionNonce: toBase64(photoKeyBox.nonce),
metadata: {
encryptedData: toBase64(metadata.ciphertext),
decryptionHeader: toBase64(metadata.header),
},
// These tests fetch no original and no thumbnail.
file: { decryptionHeader: "" },
thumbnail: { decryptionHeader: "" },
updationTime: 1,
},
];
}
const ente = buildMetaFetch({
verifier: account.verifier,
srpAttributes: account.srpAttributes,
keyAttributes: account.keyAttributes,
encryptedToken: account.encryptedToken,
collections,
filesByCollection,
encryptedMLData: {},
fileCiphertexts: {},
});
server = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.href
: input.url;
const { pathname, search } = new URL(url);
paths.push(pathname + search);
return ente(input, init);
}) as typeof globalThis.fetch;
const client = await Client.login({
email: "many@example.com",
password: PASSWORD,
apiOptions: { fetch: server },
});
snapshot = client.toJSON();
});
beforeEach(() => {
paths.length = 0;
});
// A context whose saved session sends its requests to the fake server.
const accountContext = (): CliContext => {
const ctx = context();
saveSession(ctx.sessionDir, snapshot);
return {
...ctx,
loadSession: (path) => loadSession(path, { fetch: server }),
};
};
it("collections lists every album from one request, fetching no album's files", async () => {
const ctx = accountContext();
expect(await collectionsCommand(ctx, {})).toBe(0);
let albums = "";
for (let id = 1; id <= ALBUMS; id++)
albums += `${id}\talbum\tAlbum ${id}\n`;
expect(stdout.text).toBe(albums);
expect(stderr.text).toBe("");
expect(paths).toEqual(["/collections/v2?sinceTime=0"]);
// It stored nothing that would make `refreshCollection()` skip an
// album's files: `files` after it still finds the album's photo.
stdout.text = "";
expect(await filesCommand(ctx, "7", {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
});
it("files fetches the files of the album it lists and of no other", async () => {
expect(await filesCommand(accountContext(), "7", {})).toBe(0);
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 = "";
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`;
}
expect(stdout.text).toBe(photos);
expect(stderr.text).toBe(progress);
});
// `--json` shapes stdout only, so the refresh's progress is still counted
// on stderr.
it("files --json with no album's ID counts on stderr each album whose files its refresh fetched", async () => {
expect(
await filesCommand(accountContext(), undefined, { json: true }),
).toBe(0);
const rows = JSON.parse(stdout.text);
expect(rows).toHaveLength(ALBUMS);
expect(rows[6]).toEqual({
id: 700,
title: "photo-7.jpg",
fileType: "image",
creationTime: 1000,
collectionID: 7,
});
let progress = "";
for (let n = 1; n <= ALBUMS; n++) {
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
}
expect(stderr.text).toBe(progress);
});
});
-357
View File
@@ -1,357 +0,0 @@
/**
* Tests for the CLI's log levels, set with `-v`/`--verbose` and `-q`/`--quiet`
* (`src/cli-log.ts`, issue #212).
*
* Every line quak prints on stderr is a log line at one of six levels, from
* the quietest: fatal, error, warning, info, debug, trace. A run prints the
* lines at its own level and at every quieter one. The level is info unless
* asked otherwise: `-v` raises it to debug, `-vv` to trace, and more repeats
* stay at trace. `-q` lowers it to fatal, so a run prints only the line a
* command prints as it fails with a non-zero exit code. stdout holds the
* command's output at every level.
*
* Most tests run `bin/quak.ts`, as the smoke test does, against an account of
* one album holding one photo: the saved session is in a temporary directory,
* and `fetch` is a fake of the Ente server that serves the album and the photo
* encrypted as Ente's clients encrypt them. Each run has a cache directory of
* its own, so its refresh fetches the album's files, as on a first run.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import sodium from "libsodium-wrappers-sumo";
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from "vitest";
import { ApiClient } from "../../src/api/client.js";
import { saveSession } from "../../src/cli-commands.js";
import { logRefresh, logRequests, type Log } from "../../src/cli-log.js";
import { init, toBase64 } from "../../src/crypto/index.js";
import { encryptSecretbox, encryptStreamBlob } from "../ente-server.js";
// `bin/quak.ts` reads the saved session from the data directory env-paths
// gives it, which here is a temporary directory made before the first run.
const paths = vi.hoisted(() => ({ data: "" }));
vi.mock("env-paths", () => ({ default: () => paths }));
const USER_ID = 42;
const root = mkdtempSync(join(tmpdir(), "quak-log-test-"));
const argv = process.argv;
class ExitCalled extends Error {}
// Run `quak <args>` to its exit, with a cache of its own, and return its exit
// code and what it wrote on stdout and on stderr, which are kept off the
// terminal. `process.exit` is stubbed to throw, so nothing runs after it, as
// nothing does after a real exit.
const quak = async (
...args: string[]
): Promise<{ code: number; stdout: string; stderr: string }> => {
let stdout = "";
let stderr = "";
let code = NaN;
vi.spyOn(process.stdout, "write").mockImplementation((text) => {
stdout += String(text);
return true;
});
vi.spyOn(process.stderr, "write").mockImplementation((text) => {
stderr += String(text);
return true;
});
vi.spyOn(process, "exit").mockImplementation((exitCode) => {
code = Number(exitCode ?? 0);
throw new ExitCalled();
});
const cache = mkdtempSync(join(root, "cache-"));
process.argv = ["node", "quak", "--cache-dir", cache, ...args];
vi.resetModules();
await expect(import("../../bin/quak.js")).rejects.toBeInstanceOf(
ExitCalled,
);
return { code, stdout, stderr };
};
// Album 1, "Trips", of user 42, holding photo 100, "beach.jpg". The album's
// key is encrypted with the account's master key, the photo's key with the
// album's, and the photo's metadata with its own key. Only the session and
// these two records are needed: the tests never log in.
beforeAll(async () => {
await init();
await sodium.ready;
const masterKey = sodium.crypto_secretbox_keygen();
const keyPair = sodium.crypto_box_keypair();
paths.data = join(root, "session");
saveSession(paths.data, {
email: "log@example.com",
userID: USER_ID,
token: "token",
masterKey: toBase64(masterKey),
secretKey: toBase64(keyPair.privateKey),
publicKey: toBase64(keyPair.publicKey),
});
const albumKey = sodium.crypto_secretbox_keygen();
const albumKeyBox = encryptSecretbox(albumKey, masterKey);
const name = encryptSecretbox(new TextEncoder().encode("Trips"), albumKey);
const album = {
id: 1,
owner: { id: USER_ID },
encryptedKey: toBase64(albumKeyBox.ciphertext),
keyDecryptionNonce: toBase64(albumKeyBox.nonce),
encryptedName: toBase64(name.ciphertext),
nameDecryptionNonce: toBase64(name.nonce),
type: "album",
updationTime: 1,
};
const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const photoKeyBox = encryptSecretbox(photoKey, albumKey);
const metadata = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title: "beach.jpg",
fileType: 0,
creationTime: 1000,
modificationTime: 1000,
}),
),
photoKey,
);
const photo = {
id: 100,
collectionID: 1,
ownerID: USER_ID,
encryptedKey: toBase64(photoKeyBox.ciphertext),
keyDecryptionNonce: toBase64(photoKeyBox.nonce),
metadata: {
encryptedData: toBase64(metadata.ciphertext),
decryptionHeader: toBase64(metadata.header),
},
// These tests fetch no original and no thumbnail.
file: { decryptionHeader: "" },
thumbnail: { decryptionHeader: "" },
updationTime: 1,
};
const json = (body: unknown): Response =>
new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" },
});
vi.stubGlobal("fetch", async (input: string) => {
const { pathname } = new URL(input);
if (pathname === "/collections/v2") {
return json({ collections: [album] });
}
if (pathname === "/collections/v2/diff") {
return json({ diff: [photo], hasMore: false });
}
return new Response("not found", { status: 404 });
});
});
afterEach(() => {
process.argv = argv;
vi.restoreAllMocks();
});
afterAll(() => {
vi.unstubAllGlobals();
rmSync(root, { recursive: true, force: true });
});
// What `quak files` writes on stdout at every level.
const FILES = "100\timage\tbeach.jpg\n";
// What it writes on stderr at each level. Info is the refresh's progress, one
// line for the one album whose files it fetched. Debug adds the start and end
// of the refresh, and trace each request with the status of its answer.
const INFO = "Fetched the files of 1 of 1 collections\n";
const DEBUG = "Refresh started\n" + INFO + "Refresh done\n";
const TRACE =
"Refresh started\n" +
"GET https://api.ente.io/collections/v2?sinceTime=0: HTTP 200\n" +
"GET https://api.ente.io/collections/v2/diff?collectionID=1&sinceTime=0: HTTP 200\n" +
INFO +
"Refresh done\n";
describe("the log level", () => {
it("is info by default", async () => {
expect(await quak("files")).toEqual({
code: 0,
stdout: FILES,
stderr: INFO,
});
});
it("is debug with -v", async () => {
expect(await quak("-v", "files")).toEqual({
code: 0,
stdout: FILES,
stderr: DEBUG,
});
});
it("is trace with -vv", async () => {
expect(await quak("-vv", "files")).toEqual({
code: 0,
stdout: FILES,
stderr: TRACE,
});
});
it("stays at trace with -vvv", async () => {
expect((await quak("-vvv", "files")).stderr).toBe(TRACE);
});
it("counts --verbose --verbose as -vv", async () => {
expect((await quak("--verbose", "--verbose", "files")).stderr).toBe(
TRACE,
);
});
// Both are options of the program, taken before or after the command's
// name, as `--cache-dir` is.
it("is set by an option after the command's name too", async () => {
expect((await quak("files", "-v")).stderr).toBe(DEBUG);
expect((await quak("files", "--json", "-vv")).stderr).toBe(TRACE);
expect((await quak("files", "-q")).stderr).toBe("");
});
});
describe("-q", () => {
it("prints nothing on stderr when the command works", async () => {
expect(await quak("-q", "files")).toEqual({
code: 0,
stdout: FILES,
stderr: "",
});
});
// The line saying why the command failed is the fatal one, and it is
// printed.
it("prints only the line a failing command exits with", async () => {
expect(await quak("-q", "files", "9")).toEqual({
code: 1,
stdout: "",
stderr: "Collection 9 not found\n",
});
});
it("is refused with -v, in one line, before the command runs", async () => {
for (const args of [
["-q", "-v", "files"],
["files", "--verbose", "--quiet"],
]) {
const { code, stdout, stderr } = await quak(...args);
expect(code).toBe(1);
expect(stdout).toBe("");
expect(stderr).toBe(
"error: option '-q, --quiet' cannot be used with option '-v, --verbose'\n",
);
}
});
});
// `--help` lists both options, and each command's help lists them too, since
// each command takes them.
describe("--help", () => {
it("lists -v and -q for the program and for each command", async () => {
for (const args of [["--help"], ["files", "--help"]]) {
const { stdout } = await quak(...args);
expect(stdout).toContain("-v, --verbose");
expect(stdout).toContain("-q, --quiet");
}
});
});
// The library writes no lines itself. Its layers report through callbacks,
// and the CLI logs what they report.
describe("what the library reports", () => {
const collect = (): { log: Log; lines: string[] } => {
const lines: string[] = [];
return { log: (level, line) => lines.push(`${level}: ${line}`), lines };
};
// A request answered 503 and retried: each attempt is a request, logged
// at trace with its status, and the retry is logged at debug with how
// long it waits and why.
it("logs each request at trace and each retry at debug", async () => {
const { log, lines } = collect();
const answers = [
new Response("unavailable", { status: 503 }),
new Response("{}", {
headers: { "content-type": "application/json" },
}),
];
const client = new ApiClient({
...logRequests(log, {
sleep: async () => {},
random: () => 0.5,
}),
fetch: async () => answers.shift()!,
});
await client.getJSON("/collections/v2", { sinceTime: 0 });
const url = "https://api.ente.io/collections/v2?sinceTime=0";
expect(lines).toEqual([
`trace: GET ${url}: HTTP 503`,
"debug: Attempt 1 failed, retrying in 250 ms: HTTP 503",
`trace: GET ${url}: HTTP 200`,
]);
});
it("logs a request that failed with its error", async () => {
const { log, lines } = collect();
const client = new ApiClient({
...logRequests(log, { attempts: 1 }),
fetch: async () => {
throw new TypeError("fetch failed", {
cause: new Error("connect ECONNREFUSED 1.2.3.4:443"),
});
},
});
await expect(client.getJSON("/collections/v2")).rejects.toThrow();
expect(lines).toEqual([
"trace: GET https://api.ente.io/collections/v2: fetch failed: connect ECONNREFUSED 1.2.3.4:443",
]);
});
// Each operation is named in plain words, as the README names it.
it("logs a refresh's progress at info, and its start and end at debug", () => {
const { log, lines } = collect();
const onProgress = logRefresh(log);
onProgress({ operation: "refresh", status: "started" });
onProgress({
operation: "refresh",
status: "progress",
fetched: 1,
total: 2,
});
onProgress({
operation: "refresh",
status: "failed",
error: "HTTP 503",
});
onProgress({ operation: "fetchMLData", status: "started" });
onProgress({ operation: "fetchMLData", status: "done", fetched: 3 });
expect(lines).toEqual([
"debug: Refresh started",
"info: Fetched the files of 1 of 2 collections",
"debug: Refresh failed: HTTP 503",
"debug: ML data fetch started",
"debug: ML data fetch done",
]);
});
});
+17 -152
View File
@@ -41,14 +41,12 @@ 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,
type MetadataBackupOptions, type MetadataBackupOptions,
} from "../../src/metadata-backup.js"; } from "../../src/metadata-backup.js";
import { backupMetadataCommand } from "../../src/cli-commands.js"; import { backupMetadataCommand } from "../../src/cli-commands.js";
import { stderrLog, type LogLevel } from "../../src/cli-log.js";
import { import {
buildAccount, buildAccount,
buildMetaFetch, buildMetaFetch,
@@ -57,8 +55,8 @@ import {
type MetaMockState, type MetaMockState,
} from "../ente-server.js"; } from "../ente-server.js";
// One file per ML data request, so each file of the mock account is fetched in // One file per ML data request, so the two files of the mock account are
// a request of its own and one of them can fail on its own. // fetched in two requests 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,
@@ -67,21 +65,6 @@ 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;
@@ -220,65 +203,6 @@ 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: {
@@ -305,7 +229,7 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
clip: { clip: {
version: 1, version: 1,
client: "test", client: "test",
embedding: SUNSET_EMBEDDING, embedding: [0.5, 0.6, 0.7],
}, },
}); });
const gzipped = gzipSync(Buffer.from(mlPayload)); const gzipped = gzipSync(Buffer.from(mlPayload));
@@ -345,23 +269,12 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
keyAttributes: account.keyAttributes, keyAttributes: account.keyAttributes,
encryptedToken: account.encryptedToken, encryptedToken: account.encryptedToken,
collections: [rawColl1, rawColl2], collections: [rawColl1, rawColl2],
filesByCollection: { filesByCollection: { 10: [rawFile1], 20: [rawFile2] },
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 },
}; };
@@ -518,9 +431,7 @@ describe("quak backup-metadata", () => {
expect(account.email).toBe(TEST_EMAIL); expect(account.email).toBe(TEST_EMAIL);
}); });
// The CLIP embedding is left out of the ML data, and the keywords it gives it("fetches and decrypts ML data by default", async () => {
// 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);
@@ -540,41 +451,8 @@ 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).not.toHaveProperty("clip"); expect(fileMeta.mlData.clip).toBeDefined();
expect(fileMeta.keywords).toEqual(["Sunset"]); expect(fileMeta.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
});
// 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 () => {
@@ -612,12 +490,9 @@ describe("quak backup-metadata", () => {
}); });
describe("quak backup-metadata when a file's ML data cannot be read", () => { describe("quak backup-metadata when a file's ML data cannot be read", () => {
// Run the CLI command against `fetch`, the mock unless given, logging at // Run the CLI command against `fetch`, the mock unless given, and return
// `level`, and return its exit code, stderr and output directory. // its exit code, stderr and output directory.
const runCommand = async ( const runCommand = async (fetch = buildMetaFetch(mock)) => {
fetch = buildMetaFetch(mock),
level: LogLevel = "info",
) => {
const client = await Client.login({ const client = await Client.login({
email: TEST_EMAIL, email: TEST_EMAIL,
password: TEST_PASSWORD, password: TEST_PASSWORD,
@@ -631,10 +506,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
const code = await backupMetadataCommand( const code = await backupMetadataCommand(
{ {
stdout: { write: () => true }, stdout: { write: () => true },
log: stderrLog( stderr: { write: (text: string) => (stderr += text) },
{ write: (text: string) => (stderr += text) },
level,
),
sessionDir: testDir, sessionDir: testDir,
cacheDir: mkdtempSync(join(testDir, "cache-")), cacheDir: mkdtempSync(join(testDir, "cache-")),
loadSession: () => client, loadSession: () => client,
@@ -645,16 +517,13 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
return { code, stderr, outDir }; return { code, stderr, outDir };
}; };
// The failed request is logged at error, so it is the first line printed
// at that level.
it("writes every file, marks the failed batch's files, and exits 1", async () => { it("writes every file, marks the failed batch's files, and exits 1", async () => {
const { code, stderr, outDir } = await runCommand( const { code, stderr, outDir } = await runCommand(
buildMetaFetch(mock, 200), buildMetaFetch(mock, 200),
"error",
); );
expect(code).toBe(1); expect(code).toBe(1);
expect(stderr).toMatch(/^ML data request for 1 file\(s\) failed: /); expect(stderr).toContain("ML data request for 1 file(s) failed");
const ok = JSON.parse( const ok = JSON.parse(
readFileSync( readFileSync(
@@ -662,7 +531,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
"utf-8", "utf-8",
), ),
); );
expect(ok.keywords).toEqual(["Sunset"]); expect(ok.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
expect(ok.mlDataError).toBeUndefined(); expect(ok.mlDataError).toBeUndefined();
const failed = JSON.parse( const failed = JSON.parse(
@@ -677,9 +546,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
}); });
// The server sends ML data for diagram.png encrypted under a key that is // The server sends ML data for diagram.png encrypted under a key that is
// not the file's, so quak cannot decrypt it. That is logged at error, and // not the file's, so quak cannot decrypt it.
// the last line is fatal, so `-q`, which prints fatal lines only, still
// says why the command exits 1.
it("writes the reason for ML data that does not decrypt, and exits 1", async () => { it("writes the reason for ML data that does not decrypt, and exits 1", async () => {
const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen(); const otherKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { header, ciphertext } = encryptBlob( const { header, ciphertext } = encryptBlob(
@@ -697,14 +564,12 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
}, },
}, },
}), }),
"error",
); );
expect(code).toBe(1); expect(code).toBe(1);
const reason = "secretstream chunk authentication failed"; const reason = "secretstream chunk authentication failed";
expect(stderr).toBe( expect(stderr).toContain(
`ML data of file 200 could not be read: ${reason}\n` + `ML data of file 200 could not be read: ${reason}\n`,
`quak: the ML data of 1 file(s) could not be read; see mlDataError in their JSON in ${outDir}\n`,
); );
const failed = JSON.parse( const failed = JSON.parse(
readFileSync( readFileSync(
@@ -720,7 +585,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
"utf-8", "utf-8",
), ),
); );
expect(ok.keywords).toEqual(["Sunset"]); expect(ok.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
}); });
it("exits 0 when every ML data request succeeds", async () => { it("exits 0 when every ML data request succeeds", async () => {
+18 -97
View File
@@ -3,18 +3,10 @@
* 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` without a collection ID, * current server state, not the local cache, so each helper forces a
* `get` and `get-thumb` force a `Library.fresh()` round-trip before they read. * `Library.fresh()` round-trip before it reads. The stand-in library below
* The stand-in library below serves nothing until `fresh()` has been awaited. * serves nothing until `fresh()` has been awaited, so a helper that read
* A helper that read without refreshing would come back empty and fail here. * without refreshing would come back empty and fail here.
* `collections` asks the library for the collection list alone
* (`freshCollections()`, issue #210). It must not wait for a refresh, which
* 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
@@ -68,19 +60,10 @@ 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. `photos.byID` returns a
// answers the collection list in that order without a refresh. When // stand-in `Photo` carrying just the fileID the helper passes through.
// `refreshCollection()` resolves to true, it reveals the files of that one
// 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;
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(
@@ -94,31 +77,16 @@ class FakeLibrary implements FreshReadLibrary {
return {}; return {};
} }
async freshCollections(): Promise<Collection[]> {
this.freshCollectionsCalls++;
return this.collections;
}
async refreshCollection(collection: Collection): Promise<boolean> {
this.refreshCollectionIDs.push(collection.id);
return this.refreshCollectionHolds;
}
getCollection(id: number): Collection | undefined {
if (!this.refreshed) return undefined;
return this.collections.find((c) => c.id === id);
}
listCollections(): Collection[] { listCollections(): Collection[] {
return this.refreshed ? this.collections : []; return this.refreshed ? this.collections : [];
} }
getCollection(id: number): Collection | undefined {
return this.listCollections().find((c) => c.id === id);
}
listFiles(collectionID: number): EnteFile[] { listFiles(collectionID: number): EnteFile[] {
const current = return this.refreshed
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,7 +106,7 @@ class FakeLibrary implements FreshReadLibrary {
} }
describe("CLI read helpers (issue #36 amendment, issue #52)", () => { describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
it("freshCollections reads the collection list alone, in enumeration order", async () => { it("freshCollections refreshes first, then lists in enumeration order", async () => {
// Enumeration order 2, 1, 3; a newest-first sort would be 3, 2, 1. // Enumeration order 2, 1, 3; a newest-first sort would be 3, 2, 1.
const lib = new FakeLibrary( const lib = new FakeLibrary(
[collection(2, 200), collection(1, 300), collection(3, 100)], [collection(2, 200), collection(1, 300), collection(3, 100)],
@@ -146,8 +114,7 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
); );
const rows = await freshCollections(lib); const rows = await freshCollections(lib);
expect(lib.freshCollectionsCalls).toBe(1); expect(lib.freshCalls).toBe(1);
expect(lib.freshCalls).toBe(0);
expect(rows.map((c) => c.id)).toEqual([2, 1, 3]); expect(rows.map((c) => c.id)).toEqual([2, 1, 3]);
// The projection's newest-first order is a different sequence, so this // The projection's newest-first order is a different sequence, so this
// is not accidentally that order. // is not accidentally that order.
@@ -158,25 +125,18 @@ 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 brings the collection alone up to date, lists in enumeration order, keeps raw fields", async () => { it("freshFiles refreshes first, 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. Collection 2's file is not // newest-first sort would reverse them.
// 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( const lib = new FakeLibrary([collection(1, 100)], files);
[collection(1, 100), collection(2, 100)],
files,
);
const rows = await freshFiles(lib, 1); const rows = await freshFiles(lib, 1);
expect(lib.freshCollectionsCalls).toBe(1); expect(lib.freshCalls).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
@@ -206,49 +166,10 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
]); ]);
}); });
// Without a collection ID, `files` lists every file in the account once
// (issue #211). File 11 is in collections 1 and 2; collection 2 is
// enumerated first, so 11 is listed once, with collection 2's record.
it("freshFiles without a collection ID lists every file once, from the first collection holding it", async () => {
const files = [
file(10, 1, 1_700_000_000_000_000),
file(11, 1, 1_700_000_000_000_001),
file(20, 2, 1_700_000_000_000_002),
file(11, 2, 1_700_000_000_000_001),
];
const lib = new FakeLibrary(
[collection(2, 100), collection(1, 200)],
files,
);
const rows = await freshFiles(lib);
expect(lib.freshCalls).toBe(1);
expect(rows?.map((f) => [f.id, f.collectionID])).toEqual([
[20, 2],
[11, 2],
[10, 1],
]);
});
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.freshCollectionsCalls).toBe(1); expect(lib.freshCalls).toBe(1);
expect(lib.refreshCollectionIDs).toEqual([]);
expect(lib.freshCalls).toBe(0);
expect(rows).toBeUndefined(); expect(rows).toBeUndefined();
}); });
+1 -8
View File
@@ -6,7 +6,6 @@ import { PassThrough } from "node:stream";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { ApiError } from "../../src/api/client.js"; import { ApiError } from "../../src/api/client.js";
import { stderrLog } from "../../src/cli-log.js";
import { run } from "../../src/cli-run.js"; import { run } from "../../src/cli-run.js";
// A stream whose written text is kept in `text`; writes finish at once, so // A stream whose written text is kept in `text`; writes finish at once, so
@@ -24,13 +23,7 @@ const runToExit = async (
const stdout = collector(); const stdout = collector();
const stderr = collector(); const stderr = collector();
const code = await new Promise<number>((resolve) => { const code = await new Promise<number>((resolve) => {
void run( void run(command, stdout.stream, stderr.stream, resolve);
command,
stdout.stream,
stderr.stream,
stderrLog(stderr.stream, "info"),
resolve,
);
}); });
return { code, stdout: stdout.text(), stderr: stderr.text() }; return { code, stdout: stdout.text(), stderr: stderr.text() };
}; };
-109
View File
@@ -1,109 +0,0 @@
{
"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
@@ -1,129 +0,0 @@
// `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"]);
});
});
-47
View File
@@ -1923,53 +1923,6 @@ describe("downloadFile live photos", () => {
]); ]);
}); });
// A file system refuses a file name over 255 bytes. Each name is cut
// short once its own extension is in it.
const imageName = `${"t".repeat(250)}.heic`;
const videoName = `${"t".repeat(251)}.mov`;
// The title fits, but ".heic" is one byte longer than ".jpg".
it("cuts the image's name short for a 255-byte title ending in .jpg when no outPath is given", async () => {
const title = `${"t".repeat(251)}.jpg`;
const t = setup(livePhotoZip(), { ...livePhoto, title });
const result = await inDirectory(t.dir, () =>
downloadFile(t.api, t.file),
);
expect(Buffer.byteLength(title)).toBe(255);
expect(Buffer.byteLength(imageName)).toBe(255);
expect(Buffer.byteLength(videoName)).toBe(255);
expect(result.path).toBe(imageName);
expect(result.videoPath).toBe(videoName);
expect(readdirSync(t.dir).sort()).toEqual([imageName, videoName]);
});
it("cuts the names of the image and the video short for a 300-byte title with no extension when no outPath is given", async () => {
const t = setup(livePhotoZip(), {
...livePhoto,
title: "t".repeat(300),
});
const result = await inDirectory(t.dir, () =>
downloadFile(t.api, t.file),
);
expect(result.path).toBe(imageName);
expect(result.videoPath).toBe(videoName);
expect(readdirSync(t.dir).sort()).toEqual([imageName, videoName]);
});
// The caller's path is used as is; only the extension is each part's own.
it("keeps an outPath too long for a file name as given", async () => {
const t = setup(livePhotoZip(), livePhoto);
const outPath = join(t.dir, `${"t".repeat(251)}.jpg`);
await expect(downloadFile(t.api, t.file, outPath)).rejects.toThrow(
/ENAMETOOLONG/,
);
});
it("gives each part its own extension from the ZIP, letters and digits only", async () => { it("gives each part its own extension from the ZIP, letters and digits only", async () => {
const zip = livePhotoZip({ "image.JPG": IMAGE, "video.m-4v": VIDEO }); const zip = livePhotoZip({ "image.JPG": IMAGE, "video.m-4v": VIDEO });
const t = setup(zip, livePhoto); const t = setup(zip, livePhoto);
+3 -65
View File
@@ -15,7 +15,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
defaultFileName,
safeExtension, safeExtension,
sanitizeFileName, sanitizeFileName,
shortenKeepingExtension, shortenKeepingExtension,
@@ -142,17 +141,9 @@ describe("shortenKeepingExtension", () => {
); );
}); });
// A name cut down to its extension would start with a dot, and so be it("cuts the extension short when it alone does not fit", () => {
// hidden.
it("keeps the first character before the extension, cutting the extension short to leave room for it", () => {
expect(shortenKeepingExtension(`b.${"e".repeat(300)}`, 255)).toBe( expect(shortenKeepingExtension(`b.${"e".repeat(300)}`, 255)).toBe(
`b.${"e".repeat(253)}`, `.${"e".repeat(254)}`,
);
expect(shortenKeepingExtension(`ab.${"e".repeat(300)}`, 255)).toBe(
`a.${"e".repeat(253)}`,
);
expect(shortenKeepingExtension(`é.${"e".repeat(300)}`, 255)).toBe(
`é.${"e".repeat(252)}`,
); );
// "€" takes three bytes, so the extension is cut to 253 and the // "€" takes three bytes, so the extension is cut to 253 and the
// part before it keeps the two bytes left over, for the "a". // part before it keeps the two bytes left over, for the "a".
@@ -170,59 +161,6 @@ describe("shortenKeepingExtension", () => {
).toBe(`${"t".repeat(247)} (7).jpg`); ).toBe(`${"t".repeat(247)} (7).jpg`);
expect( expect(
shortenKeepingExtension(`b.${"e".repeat(300)}`, 255, " (7)"), shortenKeepingExtension(`b.${"e".repeat(300)}`, 255, " (7)"),
).toBe(`b (7).${"e".repeat(249)}`); ).toBe(` (7).${"e".repeat(250)}`);
});
});
describe("defaultFileName", () => {
it("is the title when it is safe and fits", () => {
expect(defaultFileName("IMG_0001.HEIC", 42)).toBe("IMG_0001.HEIC");
});
it("replaces unsafe characters, and is file-<id> for an empty title", () => {
expect(defaultFileName("../x.jpg", 42)).toBe("__x.jpg");
expect(defaultFileName("", 42)).toBe("file-42");
});
it("cuts a title over 255 bytes short, keeping its extension", () => {
expect(defaultFileName(`${"t".repeat(296)}.jpg`, 42)).toBe(
`${"t".repeat(251)}.jpg`,
);
expect(defaultFileName(`b.${"e".repeat(300)}`, 42)).toBe(
`b.${"e".repeat(253)}`,
);
});
it("puts `prefix` before the title and leaves room for it", () => {
expect(defaultFileName("beach.jpg", 42, { prefix: "thumb_" })).toBe(
"thumb_beach.jpg",
);
expect(
defaultFileName(`${"t".repeat(296)}.jpg`, 42, { prefix: "thumb_" }),
).toBe(`thumb_${"t".repeat(245)}.jpg`);
});
// A live photo's image and video are each named with their own
// extension, and the name is cut short once that extension is in it.
it("puts `ext` in place of the title's extension before cutting the name short", () => {
expect(defaultFileName("IMG_0001.HEIC", 42, { ext: ".mov" })).toBe(
"IMG_0001.mov",
);
expect(defaultFileName("", 42, { ext: ".mov" })).toBe("file-42.mov");
expect(
defaultFileName(`${"t".repeat(251)}.jpg`, 42, { ext: ".heic" }),
).toBe(`${"t".repeat(250)}.heic`);
expect(defaultFileName("t".repeat(300), 42, { ext: ".heic" })).toBe(
`${"t".repeat(250)}.heic`,
);
});
// Cutting `CONSOLE` or `NULL` short before a long extension leaves `CON`
// or `NUL`, so the name gets the `_` `sanitizeFileName` gives a device
// name, and the part before the extension gives up a byte for it.
it("never returns a Windows device name, even once cut short", () => {
const ext = `.${"e".repeat(251)}`;
expect(defaultFileName(`CONSOLE${ext}`, 42)).toBe(`_CO${ext}`);
expect(defaultFileName(`NULL${ext}`, 42)).toBe(`_NU${ext}`);
}); });
}); });
-339
View File
@@ -14,17 +14,6 @@
* three. * three.
* 3. A refresh that fails rejects the fresh read (currency was unavailable), * 3. A refresh that fails rejects the fresh read (currency was unavailable),
* while the default reads stay silent and keep serving the last good copy. * while the default reads stay silent and keep serving the last good copy.
* 4. `freshCollections()` reads the collection list alone, with one request
* and no collection's files, and stores nothing, so the next refresh still
* 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
@@ -38,7 +27,6 @@ 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";
@@ -153,26 +141,6 @@ 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;
@@ -302,310 +270,3 @@ describe("Library.fresh", () => {
} }
}); });
}); });
describe("Library.freshCollections", () => {
let dir: string;
let cacheDirectory: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "quak-fresh-collections-"));
cacheDirectory = join(dir, "cache");
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
// A refresh from a stored cursor past 0 fetches a collection's files only
// when the collection is new or its updationTime has advanced past the
// stored copy's. So does `refreshCollection()`. Neither would fetch the
// files of a collection stored here without them.
it("asks for the collection list alone and stores nothing", async () => {
const client = new MockClient();
const page = {
collections: [collection(2, 100), collection(1, 110)],
deleted: [],
cursor: 110,
};
client.collectionsQueue.push(page, page);
client.filesFor(1, {
files: [file(1001, 1, 90)],
deleted: [],
cursor: 90,
});
client.filesFor(2, {
files: [file(2001, 2, 95)],
deleted: [],
cursor: 95,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshOnOpen: false,
refreshIntervalSeconds: SLOW_INTERVAL,
});
try {
// One request, the whole list in the server's order, and no
// collection's files.
const collections = await lib.freshCollections();
expect(collections.map((c) => c.id)).toEqual([2, 1]);
expect(collections.map((c) => c.name)).toEqual([
"album-2",
"album-1",
]);
expect(client.collectionsSinceTimes).toEqual([0]);
expect(client.filesCalls).toEqual([]);
expect(lib.listCollections()).toEqual([]);
// The next refresh still asks from the beginning and fetches the
// files of both collections.
const reads = await lib.fresh();
expect(client.collectionsSinceTimes).toEqual([0, 0]);
expect(reads.photos.byID({ fileID: 1001 })?.fileID).toBe(1001);
expect(reads.photos.byID({ fileID: 2001 })?.fileID).toBe(2001);
} finally {
await lib.close();
}
});
});
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 -107
View File
@@ -20,15 +20,12 @@
* 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 stored cursor. At 0, as on an empty cache, it * 7. `open()` branches on the cache: an empty cache awaits the first refresh
* awaits the first refresh. Past 0 it serves the cache's copy at once and * (it has nothing to serve yet); an existing cache serves its copy at once
* refreshes in the background, so a slow or dead server never stalls * and 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.
* 9. With `refreshOnOpen: false`, `open()` starts no refresh at all, and a
* refresh reports through `onProgress` each collection whose files it
* fetched.
* *
* The client is a mock: no crypto, no network. It serves scripted pages and * The client is a mock: no crypto, no network. It serves scripted pages and
* records the `sinceTime` each call carried so cursor threading is provable. * records the `sinceTime` each call carried so cursor threading is provable.
@@ -584,107 +581,6 @@ describe("Library.open and background refresh", () => {
} }
}); });
// A caller that asks for each refresh it needs, as the CLI's read commands
// do, opens without one: on an empty cache the refresh `open()` would
// await fetches the files of every collection, which `quak collections`
// never needs (issue #210).
it("starts no refresh as it opens when refreshOnOpen is false", async () => {
const client = new MockClient();
client.collectionsQueue.push({
collections: [collection(1, 100)],
deleted: [],
cursor: 100,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshOnOpen: false,
refreshIntervalSeconds: Infinity,
});
try {
expect(client.collectionsSinceTimes).toEqual([]);
expect(lib.listCollections()).toEqual([]);
expect(lib.status().lastRefreshAt).toBeUndefined();
// The refresh comes when the caller asks for it.
await lib.fresh();
expect(client.collectionsSinceTimes).toEqual([0]);
expect(lib.listCollections().map((c) => c.id)).toEqual([1]);
} finally {
await lib.close();
}
});
// A refresh fetches the files of each collection that changed, one
// collection after another, and stores nothing until it has them all. On
// a large account that takes a while, so it reports each collection whose
// files it has fetched, and how many it will fetch in all (issue #210).
it("reports each collection whose files a refresh fetched", async () => {
const events: RefreshEvent[] = [];
const client = new MockClient();
client.collectionsQueue.push({
collections: [
collection(1, 100),
collection(2, 100),
collection(3, 100),
],
deleted: [],
cursor: 100,
});
const lib = await Library.open({
client,
cacheDirectory,
refreshIntervalSeconds: Infinity,
onProgress: (e) => events.push(e),
});
try {
expect(events).toEqual([
{ operation: "refresh", status: "started" },
{
operation: "refresh",
status: "progress",
fetched: 1,
total: 3,
},
{
operation: "refresh",
status: "progress",
fetched: 2,
total: 3,
},
{
operation: "refresh",
status: "progress",
fetched: 3,
total: 3,
},
{ operation: "refresh", status: "done" },
]);
// Only collection 2 changed since, so the next refresh fetches
// its files alone and counts one.
events.length = 0;
client.collectionsQueue.push({
collections: [collection(2, 200)],
deleted: [],
cursor: 200,
});
await lib.fresh();
expect(events.filter((e) => e.status === "progress")).toEqual([
{
operation: "refresh",
status: "progress",
fetched: 1,
total: 1,
},
]);
} finally {
await lib.close();
}
});
it("keeps a save failure visible until a save actually succeeds", async () => { it("keeps a save failure visible until a save actually succeeds", async () => {
const saveSpy = vi const saveSpy = vi
.spyOn(MetadataStore.prototype, "save") .spyOn(MetadataStore.prototype, "save")
+1 -2
View File
@@ -4,8 +4,7 @@
* 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 refresh or `refreshCollection()` changes the derived records. The * a background refresh actually changes the derived records. The contracts:
* 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.
+1 -20
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 did not decrypt is damaged", async () => { it("loads an empty store when the list of what the refresh could 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,23 +266,4 @@ 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],
]);
});
}); });
-31
View File
@@ -555,36 +555,6 @@ describe("withRetry", () => {
expect(delays).toHaveLength(2); expect(delays).toHaveLength(2);
}); });
it("tells `onRetry` of each attempt it retries, before the wait", async () => {
// A caller that logs retries, as the CLI does with `-v`, learns why
// each attempt failed and how long the next one is put off. The last
// attempt is not retried, so it is not reported.
const { sleep, delays } = recordingSleep();
const retries: [string, number, number][] = [];
let calls = 0;
const failure = withRetry(
() => {
calls++;
return Promise.reject(new ApiError(`attempt ${calls}`, 503));
},
{
attempts: 3,
sleep,
random: () => 0.5,
onRetry: (err, attempt, delayMs) => {
retries.push([(err as Error).message, attempt, delayMs]);
},
},
);
await expect(failure).rejects.toThrow("attempt 3");
expect(retries).toEqual([
["attempt 1", 1, 250],
["attempt 2", 2, 500],
]);
expect(delays).toEqual([250, 500]);
});
it("does not retry at all when attempts is 1", async () => { it("does not retry at all when attempts is 1", async () => {
const { sleep, delays } = recordingSleep(); const { sleep, delays } = recordingSleep();
let calls = 0; let calls = 0;
@@ -738,7 +708,6 @@ describe("retry defaults", () => {
expect(resolved.maxDelayMs).toBe(DEFAULT_RETRY_OPTIONS.maxDelayMs); expect(resolved.maxDelayMs).toBe(DEFAULT_RETRY_OPTIONS.maxDelayMs);
expect(typeof resolved.sleep).toBe("function"); expect(typeof resolved.sleep).toBe("function");
expect(typeof resolved.random).toBe("function"); expect(typeof resolved.random).toBe("function");
expect(typeof resolved.onRetry).toBe("function");
}); });
it("resolves to the defaults when given nothing", () => { it("resolves to the defaults when given nothing", () => {
+3 -26
View File
@@ -544,22 +544,11 @@ describe("listMissingThumbnails", () => {
const client = await login(counted.fetch, { ...noWait }); const client = await login(counted.fetch, { ...noWait });
const lib = await openLib(client); const lib = await openLib(client);
const errors: string[] = []; const missing = await listMissingThumbnails(lib, client);
const missing = await listMissingThumbnails(
lib,
client,
(msg, level) => {
if (level === "error") errors.push(msg);
},
);
lib.close(); lib.close();
// Only the genuinely empty thumbnail is reported. The file that // Only the genuinely empty thumbnail is reported.
// could not be checked is reported as an error.
expect(missing.map((m) => m.fileID)).toEqual([101]); expect(missing.map((m) => m.fileID)).toEqual([101]);
expect(errors).toEqual([
expect.stringMatching(/^\[.*\] Could not check file-102\.jpg: /),
]);
// And the 500 was retried rather than accepted as an answer: four // And the 500 was retried rather than accepted as an answer: four
// attempts is the library default. // attempts is the library default.
expect(counted.matched()).toBe(4); expect(counted.matched()).toBe(4);
@@ -710,15 +699,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock)); const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client); const lib = await openLib(client);
const errors: string[] = []; const results = await fixMissingThumbnails(lib, client, [101, 102]);
const results = await fixMissingThumbnails(
lib,
client,
[101, 102],
(msg, level) => {
if (level === "error") errors.push(msg);
},
);
lib.close(); lib.close();
expect(results.length).toBe(2); expect(results.length).toBe(2);
@@ -726,10 +707,6 @@ describe("fixMissingThumbnails", () => {
const failure = results.find((r) => r.fileID === 102)!; const failure = results.find((r) => r.fileID === 102)!;
expect(success.status).toBe("fixed"); expect(success.status).toBe("fixed");
expect(failure.status).toBe("failed"); expect(failure.status).toBe("failed");
// The failed file, and only it, is reported as an error.
expect(errors).toEqual([
expect.stringMatching(/^\[.*\] FAILED file-102\.jpg: /),
]);
}); });
it("skips a non-JPEG image and a video as unsupported, not failed (issue #17)", async () => { it("skips a non-JPEG image and a video as unsupported, not failed (issue #17)", async () => {