check / check (push) Waiting to run
The documents gate on `next` failed five README sentences (#219 (comment)). Four are rewritten after a check against the code: - the keywords score sentence is split in two; - `mlData` drops the whole `clip` block, as `src/backup.ts` and `src/metadata-backup.ts` do; - a file name gets its ID past 255 bytes and a collection name past 250, and either is then cut short, as `uniqueNames` does; - `lastTriedAt` is taken once per run, after its refresh. The IDs sentence keeps `-1`: the pinned option parser passes a negative number to a command with no subcommands as an argument, so `quak get -1` reaches the ID check. Nothing else changes; `make fmt` reflowed the touched paragraphs. Model: fable-5-1 (change); opus-5-5 (landing message)
1962 lines
97 KiB
Markdown
1962 lines
97 KiB
Markdown
# quak
|
|
|
|
quak is a WTFPL-licensed TypeScript library and command-line tool by
|
|
[@sneak](https://sneak.berlin) for reading and backing up an
|
|
[Ente](https://ente.io) photo account.
|
|
|
|
Ente is a photo service that encrypts photos end to end. The server never sees a
|
|
photo, its name or its date unencrypted. Only a client that holds the account's
|
|
keys can read them. quak is such a client, built for scripts and for jobs that
|
|
run unattended. It logs in with the account's email and password, derives the
|
|
keys, and keeps a decrypted copy of the account's album and file lists in a
|
|
local cache.
|
|
|
|
With the command line you can:
|
|
|
|
- list the account's albums and the files in them;
|
|
- download and decrypt one file, or its thumbnail;
|
|
- back up every file in the account into a directory tree that a photo viewer
|
|
can open, and keep that backup current from cron;
|
|
- write the metadata quak keeps about the account, its albums and its files as
|
|
JSON;
|
|
- find files whose thumbnails are missing on the server, and repair them.
|
|
|
|
The library underneath lets a program of your own read and back up the account.
|
|
It keeps the account's album and file lists on disk and answers from that copy
|
|
without asking the server.
|
|
|
|
A backup run never prompts and never stops on one bad file. It skips files that
|
|
are already on disk, records each failure in a file, and tries the failed files
|
|
again on the next run. Every download is decrypted on its way to disk.
|
|
|
|
## Getting Started
|
|
|
|
The commands below need `git`, Node 22 or later and `yarn`.
|
|
|
|
```bash
|
|
git clone https://git.eeqj.de/sneak/quak.git
|
|
cd quak
|
|
yarn install
|
|
yarn build
|
|
|
|
# Log in. quak asks for the email and the password, and for a code when
|
|
# the account needs one.
|
|
yarn quak login
|
|
|
|
# List the account's collections. Ente calls an album a collection.
|
|
yarn quak collections
|
|
|
|
# List every file in the account.
|
|
yarn quak files
|
|
|
|
# List the files in one collection.
|
|
yarn quak files 12345
|
|
|
|
# Download and decrypt one file.
|
|
yarn quak get 67890 --out ./photo.jpg
|
|
|
|
# Back up every file in the account.
|
|
yarn quak backup ./my-backup
|
|
```
|
|
|
|
`yarn quak` runs the command line that `yarn build` wrote to `dist/`. To run
|
|
quak from cron, build it as one binary instead; see "Running the backup from
|
|
cron".
|
|
|
|
### Using the library
|
|
|
|
The library keeps a local copy of the account's album and file lists, the cache.
|
|
It brings the cache up to date with the server in rounds called refreshes.
|
|
|
|
quak is not published to a package registry: `package.json` marks it `private`,
|
|
and `dist/` is not committed. Clone and build it as above, then add the clone to
|
|
your own program's `package.json` by its path, for example
|
|
`yarn add file:../quak`. The package's `main`, `types` and `exports` fields
|
|
point into `dist/`, so the clone must be built first. The package is an ES
|
|
module only, so a CommonJS program cannot `require` it.
|
|
|
|
```ts
|
|
import { Client, Library } from "quak";
|
|
|
|
// Log in once. The client holds the account's keys in memory.
|
|
const client = await Client.login({
|
|
email: "you@example.com",
|
|
password: "your-password",
|
|
});
|
|
|
|
// Open the library. On an empty cache this waits for one refresh. On an
|
|
// existing cache it returns at once and refreshes in the background.
|
|
const lib = await Library.open({
|
|
client,
|
|
// Without these two, open() also starts downloading every thumbnail in
|
|
// the account, and the originals of the favorites album and the latest
|
|
// week, into the cache. It fetches machine-learning data after each
|
|
// refresh either way.
|
|
precacheThumbnails: false,
|
|
precacheOriginals: false,
|
|
});
|
|
|
|
// Default reads answer from the cache and never touch the network.
|
|
for (const album of lib.albums.list()) {
|
|
console.log(album.collectionID, album.name);
|
|
for (const photo of album.photos.list()) {
|
|
console.log(` ${photo.title} [${photo.fileType}]`);
|
|
}
|
|
}
|
|
|
|
// A fresh read waits for a refresh first.
|
|
const { albums } = await lib.fresh();
|
|
console.log(`${albums.list().length} albums as of now`);
|
|
|
|
// Fetch one photo's original. The bytes land in the cache.
|
|
const photo = lib.photos.byID({ fileID: 12345 });
|
|
if (photo) {
|
|
const { path } = await photo.original();
|
|
console.log(`original at ${path}`);
|
|
}
|
|
|
|
await lib.close();
|
|
```
|
|
|
|
`Library.open({ client })` with no other options does more than the example asks
|
|
for. It refreshes every 3 seconds until `close()`. It also downloads, in the
|
|
background, every thumbnail in the account and the originals of the favorites
|
|
album and of the latest week (see "Opening a library"). The example turns those
|
|
two downloads off. After each refresh that succeeds, the library also fetches
|
|
each file's machine-learning data into the cache. That is the faces and the
|
|
other data Ente's apps compute for a photo (see "The JSON beside each
|
|
original"). No option in `LibraryOptions` turns this off. A `client` without a
|
|
`fetchMLData` method fetches none, which is how quak's own read commands open
|
|
their library.
|
|
|
|
The `Client` under the library handles login, saved sessions and the raw
|
|
requests. The "Library reference" below covers both.
|
|
|
|
## Running the backup from cron
|
|
|
|
`quak backup` never prompts, so cron can run it. `make install` builds quak as
|
|
one binary and copies it to `~/bin/quak`. Besides what Getting Started needs,
|
|
`make install` needs `make` and [nix](https://nixos.org/download). It uses bun,
|
|
a JavaScript runtime, to bundle the built `dist/` into one executable. It runs
|
|
bun through `nix-shell -p bun`.
|
|
|
|
Log in once with `~/bin/quak`, as the user the cron job will run as:
|
|
|
|
```bash
|
|
~/bin/quak login
|
|
```
|
|
|
|
The session is saved in that user's data directory (see "The session file").
|
|
Then add the backup to that user's crontab with `crontab -e`. These two lines
|
|
back up the account to `~/photos-backup` at 03:30 every night and append all
|
|
output to `~/quak-backup.log`. Sunday's run adds `--verify`:
|
|
|
|
```
|
|
30 3 * * 1-6 $HOME/bin/quak backup $HOME/photos-backup >> $HOME/quak-backup.log 2>&1
|
|
30 3 * * 0 $HOME/bin/quak backup --verify $HOME/photos-backup >> $HOME/quak-backup.log 2>&1
|
|
```
|
|
|
|
A run with `--verify` does all a plain run does. It also hashes each original
|
|
already in the backup and compares the result with the content hash. The content
|
|
hash is the hash of the file's bytes that Ente recorded at upload. The run
|
|
fetches a new copy of each original that does not match, and replaces the
|
|
original once the copy matches. Sunday's run is the `--verify` run, rather than
|
|
a second job that night, because two backups of one directory cannot run at
|
|
once. The second one exits 2 without doing anything.
|
|
|
|
Cron runs the job with a short `PATH`, usually `/usr/bin:/bin`, so the crontab
|
|
names quak by its full path. quak finds the saved session through `HOME`, which
|
|
cron sets from `/etc/passwd`. On Linux it also reads `XDG_DATA_HOME`: the
|
|
session is in `$XDG_DATA_HOME/quak`, or in `~/.local/share/quak` when that
|
|
variable is not set. Cron does not set `XDG_DATA_HOME`. If your login shell sets
|
|
it, set it at the top of the crontab too, as a full path, because cron does not
|
|
expand variables there:
|
|
|
|
```
|
|
XDG_DATA_HOME=/home/you/.data
|
|
```
|
|
|
|
On macOS the session is in `~/Library/Application Support/quak`, and only `HOME`
|
|
matters.
|
|
|
|
A backup that ends with failures exits 1, and its last line says how many files
|
|
and albums failed. The next run fetches each missing original again and rebuilds
|
|
the album links. The exit codes are listed under "Exit codes", and the backup's
|
|
files under "The backup directory".
|
|
|
|
## Command reference
|
|
|
|
Every command has the form `quak [options] <command> [arguments]`. The options
|
|
below belong to quak itself, and go before or after the command's name.
|
|
|
|
### Global options
|
|
|
|
- `--cache-dir <path>`: where the local cache lives. Without it, each account
|
|
gets its own directory under the per-user cache directory (see "The cache").
|
|
- `-v`, `--verbose`: log more on stderr. `-v` adds debug lines, and `-vv` adds
|
|
trace lines too (see "What quak prints").
|
|
- `-q`, `--quiet`: print nothing on stderr but the reason a failing command
|
|
exits. `-q` with `-v` is refused with one line,
|
|
`error: option '-q, --quiet' cannot be used with option '-v, --verbose'`, and
|
|
exit status 1.
|
|
- `-V`, `--version`: print the version and exit (see "Version").
|
|
- `-h`, `--help`: print the usage and exit.
|
|
|
|
### The commands
|
|
|
|
```
|
|
quak login
|
|
quak whoami
|
|
quak logout
|
|
quak collections [--json]
|
|
quak files [collectionID] [--json]
|
|
quak get <fileID> [--out <path>]
|
|
quak get-thumb <fileID> [--out <path>]
|
|
quak backup <dir> [--json] [--verify]
|
|
quak backup-metadata <dir> [--exif]
|
|
quak helper list-missing-thumbnails [--json]
|
|
quak helper fix-missing-thumbnails [--file <id>...] [--json]
|
|
```
|
|
|
|
Every command but `login` and `logout` needs a saved session. Every command but
|
|
`login`, `whoami` and `logout` opens the library. How each one gets current data
|
|
from the server is described under "How a command gets current data".
|
|
|
|
#### `quak login`
|
|
|
|
Logs in to an account and saves the session. It reads the email from
|
|
`QUAK_EMAIL` and the password from `QUAK_PASSWORD`. For each one that is not
|
|
set, it asks on the terminal. The password shows as `*` characters while it is
|
|
typed.
|
|
|
|
When the account has a second factor, quak asks for the code. It asks
|
|
`TOTP code: ` for a time-based code from an authenticator app. An account whose
|
|
only second factor is a passkey cannot log in with quak. When the account has
|
|
email verification on, quak asks `Email verification code: ` for the code sent
|
|
by email. The password is then used only to unlock the keys. After the email
|
|
code quak expects the login to complete. When the server asks for a second
|
|
factor at that point, login fails.
|
|
|
|
On success it logs `Logged in as <email> (user <id>)` and
|
|
`Session saved to <path>`. On failure it logs `Login failed: <reason>`, and the
|
|
exit status is 1. `login` is the only command that prompts, and its prompts go
|
|
to stdout.
|
|
|
|
#### `quak whoami`
|
|
|
|
Prints the saved session's account on stdout as one line of JSON:
|
|
`{"email":"you@example.com","userID":12345}`. It reads the session file only and
|
|
sends no request, so it succeeds even when the server no longer accepts the
|
|
session.
|
|
|
|
#### `quak logout`
|
|
|
|
Ends the session on the server and deletes the session file. Ending it on the
|
|
server makes the saved token stop working everywhere, even in a copy of the
|
|
file. With no session file, the command logs `No session found.` and exits 0.
|
|
|
|
When the server call fails, or the session file is corrupt, the file is still
|
|
deleted. The command then logs
|
|
`Could not end the session on the server: <reason>` and exits 1.
|
|
|
|
`logout` does not delete the cache. It warns that the cache directory still
|
|
holds decrypted data. It knows the directory from `--cache-dir` or from a
|
|
session file it can read. Delete that directory yourself if you want the data
|
|
gone.
|
|
|
|
#### `quak collections [--json]`
|
|
|
|
Lists every collection in the account, in the server's order, after one request
|
|
to the server.
|
|
|
|
Without `--json`, each line holds the collection's ID, its type and its name,
|
|
separated by tabs. A collection another account owns gets ` (shared)` after its
|
|
name. A collection's type is one of `album`, `folder`, `favorites`,
|
|
`uncategorized` and `unknown`.
|
|
|
|
With `--json`, the output is a JSON array with one object per collection. Each
|
|
object holds `id`, `name`, `type`, `ownerID`, `isShared` and `updationTime`.
|
|
Update times are in microseconds, as Ente records them.
|
|
|
|
#### `quak files [collectionID] [--json]`
|
|
|
|
With a collection ID, lists the files in that collection, in the server's order.
|
|
Without one, lists every file in the account once, collection by collection. A
|
|
file that is in several collections is listed under the first collection that
|
|
holds it.
|
|
|
|
Without `--json`, each line holds the file's ID, its type and its title,
|
|
separated by tabs. A file's type is one of `image`, `video`, `livePhoto` and
|
|
`unknown`. With `--json`, each object holds `id`, `title`, `fileType`,
|
|
`creationTime` in microseconds and `collectionID`.
|
|
|
|
A collection ID that does not exist gives `Collection <id> not found` and exit
|
|
status 1.
|
|
|
|
#### `quak get <fileID> [--out <path>]`
|
|
|
|
Downloads and decrypts one file's original, the full-size file as uploaded.
|
|
Without `--out`, it writes the file to the working directory under the file's
|
|
title, with unsafe characters replaced (see "File names from the server"). With
|
|
`--out`, it writes to that path as given. It logs `<bytes> bytes -> <path>`. The
|
|
original is kept in the cache as well.
|
|
|
|
A file ID that no collection holds gives `File <id> not found`, and the exit
|
|
status is 1. The option `--collection <id>` is accepted and ignored.
|
|
|
|
A live photo is a still image and a short video that Ente stores as one file.
|
|
`get` writes both, each named after the title with its own extension, as Ente's
|
|
own clients name them: `IMG_0001.heic` and `IMG_0001.mov`. With `--out <path>`,
|
|
the image goes to `<path>` and the video beside it, with the same name and the
|
|
video's extension. A `<path>` that already ends in the video's extension is
|
|
refused, because both files would land on it.
|
|
|
|
#### `quak get-thumb <fileID> [--out <path>]`
|
|
|
|
Downloads and decrypts one file's thumbnail, the small JPEG Ente shows in a
|
|
grid. It writes one file, also for a live photo. Without `--out`, the name is
|
|
`thumb_` followed by the title. In all else it works as `get` does.
|
|
|
|
#### `quak backup <dir> [--json] [--verify]`
|
|
|
|
Backs up every file in the account to `<dir>`. The layout is described under
|
|
"The backup directory". The command downloads only the originals that are not
|
|
yet in the backup, and it goes on past a file that fails. A file is downloaded
|
|
once, however many collections hold it. An original the cache already holds is
|
|
copied from there instead.
|
|
|
|
Progress goes to stderr: one line per original fetched, and a `FAILED` line for
|
|
each original or album that failed. The run ends with a summary of counts and,
|
|
when any file failed, a `Failed files:` list of every failed file. With
|
|
`--json`, the summary and the list are replaced by one JSON object on stdout.
|
|
Its fields are:
|
|
|
|
- `totalFiles`: how many distinct files the run covered, however many
|
|
collections hold each;
|
|
- `downloaded`: how many originals this run fetched or copied from the cache;
|
|
- `skipped`: how many originals were already in the backup and left alone;
|
|
- `verified`, `mismatched` and `unchecked`: the counts a `--verify` run makes
|
|
(see "`--verify`"), all 0 without it;
|
|
- `failed`: how many files are in `failures.json` after this run;
|
|
- `errors`: this run's failures, one object per file, with `fileID`, `title`,
|
|
`collection` and `error`;
|
|
- `undecryptableAlbums`: the IDs of the collections quak could not decrypt;
|
|
- `failedAlbums`: the IDs of the collections whose directory or JSON could not
|
|
be written.
|
|
|
|
With `--verify`, the run also hashes each original already in the backup and
|
|
replaces any whose bytes do not match the content hash Ente records. "The backup
|
|
directory" describes what it checks and what it changes.
|
|
|
|
A backup refreshes the library as it starts and not again while it runs. Each
|
|
request it sends retries for longer than other commands' requests do (see
|
|
"Retries and timeouts").
|
|
|
|
The exit code is 0 when every original is in the backup and nothing failed. It
|
|
is 1 when files or albums failed, 2 when another backup of `<dir>` is running,
|
|
and 3 when there is no usable session (see "Exit codes").
|
|
|
|
#### `quak backup-metadata <dir> [--exif]`
|
|
|
|
Writes the metadata quak keeps about the account, its collections and its files
|
|
as JSON files under `<dir>`. The layout is described under "The metadata dump
|
|
directory". Photo content is not written. With `--exif`, or its alias `--all`,
|
|
the command also fetches each file's original and records the image's own
|
|
metadata from it (see "The metadata dump directory"). Each original it fetches
|
|
stays in the cache.
|
|
|
|
Ente's apps compute machine-learning data for each photo, such as the faces in
|
|
it (see "The JSON beside each original"). The command fetches it in requests of
|
|
up to 200 files. When a request fails, each of its files is written with the
|
|
reason in `mlDataError` instead of `mlData`, and the dump goes on. A file whose
|
|
data does not decrypt, decompress or parse also gets `mlDataError`. When any
|
|
file was written with `mlDataError`, the last line says how many and the exit
|
|
status is 1.
|
|
|
|
A collection or file that quak cannot decrypt is left out of the dump without a
|
|
word, and does not change the exit status.
|
|
|
|
#### `quak helper list-missing-thumbnails [--json]`
|
|
|
|
Finds the files whose thumbnail is missing on the server. It downloads the
|
|
thumbnail of every file you own, one collection at a time, newest updated first,
|
|
so on a large account it takes a while. A thumbnail counts as missing when the
|
|
server answers with an empty body or with HTTP 404. Any other failure is logged
|
|
as `[<collection>] Could not check <title>: <reason> (not reported as missing)`,
|
|
and the file is left out of the list. The command exits 0 even when some files
|
|
could not be checked.
|
|
|
|
The server lets only a file's owner replace its thumbnail. A file another
|
|
account owns, in a collection shared with you, is skipped and not checked.
|
|
|
|
Without `--json`, the command logs `<n> file(s) with missing thumbnails:` and
|
|
prints one line per file on stdout. Each line holds the file's ID, its title,
|
|
its collection and the reason, separated by tabs. With nothing missing it logs
|
|
`No missing thumbnails found.` With `--json`, the output is a JSON array; each
|
|
object holds `fileID`, `title`, `collection` and `reason`.
|
|
|
|
#### `quak helper fix-missing-thumbnails [--file <id>...] [--json]`
|
|
|
|
Makes a new thumbnail for each file that lacks one, and uploads it. Without
|
|
`--file`, it first finds the files as `list-missing-thumbnails` does. With
|
|
`--file`, it works on the files named and checks nothing else.
|
|
|
|
For each file, the helper:
|
|
|
|
1. downloads the original;
|
|
2. makes a JPEG thumbnail from it;
|
|
3. encrypts the thumbnail under the file's own key;
|
|
4. uploads it.
|
|
|
|
The server refuses a thumbnail larger than the one it already records for the
|
|
file. The helper therefore tries five encodings in turn and uploads the first
|
|
that fits. They run from 720 pixels on the long side at JPEG quality 50 down to
|
|
160 pixels at quality 20.
|
|
|
|
Each file ends in one of three states:
|
|
|
|
- `fixed`: a thumbnail was uploaded.
|
|
- `skipped`: the helper cannot make a thumbnail for the file, or the server
|
|
would refuse it. This covers:
|
|
- a file another account owns;
|
|
- a file that is not an image;
|
|
- an image that is not a JPEG;
|
|
- a file whose recorded thumbnail size is 0 or unknown;
|
|
- a file for which even the smallest encoding is too large.
|
|
- `failed`: the download, the encoding or the upload failed, or an ID given with
|
|
`--file` is held by no collection.
|
|
|
|
Without `--json`, the command ends with counts of fixed, skipped and failed
|
|
files. A `Skipped:` list and a `Failed files:` list follow when they have
|
|
entries. With `--json`, one JSON array on stdout replaces them; each object
|
|
holds `fileID`, `title`, `collection`, `status` and, for a skipped or failed
|
|
file, `reason`. When any file failed, the exit status is 1. A skipped file does
|
|
not change it. When nothing needs fixing, the command logs
|
|
`No missing thumbnails found.` and exits 0. With `--json` it then writes nothing
|
|
on stdout.
|
|
|
|
quak refuses the whole command, before any request, when one `--file` ID is
|
|
invalid (see "IDs").
|
|
|
|
### How a command gets current data
|
|
|
|
Every command that reads the account answers for the account as it is now, not
|
|
for whatever the cache last held. Each one gets current data this way:
|
|
|
|
- `collections` asks for the collection list alone, in one request. It fetches
|
|
no collection's files and changes nothing in the cache.
|
|
- `files` with a collection ID asks for the same list, then for that one
|
|
collection's files. It changes nothing in the cache either.
|
|
- `get`, `get-thumb` and `fix-missing-thumbnails --file` look the file up in the
|
|
cache after a refresh. On an empty cache, they ask for the collection list
|
|
instead, then for one collection's files at a time, and stop at the collection
|
|
that holds the file. `fix-missing-thumbnails --file` stops once it has found
|
|
every file named. These searches store nothing in the cache. The original or
|
|
thumbnail fetched is kept in the cache.
|
|
- `files` without a collection ID, `backup`, `backup-metadata` and the two
|
|
thumbnail helpers without `--file` wait for a refresh.
|
|
|
|
A refresh asks the server which collections changed since the last refresh and
|
|
fetches the files of each one, one collection after another. On an empty cache
|
|
that is every collection. On a large account this takes a while. Nothing is
|
|
stored until every collection is in. Meanwhile the command logs a line for each
|
|
collection fetched, such as `Fetched the files of 3 of 120 collections`.
|
|
|
|
When a refresh or a search fails, the command logs the error on one line and
|
|
exits 1, or 3 when the server no longer accepts the saved session. A collection
|
|
or file that quak cannot decrypt does not stop a refresh or a search: the
|
|
command leaves it out and answers from what did decrypt (see "Default reads and
|
|
fresh reads"). `backup` counts it as failed (see "What quak cannot decrypt").
|
|
|
|
### IDs
|
|
|
|
A collection ID or file ID given to `files`, `get`, `get-thumb` or
|
|
`fix-missing-thumbnails --file` is written in decimal digits and is at
|
|
most 9007199254740991. Anything else, such as `0x10`, `1e3` or `-1`, is refused
|
|
before any request is sent. The command logs `Invalid file ID: <arg>` or
|
|
`Invalid collection ID: <arg>` and exits 1.
|
|
|
|
### What quak prints
|
|
|
|
stdout holds a command's output, such as the listing or the JSON, and the
|
|
prompts of `login`. Every other line goes to stderr as a log line at one of six
|
|
levels. From the quietest, the levels are fatal, error, warning, info, debug and
|
|
trace. A run prints the lines at its own level and at every quieter one. The
|
|
level is info unless `-v`, `-vv` or `-q` says otherwise.
|
|
|
|
- Fatal: why a command exits non-zero. A command that runs and exits non-zero
|
|
logs at least one fatal line. A command that exits 1 because files or albums
|
|
failed logs it last, saying how many failed and where to look.
|
|
- Error: something that failed without ending the run. These lines are:
|
|
- the `FAILED` lines of `backup` and `fix-missing-thumbnails`;
|
|
- their `Failed files:` lists;
|
|
- the `ML data` lines of `backup-metadata`;
|
|
- `[<collection>] Could not check <title>` from the thumbnail helpers.
|
|
- Warning: something that did not fail but needs your attention. The only
|
|
warning is the one `logout` prints: the cache directory still holds decrypted
|
|
data.
|
|
- Info: everything else quak prints by default, such as a refresh's progress,
|
|
each original a backup fetches, and the summaries.
|
|
- Debug, with `-v`: each retry of a request, with why the attempt failed and how
|
|
long quak waits. Also the start and end of each refresh and of each fetch of
|
|
machine-learning data, with the error of one that failed.
|
|
- Trace, with `-vv`: each request quak sends, as its method, its URL and the
|
|
HTTP status of the answer or the error it failed with. The URL of a thumbnail
|
|
upload is logged without its query, which holds the upload's signature.
|
|
|
|
`-q` lowers the level 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 alone.
|
|
|
|
Two things on stderr are not log lines. A command line that quak cannot parse,
|
|
such as a missing argument, an unknown option or `-q` with `-v`, is refused with
|
|
an `error: …` message, and quak exits 1. `-q` does not hide the message. `quak`
|
|
with no command prints the usage on stderr and exits 1.
|
|
|
|
`yarn quak` prints yarn's own lines on stdout as well. To pipe a command's
|
|
output, run `yarn --silent quak files --json` or
|
|
`node dist/bin/quak.js files --json` instead.
|
|
|
|
With `--json`, a command's JSON on stdout takes the place of its summary on
|
|
stderr. For `backup`, that is the `--- Backup complete ---` line, the counts and
|
|
the `Failed files:` list. For `list-missing-thumbnails`, it is
|
|
`No missing thumbnails found.` or `<n> file(s) with missing thumbnails:`. For
|
|
`fix-missing-thumbnails`, it is the `--- Done ---` line, the counts, the
|
|
`Skipped:` list and the `Failed files:` list. Every other line is logged as
|
|
without `--json`.
|
|
|
|
### Exit codes
|
|
|
|
- `0`: the command succeeded. For `backup`, every original is in the backup and
|
|
no file or album failed.
|
|
- `1`: the command failed. For `backup`, the run finished with files in
|
|
`failures.json` or with albums it could not decrypt or write, and its last
|
|
line says how many. The next backup run tries again. An unexpected error is
|
|
printed as one line, `quak: <message>` (see "Error messages").
|
|
- `2`: another backup of the same directory is running. This run sent no request
|
|
and changed nothing. Only `backup` exits 2.
|
|
- `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 command runs
|
|
as.
|
|
|
|
### Error messages
|
|
|
|
An unexpected error is printed as one line, `quak: <message>`, with no stack
|
|
trace. `<message>` is the error's own message, followed by the message of each
|
|
error that caused it, separated by `: `. Some lines a command logs itself, such
|
|
as `File <id> not found` and `Login failed: <reason>`, have no prefix.
|
|
|
|
`yarn quak` runs quak under Node. Node's `fetch` reports every network failure
|
|
as `fetch failed`, with the reason as its cause. A run that could not reach the
|
|
server therefore prints, for example,
|
|
`quak: fetch failed: connect ECONNREFUSED 1.2.3.4:443`. When the server has
|
|
several addresses and none could be reached, the line gives the reason for each
|
|
one, separated by `, `.
|
|
|
|
The `~/bin/quak` that `make install` builds runs under bun. Bun gives its own
|
|
wording for such a failure, with no reason after it. A refused connection, for
|
|
example, prints
|
|
`quak: Unable to connect. Is the computer able to access the url?`.
|
|
|
|
A file whose download failed that way goes into the backup's `failures.json` as
|
|
a transient failure, with that error. So does each file whose machine-learning
|
|
data could not be fetched that way, with an error starting `ML data: `.
|
|
|
|
## Files quak writes
|
|
|
|
### The session file
|
|
|
|
`quak login` writes the session to `session.json` in quak's data directory. On
|
|
Linux that is `$XDG_DATA_HOME/quak/session.json`, or
|
|
`~/.local/share/quak/session.json` when the variable is not set. On macOS it is
|
|
`~/Library/Application Support/quak/session.json`. quak creates the file with
|
|
mode `0600`, in a directory it creates with mode `0700`.
|
|
|
|
The file holds the account's email, its user ID, the session token and the
|
|
account's three keys, in base64 and unencrypted. Treat it as you would treat the
|
|
password. A missing file means "not logged in". A file that cannot be read back
|
|
is reported as corrupt, with the reason. Run `quak logout`, then `quak login`.
|
|
|
|
A command that needs a session exits 3 when the file is missing or corrupt. It
|
|
also exits 3 when the server no longer accepts the session, and prints
|
|
`quak: the saved session is no longer valid; run "quak login"`. `quak backup`
|
|
learns this at its first refresh. By then it has written only its lock. When the
|
|
session stops working partway through a backup, each remaining file goes into
|
|
`failures.json`. That run exits 1, and the next run stops at its refresh with
|
|
status 3.
|
|
|
|
### The cache
|
|
|
|
Without `--cache-dir`, each account has its own cache directory:
|
|
|
|
- on Linux, `$XDG_CACHE_HOME/quak/<userID>`, or `~/.cache/quak/<userID>` when
|
|
the variable is not set;
|
|
- on macOS, `~/Library/Caches/quak/<userID>`.
|
|
|
|
The directory is readable by its owner only (mode `0700`), because it holds
|
|
decrypted data, file keys included.
|
|
|
|
```
|
|
<cacheDirectory>/
|
|
metadata.json the decrypted collection and file records,
|
|
the refresh cursor, and the list of what
|
|
did not decrypt
|
|
originals/<fileID>.<ext> cached originals
|
|
originals/<fileID>.livephoto.json
|
|
which of a live photo's two files is which
|
|
thumbnails/<fileID>.jpg cached thumbnails
|
|
mldata/
|
|
<fileID>.json one decrypted machine-learning payload per
|
|
file
|
|
clip.f32, clip.json the packed CLIP index and its list of IDs
|
|
fetched.json per-file fetch bookkeeping
|
|
```
|
|
|
|
When `metadata.json` belongs to another account than the one logged in, opening
|
|
the library deletes it and `mldata/` and starts from an empty cache. Cached
|
|
originals and thumbnails are kept. They are reached only through the records of
|
|
the current account, so another account's files are never served.
|
|
|
|
A file appears in the cache only once it is complete: each one is written to a
|
|
temporary file and renamed into place. Ente's clients record a hash of each
|
|
file's bytes at upload, the content hash. Every original whose metadata records
|
|
one is hashed as it is written. One that does not match is not stored: the
|
|
download fails with an error naming the file, so `quak get` exits 1. The hash is
|
|
Ente's: unkeyed BLAKE2b with a 64-byte output, in standard base64. A live photo
|
|
arrives as a ZIP of its image and its video and is unpacked as it is written.
|
|
Its two files are hashed separately and compared as `<imageHash>:<videoHash>`,
|
|
and neither is stored unless both match. quak refuses a live photo ZIP that
|
|
holds a second image or video. It also refuses one whose files unpack to more
|
|
than 20 times its size plus 16 MiB. An original with no recorded hash, uploaded
|
|
by a very old client, is stored unchecked.
|
|
|
|
The cache has a size limit for originals (see "Opening a library"). Thumbnails
|
|
have none. A live photo's image and video leave the cache together.
|
|
|
|
### The backup directory
|
|
|
|
`quak backup <dir>` produces this tree:
|
|
|
|
```
|
|
<dir>/
|
|
YYYY/YYYY-MM/YYYY-MM-DD/
|
|
YYYY-MM-DD.<fileID>.<ext> the original, at its save path (one per
|
|
file, two for a live photo)
|
|
YYYY-MM-DD.<fileID>.json the metadata quak keeps about the file,
|
|
plus its machine-learning data, its
|
|
keywords and its image metadata
|
|
YYYY-MM-DD.<fileID>.livephoto.json
|
|
which of a live photo's two files is which
|
|
collections/
|
|
<name>/
|
|
<title> -> ../../YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>
|
|
a symlink per file
|
|
<name>.json the collection's metadata and file list
|
|
account.json the account's email and user ID
|
|
backup.lock the lock a running backup holds
|
|
failures.json the files that failed and have not yet
|
|
succeeded
|
|
```
|
|
|
|
#### Save paths
|
|
|
|
Each original is stored once, at its save path:
|
|
`YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>` under `<dir>`. The date is
|
|
the day the photo was taken, in the time zone of the machine running quak. That
|
|
is the date set in Ente when the date was edited, and otherwise the file's
|
|
creation time. The extension is the one in the file's title, with its case kept.
|
|
It is `.bin` when the title has no extension, when the extension holds anything
|
|
but letters and digits, or when it is longer than 16 characters.
|
|
|
|
When the date or the time zone changes, the next run stores the original at its
|
|
new path and leaves the old copy where it is.
|
|
|
|
The library gives the same path as `photo.savePath` and writes it with
|
|
`photo.download()` (see "A photo's content").
|
|
|
|
#### The JSON beside each original
|
|
|
|
The JSON file beside an original holds:
|
|
|
|
- `id`, `collectionID`, `ownerID` and `updationTime`;
|
|
- `metadata`: the fields quak keeps from the file's own metadata. They are
|
|
`title`, `fileType`, `creationTime`, `modificationTime`, `latitude`,
|
|
`longitude` and `hash`. The last three are present only when the file has
|
|
them.
|
|
- `magicMetadata` and `pubMagicMetadata`, when the file has them. The magic
|
|
metadata is Ente's name for the fields its clients add later, such as an
|
|
edited name, an edited date or a caption.
|
|
|
|
Update times are in microseconds, as Ente records them.
|
|
|
|
The JSON also holds the machine-learning data Ente computed for the photo, as
|
|
`mlData`. Ente's apps detect faces in each photo and compute a CLIP embedding
|
|
for it, a list of 512 numbers that describes what the photo shows. `mlData`
|
|
holds everything but the `clip` block, which holds that embedding. In its place
|
|
the JSON holds `keywords`, derived from the embedding as described under
|
|
"Keywords". A file with no machine-learning data has neither field. A file whose
|
|
data holds no usable embedding has no `keywords`.
|
|
|
|
The backup waits for the library's fetch of machine-learning data to finish
|
|
before it writes the JSON files. When a file's data cannot be fetched,
|
|
decrypted, decompressed or parsed, the JSON holds the reason in `mlDataError`
|
|
instead of `mlData`. Such a file counts as failed and goes into `failures.json`
|
|
with an error starting `ML data: `. Nothing is cached for it, so the next run
|
|
asks for its data again. The JSON files are rewritten on every run, so data that
|
|
arrived since the last run appears.
|
|
|
|
Every file but a video also holds `imageMetadata`: what the original itself
|
|
records. That is its `format`, `width` and `height` for a JPEG, its EXIF tags as
|
|
`exif`, and its XMP packet as `xmp`. EXIF and XMP are the two blocks of metadata
|
|
a camera or an editor writes inside an image file. For a live photo, these come
|
|
from its image. An original with none of them gets `{}`. When the original
|
|
cannot be read, the reason is in `imageMetadataError` instead, and neither the
|
|
file nor the run fails.
|
|
|
|
EXIF is read with [exifreader](https://github.com/mattiasw/ExifReader) from any
|
|
image format it reads, such as JPEG, HEIC, AVIF, PNG and WebP. `exif` holds each
|
|
tag by name, with its `value`, `description` and `computed` value. An EXIF block
|
|
that exifreader finds but reads no tag from is kept in base64 as `exifRaw`, with
|
|
the reason in `exifError`.
|
|
|
|
The original is read for this when the run stores it, or when the JSON beside it
|
|
has neither `imageMetadata` nor `imageMetadataError`. Otherwise the two fields
|
|
are copied from the JSON being rewritten, so a run does not read every stored
|
|
original again.
|
|
|
|
#### Keywords
|
|
|
|
The Discover section of Ente's photos app sorts photos into 16 categories. quak
|
|
derives a file's keywords the way the app sorts it. The categories are:
|
|
|
|
- Identity (no videos)
|
|
- Screenshots (no videos)
|
|
- QR Codes (no videos)
|
|
- Receipts
|
|
- Notes
|
|
- Memes
|
|
- Visiting Cards (no videos)
|
|
- Babies
|
|
- Pets
|
|
- Selfies
|
|
- Wallpapers (no videos)
|
|
- Food
|
|
- Celebrations
|
|
- Sunset
|
|
- Hills
|
|
- Greenery
|
|
|
|
Each category has a query, and a text embedding of that query from Ente's CLIP
|
|
text model. A file's score for a category is the cosine similarity of the file's
|
|
embedding and the category's. That is a number from -1 to 1; the more alike the
|
|
two embeddings are, the higher it is. The file falls in the category when its
|
|
score reaches the category's minimum score. The keywords are the titles of the
|
|
categories the file falls in, highest score first. A video never falls in a
|
|
category marked "no videos", as in the app.
|
|
|
|
#### Collection directories
|
|
|
|
Each collection gets a directory under `collections/`, named after the
|
|
collection, and a JSON file beside it, `<name>.json`. The directory holds one
|
|
symlink per file, named after the file's title, pointing at the original's save
|
|
path. A live photo gets two symlinks, one to its image and one to its video,
|
|
each named after the title with that file's extension.
|
|
|
|
The JSON file holds:
|
|
|
|
- `id`, `name`, `type`, `ownerID`, `isShared` and `updationTime`;
|
|
- `magicMetadata`, `pubMagicMetadata` and `sharedMagicMetadata`, when the
|
|
collection has them;
|
|
- `files`: each file's `id` and `metadata`.
|
|
|
|
Names come from the server, so unsafe characters in them are replaced (see "File
|
|
names from the server"). When two collections would get the same directory name,
|
|
ignoring case, each gets its ID added: two albums named `Trip` become
|
|
`Trip (10)/` and `Trip (11)/`. Two files in one collection whose titles match,
|
|
ignoring case, get their IDs added before the extension: `IMG_0001 (12345).JPG`
|
|
and `IMG_0001 (12346).JPG`. A collection named like another collection's JSON
|
|
file, such as `Trip.json` beside `Trip`, also gets its ID added. A file name
|
|
longer than 255 bytes, the limit on most file systems, gets its ID added too. So
|
|
does a collection name longer than 250 bytes, which leaves room for `.json`.
|
|
Either name is then cut short before the ID, just far enough to fit. IDs never
|
|
change, so a name stays the same from run to run until such a clash appears or
|
|
goes away.
|
|
|
|
Each run removes every symlink in a collection's directory that points into the
|
|
date folders and is not one of its current links, yours included. A file or
|
|
symlink of yours at the name of one of its links is replaced by the link. A
|
|
directory of yours at that name stays, and the photo whose link it blocks goes
|
|
into `failures.json`. Anything else you put under `collections/` stays. A run
|
|
removes the directory and JSON of a collection that was deleted or renamed, once
|
|
the directory holds nothing else. One that still holds something of yours stays,
|
|
with its JSON.
|
|
|
|
A collection whose directory or JSON cannot be written does not stop the run.
|
|
For example, a file you put there may already have the directory's name. The run
|
|
logs `FAILED album <name>: <reason>`, lists the collection's ID in
|
|
`failedAlbums`, and exits 1. The other collections and `failures.json` are still
|
|
written.
|
|
|
|
#### What quak cannot decrypt
|
|
|
|
A collection or file that quak cannot decrypt is left out of the library (see
|
|
"Default reads and fresh reads"). The backup treats the two cases this way:
|
|
|
|
- A file quak cannot decrypt goes into `failures.json` with an error starting
|
|
`could not be decrypted:` and the reason. It stays out of `failures.json` when
|
|
its copy in another collection decrypts. The original is then stored from that
|
|
copy, and the collection where it does not decrypt neither links nor lists it.
|
|
- A collection quak cannot decrypt has no name quak can read and no files it can
|
|
list. The run logs `FAILED album <id>: could not be decrypted: <reason>` and
|
|
lists the ID in `undecryptableAlbums`.
|
|
|
|
Every run exits 1 until the server either sends a copy that decrypts or deletes
|
|
the record. The run after that drops the entry. A collection that stops
|
|
decrypting loses its directory and JSON as a deleted one does, and a file that
|
|
stops decrypting loses its symlink in that collection. The originals stay at
|
|
their save paths.
|
|
|
|
#### Live photos
|
|
|
|
Ente stores a live photo as one ZIP of its image and its video. The backup
|
|
stores the two files, which a photo viewer can open: each is
|
|
`YYYY-MM-DD.<fileID>.<ext>` with the extension it has inside the ZIP, such as
|
|
`2026-03-01.12345.heic` and `2026-03-01.12345.mov`. The file
|
|
`YYYY-MM-DD.<fileID>.livephoto.json` beside them names which is which. The live
|
|
photo counts as stored only when its `.livephoto.json` and both files it names
|
|
are present and not empty.
|
|
|
|
#### `failures.json`
|
|
|
|
`failures.json` records each file that failed and has not succeeded since. It
|
|
holds `version`, which is 1, and `files`, one entry per file keyed by its ID.
|
|
Each entry holds:
|
|
|
|
- `fileID` and `title`;
|
|
- `classification`: the kind of failure;
|
|
- `attempts`: how many runs in a row have failed the file;
|
|
- `lastTriedAt`: when the run that last tried it finished its refresh, as a Unix
|
|
time in milliseconds;
|
|
- `error`: the error, as quak printed it.
|
|
|
|
The kinds are:
|
|
|
|
- `transient`: a network or server problem;
|
|
- `permanent`: a refusal, or a local file-system or decryption error;
|
|
- `unknown`: anything else.
|
|
|
|
A file leaves the list once it succeeds, or once it is no longer in the library.
|
|
A library caller that backs up only some albums (`onlyAlbumNames`) also drops a
|
|
file outside them. `failures.json` is deleted when the list is empty.
|
|
|
|
#### `backup.lock`
|
|
|
|
`backup.lock` keeps two backups of the same directory from running at once, such
|
|
as a cron run that starts while the previous one is still going. A backup
|
|
creates `<dir>` if it is missing and takes the lock before its refresh, so a
|
|
refused run sends no request. A run that still holds the lock removes it when it
|
|
ends, whether it succeeded or failed.
|
|
|
|
The lock is a directory that
|
|
[proper-lockfile](https://github.com/moxystudio/node-proper-lockfile) creates.
|
|
While the backup runs, it updates the modification time of `backup.lock` every 5
|
|
seconds. A second backup of `<dir>` fails at once: it prints
|
|
`quak: another backup of <dir> is running` and exits 2. A run that is killed, or
|
|
cut off by a crash, can leave the lock behind. Once the lock has gone 10 seconds
|
|
without an update, the next run takes it over, so nobody has to remove it by
|
|
hand.
|
|
|
|
A running backup loses its lock when someone removes `backup.lock`. Another
|
|
backup takes the lock over when this one has not updated it for 10 seconds, for
|
|
example because this process was stopped. The backup notices at its next update
|
|
of the lock, and stops before it starts another file. The originals it is
|
|
downloading at that moment are still stored. It leaves `failures.json` and
|
|
`backup.lock` as they are, since another backup may be using them. It prints
|
|
`quak: lost the lock on <dir>: <reason>` and exits 1.
|
|
|
|
#### `--verify`
|
|
|
|
With `--verify`, a run hashes each original already at its save path the way a
|
|
download is checked. For a live photo it hashes the image and the video and
|
|
joins the two as `<imageHash>:<videoHash>`. An original that matches the content
|
|
hash in its metadata is left as it is. One that does not is logged on one line
|
|
naming the file, downloaded again in the same run, and replaced once the new
|
|
copy matches. The new copy is downloaded even when the cache holds the original,
|
|
because a copy from the cache is not checked as a download is.
|
|
|
|
The new copy is written to a temporary file beside the original, hashed the same
|
|
way, and renamed over the original only when it matches. A live photo's image
|
|
and video are both hashed before either is renamed. Until then the original
|
|
stays as it was. When the new copy fails or does not match:
|
|
|
|
- the temporary files are deleted;
|
|
- the original keeps its bytes and its album links;
|
|
- the file goes into `failures.json`.
|
|
|
|
A run that stops between renaming a live photo's new image and its new video
|
|
leaves the new image beside the old video. A later `--verify` run then replaces
|
|
the video, if the video was the part that did not match.
|
|
|
|
A file whose metadata records no hash is left as it is and counted as unchecked.
|
|
A stored original that cannot be read is left as it is and counted as failed.
|
|
The summary and the `--json` output add the counts `verified`, `mismatched` and
|
|
`unchecked`. Each counts only originals that were already stored. A mismatch
|
|
that was replaced with matching bytes does not make the exit code non-zero.
|
|
|
|
A run without `--verify` drops two kinds of failure from `failures.json`:
|
|
|
|
- a mismatched original whose new copy failed;
|
|
- a stored original that cannot be read.
|
|
|
|
That run finds the original in place, counts it as skipped and drops the entry,
|
|
so it can exit 0. Only a `--verify` run hashes the original again.
|
|
|
|
Without `--verify`, no stored original is hashed, the summary is unchanged, and
|
|
the three counts are 0 in the JSON.
|
|
|
|
#### Temporary files and crash safety
|
|
|
|
Each original is written to a temporary file in the same directory, synced to
|
|
disk, and renamed into place. So an original is either complete or absent, even
|
|
after a power cut. The temporary files are named:
|
|
|
|
- `.quak-<pid>-<random>.tmp` for a downloaded original, and for the copy that
|
|
`--verify` downloads;
|
|
- `.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp` for an original
|
|
copied from the cache;
|
|
- `.quak-<pid>-<random>.<ext>` for the image and the video of a live photo that
|
|
`--verify` downloads, each with its own extension.
|
|
|
|
A run that is killed can leave one of these files behind. The next backup
|
|
deletes those whose process is no longer running. The cache uses the same
|
|
scheme. Opening a library deletes such leftovers in the cache's `originals/` and
|
|
`thumbnails/`. A download another process has in progress is left alone.
|
|
|
|
The rename replaces whatever was at the destination rather than writing through
|
|
it. A symlink there is replaced, not followed, and the new file has the
|
|
temporary file's permissions, not those of the file it replaced. Each original
|
|
is written with mode `0600`, readable by its owner only. Everything else the
|
|
backup writes, its folders and JSON files included, gets the mode your umask
|
|
allows, which usually lets every user on the machine read it. The JSON beside an
|
|
original holds the file's location and faces. The `backup-metadata` tree is
|
|
written the same way.
|
|
|
|
### The metadata dump directory
|
|
|
|
`quak backup-metadata <dir>` produces this tree:
|
|
|
|
```
|
|
<dir>/
|
|
account.json the account's email and user ID
|
|
collections/
|
|
<id>-<name>/
|
|
_collection.json the collection's metadata
|
|
<fileID>.json one file's metadata, one per file in
|
|
the collection
|
|
```
|
|
|
|
Each run writes into `<dir>` and deletes nothing. `account.json` holds `email`
|
|
and `userID`. In `<id>-<name>`, the name has unsafe characters replaced. It is
|
|
cut short so the directory name fits in 255 bytes. The ID keeps two collections
|
|
of the same name apart.
|
|
|
|
`_collection.json` holds the fields a backup's collection JSON holds, except
|
|
`files`. Each `<fileID>.json` holds the fields a backup's JSON beside an
|
|
original holds, with these differences:
|
|
|
|
- `imageMetadata` is written only with `--exif`. The original of every file,
|
|
videos included, is then downloaded and read for its EXIF, XMP and JPEG
|
|
dimensions.
|
|
- A file whose original holds no image metadata gets no `imageMetadata` field.
|
|
- An original that cannot be downloaded or read gives `imageMetadataError`
|
|
instead.
|
|
- A file in several collections has its original read once; its image metadata
|
|
is written with the first of them.
|
|
|
|
### File names from the server
|
|
|
|
A file's title and a collection's name come from the server, so a name taken
|
|
from them is made safe before it is used on disk. Each path separator, each
|
|
character Windows forbids in a file name and each control character becomes `_`.
|
|
A leading run of dots becomes one `_`. A name Windows reserves for a device,
|
|
such as `CON`, gets a leading `_`. An empty title becomes `file-<id>`. An empty
|
|
collection name becomes `collection-<id>` in a backup and `unnamed` in the
|
|
metadata dump.
|
|
|
|
A file name over 255 bytes is shortened before its extension, without splitting
|
|
a character, so the extension is kept when it fits. An extension too long to
|
|
leave room for the name's first character is cut short too, so the name never
|
|
starts with a dot. A collection's directory name is cut at its end. A path you
|
|
give with `--out` is used as it is.
|
|
|
|
## Library reference
|
|
|
|
The library's main class is `Library`. The `Client` sits underneath it and
|
|
handles login, saved sessions and the raw requests. The test suite is the
|
|
executable documentation:
|
|
|
|
- `test/library/` shows most of what `Library` does;
|
|
- `test/client/usage.test.ts` shows the `Client`;
|
|
- `test/cli/backup.test.ts` shows `lib.backup()`;
|
|
- `test/cli/backup-end-to-end.test.ts` runs `quak backup` against a fake of the
|
|
Ente HTTP API.
|
|
|
|
### Opening a library
|
|
|
|
`Library.open(options)` loads the on-disk cache and resolves to a `Library`. On
|
|
an empty cache it waits for the first refresh, so it opens onto the account's
|
|
data whenever the server is reachable. If that refresh fails, it opens with no
|
|
data and records the error in `lib.status()`. On an existing cache it returns at
|
|
once and refreshes in the background, so an unreachable server does not block
|
|
opening. With `refreshOnOpen: false` it starts no refresh and returns at once
|
|
onto what the cache holds, empty or not. A refresh then starts only when
|
|
`fresh()` or `lib.backup()` asks, or when the background timer fires.
|
|
|
|
`LibraryOptions`:
|
|
|
|
- `client`, required: the account's `Client`, or anything that satisfies
|
|
`LibraryClient`.
|
|
- `cacheDirectory`: where `metadata.json` and the content cache live. Default:
|
|
`quak/<userID>` under the per-user cache directory (see "The cache").
|
|
- `downloadDirectory`: the root of the save paths. Default: `photos` in the
|
|
working directory, resolved once when the library opens;
|
|
`lib.downloadDirectory` holds the result. An original stored at its save path
|
|
counts as cached.
|
|
- `refreshIntervalSeconds`: how long after one refresh ends the background timer
|
|
starts the next. Default `3`. `Infinity` starts no timer.
|
|
- `refreshOnOpen`: whether `open()` starts a refresh. Default `true`.
|
|
- `precacheThumbnails`: fetch every thumbnail in the account in the background,
|
|
newest first, until all are on disk. Default `true`.
|
|
- `precacheOriginals`: fetch, in the background, the originals of the favorites
|
|
album and of every file taken in the latest days (see the next option). These
|
|
originals are pinned: they are never evicted from the cache. Default `true`.
|
|
- `precacheOriginalsDays`: how many days count as the latest, counted back from
|
|
the newest file in the account. Default `7`.
|
|
- `cacheOriginalsMaxBytes`: the size limit on the originals cache. Default 100
|
|
GiB. The limit in force is this, or less when the disk is fuller (see the next
|
|
option). Pinned originals can push the cache over it.
|
|
- `freeBelowBytes`: the free space to keep on the cache's volume. Default 50
|
|
GiB. When the volume cannot hold the limit above and this much free space too,
|
|
the limit in force shrinks to what fits. `lib.status().originalsLimitBytes`
|
|
reports the limit in force.
|
|
- `isOriginalPinned`: a function that names more originals to pin.
|
|
- `pools`: the request pools, which set how many requests run at once (see
|
|
"Request pools"). Default: a fresh `RequestPools`.
|
|
- `onProgress`: a callback for the progress of refreshes, machine-learning
|
|
fetches and the background downloads, called with a `RefreshEvent`.
|
|
- `contentSource`: a replacement for the client's own byte source. Mainly for
|
|
tests.
|
|
|
|
In the content and thumbnail pools, your own requests take the next free slot
|
|
ahead of the two background downloads. A file already in the cache is not
|
|
fetched again. When the originals cache grows past its limit, the least recently
|
|
used originals are evicted, but never a pinned one, and never one whose fetch is
|
|
still in flight.
|
|
|
|
`onProgress` receives a `RefreshEvent`. Its `operation` names the work:
|
|
`"refresh"`, `"fetchMLData"` for a machine-learning fetch, and
|
|
`"precacheThumbnails"` or `"precacheOriginals"` for the two background
|
|
downloads. Its `status` is `"started"` as the work starts and `"done"` as it
|
|
ends. A refresh or a machine-learning fetch that fails ends with `"failed"`
|
|
instead, with the message in `error`. In between, a refresh sends `"progress"`
|
|
after each collection whose files it fetched: `fetched` is the number of
|
|
collections fetched so far, and `total` the number it will fetch. A refresh
|
|
fetches the files of each collection that is new or changed since the cache's
|
|
copy, so on an empty cache it fetches those of every collection. The `"done"`
|
|
event of a machine-learning fetch, and of each background download, also carries
|
|
`fetched`: how many items it newly stored.
|
|
|
|
`lib.status()` returns a `LibraryStatus`. It holds the collection and file
|
|
counts. `lastRefreshAt` is the time of the last refresh that succeeded, and
|
|
`lastError` the message of the latest refresh, set only while that refresh
|
|
failed. `lastMLFetchAt` and `lastMLError` do the same for machine-learning
|
|
fetches. It also holds the originals cache's usage and limit in force, the
|
|
background downloads' progress and `closed`. `lib.status().mlDataErrors` lists
|
|
each file whose data arrived in the last successful fetch but could not be
|
|
decrypted, decompressed or parsed. Each entry is `{ fileID, error }`. The
|
|
`fetchMLData` event with status `"done"` carries the same list. Such data is not
|
|
cached, so the next fetch asks for it again. The reasons are kept in memory
|
|
only.
|
|
|
|
`lib.close()` stops the background timer. It can be called more than once. A
|
|
refresh in flight is left to finish, with its write to the cache. The promise
|
|
`close()` returns resolves once that refresh, the machine-learning fetch and the
|
|
background downloads already running have all finished. The cache directory can
|
|
then be removed. After `close()`, `fresh()`, `freshCollections()`,
|
|
`freshFiles()` and `lib.backup()` reject with `the library is closed`.
|
|
|
|
### Default reads and fresh reads
|
|
|
|
Default reads, through `lib.albums`, `lib.photos` and `lib.timeline`, answer
|
|
synchronously from the copy held in memory and never touch the network. A
|
|
refresh replaces that copy only once all its server requests have succeeded. So
|
|
a default read is immediate, but only as current as the last refresh whose
|
|
requests all succeeded. Right after opening an existing cache, it is the copy on
|
|
disk.
|
|
|
|
`await lib.fresh()` waits for a refresh to complete and persist, then returns
|
|
the same `{ albums, photos, timeline }`, which then reflect a completed server
|
|
round-trip. When a refresh is already running, `fresh()` waits for that one, so
|
|
its answer can come from requests made before the call. Only when none is
|
|
running does it start one. When the refresh fails, `fresh()` rejects. Default
|
|
reads stay silent and keep serving the last good copy.
|
|
|
|
`await lib.freshCollections()` is the fresh read of the collection list alone:
|
|
one request, answered with every collection that is not deleted and decrypts, as
|
|
`Collection` objects, in the server's order. It fetches no collection's files
|
|
and stores nothing, not even the list. It stores nothing because a refresh
|
|
fetches a collection's files only when the collection is new or changed. A
|
|
collection stored without its files would never get them.
|
|
|
|
`await lib.freshFiles(collection)` is the fresh read of one collection's files,
|
|
as `EnteFile` objects, in the server's order, each file once. `collection` is an
|
|
object that `freshCollections()` returned. The method fetches no other
|
|
collection's files and stores nothing.
|
|
|
|
The refresh does not fail on a record it cannot decrypt, or whose metadata is
|
|
not JSON. It leaves the record out; a collection left out takes its files with
|
|
it. It also drops any older copy the cache held, so reads answer from what did
|
|
decrypt. The library keeps a list of what it left out, with the reason, in
|
|
`metadata.json`. It needs the list because the next refresh asks only for what
|
|
changed, and would not see the record again. A record stays on that list until a
|
|
later refresh gets a copy that decrypts or learns that the server deleted it.
|
|
|
|
### Albums, photos and the timeline
|
|
|
|
- `lib.albums.list()` returns `Album[]`, newest updated first.
|
|
`lib.albums.byID({ collectionID })` and `lib.albums.byName({ albumName })`
|
|
return `Album | undefined`. Names are not unique in Ente, so `byName` picks
|
|
the newest updated album of that name.
|
|
- `lib.photos.byID({ fileID })` returns `Photo | undefined`.
|
|
`lib.photos.records({ fileIDs })` returns `PhotoRecord[]` in the order asked
|
|
for, each ID once, unknown IDs dropped.
|
|
- `lib.timeline.groups({ groupBy, filter? })` returns `TimelineGroup[]`, newest
|
|
group first. `groupBy` is `"day"`, `"week"` or `"month"`, and the group keys
|
|
are `YYYY-MM-DD`, `YYYY-Www` (the ISO week) and `YYYY-MM`. Each group holds
|
|
`key`, `startsAt` and `fileIDs`. `key` is that group key. `startsAt` is the
|
|
start of the period, in local-time milliseconds. `fileIDs` lists its files,
|
|
newest first. Weeks start on Monday.
|
|
|
|
A `PhotoFilter` narrows the timeline. Its fields combine with AND:
|
|
|
|
- `albumID`: only photos in that album.
|
|
- `text`: a case-insensitive substring of the title, the caption or the name of
|
|
an album the photo is in.
|
|
- `fileTypes`: only photos of one of these types.
|
|
- `hasLocation`: `true` keeps only photos with coordinates, `false` only those
|
|
without.
|
|
- `includeArchived`: archived photos are left out unless this is `true`.
|
|
|
|
Hidden photos are always left out.
|
|
|
|
An `Album` exposes its record's fields and `album.photos.list()`, which returns
|
|
`Photo[]`, newest first. A `Photo` exposes its record's fields except the cache
|
|
paths, plus `photo.year`, the local-time year of `takenAt`. All of these are
|
|
synchronous getters read from memory. `photo.record()` returns the
|
|
`PhotoRecord`.
|
|
|
|
Two more getters look at the disk and never touch the network:
|
|
|
|
- `photo.savePath`: where `photo.download()` and `lib.backup()` put the
|
|
original, whether or not it is there yet (see "Save paths"). For a live photo
|
|
already stored, it is the image. For a live photo not yet stored, it carries
|
|
the title's extension, and the image may be stored under another one.
|
|
- `photo.isLocal`: whether the whole original is at `savePath`. A copy only in
|
|
the cache does not count.
|
|
|
|
### A photo's content
|
|
|
|
These methods may download. They serve from the cache when the bytes are present
|
|
and otherwise fetch through the pools. They throw when the library was opened
|
|
without a content source. `opts.onProgress`, where accepted, reports the
|
|
progress of that one file.
|
|
|
|
- `await photo.original(opts?)` returns `{ path, bytes, videoPath? }`, the
|
|
full-size file. `bytes` is the file's size. For a live photo, `path` and
|
|
`bytes` are its image's and `videoPath` is its video. `original()` also serves
|
|
an original already stored at its save path.
|
|
- `await photo.download()` returns the same shape and puts the original at
|
|
`savePath`, creating its folders. When it is already there, nothing is
|
|
written. When the cache holds it, it is copied from the cache. Otherwise it is
|
|
fetched straight to `savePath`, with no copy left in the cache. Afterwards
|
|
`isLocal` is true.
|
|
- `await photo.thumbnail(opts?)` returns `{ path, bytes }`.
|
|
- `await photo.content(opts?)` returns the original's bytes as a `Uint8Array`,
|
|
read through `original()`. For a live photo, its image's.
|
|
- `await photo.exif(opts?)` returns `ExifTags`: every EXIF tag in the file,
|
|
keyed by tag name, each as exifreader decodes it with its `id`, `value`,
|
|
`description` and `computed` value. For example, `Make` is
|
|
`{ id: 271, value: ["Example"], description: "Example", computed: "Example" }`.
|
|
A tag that exifreader has no name for is keyed `undefined-<tag number>`. The
|
|
tags of the thumbnail embedded in the file are under `Thumbnail`, so they
|
|
cannot hide the main image's tags of the same name. The thumbnail image itself
|
|
is left out. Any file exifreader cannot read gives `{}`, and a video gives
|
|
`{}` without being downloaded.
|
|
- `await photo.make(opts?)` returns one common EXIF field, picked from the tags
|
|
`exif()` returns and typed as in `PhotoExif`, or `undefined` when the file
|
|
lacks it. These methods do the same for their fields:
|
|
- `model()` and `lensModel()`;
|
|
- `dateTimeOriginal()` and `offsetTimeOriginal()`;
|
|
- `exposureTime()`, `fNumber()`, `iso()` and `focalLength()`;
|
|
- `orientation()`;
|
|
- `gpsLatitude()`, `gpsLongitude()` and `gpsAltitude()`, as signed decimal
|
|
degrees and metres.
|
|
|
|
`dateTimeOriginal()` is the camera's clock reading, held in the `Date`'s UTC
|
|
fields. `offsetTimeOriginal()`, when present, is that clock's offset from UTC.
|
|
Each of these methods calls `exif()`, so each call reads the original again.
|
|
|
|
An original that `original()`, `content()` or `exif()` downloads lands in the
|
|
cache, which does not make `isLocal` true. Only `download()` and `lib.backup()`
|
|
do.
|
|
|
|
`await lib.originalOf(file, opts?)` and `await lib.thumbnailOf(file, opts?)`
|
|
fetch and cache as `photo.original()` and `photo.thumbnail()` do. `file` is an
|
|
`EnteFile` the caller holds, and the library need not hold it. `quak get` and
|
|
`quak get-thumb` use them; on an empty cache they pass a file that
|
|
`lib.freshFiles()` returned.
|
|
|
|
Lower-level reads return the decrypted model objects, which hold key material:
|
|
`lib.listCollections()`, `lib.getCollection(id)`, `lib.listFiles(collectionID)`,
|
|
`lib.getFile(collectionID, fileID)` and `lib.getFileByID(fileID)`.
|
|
|
|
### Records and change notifications
|
|
|
|
The records hold no key material and no binary, so they survive
|
|
`structuredClone` and JSON, for example between processes:
|
|
|
|
- `PhotoRecord` holds:
|
|
- `fileID` and `albumIDs`;
|
|
- `title`;
|
|
- `takenAt` and `modifiedAt`, in milliseconds;
|
|
- `fileType`, `isArchived` and `isHidden`;
|
|
- optional: `caption`, `width`, `height`, `latitude` and `longitude`;
|
|
- optional: `hash`, the content hash recorded at upload, which very old
|
|
files lack;
|
|
- optional: `thumbnailPath` and `originalPath`, once the bytes are cached. A
|
|
live photo's `originalPath` is its image.
|
|
- `AlbumRecord` holds `collectionID`, `name`, `type`, `isShared`, `updationTime`
|
|
and `fileIDs`, newest first.
|
|
- `LibrarySnapshot` is `{ albums, photos, takenAt }`. Here `takenAt` is when the
|
|
snapshot was taken, in milliseconds.
|
|
|
|
`lib.snapshot()` returns a `LibrarySnapshot`, albums newest updated first and
|
|
photos newest first. `lib.subscribe({ onChange })` delivers a `LibraryChange`
|
|
whenever a refresh changes the records, and returns `{ unsubscribe }`. A
|
|
`LibraryChange` holds `albumsChanged`, `photosChanged`, `fileIDsRemoved`,
|
|
`albumIDsRemoved` and `refreshedAt`. A refresh that changes nothing delivers
|
|
nothing.
|
|
|
|
### Thumbnails, machine-learning data and backup
|
|
|
|
`lib.thumbnails.ensure({ fileIDs, priority, signal?, onProgress? })` fetches
|
|
thumbnails ahead of need through the thumbnail pool, each file once, and returns
|
|
one `EnsureResult` per file: `{ fileID, path?, error? }`. `priority` is
|
|
`"visible"`, `"ahead"` or `"background"`. Only `"visible"` goes ahead of
|
|
background work; `"ahead"` is treated as `"background"`.
|
|
|
|
`lib.mldata` searches the CLIP index built from Ente's per-file machine-learning
|
|
data:
|
|
|
|
- `forFile({ fileID })` returns `Promise<MLData | undefined>`, the whole stored
|
|
payload (faces, landmarks and embedding), read from disk on demand. `MLData`
|
|
is a plain object type and is not exported by name.
|
|
- `similar({ fileID, limit? })` and `searchByEmbedding({ embedding, limit? })`
|
|
return `SimilarResult[]`, each `{ fileID, score }`, most similar first. The
|
|
score is the cosine similarity, from -1 to 1 (see "Keywords"). `limit`
|
|
defaults to 20. quak bundles no text encoder, so `searchByEmbedding` takes a
|
|
query vector the caller produced elsewhere.
|
|
|
|
`await lib.backup(opts?)` returns a `BackupResult` and writes the tree described
|
|
under "The backup directory". It takes the lock in the download directory and
|
|
fails at once, with an error whose `code` is `ELOCKED`, while another backup of
|
|
it runs. Then it waits for a refresh as `fresh()` does. It puts the original of
|
|
every file in the albums it backs up at its save path, as `photo.download()`
|
|
does. It waits for a machine-learning fetch, and rebuilds the JSON files and
|
|
symlinks. Once the lock is lost, it rejects with
|
|
`lost the lock on <dir>: <reason>` before it starts another file.
|
|
|
|
`BackupOptions`:
|
|
|
|
- `downloadDirectory`: where the tree lives. Default: the library's.
|
|
- `includeOriginals`: fetch and store originals. Default `true`. With `false`,
|
|
the backup also fetches no machine-learning data, and writes no JSON beside
|
|
the originals and no symlinks.
|
|
- `includeThumbnails`: also store thumbnails as `thumbnails/<fileID>.jpg` beside
|
|
`collections/`. Default `false`. `quak backup` does not set it.
|
|
- `onlyAlbumNames`: back up only the albums with these names.
|
|
- `verify`: as `--verify`. Default `false`.
|
|
- `onProgress`: called with each progress line and, for a line that reports a
|
|
failure, the level `"error"`.
|
|
- `lock`: a lock the caller took itself.
|
|
|
|
A caller can take the lock before it opens its library, as `quak backup` does:
|
|
`await lockBackupDirectory(dir)` takes it, failing the same way, and returns a
|
|
`BackupLock`. Its `release()` removes the lock unless it was lost, and its
|
|
`lost` holds why it was lost. The caller passes it to the backup as `lock` and
|
|
releases it once the backup ends.
|
|
|
|
### Request pools
|
|
|
|
`RequestPools` holds three independent pools, each with its own limit on how
|
|
many requests run at once, because Ente meters these three kinds of traffic
|
|
differently:
|
|
|
|
- metadata, with 10 slots: only the machine-learning data requests;
|
|
- content, with 5: originals;
|
|
- thumbnails, with 25: thumbnails.
|
|
|
|
Only the requests named above go through the pools. A refresh,
|
|
`freshCollections()` and `freshFiles()` send theirs outside them. Construct one
|
|
with
|
|
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
|
|
and pass it as `pools`. Each pool serves on-demand work ahead of background
|
|
work. Two tasks given to `BoundedPool.run()` with the same `key` run once while
|
|
the first is pending, and both get its result. An idle pool never lends its
|
|
slots to a busy one.
|
|
|
|
`lib.backup()` works on as many originals at once as the content pool allows, 5
|
|
by default. `quak backup` uses the default. The limit covers everything it does
|
|
for one original, hashing for `verify` included.
|
|
|
|
### Retries and timeouts
|
|
|
|
Every request in the library goes through one policy. A request is repeated only
|
|
when repeating it could produce a different answer:
|
|
|
|
- An `ApiError` with a 5xx status is retried. So are `408` and `429`, the two
|
|
4xx codes that are statements about timing rather than about the request.
|
|
- Every other 4xx is not retried. A 404 in particular is an answer, and
|
|
`list-missing-thumbnails` depends on getting it promptly and once.
|
|
- A transport failure is retried: a rejected `fetch`, `ECONNRESET`, `ETIMEDOUT`,
|
|
a DNS or TLS failure, and one of quak's own deadlines. Node's `fetch` rejects
|
|
with a plain `TypeError`, so every `TypeError` is retried. The error code is
|
|
looked for along the error's `cause` chain, which is where Node's `fetch` puts
|
|
it.
|
|
- A truncated download is retried.
|
|
- Anything else is not retried. This includes an authentication failure in the
|
|
encrypted stream that is not truncation.
|
|
|
|
The binary that `make install` builds runs under bun. Bun's `fetch` rejects with
|
|
a plain `Error` that has no `cause`. That error carries a code of bun's own.
|
|
quak retries a refused connection, a name that does not resolve, a reset
|
|
connection and a deadline under bun as it does under Node. Bun reports a refused
|
|
connection, and a failed TLS handshake, as `ConnectionRefused`. For a name that
|
|
does not resolve it gives `ConnectionRefused` on some requests and
|
|
`FailedToOpenSocket` on others. A connection reset or closed before the response
|
|
ended is `ECONNRESET`, as under Node, and a deadline is the same `TimeoutError`.
|
|
A certificate bun rejects, with a code such as `DEPTH_ZERO_SELF_SIGNED_CERT`, is
|
|
not retried.
|
|
|
|
Backoff is exponential with full jitter. The delay before retry _n_ is
|
|
`random() * min(maxDelayMs, baseDelayMs * 2 ** (n - 1))`. The exponential term
|
|
is the ceiling, and the wait is drawn at random below it. So a client that lost
|
|
many parallel downloads to one blip does not send them all again at the same
|
|
instant. The numbers are set through `ApiClientOptions.retry`:
|
|
|
|
- `attempts`: total calls, not retries. Default `4`; `quak backup` uses `10`.
|
|
- `baseDelayMs`: the ceiling for the first retry's delay. Default `500`;
|
|
`quak backup` uses `1000`.
|
|
- `maxDelayMs`: the upper bound on that ceiling. Default `10000`; `quak backup`
|
|
uses `60000`.
|
|
|
|
With the defaults, a request that keeps failing gives up after at most three and
|
|
a half seconds of waiting. `quak backup` usually runs from cron with nobody
|
|
watching, so every request it makes uses its own numbers, exported as
|
|
`UNATTENDED_RETRY_OPTIONS`. Under them, a request that keeps failing gives up
|
|
after at most 243 seconds of waiting. It usually gives up after about half that,
|
|
since each wait is drawn at random below its ceiling. Every other command uses
|
|
the defaults. A library user gets the backup's budget by passing
|
|
`UNATTENDED_RETRY_OPTIONS` as `ApiClientOptions.retry`. `sleep` and `random` can
|
|
be injected through the same option, which is how the test suite exercises the
|
|
policy without waiting. `onRetry`, given there too, is called before each wait
|
|
with the error, the attempt's number and the wait.
|
|
|
|
Two deadlines apply, each renewed for every attempt:
|
|
|
|
- `requestTimeoutMs`, default `30000`, bounds a whole JSON request or upload.
|
|
- `downloadTimeoutMs`, default `60000`, is an idle deadline on a file or
|
|
thumbnail download: the download is aborted when no bytes have arrived for
|
|
that long.
|
|
|
|
They differ because a download's length depends on the file and the link. A
|
|
whole-transfer deadline short enough to catch a hung connection would cancel a
|
|
large video on a slow link that is still making progress. The download deadline
|
|
restarts each time bytes arrive, so a slow download runs as long as it keeps
|
|
moving, and one that stalls is aborted after 60 seconds of silence. The download
|
|
deadline covers the wait for the headers as well as the body. There is no limit
|
|
on the total length of a download.
|
|
|
|
A request that changes state is not blindly replayed. Every `POST` and `PUT` in
|
|
the endpoint list under "HTTP API" goes through this rule. Verifying a second
|
|
factor, for one, uses up one of a small number of attempts. Such a request is
|
|
retried only when every error code in its `cause` chain proves that no TCP
|
|
connection to the server ever existed. Three codes prove that:
|
|
|
|
- `ENOTFOUND`: name resolution found no address;
|
|
- `EAI_AGAIN`: name resolution failed for the moment;
|
|
- `ECONNREFUSED`: the peer refused the connection.
|
|
|
|
A 5xx, a reset mid-flight and a deadline are all left to the caller, because
|
|
each of them can happen after the server has already acted. These requests do
|
|
not follow redirects either: a redirect means the server already received the
|
|
request, so it is reported as an error and not retried.
|
|
|
|
The routing errors `EHOSTUNREACH`, `ENETUNREACH` and `ENETDOWN` are left out of
|
|
that rule although they look like connect-time failures. On Linux they are also
|
|
delivered on an established socket, after the request may have been written.
|
|
They stay retryable for requests that change nothing. Bun's `ConnectionRefused`
|
|
and `FailedToOpenSocket` are treated the same way: a request that changes
|
|
nothing is retried on them, and a `POST` or `PUT` is not replayed on them. Bun
|
|
reuses a connection it kept open from an earlier request. When that connection
|
|
closes before any answer, bun sends the request again on a new one. If that one
|
|
fails, bun gives one of these codes although the server may already have the
|
|
request. The upload of a thumbnail to its presigned URL is exempt from the rule.
|
|
It stores one whole object at one key in one request, so replaying it has no
|
|
partial state to damage.
|
|
|
|
A download is retried as a whole: the request, the reading of the body and the
|
|
decryption. A socket reset after the response headers have arrived surfaces in
|
|
the download layer rather than in the HTTP client. That is the common failure
|
|
for a large file over a CDN. The encrypted stream cannot be resumed and the
|
|
endpoints have no Range support, so a retry starts the file over. Each attempt
|
|
writes its own temporary files and removes them if it fails. Only the attempt
|
|
that completes renames anything into place.
|
|
|
|
One imprecision is deliberate. When a body ends partway through an encrypted
|
|
chunk, the authentication check fails and carries no sign of why, so a cut
|
|
connection and corrupt bytes look the same. quak reports that as truncation,
|
|
which means it is retried. quak can tell the two apart only in a body of more
|
|
than one chunk. There, a chunk that fails while the stream carries on past it
|
|
stays an authentication failure and is not retried. Most thumbnails and every
|
|
small file are one chunk. There a wrong key, corrupt bytes and a cut connection
|
|
look alike, and all are retried. The attempt count limits the extra retries. In
|
|
return, quak never keeps a truncated file.
|
|
|
|
### The `Client`
|
|
|
|
`Client.login({ email, password, totp?, emailOTP?, apiOptions? })` logs in and
|
|
resolves to a `Client`. `totp` and `emailOTP` are callbacks that return the code
|
|
when the account asks for one. Without the callback the account needs, login
|
|
throws. A passkey challenge always throws. After an email code, login expects
|
|
the key attributes and the token. When the server asks for a second factor
|
|
instead, login throws. `apiOptions` configures the HTTP client (see below).
|
|
|
|
The `Client` holds the session token, the master key, the secret key and the
|
|
public key in memory. There is no on-disk session store in the library; the
|
|
caller decides how to keep a session. `client.toJSON()` returns a
|
|
`ClientSnapshot`, a plain object with the keys in base64, which the caller can
|
|
write wherever it likes. `Client.fromJSON(snapshot, apiOptions?)` restores a
|
|
working client from that snapshot without logging in again. It checks every
|
|
field and each key's length first, and throws an error naming the bad field.
|
|
|
|
`Client.fromJSON` works only once libsodium, the cryptography library quak uses,
|
|
is loaded. `Client.login` loads it, and `Client.fromJSON` does not. A program
|
|
that restores a session in a new process first runs `await sodium.ready`, with
|
|
`sodium` imported from `libsodium-wrappers-sumo`, which quak depends on. The
|
|
exported `beginLogin`, `unwrapAuth`, `decryptCollection`, `decryptFile`,
|
|
`downloadFile` and `downloadThumbnail` need the same.
|
|
|
|
`client.logout()` clears the token and zeroes the key buffers in place. After
|
|
it, every other method on that client throws. `logout()` does not contact the
|
|
server, so the token stays valid there and in any saved snapshot. Call
|
|
`await client.logoutOnServer()` before `logout()` to end the session on the
|
|
server.
|
|
|
|
`client.whoami()` returns `{ email, userID }`.
|
|
|
|
`client.collectionsSince({ sinceTime })` and
|
|
`client.filesSince({ collectionID, collectionKey, sinceTime })` enumerate the
|
|
changes since a microsecond timestamp, with `0` for everything. Each returns:
|
|
|
|
- `collections` or `files`: the decrypted records that are not deleted;
|
|
- `deleted`: the IDs of deleted records;
|
|
- `undecryptable`: each record that did not decrypt, as `{ id, error }`;
|
|
- `cursor`: the timestamp to pass next time.
|
|
|
|
`client.listCollections()` and `client.listFiles(collectionID, collectionKey)`
|
|
return only the decrypted records that are not deleted, from `sinceTime` `0`.
|
|
`client.fetchMLData({ fileIDs, fileKeys })` fetches and decrypts the
|
|
machine-learning data of up to 200 files. `client.downloadFile(file, outPath?)`
|
|
and `client.downloadThumbnail(file, outPath?)` download and decrypt one file to
|
|
`outPath`. For a live photo, `downloadFile` writes the image and the video, each
|
|
named `outPath` with its own extension. `quak get --out` instead puts the image
|
|
at the path itself. Without `outPath`, they write to the working directory under
|
|
the names `quak get` and `quak get-thumb` would use.
|
|
|
|
`ApiClientOptions`:
|
|
|
|
- `apiOrigin`: the API server. Default `https://api.ente.io`. A self-hosted
|
|
server is set here; file and thumbnail downloads then go through it too.
|
|
- `filesOrigin` and `thumbsOrigin`: the download hosts used with the default API
|
|
server.
|
|
- `authToken`: a session token to send from the start.
|
|
- `fetch`: a replacement for the global `fetch`.
|
|
- `retry`, `requestTimeoutMs` and `downloadTimeoutMs`: see "Retries and
|
|
timeouts".
|
|
- `onRequest`: called once for each request sent, and once for each attempt of a
|
|
retried one. It gets the method, the URL and the status of the answer or the
|
|
error. The URL of a thumbnail upload is given without its query, which holds
|
|
the upload's signature.
|
|
|
|
The package also exports the pieces the `Client` and the `Library` are built
|
|
from, and a few constants:
|
|
|
|
- the HTTP client: `ApiClient`, `ApiError` and `TruncatedStreamError`, with
|
|
`DEFAULT_REQUEST_TIMEOUT_MS` and `DEFAULT_DOWNLOAD_TIMEOUT_MS`;
|
|
- the retry policy: `withRetry`, `isRetryable`, `isSafeToReplay`,
|
|
`resolveRetryOptions`, `DEFAULT_RETRY_OPTIONS` and `UNATTENDED_RETRY_OPTIONS`;
|
|
- the login steps: `beginLogin`, `submitTOTP`, `requestEmailOTP`,
|
|
`submitEmailOTP` and `unwrapAuth`;
|
|
- the record decryption: `decryptCollection` and `decryptFile`;
|
|
- the downloads: `downloadFile` and `downloadThumbnail`;
|
|
- the backup: `runBackup` and `lockBackupDirectory`;
|
|
- the pools: `RequestPools` and `BoundedPool`, with
|
|
`DEFAULT_METADATA_CONCURRENCY`, `DEFAULT_CONTENT_CONCURRENCY` and
|
|
`DEFAULT_THUMBNAIL_CONCURRENCY`;
|
|
- the library: `Library`, `Album`, `Photo` and
|
|
`DEFAULT_REFRESH_INTERVAL_SECONDS`;
|
|
- `VERSION`: the version string `quak --version` prints.
|
|
|
|
### Key types by source file
|
|
|
|
- `src/library/index.ts`: `Library`, `LibraryOptions`, `LibraryStatus`,
|
|
`LibraryClient`, `RefreshEvent`
|
|
- `src/library/read.ts`: `Album`, `Photo`, `AlbumsAPI`, `PhotosAPI`,
|
|
`TimelineAPI`, `PhotoFilter`, `TimelineGroup`, `GroupBy`, `SavePathLookup`
|
|
- `src/library/content.ts`: `ContentResult`, `ContentOptions`, `ThumbnailsAPI`,
|
|
`EnsureOptions`, `EnsureResult`, `ContentSource`
|
|
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
|
|
`LibraryChange`
|
|
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
|
|
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
|
|
- `src/exif.ts`: `ExifTags`, `PhotoExif`
|
|
- `src/backup.ts`: `BackupLock`, `BackupOptions`, `BackupResult`, `BackupError`
|
|
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
|
|
- `src/api/client.ts`: `ApiClient`, `ApiClientOptions`, `StreamOptions`
|
|
- `src/errors.ts`: `ApiError`, `TruncatedStreamError`
|
|
- `src/retry.ts`: `withRetry`, `isRetryable`, `isSafeToReplay`, `RetryOptions`
|
|
- `src/model/types.ts`: `Collection`, `EnteFile`, `FileMetadata`, `FileType`,
|
|
`CollectionType`, `RawCollection`, `RawEnteFile`
|
|
|
|
`src/index.ts` lists what the package exports. `MLDataAPI` and `SimilarResult`
|
|
are the types of `lib.mldata` and of its results, and `MLData` is the type of a
|
|
stored payload. None of the three is exported by name.
|
|
|
|
## Examples
|
|
|
|
`examples/download-albums.ts` downloads every album's photos and their metadata
|
|
into a directory, `photos` in the working directory unless you name another. It
|
|
uses only the library's public API. The build compiles it; run it after
|
|
`yarn install`:
|
|
|
|
```bash
|
|
yarn build
|
|
QUAK_EMAIL=… QUAK_PASSWORD=… node dist/examples/download-albums.js [dir]
|
|
```
|
|
|
|
It opens the library with `precacheThumbnails` and `precacheOriginals` off, as
|
|
`quak backup` does, so the only file content it fetches is the originals it
|
|
saves. It asks on the terminal for a second-factor code or an email code when
|
|
the account requires one. It writes:
|
|
|
|
- each photo's original at its save path under `dir`, as `photo.download()`
|
|
writes it, and for a live photo its image, its video and the `.livephoto.json`
|
|
file naming them;
|
|
- beside each original, a JSON file named after it with `.json` added, such as
|
|
`2026-03-01.12345.jpg.json`. It holds the photo's record without its cache
|
|
paths, and under `exif` every EXIF tag of the photo;
|
|
- `albums/<collectionID>.json` for each album, holding its `collectionID`, its
|
|
`name` and, under `savePaths`, the save paths of its photos relative to `dir`,
|
|
newest first.
|
|
|
|
A photo in several albums is downloaded once. A second run downloads nothing and
|
|
rewrites only the JSON files whose content changed. A failed download stops the
|
|
run; running it again carries on, since every photo already saved is skipped.
|
|
|
|
## Rationale
|
|
|
|
Ente encrypts photos end to end, so a backup of an Ente account cannot be made
|
|
from the server's side. Only a client that holds the account's keys can read the
|
|
photos. Ente's own clients are apps and a command-line tool; none is a library a
|
|
program can use. quak is the client for scripts and for cron.
|
|
|
|
quak is a library first, so that a program of your own can read an account the
|
|
way quak's command line does. The backup it writes is plain files in a tree a
|
|
photo viewer can open, with no quak needed to read it. One failed file never
|
|
ends a run; it is recorded and tried again next time.
|
|
|
|
## Design
|
|
|
|
quak is a TypeScript library with a thin command-line wrapper. The library does
|
|
the work; the command line is for people and cron. Login uses SRP, a protocol
|
|
that proves the password to the server without sending it.
|
|
|
|
### Layout
|
|
|
|
```
|
|
quak/
|
|
src/
|
|
crypto/ libsodium primitives: boxes, streams, key derivation,
|
|
hashing
|
|
api/ the HTTP client (ApiClient)
|
|
auth/ the login flow (SRP, email code, TOTP) and key unwrap
|
|
model/ the decrypted Collection, EnteFile and FileMetadata
|
|
types, and the functions that decrypt them
|
|
download/ streaming download and decryption of files and
|
|
thumbnails
|
|
library/ the Library over the cache: metadata store, reads,
|
|
records, content cache, background downloads,
|
|
machine-learning data and search, request pools
|
|
backup.ts the full-account backup
|
|
metadata-backup.ts
|
|
backup-metadata: the metadata quak keeps, as JSON
|
|
exif.ts EXIF read from an image's bytes with exifreader
|
|
mldata-fetch.ts fetch and decrypt per-file machine-learning 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
|
|
errors.ts error types shared across layers, and how an error
|
|
is described
|
|
retry.ts the retry classifier and the backoff loop
|
|
thumbnails.ts find and regenerate missing thumbnails
|
|
client.ts the Client class assembled from the above
|
|
cli-commands.ts the command line's commands as functions returning
|
|
exit codes
|
|
cli-log.ts the command line's log level and the one function
|
|
its commands write stderr lines through
|
|
cli-output.ts how the command line prints a file's title and time
|
|
cli-read.ts how the read commands get current data
|
|
cli-run.ts run a command, print its error, exit with its code
|
|
cli-session.ts read the saved session file back into a Client
|
|
index.ts the package's exports
|
|
bin/
|
|
quak.ts the command-line entrypoint (commander.js)
|
|
clip-keyword-vectors, clip-keyword-vectors.py
|
|
write src/clip-keyword-vectors.ts and a test
|
|
fixture (see "Source attribution")
|
|
examples/
|
|
download-albums.ts
|
|
download every album's photos and metadata
|
|
test/ unit and integration tests (vitest)
|
|
script/ the entrypoints (see "Entrypoints")
|
|
Makefile
|
|
Dockerfile lint phase, test phase, compile
|
|
package.json
|
|
tsconfig.json
|
|
```
|
|
|
|
`make build` compiles `src/`, `bin/` and `examples/` into `dist/`, keeping their
|
|
shape: the library lands in `dist/src/`, the examples in `dist/examples/` and
|
|
the command line in `dist/bin/quak.js`. `package.json` points `main` and `types`
|
|
at `dist/src/index.js` and `dist/src/index.d.ts`, and `bin` at
|
|
`dist/bin/quak.js`. The compiler's `rootDir` is the repository root rather than
|
|
`src/`, because `bin/` and `examples/` are compiled too.
|
|
|
|
### Cryptography
|
|
|
|
All cryptography is done by `libsodium-wrappers-sumo`, with two exceptions. The
|
|
SRP handshake uses `fast-srp-hap`, and the MD5 checksum sent with a thumbnail
|
|
upload uses Node's `node:crypto`. The "sumo" build is required for
|
|
`crypto_pwhash`, the Argon2id function. No cryptography is written by hand.
|
|
|
|
Login derives the keys in this order:
|
|
|
|
1. The user enters the password.
|
|
2. Argon2id (`crypto_pwhash`) derives a 32-byte key encryption key, the KEK,
|
|
from the password. It uses the server-issued `kekSalt`, `memLimit` and
|
|
`opsLimit`.
|
|
3. For SRP login, `crypto_kdf_derive_from_key` (BLAKE2b) derives a 32-byte
|
|
subkey from the KEK with subkey ID 1 and context `loginctx`. Its first 16
|
|
bytes are the SRP password.
|
|
4. When SRP completes, the server returns the key attributes and an encrypted
|
|
token, or first asks for a second factor. quak answers a TOTP request with
|
|
the code (`POST /users/two-factor/verify`); the server then returns the key
|
|
attributes and the token. quak cannot answer a passkey request. When the
|
|
account has email verification on (`isEmailMFAEnabled`), an email code
|
|
replaces SRP. quak then expects the key attributes and the token in the
|
|
answer, and fails the login when the server asks for a second factor instead.
|
|
5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK
|
|
yields the 32-byte master key.
|
|
6. `crypto_secretbox_open_easy` over the encrypted secret key with the master
|
|
key yields the user's X25519 private key. The matching public key arrives in
|
|
cleartext.
|
|
7. `crypto_box_seal_open` over the encrypted token with the user's key pair
|
|
yields the token's bytes. Encoded as URL-safe base64 with padding, they are
|
|
the `X-Auth-Token` value for every later call.
|
|
|
|
A collection the account owns carries its key as a secretbox under the master
|
|
key. A collection shared with the account carries it as a sealed box to the
|
|
account's public key. Each file's key is a secretbox under its collection's key.
|
|
File metadata is a secretstream blob, a single chunk with the final tag, under
|
|
the file key. File content is a chunked `crypto_secretstream_xchacha20poly1305`
|
|
stream under the file key, with 4 MiB plaintext chunks and 17 bytes of
|
|
authentication overhead per chunk. A thumbnail is such a stream too. For a
|
|
thumbnail upload, `encryptBlob` does the push side: one chunk with the final
|
|
tag, returning the header and the ciphertext.
|
|
|
|
### HTTP API
|
|
|
|
The production endpoints are:
|
|
|
|
- API: `https://api.ente.io`
|
|
- File downloads: `https://files.ente.io/?fileID=<id>`
|
|
- Thumbnail downloads: `https://thumbnails.ente.io/?fileID=<id>`
|
|
|
|
A self-hosted server is set through the `apiOrigin` option. File downloads then
|
|
go through `<apiOrigin>/files/download/<id>` and thumbnail downloads through
|
|
`<apiOrigin>/files/preview/<id>`.
|
|
|
|
Every authenticated call carries two headers: `X-Auth-Token`, the token from
|
|
login, and `X-Client-Package`, which names the client as `berlin.sneak.quak`.
|
|
|
|
Endpoints used:
|
|
|
|
- `GET /users/srp/attributes?email=<email>`: fetch the SRP and key-derivation
|
|
parameters.
|
|
- `POST /users/srp/create-session`: begin the SRP handshake.
|
|
- `POST /users/srp/verify-session`: complete SRP; receive the second-factor
|
|
challenge or the encrypted token with the key attributes.
|
|
- `POST /users/ott` and `POST /users/verify-email`: the email code, used instead
|
|
of SRP when the SRP attributes have `isEmailMFAEnabled` set.
|
|
- `POST /users/two-factor/verify`: the TOTP second factor.
|
|
- `POST /users/logout`: end the calling token's session (`quak logout`).
|
|
- `GET /collections/v2?sinceTime=<usec>`: list the collections changed since a
|
|
microsecond timestamp; `0` lists them all.
|
|
- `GET /collections/v2/diff?collectionID=<id>&sinceTime=<usec>`: list the files
|
|
changed in a collection; quak pages while `hasMore` is true.
|
|
- `GET https://files.ente.io/?fileID=<id>`: download a file's encrypted bytes.
|
|
- `GET https://thumbnails.ente.io/?fileID=<id>`: download a thumbnail's
|
|
encrypted bytes.
|
|
- `POST /files/data/fetch`: fetch the encrypted machine-learning data of a batch
|
|
of files.
|
|
- `POST /files/upload-url`: get a presigned upload URL, for thumbnail repair.
|
|
- `PUT /files/thumbnail`: register an uploaded thumbnail's object key.
|
|
|
|
## Source attribution
|
|
|
|
The cryptographic protocol and wire format are Ente's, read from the Ente
|
|
clients at <https://github.com/ente-io/ente>. No code from those projects is
|
|
included; quak's implementation is written in TypeScript from reading them.
|
|
|
|
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 the text embedding of its
|
|
query 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 SHA-256 hash. It is run
|
|
by hand only, followed by `make fmt`.
|
|
|
|
## Entrypoints
|
|
|
|
This repository adheres to the
|
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
|
standard. Normalized scripts in `script/` are the entrypoints for the
|
|
development workflow, and most Makefile targets are thin shims that call them.
|
|
The scripts are POSIX `sh`, not bash, so they run in minimal containers such as
|
|
Alpine. Of these, only `bootstrap`, `setup`, `test` and `cibuild` come from the
|
|
standard. The others are additions to it.
|
|
|
|
- `script/bootstrap`: install all dependencies. It installs `make`, `git`, Node
|
|
and `yarn` when they are missing, then runs `yarn install --frozen-lockfile`.
|
|
- `script/setup`: set up the repo for development after a fresh clone. It runs
|
|
`script/bootstrap`, then `script/install-precommit`.
|
|
- `script/projectname`: print the project name. `script/docker` uses it for the
|
|
image tag.
|
|
- `script/build`: compile the TypeScript sources into `dist/` and stamp the
|
|
version into `dist/package.json`. Then check that the entrypoints
|
|
`package.json` declares (`main`, `types`, `bin`) were written, and make the
|
|
command line executable.
|
|
- `script/version`: print the version `script/build` stamps (see "Version").
|
|
- `script/test`: run the test suite, by building the `test` phase of the
|
|
`Dockerfile`. Requires Docker.
|
|
- `script/lint`: run eslint and a prettier check, by building the `lint` phase
|
|
of the `Dockerfile`. Requires Docker.
|
|
- `script/fmt`: format all files with prettier. It rewrites files in place.
|
|
- `script/fmt-check`: check formatting on the host. It is read-only.
|
|
- `script/check`: run all checks: `test`, `lint`, `fmt-check`.
|
|
- `script/docker`: build the image, tagged with `script/projectname`.
|
|
- `script/cibuild`: what CI runs: `script/bootstrap`, then `script/check`, then
|
|
the image build.
|
|
- `script/precommit`: run by the git pre-commit hook. It runs `script/lint` and
|
|
`script/fmt-check`, and not the tests, so that a commit of failing tests can
|
|
land (see "Development workflow").
|
|
- `script/install-precommit`: install the git pre-commit hook. `make hooks`
|
|
calls it.
|
|
|
|
The Makefile targets are:
|
|
|
|
- `make bootstrap`: runs `script/bootstrap`.
|
|
- `make setup`: runs `script/setup`.
|
|
- `make test`: runs `script/test`.
|
|
- `make lint`: runs `script/lint`.
|
|
- `make fmt`: runs `script/fmt`.
|
|
- `make fmt-check`: runs `script/fmt-check`.
|
|
- `make check`: runs `script/check`.
|
|
- `make build`: runs `script/build`.
|
|
- `make docker`: runs `script/docker`.
|
|
- `make hooks`: runs `script/install-precommit`.
|
|
- `make build-bin`: bundles the built `dist/` into one binary, `bin/quak`. It
|
|
runs bun through `nix-shell -p bun`.
|
|
- `make install`: runs `make build-bin` and copies the binary to `~/bin/quak`.
|
|
- `make dev`: runs the TypeScript compiler in watch mode.
|
|
- `make clean`: removes `dist/`, `coverage/`, `.vitest-cache/` and the
|
|
compiler's `*.tsbuildinfo` files.
|
|
|
|
### Linting and testing
|
|
|
|
Linting and testing are phases of the `Dockerfile`. The `lint` phase copies the
|
|
repo into a node image pinned by digest and runs eslint and
|
|
`prettier --check .`. The `test` phase runs the test suite the same way, as the
|
|
image's non-root `node` user. A 90-second `timeout` stops a hung test. A suite
|
|
that fails is run again with verbose output. `script/lint` and `script/test`
|
|
each build one phase with `docker build --no-cache --target <phase>`. Neither
|
|
script runs the tools on the host, so Docker is required. The scripts also work
|
|
where the Docker daemon is remote, because they copy the repo into the image
|
|
instead of sharing a directory with the container.
|
|
|
|
The last stage of the `Dockerfile` compiles the package. It copies a file from
|
|
each phase, so it cannot be built unless lint and the tests pass. The image
|
|
build in `script/cibuild` therefore runs lint and the tests a second time, after
|
|
`script/check` has run them.
|
|
|
|
Every `docker build` in `script/` passes `--no-cache`. On an unchanged tree
|
|
Docker would otherwise serve the lint and test steps from its cache, nothing
|
|
would run, and the build would still exit 0.
|
|
|
|
`script/fmt-check` runs prettier on the host. Its verdict matches the `lint`
|
|
phase's, because prettier is pinned to one version and installed from
|
|
`yarn.lock` in both places. Prettier reads `.gitignore` as its default ignore
|
|
file, which is why `.dockerignore` keeps `.gitignore` in the build context.
|
|
|
|
### Version
|
|
|
|
`quak --version` reports the `version` in `dist/package.json`, which
|
|
`script/build` writes after compiling. The repo's own `package.json` keeps
|
|
`0.0.0`. The tests run from source, so they report `0.0.0`. `script/version`
|
|
decides what is written:
|
|
|
|
- the `VERSION` environment variable, or the `Dockerfile`'s `VERSION` build
|
|
argument (`--build-arg VERSION=...`), when one is given and not empty;
|
|
- otherwise, in a checkout with `.git`, what `git describe --tags --always`
|
|
prints. That is the tag on a tagged commit. On a later commit it is the tag,
|
|
the number of commits since it and the short commit, as in
|
|
`v1.2.3-4-gabc1234`. When no tag is reachable it is the short commit;
|
|
- otherwise, as in a source tarball, the version `package.json` declares.
|
|
|
|
The build fails if the checkout has `.git` and the version still comes out
|
|
empty, `dev` or `unknown`, because such a build could not be traced to its
|
|
commit.
|
|
|
|
`.dockerignore` therefore does not leave out `.git`, so any `docker build .` of
|
|
a clone stamps the commit it was built from. A clone of depth 1 stamps a tag
|
|
only when its commit carries one, and otherwise the short commit.
|
|
`.dockerignore` does leave out `.git/config`, which holds the clone's remote URL
|
|
and any credential in it; `git describe` does not need that file.
|
|
`script/docker` and `script/cibuild` work out the version on the host, with
|
|
`--dirty`, and pass it as the `VERSION` build argument. That argument takes
|
|
precedence. The image's `org.opencontainers.image.version` label carries that
|
|
build argument only, so a build given none leaves it empty. `make build-bin`
|
|
bundles the built `dist/`, so the single binary reports the stamped version too.
|
|
|
|
## Development workflow
|
|
|
|
All work on quak is test-driven.
|
|
|
|
1. Every change starts on a feature branch off `next`, and its pull request
|
|
targets `next`.
|
|
2. The first commit on the branch is the tests for what is being added or
|
|
changed. Those tests fail at that commit; the branch is red until the
|
|
implementation lands.
|
|
3. Later commits add the implementation and any refactors needed to make the
|
|
tests pass.
|
|
4. A pull request is merged into `next` only when `make check` is green. Once it
|
|
has passed review, a maintainer squash-merges it into `next`. Only sneak
|
|
merges `next` into `main`. `main` and `next` are always green. CI runs
|
|
`script/cibuild`, which builds the `Dockerfile`: its `lint` and `test`
|
|
phases, then the compile. So neither a red branch nor one that does not
|
|
compile can pass CI.
|
|
5. Tests are the canonical API documentation for this library. Every test file
|
|
is commented well enough that a reader who has never seen quak can learn how
|
|
to use it from the tests alone. Comments explain why a behavior matters, not
|
|
just what the assertion checks.
|
|
6. Test fixtures, such as cryptographic vectors, recorded HTTP responses and
|
|
sample files, are committed beside their tests. Where possible they are
|
|
generated by deterministic helpers in `test/`, so a reviewer can reproduce
|
|
them by running the helper.
|
|
7. `git rebase -i` is allowed on a feature branch before merge, to clean the
|
|
tests-then-implementation sequence into reviewable commits. The final history
|
|
must still show tests landing before, or with, the matching implementation.
|
|
8. The pre-commit hook that `make hooks` installs runs `script/precommit`, which
|
|
runs `script/lint` and `script/fmt-check` and not the tests. This is
|
|
deliberate, so that the commit of failing tests can land. CI runs the tests,
|
|
so a red branch still cannot reach `next`.
|
|
|
|
### Rules for contributors
|
|
|
|
These apply to people and to coding agents alike.
|
|
|
|
- Read and follow `REPO_POLICIES.md` in the repo root. It is copied from
|
|
<https://git.eeqj.de/sneak/prompts> and covers repository structure, tooling,
|
|
Makefile targets, Dockerfile conventions, dependency pinning and commit
|
|
hygiene. Never `git add -A`. Never force-push `main`.
|
|
- Dependencies are pinned:
|
|
- npm packages by hash, in `yarn.lock`;
|
|
- container images by digest;
|
|
- the nvm archive `script/bootstrap` downloads, and the Python packages
|
|
`bin/clip-keyword-vectors` installs, by SHA-256 hash;
|
|
- Node and yarn, when `script/bootstrap` installs them, by version.
|
|
- Not pinned:
|
|
- bun, which `make build-bin` takes from `nix-shell -p bun`;
|
|
- `make` and `git`, which `script/bootstrap` installs from the system
|
|
package manager when they are missing, as every `Dockerfile` stage does;
|
|
- `bash` and `curl`, which `script/bootstrap` installs the same way when it
|
|
has to install Node.
|
|
- Follow the workflow above: branches off `next`, tests first and committed
|
|
failing, and `next` merged into `main` by sneak alone.
|
|
- Before every commit, `make lint` and `make fmt-check` must pass. `make lint`
|
|
builds the `lint` phase of the `Dockerfile`, so it needs Docker. The
|
|
pre-commit hook enforces exactly that. `make check`, which also runs the
|
|
tests, must pass before a merge into `next`. Never invoke eslint or prettier
|
|
directly; linting runs in the container only.
|
|
- Format with `make fmt`: prettier with 4-space indents and `proseWrap: always`
|
|
for markdown. Use `yarn`, not `npm`.
|
|
- Tests use vitest and live in `test/`, mirroring `src/`. Use `mkdtempSync` for
|
|
temporary directories, never a path built from a timestamp. `make test` must
|
|
finish in under 60 seconds. The 90-second `timeout` in the `test` phase of the
|
|
`Dockerfile` only catches a hung test.
|
|
- Code style: `const` for everything, `let` only when a variable is reassigned,
|
|
never `var`. Avoid comments that restate the code. No cryptography written by
|
|
hand. `LLM_PROSE_TELLS.md` in the prompts repo applies to any prose written in
|
|
this repository: the README, comments and commit messages.
|
|
|
|
## TODO
|
|
|
|
`TODO.md` in the repo root is the work log: the next step, the steps done and
|
|
the steps planned. Open now:
|
|
|
|
- [ ] Check quak against a live Ente account. This needs an account only sneak
|
|
has.
|
|
|
|
Planned for a desktop client, in a separate repository, on top of this library:
|
|
|
|
- [ ] An Electron app skeleton that uses this library.
|
|
- [ ] A local cache in SQLite keyed on `(collectionID, fileID, updationTime)`.
|
|
- [ ] A background sync worker that streams new files into the cache.
|
|
- [ ] A gallery UI: thumbnails, full-image view, basic search.
|
|
- [ ] Upload, delete and share operations in the library.
|
|
|
|
Tagging and releases are decided by sneak alone, and happen only when he
|
|
declares one.
|
|
|
|
## License
|
|
|
|
WTFPL. See [LICENSE](LICENSE).
|
|
|
|
## Author
|
|
|
|
[@sneak](https://sneak.berlin)
|