Author SHA1 Message Date
sneak 5034ae94e8 Merge pull request 'next → main: Photo save path, download and EXIF; version from git; example script' (#134) from next into main
check / check (push) Canceled after 0s
Reviewed-on: #134
2026-10-22 18:55:49 +02:00
31 changed files with 2194 additions and 5181 deletions

No files matched your search

+1198 -1766
View File
File diff suppressed because it is too large. Load diff
+691 -1
View File
@@ -2,7 +2,7 @@
- branch from `next` - branch from `next`
- do the work in Next Step - do the work in Next Step
- delete Next Step from `TODO.md` - move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step - move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work) - commit (`TODO.md` changes in the same commit as the work)
- push - push
@@ -24,6 +24,696 @@ only sneak has, so it waits on him.
Tagging and releases are decided by sneak alone, and happen only when he Tagging and releases are decided by sneak alone, and happen only when he
declares one. declares one.
# Completed Steps
- 2026-10-18: `-v`/`--verbose` raises the CLI's log level once for each repeat,
and `-q`/`--quiet` prints only fatal errors
(https://git.eeqj.de/sneak/quak/issues/212). The levels are fatal, error,
warning, info, debug and trace; the default is info, everything quak printed
on stderr before, `-v` gives debug and `-vv` trace. `src/cli-log.ts` holds the
level and the log the commands write stderr lines through. Debug adds each
retry, which `withRetry` reports through the new `onRetry`, and the start and
end of each refresh and ML data fetch; trace adds each request, which
`ApiClient` reports through the new `onRequest`. `--json` no longer keeps the
progress lines off stderr, so `backup` and `--json` runs log a refresh's
progress too. Every line that reports a failed file or album is at error,
which the progress callbacks of `backup`, `backup-metadata` and the thumbnail
helpers now say, and a command that exits 1 because files or albums failed
ends with one fatal line saying how many and where to look.
- 2026-10-18: `quak files` takes the collection ID as an optional argument
instead of the `--collection` option, which is gone
(https://git.eeqj.de/sneak/quak/issues/211). With no argument it lists every
file in the account once, collection by collection; a file in several
collections is listed with the first of them, which is its `collectionID` in
`--json`. The fresh read in `src/cli-read.ts` does this.
- 2026-10-18: Names quak builds from an album name or a title fit in a file name
(https://git.eeqj.de/sneak/quak/issues/205). `shortenKeepingExtension` in
`src/filename.ts` cuts a name short to a number of bytes, keeping its
extension when it fits and always its first character, and can put text right
before the extension; the backup's `withID` uses it for an ID.
`defaultFileName` there gives the name a file is saved under when the caller
gives no path: a prefix such as `thumb_` and the title with unsafe characters
replaced, cut short with it to 255 bytes, and, for a live photo's image or
video, with that part's own extension before the cut. `quak get`,
`quak get-thumb`, `downloadFile` and `downloadThumbnail` use it without
`--out` or `outPath`. `backup-metadata` cuts an album's name short so that its
folder, `<id>-<name>`, fits in 255 bytes. `safeExtension` gives `.bin` for an
extension of more than 16 letters and digits, so a save path's name always
fits.
- 2026-10-18: `quak collections` no longer waits for the files of every
collection (issue 210). On an empty cache it opened its library with a refresh
that fetched and decrypted the files of each collection in turn, printing
nothing and storing nothing until all were done, so on a large account it
seemed to hang. It now reads the collection list alone through the new
`lib.freshCollections()`, one request that stores nothing. The read commands
open their library with the new `refreshOnOpen: false`, so each makes only the
refresh it asks for. A refresh sends a `"progress"` event after each
collection whose files it fetched, and the commands that wait on one print it
on stderr, except with `--json`.
- 2026-10-16: ML data the server sends that does not decrypt, gunzip or parse is
no longer taken for no ML data (issue 198). `fetchMLDataBatch` returns why for
each such file beside the payloads; ML data that parses to something other
than a JSON object, such as `null`, is one of them. The library keeps the
reasons from its last ML data fetch that succeeded in memory, not in the
cache, and gives them in `status().mlDataErrors` and in the `fetchMLData`
event with status `"done"`; a fetch still running or one that fails does not
change them. `quak backup` writes the reason as `mlDataError` in the file's
JSON when no ML data is cached for it, and records the file in `failures.json`
with an error starting `ML data: `; `backup-metadata` writes `mlDataError`
too. Both exit 1. Tests serve ML data encrypted under a key that is not the
file's.
- 2026-10-16: With `--verify`, an original whose hash does not match is no
longer removed before its replacement is downloaded (issue 199).
`replaceOriginal` in `src/library/content.ts` downloads the replacement to a
temporary file beside it, a live photo's image and video each to its own,
hashes it and renames it over the original only when it matches. When the
download fails or does not match either, the original keeps its bytes and
links, and the file goes into `failures.json`. The replacement is always
downloaded, through the new `fetchOriginal` of the content cache, never copied
from the cache.
- 2026-10-16: The `~/bin/quak` that `make install` builds retries a refused
connection and a name that does not resolve, and the backup records either as
transient (issue 206). Bun's `fetch` reports them with codes of its own,
`ConnectionRefused` and `FailedToOpenSocket`, which `src/retry.ts` now counts
as transport failures. A `POST` or `PUT` is not replayed on them, because bun
also gives them when it resends a request on a new connection after a
connection it kept open closed, and the server may already have the request.
Bun already gave `ECONNRESET` for a reset and a `TimeoutError` for a deadline.
`classify` in `src/backup.ts` now records any `TimeoutError` as transient, so
a stalled download, whose error says only that it stalled, is no longer
`unknown`. The shapes were found by running the binary against local
listeners.
- 2026-10-15: An album whose folder or JSON `quak backup` cannot write no longer
ends the run before the other albums and `failures.json` are written (issue
197). The run prints `FAILED album <name>: <reason>`, lists the album's ID in
`failedAlbums` in the result and in `--json`, goes on, and exits 1. An album
whose folder would have the name of another album's JSON, as `x.json` beside
`x` would, gets its ID added. A folder or link name over 255 bytes is cut
short between two characters, by `shortenToBytes` in `src/filename.ts`, and
gets the ID.
- 2026-10-15: A network failure is printed and recorded with its reason, not as
just `fetch failed` (issue 196). `describeError` in `src/errors.ts` gives an
error's message followed by the message of each error on its `cause` chain,
such as `fetch failed: connect ECONNREFUSED 1.2.3.4:443`, and each address's
reason when a host has several and none could be reached. `run` prints that,
and the backup logs it and records it in `failures.json`, where `classify` now
calls the failure transient when the chain holds an errno that
`hasTransportCode` in `src/retry.ts` recognises, for a failed ML data fetch
too. The walk along the chain moved from `src/retry.ts` to `src/errors.ts`,
which both use. Tests refuse every connection of a backup, then only one
file's download, and fail the ML data fetch.
- 2026-10-15: The README says that `make install` needs nix (issue 200), in
Getting Started and in "Running the backup from cron": `make build-bin` runs
bun through `nix-shell -p bun`. The `Makefile` is unchanged.
- 2026-10-14: `lib.backup()`, and so `quak backup`, works on up to the content
pool's limit of originals at once, 5 by default, instead of one at a time
(issue 182). That many workers take files from one shared list, each doing all
the work for one original, its `--verify` hashing included, so no more
originals than that are open at once. `BackupLibrary` says the limit as
`contentConcurrency`. A test holds each download at the fake Ente server to
count those in flight. Once a worker has thrown, as one does when `onProgress`
throws, or the lock is lost, no worker takes another file; the backup fails
once the originals under way are in place, and a lost lock stops it before it
fetches ML data.
- 2026-10-14: Next Step said the cache design, issue 36, waits on sneak's
review, though that issue closed on 2026-10-03 (issue 194). It now names the
issues open on the tracker.
- 2026-10-14: A backup whose lock is lost no longer dies with a stack trace
(issue 184). `lockBackupDirectory` gives proper-lockfile an `onCompromised`
that records why the lock was lost instead of throwing from its timer, and
returns the lock, which `quak backup` passes to `lib.backup()` as `lock`. The
backup then stops before it starts another file with
`lost the lock on DIR: REASON`, which `quak backup` prints as one line before
it exits 1. Releasing a lost lock leaves `backup.lock` alone. Tests in
`test/cli/backup-end-to-end.test.ts` remove the lock, or put another backup's
in its place, while a download is held open.
- 2026-10-14: An album or file quak cannot decrypt, or whose metadata is not
JSON, no longer fails the refresh, so one bad record no longer stops
`quak backup` from storing anything (issue 181). The refresh leaves it out, an
album with its files, and the library keeps it with the reason in
`metadata.json` until the server sends a copy that decrypts or deletes it.
`quak backup` stores every other file, records each such file in
`failures.json` with an error starting `could not be decrypted:`, names each
such album's ID in a printed line, or with `--json` in `undecryptableAlbums`,
and exits 1 while any remain. A file whose copy in another album decrypts is
not recorded: its original is stored from that copy, and the album where it
does not decrypt neither links nor lists it. The read commands answer from
what decrypted.
- 2026-10-14: `quak backup` no longer asks the server for changes every 3
seconds while it runs (issue 183). It opens its library with
`refreshIntervalSeconds: Infinity`, which the library now takes as no
background timer, so the backup refreshes only as it starts and no timed
refresh starts another ML data pass during the run. A test holds a backup's
download open while two hours pass and counts its `/collections/v2` requests.
- 2026-10-14: `test/cli/backup-end-to-end.test.ts` runs `quak backup` as cron
does (issue 186): `backupCommand` and `run`, the real `loadSession` reading a
session saved after logging in, and every request answered by a fake of the
Ente HTTP API serving data encrypted with libsodium. It covers the backup tree
of an account with two albums of its own and one shared with it, a second run
that downloads nothing, a download that succeeds after two 503s, a 404 that
lands in `failures.json` with exit 1, a refresh answered 401 with exit 3, and
`--verify` putting back an original changed on disk. The fake server and the
account it logs in moved from the backup-metadata test into
`test/ente-server.ts`, unchanged.
- 2026-10-14: `files --collection`, `get`, `get-thumb` and
`helper fix-missing-thumbnails --file` take an ID only when it is written in
decimal digits and is a safe integer (issue 185). Before, `Number()` read
`0x10` as 16, `1e3` as 1000 and an empty string as 0, and `--file` dropped an
ID it could not read without a word. Anything else now prints
`Invalid file ID: <arg>` or `Invalid collection ID: <arg>` and exits 1 before
any request; one bad `--file` ID fails the whole command. `parseID` in
`src/cli-commands.ts` does the check for all four.
- 2026-10-06: The README says how to run `quak backup` from cron (issue 170):
log in once as the job's user, a crontab with a backup every night and
`--verify` on Sundays appending to a log file, and the `HOME` and
`XDG_DATA_HOME` the job needs to find the saved session. A table gives each
exit code: 0, the backup is complete; 1, files are in `failures.json` or
another error stopped the run; 2, another backup of the directory is running;
3, there is no usable session. The introduction lists everything the backup
keeps for each file.
- 2026-10-06: `quak backup --verify` and `lib.backup({ verify: true })` hash
each original already at its save path as the download check does, streamed, a
live photo as `<imageHash>:<videoHash>` (issue 168). One that does not match
the content hash its metadata records is logged, removed (both files of a live
photo) and put back in the same run, downloaded or copied from the cache, and
what is put back is hashed too. One that still does not match, or whose
download fails, goes into `failures.json`. One with no recorded hash is left
alone. The result, `--json` and the summary gain `verified`, `mismatched` and
`unchecked`. Without `--verify` nothing is hashed.
- 2026-10-06: Two backups of the same directory never run at once (issue 169).
`lib.backup()` takes a lock, `backup.lock` in its download directory, made
with `proper-lockfile`, before its refresh, and removes it when it ends,
whether it succeeds or fails. A second backup of the directory, from another
process or the same one, fails at once with an error naming the directory.
`quak backup` takes the lock before it opens its library, so a refused run
sends no request; it prints the error as one line and exits 2. A lock that has
gone 10 seconds untouched, left by a run that could not remove it, is taken
over by the next run.
- 2026-10-06: `quak backup` writes each original's EXIF, XMP and dimensions into
the file's JSON as `imageMetadata`, what `backup-metadata --exif` records
(issue 167): for a live photo from its image, for a video nothing, and `{}`
for an original with none of them. A failed read puts the reason in
`imageMetadataError` and fails neither the file nor the run. An original is
read when the run stores it or when its JSON has neither field; otherwise the
field is taken from that JSON. The hand-built JPEGs moved to
`test/exif-jpeg.ts`, beside the HEIC.
- 2026-10-06: When the server answers HTTP 401 and that ends a command that
loads the saved session, because the server no longer accepts its token, the
command prints one line,
`quak: the saved session is no longer valid; run "quak login"`, and exits 3
(issue 164). `quak backup` meets that 401 on the refresh it starts with,
before it touches any file. For the same commands, a missing or corrupt
session file keeps its message and also exits 3, so a cron job can tell that
the user must log in again. `quak logout` is unchanged. `run` in
`src/cli-run.ts` recognises the 401, which reaches it unchanged.
- 2026-10-06: `quak backup` retries a failed request for longer than the other
commands do (issue 165). `src/retry.ts` exports `UNATTENDED_RETRY_OPTIONS`
beside the unchanged default: 10 attempts, a 1 s base delay and a 60 s cap, so
a request that keeps failing waits at most 243 s before it gives up.
`bin/quak.ts` loads the backup's session with them, so its refresh, ML data
and downloads all use them. What is retried and the backoff formula are
unchanged.
- 2026-10-06: The files this repository copies from `sneak/prompts` are copied
again from its commit `dd4027b` (issue 171). `.gitignore` and `.dockerignore`
keep out more secret files, and this repository's build artifacts follow the
copied content. `script/check` runs `script/fmt-check` again, `script/cibuild`
runs `script/bootstrap` and `script/check` before the image build,
`script/fmt` and `script/fmt-check` find the pinned yarn under nvm, and the
image's last stage marks `/app` safe for git.
- 2026-10-06: `quak backup` writes the account and album records
`backup-metadata` writes (issue 166): `account.json` with the account's
`email` and `userID`, and in each album's JSON its `ownerID`, `isShared`,
`updationTime` and, when present, its three layers of magic metadata. Each
file's JSON gains its `updationTime`.
- 2026-10-05: `quak backup` writes each file's ML data (its faces and CLIP
embedding) into the file's JSON as `mlData`, the payload
`lib.mldata.forFile()` returns (issue 163). `lib.backup()` waits for an ML
data fetch before it writes the JSON files. When that fetch fails, each file
whose ML data is not cached gets the reason in `mlDataError` and counts as
failed, and the next run fetches it again.
- 2026-10-03: The download-albums example test no longer fails on a disk with
under 50 GiB free (issue 160). It opens its libraries with
`freeBelowBytes: 0`, so the free space of the disk it runs on cannot shrink
the cache, evict the original it cached and add a fetch to the count. No other
test's result depends on that free space. The 50 GiB default is unchanged.
- 2026-10-02: `docker build .` stamps the commit's tag or short commit, not
`dev` (issue 154). `script/build` writes the version `script/version` prints
into `dist/package.json`: the `VERSION` environment variable or build arg when
one is given, otherwise `git describe --tags --always`, otherwise the version
`package.json` declares. `.dockerignore` sends `.git`, and a checkout with
`.git` whose version comes out empty, `dev` or `unknown` fails the build.
`make build-bin` bundles the built `dist/`, so the single binary reports the
same version.
- 2026-10-02: `photo.exif()` returns every EXIF tag in the file as `ExifTags`,
keyed by tag name, each as exifreader decodes it, not only the thirteen common
fields (issue 156). The embedded thumbnail's tags are under `Thumbnail`,
without the thumbnail image. The thirteen typed methods stay, each picking its
field from the tags `exif()` returns, typed as in `PhotoExif`. The example
script's JSON files now carry every tag.
- 2026-10-01: The content cache no longer looks for a live photo that an earlier
version cached as one ZIP (issue 151). When the cache opens, a live photo's
file that no JSON file names is now always left alone.
- 2026-10-01: `examples/download-albums.ts` logs in, opens the library, and for
every album downloads each photo to its save path, writes the photo's record
and EXIF fields to a JSON file beside it, and writes the album's photos to
`albums/<collectionID>.json` (issue 144). The build compiles it to
`dist/examples/`; the README's "Examples" section says how to run it.
- 2026-10-01: A `Photo` has one async method for each field of `exif()`, named
and typed as in `PhotoExif`: `make()`, `model()`, `lensModel()`,
`dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`,
`iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()`
and `gpsAltitude()` (issue 148). Each calls `exif()` and returns its one
field, or undefined when the file lacks it. `Photo` implements a type with one
method per `PhotoExif` field, so the build's type check fails when a field has
no method. A test checks, on the JPEG and the HEIC, that each method gives the
same value as `exif()`.
- 2026-10-01: Each original's save path is
`YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>` under the library's
download directory, which defaults to `photos` in the working directory (issue
143). The date is the photo's `takenAt` in the machine's time zone.
`photo.savePath` is always a string, with or without a content cache, and
`isLocal` is true only when the original is at its save path.
`photo.download()` puts the original there, copied from the cache when the
cache holds it and fetched otherwise. `lib.backup()` does the same for each
file, writes each file's JSON beside its original, and links `collections/` to
the save paths.
- 2026-10-01: `photo.exif()` and `backup-metadata --exif` read EXIF from
HEIC/HEIF originals, a live photo's HEIC image included, as well as JPEG and
the other image formats `exifreader` reads (issue 145). `exifreader` replaces
`exif-reader` and the JPEG segment scan; `PhotoExif` is unchanged. The `exif`
field of `backup-metadata --exif` is now exifreader's tag output, and
`exifRaw` holds the whole EXIF block it could not read. The tests use a real
HEIC, `test/exif.heic`.
- 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`,
`modifiedAt`, `hash` and `year` (issue 141). `savePath` is where
`lib.backup()` writes the original under the library's download directory; for
a live photo not yet stored, it carries the title's extension, and the backup
may store the image under a different one. `isLocal` says whether the whole
original is there. Both look only at the disk. `content()` returns the
original's bytes and `exif()` the common EXIF fields of a JPEG; both may
download the original, and `exif()` downloads no video. `PhotoRecord` gains
`modifiedAt` and `hash`, and the JPEG EXIF scan moved to `src/exif.ts`.
- 2026-09-29: The "Workflow" list at the top of this file now says to branch
from `next` and open a pull request that targets `next`, that the repository
manager squash-merges a reviewed pull request into `next`, and that only sneak
merges `next` into `main`, as the README does (issue 137).
- 2026-09-29: The README's "Development workflow" and "For LLMs" sections now
say work branches from `next`, every pull request targets `next`, the
repository manager squash-merges reviewed pull requests into `next`, and only
sneak merges `next` into `main` (issue 135).
- 2026-09-29: Brought the README and this file in line with `next` after the
milestone merge (issue 132). The Next Step says no implementation work is open
and the cache design (issue 36) waits on sneak's review. README corrections:
the Getting Started comments say when the library refreshes; most, not all,
Makefile targets call a script; `script/lint` and `script/test` have no host
path, though `yarn test` does; the SRP handshake uses `fast-srp-hap`, outside
`crypto/`, and a thumbnail upload's MD5 uses `node:crypto`; the SRP password
is the first 16 bytes of a 32-byte subkey; the key attributes and token come
after SRP, after the TOTP code SRP may ask for, or after the email OTP that
replaces SRP when the account has email MFA on, and quak cannot answer a
passkey; the auth token is sent as URL-safe base64 with padding; every
`TypeError` is retried; each download attempt writes its own temporary files,
two for a live photo, and only the attempt that completes renames them into
place; `runMetadataBackup` records a failed download in the file's JSON; a
second `client.logout()` does not throw; `quak logout` with no session exits
0, and names the cache directory only when it knows it; `login`, `whoami` and
`logout` open no library; `--exif` records XMP and, for a JPEG, EXIF, and no
IPTC; which commands take `--json`; a failed ML data request may not have been
retried; the thumbnail fixer is not limited to baseline JPEG; `quak backup`
still fetches ML data; a backup's JSON holds the basic metadata fields quak
keeps and the private and public magic metadata, not every decrypted field,
and the README no longer lists what the magic metadata holds; the default
cache directory is the per-user one, not an XDG path on macOS; pinned
originals can exceed `cacheOriginalsMaxBytes`; which tests cover which
operations; a default read is only as current as the last refresh whose
requests all succeeded; `fresh()` and `lib.backup()` join a refresh already
running; a `Photo` has no `thumbnailPath` or `originalPath`; and `408` and
`429` are retried.
- 2026-09-28: Tested the live-photo writer's fsyncs (issue 130). A test checks
that the image's and the video's temp files are fsynced before either is
renamed into place, and the directory after both renames, as the `writeAtomic`
test does for one file.
- 2026-09-23: Live photos are stored as their image and their video (issue 107).
A live photo, which Ente stores as one ZIP, is unpacked as it downloads into
`<fileID>.<ext>` for the image and for the video, each with its extension from
the ZIP, beside `<fileID>.livephoto.json`, which names the two. Both are
checked against the recorded hash and renamed into place only when both are
complete. A ZIP holding a second image or video, or whose two files come to
more than 20 times its size plus 16 MiB, is refused. The backup and the
content cache count the live photo as stored only with both files, the album
folder links both, `quak get` writes both, and the content result gives the
video as `videoPath`. A ZIP an earlier version stored is replaced on the next
backup run, and removed from the cache when the library opens it.
- 2026-09-23: Settled the package metadata (issue 6). quak is not published, so
`package.json` is marked `"private": true` and the `files` field is gone.
`engines.node` is `>=22`, the major version `script/bootstrap` and the
`Dockerfile` use. An `exports` map makes `.` and `./package.json` the only
importable paths; `runMetadataBackup`, the thumbnail helpers and their types
stay internal to the CLI.
- 2026-09-23: Brought the README and this file in line with the tree (issue
111). The layout lists `src/library/` and the other source files, the backup
layout names `failures.json` and the optional `thumbnails/`, "Opening a
library" says what happens when the first refresh fails, the Testing section
gives the 60-second hard cap and 20-second target for `make test` and names
the 90-second `timeout` in the `Dockerfile` as the backstop for a hung test,
and "Tag v1.0.0" is no longer listed as the next step.
- 2026-09-23: Tested `quak login` and `backup-metadata --exif` (issue 110).
`loginCommand` takes its login function and its prompts from `CliContext`, and
`bin/quak.ts` passes `Client.login` and the terminal prompts. Tests cover a
login from `QUAK_EMAIL` and `QUAK_PASSWORD` with no prompt, the TOTP prompt, a
failed login, and the saved session's modes, and show that `--exif` and
`--all` each turn on EXIF extraction and that it is off without them.
- 2026-09-23: `quak backup` writes each original once and no longer fills the
cache (issue 106). An original fetched for a backup is written by the download
writer straight into the backup's `originals/`, and the content cache records
it there instead of keeping its own copy; one the cache already held is still
copied. `quak backup` opens its library with the thumbnail and originals
precache off.
- 2026-09-23: `backup-metadata`, `helper list-missing-thumbnails` and
`helper fix-missing-thumbnails` refresh before they answer (issue 100). Each
awaits `lib.fresh()` before reading, so a file added since the cache was
written is included, and a failed refresh prints one line and exits 1 instead
of answering from a stale or empty cache. The README lists them among the
commands that refresh first.
- 2026-09-23: `quak backup` waits for the server refresh and fails when it fails
(issue 99). `lib.backup()` joins a refresh already running or starts one, as
`fresh()` does, and rejects before touching any file when it fails, leaving
`failures.json` as it was, so `quak backup` prints the error as one line and
exits 1 instead of backing up the previous run's file list, or nothing, and
exiting 0.
- 2026-09-23: CLI errors print a message instead of a stack trace (issue 102).
An error a command throws is printed as one `quak: MESSAGE` line on stderr and
the CLI exits 1 once output has drained. The wrapper that does this moved from
`bin/quak.ts` to `src/cli-run.ts`, and `bin/quak.ts` now awaits
`program.parseAsync()`.
- 2026-09-23: Opening a library no longer deletes another process's download in
progress (issue 105). The download writer's temp files are named
`.quak-<pid>-<random>.tmp`, and `removeLeftoverTempFiles`, moved from the
backup into the download module, deletes a `.quak-*.tmp` file only when the
process ID in its name is no longer running. The content cache calls it at
`open()` for `originals/` and `thumbnails/`, the backup as before.
- 2026-09-23: Re-vendored the lint and test setup from the template (issue 96).
Linting and testing are the `lint` and `test` phases of the `Dockerfile`;
`script/lint` and `script/test` each build one with `--no-cache`, and the last
stage compiles and depends on both, so `script/cibuild` is one build.
`Dockerfile.lint`, `CHECK_EPOCH`, `LINT_EPOCH` and the tests that checked them
are gone; `REPO_POLICIES.md` is re-copied.
- 2026-09-23: Stopped `helper fix-missing-thumbnails` retrying files the server
always refuses (issue 109). Both thumbnail helpers skip a file another account
owns without fetching it. The fixer skips a file whose recorded thumbnail size
is 0 or unknown before downloading it, and otherwise tries smaller encodings
(720 px quality 50 down to 160 px quality 20) until the encrypted thumbnail is
no larger than that size, skipping the file if none fits.
- 2026-09-23: Tested the live-photo hash check's error paths (issue 117). Tests
download a live photo whose ZIP names an unknown compression method, one whose
ZIP has no image entry and one with no video entry, and check that nothing is
stored and the error names the file ID; the unreadable one is not retried.
- 2026-09-23: `quak logout` ends the session on the server (issue 108). It calls
`POST /users/logout` through the new `Client.logoutOnServer()`, then deletes
`session.json` even when that call fails, says so and exits 1. It prints the
account's cache directory and says it still holds decrypted data. The default
cache path is now `defaultCacheDirectory()` in the library, shared with
`Library.open`.
- 2026-09-23: Fixed the backup's per-collection folders (issue 103). Two files
in one collection with the same title, and two collections with the same name,
each get their ID added to the name (`IMG_0001 (12345).JPG`, `Trip (10)/`), so
none replaces another's symlink or JSON. Each run removes symlinks into
`originals/` for files no longer in the collection, and the folders of deleted
or renamed collections, leaving anything else in `collections/` alone. The
README backup layout states the naming rule.
- 2026-09-23: Checked downloaded originals against their recorded content hash
(issue 68). `downloadFile`, which `quak get`, the content cache and backup all
use, hashes the decrypted bytes (unkeyed BLAKE2b-512, standard base64) and
stores nothing on a mismatch, failing with an error naming the file ID. A live
photo ZIP is unpacked as it streams with `fflate` and its image and video
hashed separately as `<imageHash>:<videoHash>`. `decryptFile` reads older
clients' `imageHash` and `videoHash` fields for live photos. A file with no
recorded hash is stored unchecked.
- 2026-09-23: Kept one account's cache from mixing with another's (issue 104).
When `metadata.json` in the cache directory was written for a different,
non-zero user ID than the client's, `Library.open` deletes it and `mldata/`
and starts empty, so the first refresh enumerates from 0. This only happens
with `--cache-dir` or an explicit `cacheDirectory`; the default path already
includes the user ID. A test opens one account's cache as another account.
- 2026-09-23: `backup-metadata` no longer stops on one failed ML data request
(issue 101). Each request of up to 200 files is tried on its own; a failed one
is logged, its files are written with the reason in `mlDataError`, and the
command exits 1 once the dump is complete. `fetchMLData`, which only this
command used, is gone; the command calls `fetchMLDataBatch` per batch.
- 2026-09-23: Single-sourced the version string (issue 5). `package.json` is the
only place it is written: `src/index.ts` imports it for `VERSION` and
`bin/quak.ts` passes `VERSION` to commander. tsc copies `package.json` to
`dist/package.json`, so the import resolves from the built output too, and
`script/build` runs the built CLI with `--version` to prove it. A test checks
that `VERSION` and `quak --version` both equal the `package.json` version.
- 2026-09-23: Tested that `Library.close()` waits for the originals precache
(issue 93). The test that holds a precache fetch open while `close()` runs now
runs once with only the thumbnail fill and once with only the originals fill,
so dropping either wait from `Precache.close()` fails a test.
- 2026-09-23: Fixed two intermittently failing library tests (issue 90).
`Library.close()` now returns a promise that resolves once an in-flight
refresh (including its cache write), the ML data fetch and running precache
sweeps have finished; the library tests await it, so `afterEach` no longer
removes the cache directory while something is still writing into it. The
precache test waits for both fills to report "done" instead of for its stub
source to be called, which happened before the cache recorded the file.
- 2026-09-23: Pinned three guards reviewers found untested (issue 89). The
download idle deadline's timer is unref'd, so it can never keep the process
alive, and a test checks no timer is left after a download completes or fails.
A test covers the rejection of `#` in a request path. The EXIF scan compares
the `Exif` header only in an APP1 segment of length 8 or more, so it never
reads the next segment's bytes, with a test for a short one.
- 2026-09-23: Made the download deadline an idle deadline (issue 24).
`downloadTimeoutMs` now aborts a file or thumbnail download only after no
bytes have arrived for that long, default 60 seconds, instead of bounding the
whole transfer at 10 minutes, so a slow download that keeps making progress
completes. A download that fails before reading the whole body cancels it, so
a failed file no longer holds its connection.
- 2026-09-23: Every `ApiClient` request URL is now built by one function next to
the class (issue 18), so a self-hosted `apiOrigin` with a base path keeps it
on every request, a path works with or without a leading slash, and query
parameters are percent-encoded. A path containing `?` or `#` is rejected with
an error instead of being silently cut.
- 2026-09-23: Made the CLI testable and tested it (issue 12). The command bodies
moved from `bin/quak.ts` into `src/cli-commands.ts` as functions that take
their options and a context (output streams, session directory, cache
directory, session loader) and return an exit code; `bin/quak.ts` only wires
them to commander and exits with the code once stdout and stderr have drained,
so nothing below it calls `process.exit`. `test/cli/commands.test.ts` drives
them with a fake client: session file modes, logout, the missing and corrupt
session paths, and the output and exit code of `whoami`, `collections`,
`files`, `get`, `get-thumb`, `backup` and `helper list-missing-thumbnails`.
- 2026-09-23: Hardened the backup tree's atomic copy (issue 22). `copyAtomic`
fsyncs its temp file before the rename and the directory after it, through the
download writer's `fsyncPath`; each backup run deletes `.quak-backup-*.tmp`
files whose process is no longer running. The README backup layout names the
temp files and states that the rename replaces a symlink and takes the temp
file's permissions. Added tests for a missing and an unwritable destination
directory for `downloadFile` and `downloadThumbnail`.
- 2026-09-23: Hardened the JPEG EXIF scan behind `backup-metadata --exif` (issue
11). Every segment length is checked against the remaining bytes and lengths
under 2 stop the scan, so a truncated or corrupt original can neither throw
nor loop. A malformed or unparseable EXIF segment is recorded as
`imageMetadata.exifError`, and a failure to read the original as
`imageMetadataError` in the per-file JSON, instead of the field being left
out.
- 2026-09-22: Hardened the retry classifier (issue 80). A `POST` or `PUT` is
replayed only when every errno in the cause chain is a connect errno, and it
no longer follows redirects. `getRetryOptions()` returns a copy. Tests pin
every errno the classifier names, the cause-chain depth limit, cycle
termination, and a fresh deadline per attempt for every retrying entry point.
The README's endpoint list is the one place that names the requests the replay
rule covers.
- 2026-09-22: Stopped `make test` collecting tests from checkouts nested under
`.claude/` (issue 25). vitest ignores `.gitignore` when finding tests, so a
nested checkout ran the whole suite again; `vitest.config.ts` now adds
`.claude/**` to vitest's default excludes, and
`test/packaging/nested-checkout.test.ts` plants a nested checkout in a temp
directory and fails if vitest would collect it.
- 2026-09-22: Dropped the deprecated `@types/libsodium-wrappers-sumo` stub from
`devDependencies` (issue 27). It shipped no declarations; the types come from
`libsodium-wrappers-sumo` itself. `yarn.lock` regenerated by `yarn remove`.
- 2026-09-22: Hardened the client session lifecycle (issue 10).
`Client.fromJSON` checks every snapshot field and each key's decoded length
and names the bad field; `toJSON` reads the token through
`ApiClient.getAuthToken` and throws when there is none; `logout` zeroes the
key buffers, and `collectionsSince` re-checks for logout after its request so
it never decrypts with zeroed keys. The CLI reports a corrupt session file
separately from a missing one (`src/cli-session.ts`).
- 2026-09-22: Sanitized file names taken from server metadata (issue 9). A new
`src/filename.ts` holds the one sanitizer, used by `quak get`/`get-thumb`
without `--out`, `downloadFile`/`downloadThumbnail` without `outPath`, and the
backup and metadata backup trees; it removes separators, control characters,
leading dots and Windows device names, and falls back to a name built from the
ID for an empty title. Originals-cache extensions are letters and digits only,
else `.bin`. A user-supplied path is used as is. `decryptFile` reads a missing
or non-string title as "" and rejects metadata that is not a JSON object.
- 2026-09-22: Rewrote the README API reference (and the Getting Started / usage
snippets) to match the shipped cache/API library on `next` (issue 53, issue
13). Documented `Library.open` and its options, the default-read vs `fresh()`
distinction (and that the CLI's read commands are fresh), the record types and
`snapshot()`/`subscribe()`, the `albums`/`photos`/`timeline` read surface,
`Photo` content methods, `thumbnails.ensure`, the `mldata` search surface,
`backup()`, the three request pools (10/5/25), and the on-disk cache layout;
noted the deferred content-hash integrity check (issue 68). Docs-only; no code
changed.
- 2026-09-22: Added resumable, deletion-aware enumeration to `Client` (issue 38,
closes issue 7). `collectionsSince`/`filesSince` take a starting cursor,
decrypt live records, surface tombstoned ids in a separate `deleted` list (a
tombstone has nothing to decrypt, so it is a bare id, not a hollow record),
and return the max `updationTime` seen as the cursor to resume from.
`filesSince` refuses to loop when the diff reports `hasMore` without advancing
the cursor (issue 7). `listCollections`/`listFiles` are now thin wrappers that
enumerate from `sinceTime: 0` and drop deletions, so existing callers are
unaffected.
- 2026-09-22: Carried file size, thumbnail size, and the deletion flag through
`decryptFile` (issue 37, foundation for the cache/API design). Live files now
populate `file.size`/`thumbnail.size` from the server's `info` (left
`undefined` when the server omits it), and `isDeleted` is carried from the
diff row onto `EnteFile`. No caller change: `listFiles` still filters deleted
rows before decrypting. Surfacing a tombstone through decryption belongs to
the enumeration unit (issue 38).
- 2026-08-10: Made `lint-once.test.ts` enforce what its header claims. It walked
`make check` only, so it never read `Dockerfile` — the image CI builds through
`script/cibuild` — and a second `prettier --check .` could be added there with
the suite staying green. The walk now also starts at
`.gitea/workflows/check.yml` and follows its `run:` steps, so the graph under
test is the one CI executes rather than the one someone assumed it executes.
The lockfile assertion was a substring check against the whole of
`script/bootstrap`, which has two install sites and so reported the branch the
containers never take; the two branches are now resolved separately and every
`yarn install` in each is required to be `--frozen-lockfile`. Prettier is
counted per occurrence instead of per line, so two invocations chained with
`&&` no longer read as one, and edges are followed on counted lines instead of
being skipped. Every way for the walk to reach nothing — an unknown target, an
unknown script, a missing file, a node with no commands, an unknown node kind
— is a thrown error rather than a quiet zero. Every assertion in the file was
mutation-tested individually.
- 2026-08-10: Stopped `make check` running `prettier --check .` twice. Since
linting moved into Docker, the duplicate was one container pass and one host
pass of the same check: `script/lint` builds `Dockerfile.lint`, which runs
prettier as a build step, and `script/check` then called `script/fmt-check` as
well. The host call is gone from `script/check` and from `script/precommit`;
the container keeps checking formatting, because a successful
`Dockerfile.lint` build is what CI treats as proof of a clean tree, and it is
also what still fails the pre-commit hook on a badly formatted tree.
`script/fmt-check` survives as a standalone entrypoint, whose verdict cannot
drift from the container's. A test walks the invocation graph from each
entrypoint — through the Makefile shims, the `script/` calls and the
`docker build` — and asserts the prettier count, so the duplication cannot
come back unnoticed.
- 2026-08-10: Moved all linting into Docker. `script/lint` builds a new root
`Dockerfile.lint`, which copies the repo into the digest-pinned node image and
runs eslint and prettier as build steps, so a successful build is a clean
lint; no host lint path remains and `yarn lint` is gone from `package.json`. A
fail-closed `LINT_EPOCH` guard stops Docker serving the linter layers from
cache, which is how a lint build returns success in under a second having
linted nothing. The lint stage inside `Dockerfile` and its `COPY --from=lint`
ordering hack are gone: that image now runs `make test` and `make build` only,
because `script/check` calls `script/lint` and running it in a container would
mean docker inside docker. `script/cibuild` builds the lint image first, then
the test and build image.
- 2026-08-09: Made `make docker` green and policy-conformant. Multi-stage
Dockerfile: a lint stage runs `make fmt-check` and `make lint`, and the check
stage takes a `COPY --from=lint` dependency on it before running `make check`
and `make build`. `CHECK_EPOCH` and a fail-closed guard stop Docker serving
those two layers from cache, which is what let a build report success without
running the suite. `script/projectname` says `quak`, so the image is tagged
`quak`; `script/bootstrap` updates apt lists before installing, so a Debian
base works; `.dockerignore` no longer ships the compiled binary, the caches or
agent worktrees into the build context, and keeps `.gitignore` in it for
prettier.
- 2026-08-09: Fixed the TypeScript build. `rootDir` is the repo root, so `bin/`
compiles alongside `src/` instead of failing with TS6059; output is
`dist/src/` and `dist/bin/`, which is where `main`, `types` and `bin.quak` now
point. `script/build` verifies the declared entrypoints exist after the
compiler runs and makes the CLI executable, the Dockerfile runs `make build`
as well as `make check`, and a `quak` script makes the README's
`yarn quak <command>` examples work.
- 2026-08-09: Retry policy: no retry on 4xx (except `408` and `429`),
exponential backoff with full jitter on 5xx, transport failures and truncated
transfers, under per-attempt deadlines that cover the response body as well as
the request. Downloads retry request, stream consumption and decryption as one
unit; `postJSON` and `putJSON` are replayed only when the connection was never
established.
- 2026-08-09: Downloads verify the secretstream terminated on `TAG_FINAL` and
write output atomically: a truncated body is rejected instead of landing on
disk as a short file, and plaintext is staged in a sibling temp file and
renamed into place, so a failed download leaves the destination untouched.
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section
- 2026-06-10: Decrypted collections shared by other users (sealed-box keys);
listCollections drops deleted-collection tombstones.
- 2026-06-10: Login hardening: dual-2FA empty-string fields handled, TOTP
preferred when a passkey is also enrolled, interactive input via
@inquirer/prompts.
- 2026-06-10: Replaced sharp with pure JS (jpeg-js + exif-reader); added
single-binary bun build and make install.
- 2026-06-09: Added backup-metadata command (ML data always included, --exif
opt-in); rewrote README to match the implementation; added thumbnail helper
tests.
- 2026-05-13: Full CLI surface: login, backup with dedup symlink layout,
collections, files, get, get-thumb, thumbnail repair helpers.
- 2026-05-13: Client OO API with literate usage tests; file download and
decryption; all three metadata layers decrypted and persisted; renamed quack
to quak.
- 2026-05-11: SRP login flow (email OTP + TOTP) and ApiClient.
# Future Steps # Future Steps
- Future desktop client, separate repo: - Future desktop client, separate repo:
-31
View File
@@ -1,31 +0,0 @@
#!/bin/sh
# Writes src/clip-keyword-vectors.ts and test/clip-keywords-sunset.json by
# running bin/clip-keyword-vectors.py in a Python image pinned by digest, with
# its packages pinned by hash. Run it by hand from a checkout, then `make fmt`;
# no gate runs it. Besides the Python image, it downloads about 265 MB, most of
# it Ente's two CLIP models.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# python:3.13-slim (Python 3.13.17 on Debian 13.7), 2026-10-21. The wheels
# below are the ones for this image on linux/amd64.
IMAGE=python@sha256:57b29a7c050c4e679e4bea572e14b3965cf4b5839de2da49d55e49ffc4024f39
main() {
cd "$ROOT"
docker run --rm -i --platform linux/amd64 \
--user "$(id -u):$(id -g)" -e HOME=/tmp \
-v "$ROOT:/repo" -w /repo "$IMAGE" sh -c '
python -m venv /tmp/venv &&
/tmp/venv/bin/pip install --quiet --disable-pip-version-check \
--no-cache-dir --no-deps --require-hashes -r /dev/stdin &&
/tmp/venv/bin/python bin/clip-keyword-vectors.py' <<'EOF'
numpy==2.5.3 --hash=sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321
onnxruntime==1.30.0 --hash=sha256:86f940afc801ea9681a4da8af84fbe95e1d9ea7d80903952cc1bfad54faad38f
pillow==12.3.0 --hash=sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3
tokenizers==0.23.2 --hash=sha256:41c2f84d172449b4dadb9cdc508e3e364076613c35b16e76ecfe47a60d1e3305
EOF
}
main "$@"
-158
View File
@@ -1,158 +0,0 @@
"""Write src/clip-keyword-vectors.ts and test/clip-keywords-sunset.json.
bin/clip-keyword-vectors runs this in a pinned Python image. It downloads
Ente's Discover list, Ente's CLIP text and image models, the CLIP tokenizer
and a public-domain photo of a sunset, checks each against its sha256, and
writes:
- src/clip-keyword-vectors.ts: each Discover category of Ente's photos app,
with the text embedding Ente's CLIP text model gives its query;
- test/clip-keywords-sunset.json: the CLIP embedding Ente's image model gives
the photo, prepared as Ente's apps prepare a photo.
Both embeddings are scaled to length 1, as Ente's apps scale them.
"""
import hashlib
import io
import json
import urllib.request
import numpy as np
import onnxruntime
from PIL import Image, ImageOps
from tokenizers import Tokenizer
ENTE_COMMIT = "f5ad70a860ed25d0c8057b0efb4bd6eaefe4b0c5"
DISCOVER_PATH = "mobile/apps/photos/assets/discover.json"
DISCOVER_URL = (
f"https://raw.githubusercontent.com/ente-io/ente/{ENTE_COMMIT}/{DISCOVER_PATH}"
)
DISCOVER_SHA256 = "cf70ac7116b592f7921ca4cf67cdbb421a8e6b287fbe44b2be678b8d198e5c24"
TEXT_MODEL = "mobileclip_s2_text_opset18_quant.onnx"
TEXT_MODEL_SHA256 = "d92f33dfcff83077fc2e0d3414250710efbb51795dfd89767bdbefb5fdc47322"
IMAGE_MODEL = "mobileclip_s2_image_gelu_opset20.onnx"
IMAGE_MODEL_SHA256 = "205a430af825e501c5138e5bb9abea942482a7a4fd4a680e98e47cf0830dce7e"
# The CLIP tokenizer Hugging Face publishes for OpenAI's CLIP. Its vocabulary
# and merges are those of bpe_simple_vocab_16e6.txt, the vocabulary Ente's
# apps tokenize with.
TOKENIZER_URL = (
"https://huggingface.co/openai/clip-vit-base-patch32/resolve/"
"3d74acf9a28c67741b2f4f2ea7635f0aaf6f0268/tokenizer.json"
)
TOKENIZER_SHA256 = "b556ac8c99757ffb677208af34bc8c6721572114111a6e0aaf5fa69ff0b8d842"
# "Sunset off the Massachusetts coast", a NOAA photo in the public domain:
# https://commons.wikimedia.org/wiki/File:Sunset_off_the_Massachusetts_coast_-_NOAA.jpg
PHOTO_URL = (
"https://upload.wikimedia.org/wikipedia/commons/d/db/"
"Sunset_off_the_Massachusetts_coast_-_NOAA.jpg"
)
PHOTO_SHA256 = "be62f3289c9bf7b75af68cd6978021d54e8a531d5d4cc493f6e9fbf5e1ab74d3"
# Ente's text model takes 77 token ids, padded with 0; its image model takes
# a 256x256 RGB image.
TOKEN_COUNT = 77
IMAGE_SIZE = 256
def download(url, sha256):
# Wikimedia refuses requests without a User-Agent.
request = urllib.request.Request(url, headers={"User-Agent": "quak"})
with urllib.request.urlopen(request) as response:
data = response.read()
digest = hashlib.sha256(data).hexdigest()
if digest != sha256:
raise SystemExit(f"{url} has sha256 {digest}, not {sha256}")
return data
def ente_model(name, sha256):
return onnxruntime.InferenceSession(
download(f"https://models.ente.com/{name}", sha256),
providers=["CPUExecutionProvider"],
)
def run(session, tensor):
(output,) = session.run(None, {session.get_inputs()[0].name: tensor})
embedding = output[0]
return embedding / np.linalg.norm(embedding)
def text_embedding(session, tokenizer, query):
ids = tokenizer.encode(query).ids
ids += [0] * (TOKEN_COUNT - len(ids))
return run(session, np.array([ids], dtype=np.int32))
def image_embedding(session, photo):
image = ImageOps.exif_transpose(Image.open(io.BytesIO(photo))).convert("RGB")
# Scale to cover IMAGE_SIZE x IMAGE_SIZE and crop the centre.
square = ImageOps.fit(image, (IMAGE_SIZE, IMAGE_SIZE), Image.Resampling.BILINEAR)
pixels = np.asarray(square, dtype=np.float32) / 255
return run(session, pixels.transpose(2, 0, 1)[np.newaxis])
def numbers(embedding):
return ", ".join(str(value) for value in embedding)
def write_categories(prompts, embeddings):
lines = [
"// Written by bin/clip-keyword-vectors; do not edit by hand.",
"//",
"// The Discover categories of Ente's photos app, in their order, from",
f"// {DISCOVER_PATH} in https://github.com/ente-io/ente",
f"// at commit {ENTE_COMMIT}. Each has the text",
f"// embedding Ente's CLIP text model ({TEXT_MODEL})",
"// gives its query, scaled to length 1. `showVideo` is false for a category",
"// that leaves out videos.",
"",
"export const DISCOVER_CATEGORIES = [",
]
for prompt, embedding in zip(prompts, embeddings):
lines += [
" {",
f" title: {json.dumps(prompt['title'])},",
f" query: {json.dumps(prompt['query'])},",
f" minScore: {json.dumps(prompt['minScore'])},",
f" showVideo: {json.dumps(prompt.get('showVideo', True))},",
f" textEmbedding: [{numbers(embedding)}],",
" },",
]
lines.append("];")
with open("src/clip-keyword-vectors.ts", "w", encoding="utf-8") as file:
file.write("\n".join(lines) + "\n")
def write_fixture(embedding):
with open("test/clip-keywords-sunset.json", "w", encoding="utf-8") as file:
file.write(
"{\n"
f' "photo": {json.dumps(PHOTO_URL)},\n'
f' "sha256": {json.dumps(PHOTO_SHA256)},\n'
f' "embedding": [{numbers(embedding)}]\n'
"}\n"
)
def main():
prompts = json.loads(download(DISCOVER_URL, DISCOVER_SHA256))["prompts"]
tokenizer = Tokenizer.from_str(
download(TOKENIZER_URL, TOKENIZER_SHA256).decode("utf-8")
)
text_model = ente_model(TEXT_MODEL, TEXT_MODEL_SHA256)
embeddings = [
text_embedding(text_model, tokenizer, prompt["query"]) for prompt in prompts
]
write_categories(prompts, embeddings)
image_model = ente_model(IMAGE_MODEL, IMAGE_MODEL_SHA256)
write_fixture(image_embedding(image_model, download(PHOTO_URL, PHOTO_SHA256)))
if __name__ == "__main__":
main()
+2
View File
@@ -123,6 +123,7 @@ program
.description("Download and decrypt a single file") .description("Download and decrypt a single file")
.argument("<fileID>", "File ID (from `quak files`)") .argument("<fileID>", "File ID (from `quak files`)")
.option("--out <path>", "Output file path") .option("--out <path>", "Output file path")
.option("--collection <id>", "Accepted for compatibility; ignored")
.action((fileID: string, opts: { out?: string }) => .action((fileID: string, opts: { out?: string }) =>
run(getCommand(context(), fileID, opts)), run(getCommand(context(), fileID, opts)),
); );
@@ -132,6 +133,7 @@ program
.description("Download and decrypt a thumbnail") .description("Download and decrypt a thumbnail")
.argument("<fileID>", "File ID (from `quak files`)") .argument("<fileID>", "File ID (from `quak files`)")
.option("--out <path>", "Output file path") .option("--out <path>", "Output file path")
.option("--collection <id>", "Accepted for compatibility; ignored")
.action((fileID: string, opts: { out?: string }) => .action((fileID: string, opts: { out?: string }) =>
run(getThumbCommand(context(), fileID, opts)), run(getThumbCommand(context(), fileID, opts)),
); );
+1 -1
View File
@@ -12,7 +12,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": "^22.13.0 || >=23.5.0" "node": ">=22"
}, },
"main": "./dist/src/index.js", "main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts", "types": "./dist/src/index.d.ts",
+6 -14
View File
@@ -14,9 +14,9 @@
// YYYY/YYYY-MM/YYYY-MM-DD/ // YYYY/YYYY-MM/YYYY-MM-DD/
// YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path) // YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path)
// YYYY-MM-DD.<fileID>.json per-file metadata sidecar, with // YYYY-MM-DD.<fileID>.json per-file metadata sidecar, with
// the file's ML data, its // the file's ML data and its
// keywords, and its original's // original's EXIF, XMP and
// EXIF, XMP and dimensions // dimensions
// collections/<name>/<title> symlink to the original // collections/<name>/<title> symlink to the original
// collections/<name>.json per-collection metadata // collections/<name>.json per-collection metadata
// account.json the account's email and user ID // account.json the account's email and user ID
@@ -77,7 +77,6 @@ import { readFile } from "node:fs/promises";
import { dirname, extname, join, relative, resolve } from "node:path"; import { dirname, extname, join, relative, resolve } from "node:path";
import lockfile from "proper-lockfile"; import lockfile from "proper-lockfile";
import { clipKeywords } from "./clip-keywords.js";
import { removeLeftoverTempFiles } from "./download/index.js"; import { removeLeftoverTempFiles } from "./download/index.js";
import { describeError } from "./errors.js"; import { describeError } from "./errors.js";
import { import {
@@ -498,9 +497,8 @@ const imageMetadataFor = async (
} }
}; };
// The file's JSON: its basic fields, its magic metadata, its ML data without // The file's JSON: its basic fields, its magic metadata, its ML data or the
// the CLIP embedding and the keywords that embedding gives, or the reason the // reason the ML data is missing, and its image metadata.
// ML data is missing, and its image metadata.
const writeSidecar = ( const writeSidecar = (
path: string, path: string,
file: EnteFile, file: EnteFile,
@@ -516,13 +514,7 @@ const writeSidecar = (
}; };
if (file.magicMetadata) meta.magicMetadata = file.magicMetadata; if (file.magicMetadata) meta.magicMetadata = file.magicMetadata;
if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata; if (file.pubMagicMetadata) meta.pubMagicMetadata = file.pubMagicMetadata;
if (ml.mlData) { if (ml.mlData) meta.mlData = ml.mlData;
const { clip: _clip, ...mlDataWithoutClip } = ml.mlData;
meta.mlData = mlDataWithoutClip;
const isVideo = file.metadata.fileType === "video";
const keywords = clipKeywords(ml.mlData, isVideo);
if (keywords) meta.keywords = keywords;
}
if (ml.mlDataError) meta.mlDataError = ml.mlDataError; if (ml.mlDataError) meta.mlDataError = ml.mlDataError;
if (image.imageMetadata) meta.imageMetadata = image.imageMetadata; if (image.imageMetadata) meta.imageMetadata = image.imageMetadata;
if (image.imageMetadataError) { if (image.imageMetadataError) {
+23 -36
View File
@@ -37,16 +37,9 @@ import {
thumbnailName, thumbnailName,
} from "./cli-output.js"; } from "./cli-output.js";
import { type Log, logRefresh } from "./cli-log.js"; import { type Log, logRefresh } from "./cli-log.js";
import { import { freshCollections, freshFiles, freshFile } from "./cli-read.js";
cachedFilesByID,
freshCollections,
freshFiles,
freshFile,
freshFilesByID,
} from "./cli-read.js";
import { withExtension } from "./filename.js"; import { withExtension } from "./filename.js";
import { runMetadataBackup } from "./metadata-backup.js"; import { runMetadataBackup } from "./metadata-backup.js";
import type { Collection, EnteFile } from "./model/types.js";
import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js"; import { listMissingThumbnails, fixMissingThumbnails } from "./thumbnails.js";
export interface CliContext { export interface CliContext {
@@ -135,15 +128,12 @@ const readLibraryClient = (client: Client): LibraryClient => ({
contentSource: () => client.contentSource(), contentSource: () => client.contentSource(),
}); });
// Open a library for a single point command. The aggressive background // Open a library for a single point command: the aggressive background precache
// precache (issue #48) is off, because a one-shot `collections` or `get` must // (issue #48) is off — a one-shot `collections` or `get` must not start
// not start downloading the whole account. No refresh starts as the library // downloading the whole account — and no refresh starts as it opens or on a
// opens or on a timer. Each command asks for the refresh it needs. // timer: each command asks for the one it needs, and `collections` needs none
// `collections` and `files` with a collection ID need none (issues #210 and // (issue #210). While a refresh runs, each collection whose files it has
// #217). Nor do `get` and `get-thumb` on an empty cache (issue #222). Nor does // fetched is logged, so a large account shows progress rather than nothing.
// `helper fix-missing-thumbnails --file` on an empty cache (issue #223). While
// a refresh runs, each collection whose files it has fetched is logged, so a
// large account shows progress rather than nothing.
const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> => const openReadLibrary = (ctx: CliContext, client: Client): Promise<Library> =>
Library.open({ Library.open({
client: readLibraryClient(client), client: readLibraryClient(client),
@@ -292,11 +282,11 @@ export const filesCommand = async (
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
// Ask the server for current state (issue #36 amendment, issue #52). // Force a server round-trip and list in enumeration order (issue #36
// Each file prints from its own decrypted metadata (raw title, // amendment, issue #52). Each file prints from its own decrypted
// microsecond creationTime) via cli-output. The files print in the // metadata (raw title, microsecond creationTime) via cli-output, and in
// order `freshFiles` returns them, not the projection's newest-first // the pre-library CLI's enumeration order, not the projection's
// order. // newest-first order.
const files = await freshFiles(lib, collectionID); const files = await freshFiles(lib, collectionID);
if (!files) { if (!files) {
ctx.log("fatal", `Collection ${collectionID} not found`); ctx.log("fatal", `Collection ${collectionID} not found`);
@@ -333,13 +323,14 @@ export const getCommand = async (
try { try {
// Force a server round-trip so the file resolves against current state // Force a server round-trip so the file resolves against current state
// (issue #36 amendment, issue #52). // (issue #36 amendment, issue #52).
const file = await freshFile(lib, fileID); const resolved = await freshFile(lib, fileID);
if (!file) { if (!resolved) {
ctx.log("fatal", `File ${fileID} not found`); ctx.log("fatal", `File ${fileID} not found`);
return 1; return 1;
} }
const { photo, file } = resolved;
const result = await lib.originalOf(file); const result = await photo.original();
// Default name is the file's own title, as the pre-library CLI used // Default name is the file's own title, as the pre-library CLI used
// (not the editedName-preferring projection title) (issue #52). // (not the editedName-preferring projection title) (issue #52).
if (result.videoPath === undefined) { if (result.videoPath === undefined) {
@@ -390,13 +381,14 @@ export const getThumbCommand = async (
try { try {
// Force a server round-trip so the file resolves against current state // Force a server round-trip so the file resolves against current state
// (issue #36 amendment, issue #52). // (issue #36 amendment, issue #52).
const file = await freshFile(lib, fileID); const resolved = await freshFile(lib, fileID);
if (!file) { if (!resolved) {
ctx.log("fatal", `File ${fileID} not found`); ctx.log("fatal", `File ${fileID} not found`);
return 1; return 1;
} }
const { photo, file } = resolved;
const result = await lib.thumbnailOf(file); const result = await photo.thumbnail();
// Default name is thumb_<file's own title>, as the pre-library CLI // Default name is thumb_<file's own title>, as the pre-library CLI
// used (not the projection title) (issue #52). // used (not the projection title) (issue #52).
const outPath = opts.out ?? thumbnailName(file); const outPath = opts.out ?? thumbnailName(file);
@@ -573,13 +565,10 @@ export const fixMissingThumbnailsCommand = async (
} }
const lib = await openReadLibrary(ctx, client); const lib = await openReadLibrary(ctx, client);
try { try {
let found: Map<number, { file: EnteFile; collection: Collection }>; // Refresh first so files added since the cache was written are found;
if (fileIDs.length > 0) { // a failed refresh throws.
found = await freshFilesByID(lib, fileIDs);
} else {
// Refresh first so files added since the cache was written are
// checked; a failed refresh throws.
await lib.fresh(); await lib.fresh();
if (fileIDs.length === 0) {
ctx.log("info", "Scanning for missing thumbnails..."); ctx.log("info", "Scanning for missing thumbnails...");
const missing = await listMissingThumbnails( const missing = await listMissingThumbnails(
lib, lib,
@@ -592,14 +581,12 @@ export const fixMissingThumbnailsCommand = async (
return 0; return 0;
} }
ctx.log("info", `Found ${fileIDs.length} file(s) to fix.`); ctx.log("info", `Found ${fileIDs.length} file(s) to fix.`);
found = cachedFilesByID(lib, fileIDs);
} }
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(
lib, lib,
client, client,
fileIDs, fileIDs,
found,
(msg, level) => ctx.log(level ?? "info", msg), (msg, level) => ctx.log(level ?? "info", msg),
); );
+7 -10
View File
@@ -1,18 +1,15 @@
// The CLI's log level, and the one function the commands write stderr lines // The CLI's log level, and the one function the commands write stderr lines
// through. // through.
// //
// Every line quak logs goes to stderr, at one of six levels, from the // Every line quak prints on stderr is a log line at one of six levels, from
// quietest: fatal, error, warning, info, debug, trace. A run prints the lines // the quietest: fatal, error, warning, info, debug, trace. A run prints the
// at its own level and at every quieter one. Its level is info unless `-v` or // lines at its own level and at every quieter one. Its level is info unless
// `-q` asks for another (see "What quak prints" in the README). A command that // `-v` or `-q` asks for another (see "CLI surface" in the README). Every
// runs and exits non-zero logs a fatal line saying why, so even `-q`, which // command that exits non-zero logs a fatal line saying why, so even `-q`, which
// prints only fatal lines, never leaves such an exit unexplained. An error // prints only fatal lines, never leaves a non-zero exit unexplained. An error
// line reports something that failed, such as a file or an album, and a // line reports something that failed, such as a file or an album, and a
// warning something that did not fail but needs the user's attention. stdout // warning something that did not fail but needs the user's attention. stdout
// carries a command's output and the prompts of `login`, and is not a log. // carries only a command's output and is not a log.
// Two things on stderr are not log lines: the `error:` message for a
// command line that quak cannot parse, and the usage printed when no command
// is given.
// //
// The library itself writes no lines: its layers report through callbacks, and // The library itself writes no lines: its layers report through callbacks, and
// the CLI logs what they report. `logRequests` gives the API client options // the CLI logs what they report. `logRequests` gives the API client options
+37 -103
View File
@@ -2,40 +2,38 @@
// //
// `collections`, `files`, `get`, and `get-thumb` must answer for // `collections`, `files`, `get`, and `get-thumb` must answer for
// the account's state at the moment the command runs, not for whatever the // the account's state at the moment the command runs, not for whatever the
// local cache last happened to hold (owner amendment, issue #36). `get`, // local cache last happened to hold (owner amendment, issue #36). `files`,
// `get-thumb` and `files` without a collection ID therefore force a server // `get` and `get-thumb` therefore force a server round-trip through
// round-trip through `Library.fresh()` and only then read, so a change made // `Library.fresh()` and only then read — so a collection, file, or metadata
// elsewhere is visible. `collections` asks the library for the collection list // change made elsewhere is visible. `collections` asks the library for the
// alone through `Library.freshCollections()` (issue #210): a refresh would also // collection list alone through `Library.freshCollections()` (issue #210): a
// fetch the files of every collection, which on a large account takes long and // refresh would also fetch the files of every collection, which on a large
// which `collections` does not print. `files` with a collection ID reads that // account takes long and which `collections` does not print.
// list too, then that one collection's files through `Library.freshFiles()`
// (issue #217). On an empty cache, `get` and `get-thumb` read that list, then
// one collection's files at a time, until they find the file (issue #222).
// `helper fix-missing-thumbnails --file` does the same until it finds every
// file it names (issue #223).
// //
// `collections` and `files` with a collection ID list in the server's order. // `collections` lists in the server's order and `files` in the library's own
// `files` without one lists in the library's own enumeration order // enumeration order (`listFiles()`), the orders the pre-library CLI printed,
// (`listFiles()`). Neither command uses the `albums`/`photos` projection's // rather than the `albums`/`photos` projection's newest-first order, which
// newest-first order, which re-sorts the rows. The field values still come // re-sorts the rows. The field values still come from each record's raw
// from each record's raw metadata via `cli-output.ts`. `files` without a // metadata via `cli-output.ts`. `files` without a collection ID lists every
// collection ID lists every file in the account once (issue #211): the library // file in the account once (issue #211): the library keeps a record of a file
// keeps a record of a file for each collection that holds it, and only the // for each collection that holds it, and only the first collection's record is
// first collection's record is listed. // listed.
import type { Collection, EnteFile } from "./model/types.js"; import type { Collection, EnteFile } from "./model/types.js";
import type { Photo, PhotosAPI } from "./library/index.js";
// The slice of `Library` these helpers read. `Library` satisfies it // The slice of `Library` these helpers read. `Library` satisfies it
// structurally; a test can drive them with a stand-in that records each fresh // structurally; a test can drive them with a stand-in that records the
// read and serves records in a known enumeration order. // `fresh()` and `freshCollections()` calls and serves records in a known
// enumeration order.
export interface FreshReadLibrary { export interface FreshReadLibrary {
fresh(): Promise<unknown>; fresh(): Promise<unknown>;
freshCollections(): Promise<Collection[]>; freshCollections(): Promise<Collection[]>;
freshFiles(collection: Collection): Promise<EnteFile[]>; getCollection(id: number): Collection | undefined;
listCollections(): Collection[]; listCollections(): Collection[];
listFiles(collectionID: number): EnteFile[]; listFiles(collectionID: number): EnteFile[];
getFileByID(fileID: number): EnteFile | undefined; getFileByID(fileID: number): EnteFile | undefined;
photos: Pick<PhotosAPI, "byID">;
} }
// Every live collection, current as of one collections request, in the // Every live collection, current as of one collections request, in the
@@ -44,21 +42,21 @@ export const freshCollections = (
lib: FreshReadLibrary, lib: FreshReadLibrary,
): Promise<Collection[]> => lib.freshCollections(); ): Promise<Collection[]> => lib.freshCollections();
// The files of one collection, as the server holds them now, in the server's // The files of one collection, current as of a forced refresh, in enumeration
// order. `undefined` (not an empty list) when the collection does not exist, so // order. `undefined` (not an empty list) when the collection does not exist, so
// the caller can tell "no such collection" from "an empty collection". Without // the caller can tell "no such collection" from "an empty collection". Without
// a collection ID, every file in the account as of a forced refresh, each once, // a collection ID, every file in the account, each once, as held by the first
// as held by the first collection that holds it. // collection that holds it.
export const freshFiles = async ( export const freshFiles = async (
lib: FreshReadLibrary, lib: FreshReadLibrary,
collectionID?: number, collectionID?: number,
): Promise<EnteFile[] | undefined> => { ): Promise<EnteFile[] | undefined> => {
if (collectionID !== undefined) {
const collections = await lib.freshCollections();
const collection = collections.find((c) => c.id === collectionID);
return collection ? lib.freshFiles(collection) : undefined;
}
await lib.fresh(); await lib.fresh();
if (collectionID !== undefined) {
return lib.getCollection(collectionID)
? lib.listFiles(collectionID)
: undefined;
}
const listed = new Set<number>(); const listed = new Set<number>();
const files: EnteFile[] = []; const files: EnteFile[] = [];
for (const collection of lib.listCollections()) { for (const collection of lib.listCollections()) {
@@ -71,80 +69,16 @@ export const freshFiles = async (
return files; return files;
}; };
// One file as the server holds it now, or `undefined` when no collection holds // One file, current as of a forced refresh, resolved to both its content
// it. When the cache holds the account's collections, it refreshes and looks // handle (`Photo`, for fetching bytes) and its raw record (`EnteFile`, for the
// the file up in the cache. On an empty cache, it reads the collection list, // default output name and field values). `undefined` when the file is unknown.
// then one collection's files at a time, and stops at the first collection
// that holds the file. That search stores nothing.
export const freshFile = async ( export const freshFile = async (
lib: FreshReadLibrary, lib: FreshReadLibrary,
fileID: number, fileID: number,
): Promise<EnteFile | undefined> => { ): Promise<{ photo: Photo; file: EnteFile } | undefined> => {
// Only a refresh stores collections, so a cache holding any holds them all.
if (lib.listCollections().length > 0) {
await lib.fresh(); await lib.fresh();
return lib.getFileByID(fileID); const photo = lib.photos.byID({ fileID });
} const file = lib.getFileByID(fileID);
for (const collection of await lib.freshCollections()) { if (!photo || !file) return undefined;
const files = await lib.freshFiles(collection); return { photo, file };
const file = files.find((f) => f.id === fileID);
if (file) return file;
}
return undefined;
};
// The most recently updated collection first, and the higher ID first on a
// tie. `Library.albums.list()` lists albums in this order.
const byNewestCollection = (a: Collection, b: Collection): number =>
b.updationTime - a.updationTime || b.id - a.id;
// Each file named by `fileIDs` that the cache holds, mapped to its record and
// the collection it was found in. Collections are searched in
// `byNewestCollection` order, and a file is found in the first that holds it.
// So `helper fix-missing-thumbnails` names a file after the same album as
// `helper list-missing-thumbnails`. It reads the cache alone, so the caller
// refreshes first.
export const cachedFilesByID = (
lib: FreshReadLibrary,
fileIDs: number[],
): Map<number, { file: EnteFile; collection: Collection }> => {
const wanted = new Set(fileIDs);
const found = new Map<number, { file: EnteFile; collection: Collection }>();
const collections = [...lib.listCollections()].sort(byNewestCollection);
for (const collection of collections) {
for (const file of lib.listFiles(collection.id)) {
if (wanted.has(file.id) && !found.has(file.id)) {
found.set(file.id, { file, collection });
}
}
}
return found;
};
// Each file named by `fileIDs` as the server holds it now, mapped to its record
// and collection as `cachedFilesByID` maps it. When the cache holds the
// account's collections, it refreshes and looks the files up in the cache. On
// an empty cache, it reads the collection list, then one collection's files at
// a time, and stops once it has found every file. That search stores nothing.
export const freshFilesByID = async (
lib: FreshReadLibrary,
fileIDs: number[],
): Promise<Map<number, { file: EnteFile; collection: Collection }>> => {
// Only a refresh stores collections, so a cache holding any holds them all.
if (lib.listCollections().length > 0) {
await lib.fresh();
return cachedFilesByID(lib, fileIDs);
}
const wanted = new Set(fileIDs);
const found = new Map<number, { file: EnteFile; collection: Collection }>();
const collections = await lib.freshCollections();
for (const collection of [...collections].sort(byNewestCollection)) {
if (found.size === wanted.size) break;
for (const file of await lib.freshFiles(collection)) {
if (wanted.has(file.id) && !found.has(file.id)) {
found.set(file.id, { file, collection });
}
}
}
return found;
}; };
File diff suppressed because it is too large. Load diff
-57
View File
@@ -1,57 +0,0 @@
// Keywords for a file from its CLIP embedding.
//
// Ente stores no keywords. A file's ML data holds a CLIP embedding, the 512
// numbers Ente's image model gave the photo, and Ente's photos app sorts files
// into the categories of its Discover section with it: a file falls in a
// category when the cosine similarity of its CLIP embedding and the text
// embedding of the category's query is at least the category's `minScore`,
// unless the file is a video and the category leaves out videos. A file's
// keywords are the titles of the categories it falls in. The categories and
// their text embeddings are in `clip-keyword-vectors.ts`.
import { DISCOVER_CATEGORIES } from "./clip-keyword-vectors.js";
import { clipEmbedding } from "./library/mldata.js";
import type { MLData } from "./mldata-fetch.js";
// How many numbers a CLIP embedding from Ente's image model has.
const EMBEDDING_LENGTH = 512;
const norm = (vector: number[]): number => Math.hypot(...vector);
const cosineSimilarity = (a: number[], b: number[]): number => {
let dot = 0;
a.forEach((value, i) => {
dot += value * (b[i] ?? 0);
});
return dot / (norm(a) * norm(b));
};
// The titles of the Discover categories a file falls in, highest score first,
// or undefined when its ML data has no CLIP embedding of 512 finite numbers
// that are not all zero. Tests pass their own `categories`.
export const clipKeywords = (
mlData: MLData,
isVideo: boolean,
categories = DISCOVER_CATEGORIES,
): string[] | undefined => {
const embedding = clipEmbedding(mlData);
if (
!embedding ||
embedding.length !== EMBEDDING_LENGTH ||
norm(embedding) === 0
) {
return undefined;
}
const matches: { title: string; score: number }[] = [];
for (const category of categories) {
if (isVideo && !category.showVideo) continue;
const score = cosineSimilarity(embedding, category.textEmbedding);
if (score >= category.minScore) {
matches.push({ title: category.title, score });
}
}
return matches
.sort((a, b) => b.score - a.score)
.map((match) => match.title);
};
+11 -38
View File
@@ -581,34 +581,14 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
fileID: number, fileID: number,
opts?: ContentOptions, opts?: ContentOptions,
): Promise<ContentResult> { ): Promise<ContentResult> {
const file = this.fileOrThrow(fileID); return this.get(fileID, "original", "on-demand", opts?.onProgress);
return this.get(file, "original", "on-demand", opts?.onProgress);
} }
async thumbnail( async thumbnail(
fileID: number, fileID: number,
opts?: ContentOptions, opts?: ContentOptions,
): Promise<ContentResult> { ): Promise<ContentResult> {
const file = this.fileOrThrow(fileID); return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
return this.get(file, "thumbnail", "on-demand", opts?.onProgress);
}
// The original of `file`, fetched and cached as `original` does. `file` is
// used as given, so `getFile` need not know it.
async originalOf(
file: EnteFile,
opts?: ContentOptions,
): Promise<ContentResult> {
return this.get(file, "original", "on-demand", opts?.onProgress);
}
// The thumbnail of `file`, fetched and cached as `thumbnail` does. `file`
// is used as given, so `getFile` need not know it.
async thumbnailOf(
file: EnteFile,
opts?: ContentOptions,
): Promise<ContentResult> {
return this.get(file, "thumbnail", "on-demand", opts?.onProgress);
} }
// Put the original at the save path of `file` under the download directory // Put the original at the save path of `file` under the download directory
@@ -635,7 +615,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
destination: string, destination: string,
): Promise<ContentResult> { ): Promise<ContentResult> {
const result = await this.acquire( const result = await this.acquire(
this.fileOrThrow(fileID), fileID,
"original", "original",
"on-demand", "on-demand",
undefined, undefined,
@@ -658,7 +638,8 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
fileID: number, fileID: number,
destination: string, destination: string,
): Promise<{ path: string; videoPath?: string }> { ): Promise<{ path: string; videoPath?: string }> {
const file = this.fileOrThrow(fileID); const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
return this.pools.content.run( return this.pools.content.run(
async () => { async () => {
const fetched = await this.fetchInto( const fetched = await this.fetchInto(
@@ -740,8 +721,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
onProgress: ((event: EnsureEvent) => void) | undefined, onProgress: ((event: EnsureEvent) => void) | undefined,
): Promise<EnsureResult> { ): Promise<EnsureResult> {
try { try {
const file = this.fileOrThrow(fileID); const result = await this.acquire(fileID, kind, priority, signal);
const result = await this.acquire(file, kind, priority, signal);
const status = result.cached ? "skipped" : "done"; const status = result.cached ? "skipped" : "done";
onProgress?.({ fileID, status, path: result.path }); onProgress?.({ fileID, status, path: result.path });
return { fileID, path: result.path }; return { fileID, path: result.path };
@@ -756,16 +736,8 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
} }
} }
// The record `getFile` gives for `fileID`. It throws for a file `getFile`
// does not know.
private fileOrThrow(fileID: number): EnteFile {
const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
return file;
}
private async get( private async get(
file: EnteFile, fileID: number,
kind: Kind, kind: Kind,
priority: Priority, priority: Priority,
onProgress: ((event: ContentEvent) => void) | undefined, onProgress: ((event: ContentEvent) => void) | undefined,
@@ -773,7 +745,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
const onByte: ProgressCallback | undefined = onProgress const onByte: ProgressCallback | undefined = onProgress
? (bytesDone) => onProgress({ status: "downloading", bytesDone }) ? (bytesDone) => onProgress({ status: "downloading", bytesDone })
: undefined; : undefined;
const result = await this.acquire(file, kind, priority, undefined, { const result = await this.acquire(fileID, kind, priority, undefined, {
onByte, onByte,
}); });
onProgress?.( onProgress?.(
@@ -795,7 +767,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
// photo's original is present only with its video, and is returned with // photo's original is present only with its video, and is returned with
// it. // it.
private async acquire( private async acquire(
file: EnteFile, fileID: number,
kind: Kind, kind: Kind,
priority: Priority, priority: Priority,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
@@ -806,7 +778,8 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
videoPath?: string; videoPath?: string;
cached: boolean; cached: boolean;
}> { }> {
const fileID = file.id; const file = this.getFile(fileID);
if (!file) throw new Error(`content cache: unknown file ${fileID}`);
const isLivePhoto = const isLivePhoto =
kind === "original" && file.metadata.fileType === "livePhoto"; kind === "original" && file.metadata.fileType === "livePhoto";
+3 -54
View File
@@ -20,10 +20,9 @@
// //
// `freshCollections()` is the fresh read of the collection list alone (issue // `freshCollections()` is the fresh read of the collection list alone (issue
// #210): one collections request, no collection's files, and nothing stored, // #210): one collections request, no collection's files, and nothing stored,
// for a caller such as `quak collections` that needs no more. `freshFiles()` // for a caller such as `quak collections` that needs no more. A caller that
// is the fresh read of one collection's files, and stores nothing either // asks for each refresh it needs can also open the library with
// (issue #217). A caller that asks for each refresh it needs can also open the // `refreshOnOpen: false`, so that `open()` starts none.
// library with `refreshOnOpen: false`, so that `open()` starts none.
// //
// A refresh reports through `onProgress` each collection whose files it has // A refresh reports through `onProgress` each collection whose files it has
// fetched, so a large account, whose first refresh fetches the files of every // fetched, so a large account, whose first refresh fetches the files of every
@@ -66,8 +65,6 @@ import {
ContentCache, ContentCache,
savePath, savePath,
storedAtSavePath, storedAtSavePath,
type ContentOptions,
type ContentResult,
type ContentSource, type ContentSource,
type ThumbnailsAPI, type ThumbnailsAPI,
type EnsureOptions, type EnsureOptions,
@@ -628,54 +625,6 @@ export class Library {
return collections; return collections;
} }
// The fresh read of one collection's files (issue #217): every live file
// in it that decrypts, in the server's order. `collection` is a record
// `freshCollections()` returned, whose key decrypts the files. It fetches
// no other collection's files. Like `freshCollections()`, it stores
// nothing. It returns each file once, as the latest copy the server sent,
// where the first copy was.
async freshFiles(collection: Collection): Promise<EnteFile[]> {
if (this.closed) throw new Error("the library is closed");
const { files } = await this.client.filesSince({
collectionID: collection.id,
collectionKey: collection.key,
sinceTime: 0,
});
const byID = new Map<number, EnteFile>();
for (const file of files) byID.set(file.id, file);
return [...byID.values()];
}
// The original of `file`, fetched and cached as `Photo.original()` does.
// `file` is used as given, so the store need not hold it. On an empty
// cache, `quak get` passes a file that `freshFiles()` returned.
async originalOf(
file: EnteFile,
opts?: ContentOptions,
): Promise<ContentResult> {
if (!this.cache) {
throw new Error(
"originalOf requires a library opened with a content cache",
);
}
return this.cache.originalOf(file, opts);
}
// The thumbnail of `file`, fetched and cached as `Photo.thumbnail()` does.
// `file` is used as given, so the store need not hold it. On an empty
// cache, `quak get-thumb` passes a file that `freshFiles()` returned.
async thumbnailOf(
file: EnteFile,
opts?: ContentOptions,
): Promise<ContentResult> {
if (!this.cache) {
throw new Error(
"thumbnailOf requires a library opened with a content cache",
);
}
return this.cache.thumbnailOf(file, opts);
}
// Back up every in-scope file to `opts.downloadDirectory`, or else the // Back up every in-scope file to `opts.downloadDirectory`, or else the
// library's, each original at its save path, with a durable failure // library's, each original at its save path, with a durable failure
// ledger (issue #51). Takes the lock in that directory first, unless the // ledger (issue #51). Takes the lock in that directory first, unless the
+1 -1
View File
@@ -61,7 +61,7 @@ export interface MLIndex {
// Pull the CLIP embedding out of a payload, or undefined when it is absent or // Pull the CLIP embedding out of a payload, or undefined when it is absent or
// misshapen. Kept strict so a bad payload is skipped rather than corrupting the // misshapen. Kept strict so a bad payload is skipped rather than corrupting the
// packed index. // packed index.
export const clipEmbedding = (payload: MLData): number[] | undefined => { const clipEmbedding = (payload: MLData): number[] | undefined => {
const clip = payload.clip; const clip = payload.clip;
if (typeof clip !== "object" || clip === null) return undefined; if (typeof clip !== "object" || clip === null) return undefined;
const embedding = (clip as { embedding?: unknown }).embedding; const embedding = (clip as { embedding?: unknown }).embedding;
+2 -12
View File
@@ -1,7 +1,6 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import * as jpeg from "jpeg-js"; import * as jpeg from "jpeg-js";
import { clipKeywords } from "./clip-keywords.js";
import type { Client } from "./client.js"; import type { Client } from "./client.js";
import { describeError } from "./errors.js"; import { describeError } from "./errors.js";
import { readExifTags } from "./exif.js"; import { readExifTags } from "./exif.js";
@@ -98,8 +97,7 @@ const extractExif = async (
// Dump every decrypted metadata layer the account holds into a directory tree // Dump every decrypted metadata layer the account holds into a directory tree
// of plain JSON: account, per-collection, and per-file records including the // of plain JSON: account, per-collection, and per-file records including the
// private and public magic metadata and the ML data, with the keywords of its // private and public magic metadata and (by default) the ML data. Collections
// CLIP embedding in place of the embedding (see clip-keywords.ts). Collections
// and files are enumerated from the library's cache, which the caller refreshes // and files are enumerated from the library's cache, which the caller refreshes
// first. Returns how many files have no ML data because their ML data request // first. Returns how many files have no ML data because their ML data request
// failed or their ML data did not decrypt, gunzip or parse; they are still // failed or their ML data did not decrypt, gunzip or parse; they are still
@@ -232,16 +230,8 @@ export const runMetadataBackup = async (
if (file.pubMagicMetadata) if (file.pubMagicMetadata)
fileMeta.pubMagicMetadata = file.pubMagicMetadata; fileMeta.pubMagicMetadata = file.pubMagicMetadata;
// The ML data without the CLIP embedding, and the keywords that
// embedding gives.
const ml = mlDataMap.get(file.id); const ml = mlDataMap.get(file.id);
if (ml) { if (ml) fileMeta.mlData = ml;
const { clip: _clip, ...mlDataWithoutClip } = ml;
fileMeta.mlData = mlDataWithoutClip;
const isVideo = file.metadata.fileType === "video";
const keywords = clipKeywords(ml, isVideo);
if (keywords) fileMeta.keywords = keywords;
}
const mlError = mlDataErrors.get(file.id); const mlError = mlDataErrors.get(file.id);
if (mlError) fileMeta.mlDataError = mlError; if (mlError) fileMeta.mlDataError = mlError;
+36 -11
View File
@@ -5,7 +5,7 @@ import type { Client } from "./client.js";
import type { Library } from "./library/index.js"; import type { Library } from "./library/index.js";
import { ApiError } from "./api/client.js"; import { ApiError } from "./api/client.js";
import { encryptBlob, toBase64 } from "./crypto/index.js"; import { encryptBlob, toBase64 } from "./crypto/index.js";
import type { Collection, EnteFile } from "./model/types.js"; import type { EnteFile } from "./model/types.js";
// The server refuses a thumbnail larger than the one it already records for the // The server refuses a thumbnail larger than the one it already records for the
// file (`thumbnail.size`, the encrypted size), so these encodings are tried // file (`thumbnail.size`, the encrypted size), so these encodings are tried
@@ -226,18 +226,16 @@ const encryptThumbnailWithin = (
return undefined; return undefined;
}; };
// Regenerate and upload a thumbnail for each requested file. `found` holds the // Regenerate and upload a thumbnail for each requested file. Originals are read
// record of each requested file that a collection holds, and the collection it // through the library's content cache (`photo.original()`); the generated
// was found in. Each original is read through the library's content cache, by // thumbnail is JPEG-encoded, encrypted under the file's own key, and registered
// `lib.originalOf(file)`. The new thumbnail is a JPEG, encrypted under the // with the server — the encrypt-and-upload path is unchanged. Each file is
// file's own key. It is uploaded, then registered with the server. Each file is
// resolved to one outcome (fixed / skipped / failed) and a failure on one file // resolved to one outcome (fixed / skipped / failed) and a failure on one file
// never stops the others. // never stops the others.
export const fixMissingThumbnails = async ( export const fixMissingThumbnails = async (
lib: Library, lib: Library,
client: Client, client: Client,
fileIDs: number[], fileIDs: number[],
found: Map<number, { file: EnteFile; collection: Collection }>,
onProgress?: ProgressCallback, onProgress?: ProgressCallback,
): Promise<ThumbnailFixResult[]> => { ): Promise<ThumbnailFixResult[]> => {
const log = onProgress ?? (() => {}); const log = onProgress ?? (() => {});
@@ -245,8 +243,31 @@ export const fixMissingThumbnails = async (
const api = client.getApiClient(); const api = client.getApiClient();
const { userID } = client.whoami(); const { userID } = client.whoami();
// Resolve each requested fileID to its file record and owning album by
// enumerating the library, each file taken from the first album that holds
// it. The raw `EnteFile` carries the per-file key the thumbnail is
// encrypted under, which the projected records deliberately do not.
const wanted = new Set(fileIDs);
const fileMap = new Map<
number,
{ file: EnteFile; collectionName: string }
>();
for (const album of lib.albums.list()) {
for (const photo of album.photos.list()) {
if (!wanted.has(photo.fileID) || fileMap.has(photo.fileID))
continue;
const file = lib.getFile(album.collectionID, photo.fileID);
if (file) {
fileMap.set(photo.fileID, {
file,
collectionName: album.name,
});
}
}
}
for (const fileID of fileIDs) { for (const fileID of fileIDs) {
const entry = found.get(fileID); const entry = fileMap.get(fileID);
if (!entry) { if (!entry) {
results.push({ results.push({
fileID, fileID,
@@ -258,8 +279,7 @@ export const fixMissingThumbnails = async (
continue; continue;
} }
const { file } = entry; const { file, collectionName } = entry;
const collectionName = entry.collection.name;
const title = file.metadata.title; const title = file.metadata.title;
const skipReason = reasonToSkip(file, userID); const skipReason = reasonToSkip(file, userID);
@@ -277,10 +297,15 @@ export const fixMissingThumbnails = async (
const maxSize = file.thumbnail.size!; const maxSize = file.thumbnail.size!;
try { try {
const photo = lib.photos.byID({ fileID });
if (!photo) {
throw new Error("file not present in the library cache");
}
log( log(
`[${collectionName}] Downloading ${title} for thumbnail generation...`, `[${collectionName}] Downloading ${title} for thumbnail generation...`,
); );
const { path } = await lib.originalOf(file); const { path } = await photo.original();
const fileBytes = new Uint8Array(readFileSync(path)); const fileBytes = new Uint8Array(readFileSync(path));
if (!isJpeg(fileBytes)) { if (!isJpeg(fileBytes)) {
+4 -15
View File
@@ -86,17 +86,10 @@ const DAY = "2024/2024-03/2024-03-01";
const TOAST = new TextEncoder().encode("a few seconds of the wedding toast"); const TOAST = new TextEncoder().encode("a few seconds of the wedding toast");
// Ente's ML data for beach.jpg: a face it found, and the photo's CLIP // Ente's ML data for beach.jpg: a face it found, and the photo's CLIP
// embedding, here that of a photo of a sunset (see clip-keywords.test.ts). // embedding.
const ML_DATA = { const ML_DATA = {
face: { faces: [{ faceID: "100_1", score: 0.98 }] }, face: { faces: [{ faceID: "100_1", score: 0.98 }] },
clip: { clip: { embedding: [0.5, 0.6, 0.7] },
embedding: JSON.parse(
readFileSync(
new URL("../clip-keywords-sunset.json", import.meta.url),
"utf-8",
),
).embedding,
},
}; };
// Each file's ID, its owner, its original as uploaded, and the metadata its // Each file's ID, its owner, its original as uploaded, and the metadata its
@@ -418,7 +411,7 @@ describe("the first backup of the account", () => {
expect(downloads.sort()).toEqual([100, 200, 300]); expect(downloads.sort()).toEqual([100, 200, 300]);
}); });
// The tree under "The backup directory" in the README. Each original is at // The tree "Backup layout" in the README describes. Each original is at
// its save path, `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>`, with // its save path, `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>`, with
// the extension of its title; the live photo is its image and its video, // the extension of its title; the live photo is its image and its video,
// each with the extension it had inside the ZIP, and a `.livephoto.json` // each with the extension it had inside the ZIP, and a `.livephoto.json`
@@ -493,10 +486,7 @@ describe("the first backup of the account", () => {
}, },
updationTime: TAKEN, updationTime: TAKEN,
}); });
// The JSON holds the ML data without its CLIP embedding, and the expect(beach.mlData).toEqual(ML_DATA);
// keywords the embedding gives in its place.
expect(beach.mlData).toEqual({ face: ML_DATA.face });
expect(beach.keywords).toEqual(["Sunset"]);
expect(beach.imageMetadata.exif.Make.description).toBe("Canon"); expect(beach.imageMetadata.exif.Make.description).toBe("Canon");
expect(beach.imageMetadata.exif.Model.description).toBe("EOS R5"); expect(beach.imageMetadata.exif.Model.description).toBe("EOS R5");
@@ -507,7 +497,6 @@ describe("the first backup of the account", () => {
metadata: { title: "toast.mp4", fileType: "video" }, metadata: { title: "toast.mp4", fileType: "video" },
}); });
expect(toast).not.toHaveProperty("mlData"); expect(toast).not.toHaveProperty("mlData");
expect(toast).not.toHaveProperty("keywords");
expect(toast).not.toHaveProperty("imageMetadata"); expect(toast).not.toHaveProperty("imageMetadata");
expect(toast).not.toHaveProperty("imageMetadataError"); expect(toast).not.toHaveProperty("imageMetadataError");
+7 -101
View File
@@ -52,7 +52,6 @@ import { tmpdir } from "node:os";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { runBackup, type BackupLibrary } from "../../src/backup.js"; import { runBackup, type BackupLibrary } from "../../src/backup.js";
import { DISCOVER_CATEGORIES } from "../../src/clip-keyword-vectors.js";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
import type { ContentSource } from "../../src/library/content.js"; import type { ContentSource } from "../../src/library/content.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js"; import type { CollectionsPage, FilesPage } from "../../src/client.js";
@@ -947,19 +946,9 @@ describe("the backup lock", () => {
}); });
}); });
// Also serves ML data: file 100 has `ML_PAYLOAD`, whose CLIP embedding is that // Also serves ML data: file 100 has `ML_PAYLOAD`, the other two have none.
// of a photo of a sunset (see clip-keywords.test.ts), and the other two have // While `mlError` is set, every ML data request fails with it.
// none. While `mlError` is set, every ML data request fails with it. const ML_PAYLOAD = { face: { faces: [] }, clip: { embedding: [0.5, 0.25] } };
const SUNSET_EMBEDDING: number[] = JSON.parse(
readFileSync(
new URL("../clip-keywords-sunset.json", import.meta.url),
"utf-8",
),
).embedding;
const ML_PAYLOAD = {
face: { faces: [] },
clip: { embedding: SUNSET_EMBEDDING },
};
class MLClient extends MockClient { class MLClient extends MockClient {
mlError?: string; mlError?: string;
@@ -979,99 +968,16 @@ const fileJSON = (outDir: string, fileID: number): Record<string, unknown> =>
JSON.parse(readFileSync(saved(outDir, `${fileID}.json`), "utf-8")); JSON.parse(readFileSync(saved(outDir, `${fileID}.json`), "utf-8"));
describe("ML data in each file's JSON", () => { describe("ML data in each file's JSON", () => {
// The JSON holds the ML data without its CLIP embedding, and in its place it("writes a file's ML data, and no ML field for a file that has none", async () => {
// the keywords the embedding gives.
it("writes a file's ML data and keywords, and no ML field for a file that has none", async () => {
const lib = await openLibrary(stubSource(), new MLClient()); const lib = await openLibrary(stubSource(), new MLClient());
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir }); const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(fileJSON(outDir, 100).mlData).toEqual({ face: { faces: [] } }); expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
expect(fileJSON(outDir, 100).keywords).toEqual(["Sunset"]);
expect(fileJSON(outDir, 101)).not.toHaveProperty("mlData"); expect(fileJSON(outDir, 101)).not.toHaveProperty("mlData");
expect(fileJSON(outDir, 101)).not.toHaveProperty("mlDataError"); expect(fileJSON(outDir, 101)).not.toHaveProperty("mlDataError");
expect(fileJSON(outDir, 101)).not.toHaveProperty("keywords");
await lib.close();
});
// A file's ML data can hold faces and no CLIP embedding. Its JSON then has
// the ML data and no keywords.
it("writes no keywords for ML data without a CLIP embedding", async () => {
const faces = { face: { faces: [{ faceID: "101_1", score: 0.9 }] } };
// Gives file 101 ML data holding only faces.
class FacesClient extends MockClient {
async fetchMLData(args: { fileIDs: number[] }): Promise<{
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}> {
const payloads = new Map<number, MLData>();
if (args.fileIDs.includes(101)) payloads.set(101, faces);
return { payloads, errors: new Map() };
}
}
const lib = await openLibrary(stubSource(), new FacesClient());
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(fileJSON(outDir, 101).mlData).toEqual(faces);
expect(fileJSON(outDir, 101)).not.toHaveProperty("keywords");
await lib.close();
});
// Ente's photos app keeps videos out of some Discover categories, Identity
// among them. Here a photo and a video share one CLIP embedding, Identity's
// own text embedding, so the photo gets the keyword Identity and the video
// does not.
it("keeps a video out of a category that leaves out videos", async () => {
const identity = DISCOVER_CATEGORIES.find(
(c) => c.title === "Identity",
)!;
expect(identity.showVideo).toBe(false);
const toast = file(102, 1, "toast.mp4");
const video = {
...toast,
metadata: { ...toast.metadata, fileType: "video" as const },
};
// Serves beach.jpg and the video in the Vacation album, and gives
// each ML data holding only that embedding.
class VideoClient extends MockClient {
override async filesSince(args: {
collectionID: number;
}): Promise<FilesPage> {
const files =
args.collectionID === 1
? [file(100, 1, "beach.jpg"), video]
: [];
return { files, deleted: [], cursor: 1 };
}
async fetchMLData(args: { fileIDs: number[] }): Promise<{
payloads: Map<number, MLData>;
errors: Map<number, unknown>;
}> {
const payloads = new Map<number, MLData>();
for (const id of args.fileIDs) {
payloads.set(id, {
clip: { embedding: identity.textEmbedding },
});
}
return { payloads, errors: new Map() };
}
}
const lib = await openLibrary(stubSource(), new VideoClient());
const outDir = join(root, "backup");
const result = await lib.backup({ downloadDirectory: outDir });
expect(result.failed).toBe(0);
expect(fileJSON(outDir, 100).keywords).toContain("Identity");
expect(fileJSON(outDir, 102)).toHaveProperty("keywords");
expect(fileJSON(outDir, 102).keywords).not.toContain("Identity");
await lib.close(); await lib.close();
}); });
@@ -1087,7 +993,7 @@ describe("ML data in each file's JSON", () => {
const result = await lib.backup({ downloadDirectory: outDir }); const result = await lib.backup({ downloadDirectory: outDir });
// File 100's ML data was cached by the first run and is kept. // File 100's ML data was cached by the first run and is kept.
expect(fileJSON(outDir, 100).mlData).toEqual({ face: { faces: [] } }); expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
expect(fileJSON(outDir, 100)).not.toHaveProperty("mlDataError"); expect(fileJSON(outDir, 100)).not.toHaveProperty("mlDataError");
for (const fileID of [101, 200]) { for (const fileID of [101, 200]) {
expect(fileJSON(outDir, fileID).mlDataError).toBe( expect(fileJSON(outDir, fileID).mlDataError).toBe(
@@ -1120,7 +1026,7 @@ describe("ML data in each file's JSON", () => {
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(result.skipped).toBe(3); expect(result.skipped).toBe(3);
expect(fileJSON(outDir, 100).mlData).toEqual({ face: { faces: [] } }); expect(fileJSON(outDir, 100).mlData).toEqual(ML_PAYLOAD);
for (const fileID of [100, 101, 200]) { for (const fileID of [100, 101, 200]) {
expect(fileJSON(outDir, fileID)).not.toHaveProperty("mlDataError"); expect(fileJSON(outDir, fileID)).not.toHaveProperty("mlDataError");
} }
+2 -24
View File
@@ -1,18 +1,12 @@
/** /**
* Tests for the retry options `bin/quak.ts` loads the saved session with * Tests for the retry options `bin/quak.ts` loads the saved session with
* (`quak backup` gets the unattended ones, every other command the default), * (`quak backup` gets the unattended ones, every other command the default),
* each with the callbacks that log its requests and retries, for `quak files` * each with the callbacks that log its requests and retries, and for
* taking its collection ID as an optional argument, and for `quak get` and * `quak files` taking its collection ID as an optional argument.
* `quak get-thumb` refusing `--collection`.
* *
* Each test runs `bin/quak.ts`, as the smoke test does, with its session loader * Each test runs `bin/quak.ts`, as the smoke test does, with its session loader
* replaced by one that records the options it is given and reports no session, * replaced by one that records the options it is given and reports no session,
* so the command stops before it makes any request. * so the command stops before it makes any request.
*
* The first load of the sources `bin/quak.ts` imports compiles them, and the
* first `init()` starts libsodium. On a loaded host that takes longer than a
* test's timeout, so both happen here, before the tests run. Each test's
* `vi.resetModules()` then only runs the compiled sources again.
*/ */
import { mkdtempSync, rmSync } from "node:fs"; import { mkdtempSync, rmSync } from "node:fs";
@@ -20,13 +14,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import type { ApiClientOptions } from "../../src/api/client.js"; import type { ApiClientOptions } from "../../src/api/client.js";
import "../../src/cli-commands.js";
import { init } from "../../src/crypto/index.js";
import "../../src/index.js";
import { UNATTENDED_RETRY_OPTIONS } from "../../src/retry.js"; import { UNATTENDED_RETRY_OPTIONS } from "../../src/retry.js";
await init();
const loaded = vi.hoisted(() => [] as (ApiClientOptions | undefined)[]); const loaded = vi.hoisted(() => [] as (ApiClientOptions | undefined)[]);
vi.mock("../../src/cli-session.js", () => ({ vi.mock("../../src/cli-session.js", () => ({
@@ -106,14 +95,3 @@ describe("bin/quak.ts files", () => {
expect(await quak("files", "7")).toMatch(/^Not logged in/); expect(await quak("files", "7")).toMatch(/^Not logged in/);
}); });
}); });
describe("bin/quak.ts get and get-thumb", () => {
it.each(["get", "get-thumb"])(
"%s refuses --collection as an unknown option",
async (command) => {
expect(await quak(command, "1", "--collection", "2")).toMatch(
/^error: unknown option '--collection'/,
);
},
);
});
+87 -313
View File
@@ -194,6 +194,9 @@ const FETCHED_BOTH_ALBUMS =
"Fetched the files of 1 of 2 collections\n" + "Fetched the files of 1 of 2 collections\n" +
"Fetched the files of 2 of 2 collections\n"; "Fetched the files of 2 of 2 collections\n";
// The same for an account with one album.
const FETCHED_ONE_ALBUM = "Fetched the files of 1 of 1 collections\n";
// Collects everything written to it. // Collects everything written to it.
class Output { class Output {
text = ""; text = "";
@@ -532,7 +535,9 @@ describe("files", () => {
it("exits 1 for an unknown collection", async () => { it("exits 1 for an unknown collection", async () => {
expect(await filesCommand(context(), "9", {})).toBe(1); expect(await filesCommand(context(), "9", {})).toBe(1);
expect(stderr.text).toBe("Collection 9 not found\n"); expect(stderr.text).toBe(
FETCHED_BOTH_ALBUMS + "Collection 9 not found\n",
);
}); });
// Without a collection ID it lists every file in the account once (issue // Without a collection ID it lists every file in the account once (issue
@@ -569,31 +574,31 @@ describe("files", () => {
}); });
describe("get and get-thumb", () => { describe("get and get-thumb", () => {
it("get finds a file in any album", async () => { it("get finds a file in any album without --collection", async () => {
const out = join(root, "diagram.png"); const out = join(root, "diagram.png");
expect(await getCommand(context(), "200", { out })).toBe(0); expect(await getCommand(context(), "200", { out })).toBe(0);
expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200)); expect(readFileSync(out)).toEqual(Buffer.alloc(7, 200));
expect(stderr.text).toBe(`7 bytes -> ${out}\n`); expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + `7 bytes -> ${out}\n`);
}); });
it("get-thumb finds a file in any album", async () => { it("get-thumb finds a file in any album without --collection", async () => {
const out = join(root, "thumb.jpg"); const out = join(root, "thumb.jpg");
expect(await getThumbCommand(context(), "200", { out })).toBe(0); expect(await getThumbCommand(context(), "200", { out })).toBe(0);
expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200)); expect(readFileSync(out)).toEqual(Buffer.alloc(3, 200));
expect(stderr.text).toBe(`3 bytes -> ${out}\n`); expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + `3 bytes -> ${out}\n`);
}); });
it("get exits 1 when no album has the file", async () => { it("get exits 1 when no album has the file", async () => {
const out = join(root, "x"); const out = join(root, "x");
expect(await getCommand(context(), "999", { out })).toBe(1); expect(await getCommand(context(), "999", { out })).toBe(1);
expect(stderr.text).toBe("File 999 not found\n"); expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "File 999 not found\n");
expect(existsSync(out)).toBe(false); expect(existsSync(out)).toBe(false);
}); });
it("get-thumb exits 1 when no album has the file", async () => { it("get-thumb exits 1 when no album has the file", async () => {
const out = join(root, "x"); const out = join(root, "x");
expect(await getThumbCommand(context(), "999", { out })).toBe(1); expect(await getThumbCommand(context(), "999", { out })).toBe(1);
expect(stderr.text).toBe("File 999 not found\n"); expect(stderr.text).toBe(FETCHED_BOTH_ALBUMS + "File 999 not found\n");
expect(existsSync(out)).toBe(false); expect(existsSync(out)).toBe(false);
}); });
}); });
@@ -641,7 +646,7 @@ describe("names from an album name or a title over 255 bytes", () => {
expect(Buffer.byteLength(name)).toBe(255); expect(Buffer.byteLength(name)).toBe(255);
expect(code).toBe(0); expect(code).toBe(0);
expect(readdirSync(dir)).toEqual([name]); expect(readdirSync(dir)).toEqual([name]);
expect(stderr.text).toBe(`7 bytes -> ${name}\n`); expect(stderr.text).toBe(FETCHED_ONE_ALBUM + `7 bytes -> ${name}\n`);
}); });
it("get-thumb cuts the title short so that thumb_ and it fit in 255 bytes, and exits 0", async () => { it("get-thumb cuts the title short so that thumb_ and it fit in 255 bytes, and exits 0", async () => {
@@ -653,7 +658,7 @@ describe("names from an album name or a title over 255 bytes", () => {
expect(Buffer.byteLength(name)).toBe(255); expect(Buffer.byteLength(name)).toBe(255);
expect(code).toBe(0); expect(code).toBe(0);
expect(readdirSync(dir)).toEqual([name]); expect(readdirSync(dir)).toEqual([name]);
expect(stderr.text).toBe(`3 bytes -> ${name}\n`); expect(stderr.text).toBe(FETCHED_ONE_ALBUM + `3 bytes -> ${name}\n`);
}); });
it("backup-metadata cuts the album name short so that its folder's name fits in 255 bytes, and exits 0", async () => { it("backup-metadata cuts the album name short so that its folder's name fits in 255 bytes, and exits 0", async () => {
@@ -742,6 +747,7 @@ describe("a live photo", () => {
Buffer.from(VIDEO), Buffer.from(VIDEO),
); );
expect(stderr.text).toBe( expect(stderr.text).toBe(
FETCHED_ONE_ALBUM +
`${IMAGE.length} bytes -> IMG_0300.heic\n` + `${IMAGE.length} bytes -> IMG_0300.heic\n` +
`${VIDEO.length} bytes -> IMG_0300.mov\n`, `${VIDEO.length} bytes -> IMG_0300.mov\n`,
); );
@@ -796,6 +802,7 @@ describe("a live photo", () => {
expect(readFileSync(out)).toEqual(Buffer.from(IMAGE)); expect(readFileSync(out)).toEqual(Buffer.from(IMAGE));
expect(readFileSync(video)).toEqual(Buffer.from(VIDEO)); expect(readFileSync(video)).toEqual(Buffer.from(VIDEO));
expect(stderr.text).toBe( expect(stderr.text).toBe(
FETCHED_ONE_ALBUM +
`${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`, `${IMAGE.length} bytes -> ${out}\n${VIDEO.length} bytes -> ${video}\n`,
); );
}); });
@@ -810,6 +817,7 @@ describe("a live photo", () => {
expect(existsSync(out)).toBe(false); expect(existsSync(out)).toBe(false);
expect(existsSync(join(root, "photo.mov"))).toBe(false); expect(existsSync(join(root, "photo.mov"))).toBe(false);
expect(stderr.text).toBe( expect(stderr.text).toBe(
FETCHED_ONE_ALBUM +
`File 300 is a live photo, and its video would also be written to ${out}\n`, `File 300 is a live photo, and its video would also be written to ${out}\n`,
); );
}); });
@@ -1353,23 +1361,6 @@ describe("helper fix-missing-thumbnails", () => {
).toBe(1); ).toBe(1);
expect(stderr.text).toBe(last); expect(stderr.text).toBe(last);
}); });
// beach.jpg (100) is in Vacation (1) and Work (2), both updated at the same
// time. The fixer names a file after the first album holding it in the
// order of `Library.albums.list()`. On a tie, the album with the higher ID
// comes first, so beach.jpg is named after Work.
it("names a photo in two albums after the same album on an empty cache as on a cache that holds the account", async () => {
const ctx = context(fakeClient({ beachInWork: true }));
const opts = { file: ["100"], json: true };
const inWork = [{ fileID: 100, collection: "Work", status: "skipped" }];
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(0);
expect(JSON.parse(stdout.text)).toMatchObject(inWork);
expect(await filesCommand(ctx, undefined, {})).toBe(0);
stdout.text = "";
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(0);
expect(JSON.parse(stdout.text)).toMatchObject(inWork);
});
}); });
describe("backup-metadata --exif", () => { describe("backup-metadata --exif", () => {
@@ -1399,11 +1390,11 @@ describe("backup-metadata --exif", () => {
}); });
// Each test first runs `files` so the cache holds the account as it was, then // Each test first runs `files` so the cache holds the account as it was, then
// changes the server under it. (`collections` and `files` with a collection ID // changes the server under it. (`collections` would not do: it reads the
// would not do: they store nothing.) // collection list alone and stores nothing.)
describe("backup-metadata and the thumbnail helpers refresh first", () => { describe("backup-metadata and the thumbnail helpers refresh first", () => {
beforeEach(async () => { beforeEach(async () => {
expect(await filesCommand(context(), undefined, {})).toBe(0); expect(await filesCommand(context(), "1", {})).toBe(0);
stdout.text = ""; stdout.text = "";
stderr.text = ""; stderr.text = "";
}); });
@@ -1586,83 +1577,20 @@ describe("an ID given on the command line", () => {
); );
}); });
// An account with many albums (issues #210, #217 and #222). On an empty cache // An account with many albums (issue #210). On an empty cache `collections`
// `collections`, `files` with a collection ID and `get` used to wait for a // used to wait for a refresh that fetched and decrypted the files of every
// refresh that fetched and decrypted the files of every album, one album after // album, one album after another, before it printed anything, so on such an
// another. So did `helper fix-missing-thumbnails --file` (issue #223). On such // account it seemed to hang. The account here has 40 albums of one photo each,
// an account they seemed to hang. The account here has 40 albums of one photo // encrypted as Ente's clients encrypt them, on the fake Ente server of
// each, encrypted as Ente's clients encrypt them, on the fake Ente server of // `test/ente-server.ts`, which also records the path of each request quak
// `test/ente-server.ts`. The tests record the path and query of each request // sends. The session is the one logging in to that server saves.
// quak sends. The session is the one logging in to that server saves.
describe("an account with many albums", () => { describe("an account with many albums", () => {
const ALBUMS = 40; const ALBUMS = 40;
const PASSWORD = "many-albums"; const PASSWORD = "many-albums";
const requests: string[] = []; const paths: string[] = [];
let server: typeof globalThis.fetch; let server: typeof globalThis.fetch;
let snapshot: ClientSnapshot; let snapshot: ClientSnapshot;
// What `files` without a collection ID prints on an empty cache: every
// album's photo on stdout, and on stderr each album whose files its
// refresh fetched.
let everyPhoto = "";
let progress = "";
for (let n = 1; n <= ALBUMS; n++) {
everyPhoto += `${n * 100}\timage\tphoto-${n}.jpg\n`;
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
}
// The key of each album, by album ID.
const albumKeys = new Map<number, Uint8Array>();
// A photo in album `collectionID` as the server sends it, and its original
// encrypted as the server stores it. The original's bytes are the title.
// These tests fetch no thumbnail. The server records a thumbnail size, so
// `helper fix-missing-thumbnails` downloads the original before it skips
// the photo as not a JPEG.
const photoRow = (photo: {
id: number;
collectionID: number;
title: string;
updationTime: number;
}): { row: Record<string, unknown>; original: Uint8Array } => {
const photoKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const photoKeyBox = encryptSecretbox(
photoKey,
albumKeys.get(photo.collectionID)!,
);
const metadata = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title: photo.title,
fileType: 0,
creationTime: 1000,
modificationTime: 1000,
}),
),
photoKey,
);
const original = encryptStreamBlob(
new TextEncoder().encode(photo.title),
photoKey,
);
const row = {
id: photo.id,
collectionID: photo.collectionID,
ownerID: USER_ID,
encryptedKey: toBase64(photoKeyBox.ciphertext),
keyDecryptionNonce: toBase64(photoKeyBox.nonce),
metadata: {
encryptedData: toBase64(metadata.ciphertext),
decryptionHeader: toBase64(metadata.header),
},
file: { decryptionHeader: toBase64(original.header) },
thumbnail: { decryptionHeader: "" },
info: { thumbSize: 1000 },
updationTime: photo.updationTime,
};
return { row, original: original.ciphertext };
};
// Album n is named `Album n` and holds one photo, n * 100, titled // Album n is named `Album n` and holds one photo, n * 100, titled
// `photo-n.jpg`. // `photo-n.jpg`.
beforeAll(async () => { beforeAll(async () => {
@@ -1670,10 +1598,8 @@ describe("an account with many albums", () => {
const account = await buildAccount(PASSWORD); const account = await buildAccount(PASSWORD);
const collections: Record<string, unknown>[] = []; const collections: Record<string, unknown>[] = [];
const filesByCollection: Record<number, Record<string, unknown>[]> = {}; const filesByCollection: Record<number, Record<string, unknown>[]> = {};
const fileCiphertexts: Record<number, Uint8Array> = {};
for (let id = 1; id <= ALBUMS; id++) { for (let id = 1; id <= ALBUMS; id++) {
const albumKey = sodium.crypto_secretbox_keygen(); const albumKey = sodium.crypto_secretbox_keygen();
albumKeys.set(id, albumKey);
const albumKeyBox = encryptSecretbox(albumKey, account.masterKey); const albumKeyBox = encryptSecretbox(albumKey, account.masterKey);
const name = encryptSecretbox( const name = encryptSecretbox(
new TextEncoder().encode(`Album ${id}`), new TextEncoder().encode(`Album ${id}`),
@@ -1689,14 +1615,37 @@ describe("an account with many albums", () => {
type: "album", type: "album",
updationTime: 1, updationTime: 1,
}); });
const { row, original } = photoRow({ const photoKey =
sodium.crypto_secretstream_xchacha20poly1305_keygen();
const photoKeyBox = encryptSecretbox(photoKey, albumKey);
const metadata = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title: `photo-${id}.jpg`,
fileType: 0,
creationTime: 1000,
modificationTime: 1000,
}),
),
photoKey,
);
filesByCollection[id] = [
{
id: id * 100, id: id * 100,
collectionID: id, collectionID: id,
title: `photo-${id}.jpg`, ownerID: USER_ID,
encryptedKey: toBase64(photoKeyBox.ciphertext),
keyDecryptionNonce: toBase64(photoKeyBox.nonce),
metadata: {
encryptedData: toBase64(metadata.ciphertext),
decryptionHeader: toBase64(metadata.header),
},
// These tests fetch no original and no thumbnail.
file: { decryptionHeader: "" },
thumbnail: { decryptionHeader: "" },
updationTime: 1, updationTime: 1,
}); },
filesByCollection[id] = [row]; ];
fileCiphertexts[id * 100] = original;
} }
const ente = buildMetaFetch({ const ente = buildMetaFetch({
verifier: account.verifier, verifier: account.verifier,
@@ -1706,7 +1655,7 @@ describe("an account with many albums", () => {
collections, collections,
filesByCollection, filesByCollection,
encryptedMLData: {}, encryptedMLData: {},
fileCiphertexts, fileCiphertexts: {},
}); });
server = (async (input: RequestInfo | URL, init?: RequestInit) => { server = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = const url =
@@ -1715,8 +1664,7 @@ describe("an account with many albums", () => {
: input instanceof URL : input instanceof URL
? input.href ? input.href
: input.url; : input.url;
const { pathname, search } = new URL(url); paths.push(new URL(url).pathname);
requests.push(pathname + search);
return ente(input, init); return ente(input, init);
}) as typeof globalThis.fetch; }) as typeof globalThis.fetch;
const client = await Client.login({ const client = await Client.login({
@@ -1728,17 +1676,16 @@ describe("an account with many albums", () => {
}); });
beforeEach(() => { beforeEach(() => {
requests.length = 0; paths.length = 0;
}); });
// A context whose saved session sends its requests to `fetch`, by default // A context whose saved session sends its requests to the fake server.
// the fake server. const accountContext = (): CliContext => {
const accountContext = (fetch = server): CliContext => {
const ctx = context(); const ctx = context();
saveSession(ctx.sessionDir, snapshot); saveSession(ctx.sessionDir, snapshot);
return { return {
...ctx, ...ctx,
loadSession: (path) => loadSession(path, { fetch }), loadSession: (path) => loadSession(path, { fetch: server }),
}; };
}; };
@@ -1751,219 +1698,46 @@ describe("an account with many albums", () => {
albums += `${id}\talbum\tAlbum ${id}\n`; albums += `${id}\talbum\tAlbum ${id}\n`;
expect(stdout.text).toBe(albums); expect(stdout.text).toBe(albums);
expect(stderr.text).toBe(""); expect(stderr.text).toBe("");
expect(requests).toEqual(["/collections/v2?sinceTime=0"]); expect(paths).toEqual(["/collections/v2"]);
// It stored nothing that would make a refresh skip an album's files: // It stored nothing that would make a refresh skip an album's files:
// `files` after it still lists every album's photo. // `files` after it still finds the album's photo.
stdout.text = ""; stdout.text = "";
expect(await filesCommand(ctx, undefined, {})).toBe(0);
expect(stdout.text).toBe(everyPhoto);
});
it("files with a collection ID asks for the album list and that album's files alone", async () => {
expect(await filesCommand(accountContext(), "7", {})).toBe(0);
expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
expect(stderr.text).toBe("");
expect(requests).toEqual([
"/collections/v2?sinceTime=0",
"/collections/v2/diff?collectionID=7&sinceTime=0",
]);
});
// Here the server sends album 7's files in two pages. Photo 700 changed
// between them, so both pages hold a copy of it.
it("files with a collection ID lists only the later copy of a photo sent in two pages", async () => {
const firstPage = [
photoRow({
id: 700,
collectionID: 7,
title: "photo-7.jpg",
updationTime: 1,
}).row,
photoRow({
id: 701,
collectionID: 7,
title: "photo-701.jpg",
updationTime: 1,
}).row,
];
const secondPage = [
photoRow({
id: 700,
collectionID: 7,
title: "photo-7-edited.jpg",
updationTime: 2,
}).row,
];
const paged = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = new URL(input instanceof Request ? input.url : input);
if (
url.pathname !== "/collections/v2/diff" ||
url.searchParams.get("collectionID") !== "7"
) {
return server(input, init);
}
const first = url.searchParams.get("sinceTime") === "0";
return new Response(
JSON.stringify({
diff: first ? firstPage : secondPage,
hasMore: first,
}),
);
}) as typeof globalThis.fetch;
expect(await filesCommand(accountContext(paged), "7", {})).toBe(0);
// Photo 700 is listed once, with the later title, before photo 701.
expect(stdout.text).toBe(
"700\timage\tphoto-7-edited.jpg\n701\timage\tphoto-701.jpg\n",
);
});
// The cache stays empty, so a later `files` without a collection ID still
// fetches the files of all 40 albums.
it("files with a collection ID stores nothing in the cache", async () => {
const ctx = accountContext();
expect(await filesCommand(ctx, "7", {})).toBe(0); expect(await filesCommand(ctx, "7", {})).toBe(0);
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false); expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
stdout.text = "";
expect(await filesCommand(ctx, undefined, {})).toBe(0);
expect(stdout.text).toBe(everyPhoto);
expect(stderr.text).toBe(progress);
});
// Photo 200 is in album 2, so `get` asks for no file of album 3 or later.
// The last request downloads the photo's original.
it("get on an empty cache asks for the albums' files only until one holds the photo", async () => {
const out = join(root, "photo.jpg");
expect(await getCommand(accountContext(), "200", { out })).toBe(0);
expect(readFileSync(out, "utf-8")).toBe("photo-2.jpg");
expect(stderr.text).toBe(`11 bytes -> ${out}\n`);
expect(requests).toEqual([
"/collections/v2?sinceTime=0",
"/collections/v2/diff?collectionID=1&sinceTime=0",
"/collections/v2/diff?collectionID=2&sinceTime=0",
"/?fileID=200",
]);
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
});
// `files` first fills the cache, so `get` waits for a refresh. No album has
// changed since, so the refresh asks for no album's files.
it("get on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => {
const ctx = accountContext();
expect(await filesCommand(ctx, undefined, {})).toBe(0);
requests.length = 0;
stderr.text = "";
const out = join(root, "photo.jpg");
expect(await getCommand(ctx, "4000", { out })).toBe(0);
expect(readFileSync(out, "utf-8")).toBe("photo-40.jpg");
expect(stderr.text).toBe(`12 bytes -> ${out}\n`);
expect(requests).toEqual([
"/collections/v2?sinceTime=1",
"/?fileID=4000",
]);
requests.length = 0;
stderr.text = "";
expect(await getCommand(ctx, "999", { out })).toBe(1);
expect(stderr.text).toBe("File 999 not found\n");
expect(requests).toEqual(["/collections/v2?sinceTime=1"]);
});
// The helper searches the most recently updated album first. All 40 were
// updated at the same time, and on a tie the higher ID comes first. Photo
// 3900 is in album 39, so the helper asks for no file of album 38 or
// earlier. The last request downloads the photo's original.
it("fix-missing-thumbnails --file on an empty cache asks for the albums' files only until one holds the photo", async () => {
const opts = { file: ["3900"], json: true };
expect(await fixMissingThumbnailsCommand(accountContext(), opts)).toBe(
0,
);
const reason =
"unsupported image format (only baseline JPEG can be regenerated)";
expect(JSON.parse(stdout.text)).toEqual([
{
fileID: 3900,
title: "photo-39.jpg",
collection: "Album 39",
status: "skipped",
reason,
},
]);
expect(stderr.text).toBe(
"[Album 39] Downloading photo-39.jpg for thumbnail generation...\n" +
`[Album 39] Skipping photo-39.jpg: ${reason}\n`,
);
expect(requests).toEqual([
"/collections/v2?sinceTime=0",
"/collections/v2/diff?collectionID=40&sinceTime=0",
"/collections/v2/diff?collectionID=39&sinceTime=0",
"/?fileID=3900",
]);
expect(existsSync(join(root, "cache", "metadata.json"))).toBe(false);
});
// `files` first fills the cache, so the helper waits for a refresh. No
// album has changed since, so the refresh asks for no album's files.
it("fix-missing-thumbnails --file on a cache that holds the account asks for no unchanged album's files, also for a photo in no album", async () => {
const ctx = accountContext();
expect(await filesCommand(ctx, undefined, {})).toBe(0);
requests.length = 0;
stdout.text = "";
stderr.text = "";
const opts = { file: ["3900"], json: true };
expect(await fixMissingThumbnailsCommand(ctx, opts)).toBe(0);
const reason =
"unsupported image format (only baseline JPEG can be regenerated)";
expect(JSON.parse(stdout.text)).toEqual([
{
fileID: 3900,
title: "photo-39.jpg",
collection: "Album 39",
status: "skipped",
reason,
},
]);
expect(stderr.text).toBe(
"[Album 39] Downloading photo-39.jpg for thumbnail generation...\n" +
`[Album 39] Skipping photo-39.jpg: ${reason}\n`,
);
expect(requests).toEqual([
"/collections/v2?sinceTime=1",
"/?fileID=3900",
]);
requests.length = 0;
stdout.text = "";
const notFound = { file: ["999"], json: true };
expect(await fixMissingThumbnailsCommand(ctx, notFound)).toBe(1);
expect(JSON.parse(stdout.text)).toMatchObject([
{ fileID: 999, reason: "file not found in any collection" },
]);
expect(requests).toEqual(["/collections/v2?sinceTime=1"]);
}); });
it("files counts on stderr each album whose files its refresh fetched", async () => { it("files counts on stderr each album whose files its refresh fetched", async () => {
expect(await filesCommand(accountContext(), undefined, {})).toBe(0); expect(await filesCommand(accountContext(), "7", {})).toBe(0);
expect(stdout.text).toBe(everyPhoto); expect(stdout.text).toBe("700\timage\tphoto-7.jpg\n");
let progress = "";
for (let n = 1; n <= ALBUMS; n++) {
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
}
expect(stderr.text).toBe(progress); expect(stderr.text).toBe(progress);
}); });
// `--json` shapes stdout only, so the refresh's progress is still counted // `--json` shapes stdout only, so the refresh's progress is still counted
// on stderr. // on stderr.
it("files --json counts on stderr each album whose files its refresh fetched", async () => { it("files --json counts on stderr each album whose files its refresh fetched", async () => {
const opts = { json: true }; expect(await filesCommand(accountContext(), "7", { json: true })).toBe(
expect(await filesCommand(accountContext(), undefined, opts)).toBe(0); 0,
);
expect(JSON.parse(stdout.text)).toHaveLength(ALBUMS); expect(JSON.parse(stdout.text)).toEqual([
{
id: 700,
title: "photo-7.jpg",
fileType: "image",
creationTime: 1000,
collectionID: 7,
},
]);
let progress = "";
for (let n = 1; n <= ALBUMS; n++) {
progress += `Fetched the files of ${n} of ${ALBUMS} collections\n`;
}
expect(stderr.text).toBe(progress); expect(stderr.text).toBe(progress);
}); });
}); });
+5 -7
View File
@@ -234,15 +234,13 @@ describe("-q", () => {
}); });
}); });
// No album holds file 999, so fixing it fails. The summary from // The refresh's progress is left out; the line saying why the command
// `--- Done ---` to the end of the `Failed files:` list is left out. The // failed is the fatal one, and it is printed.
// line saying why the command failed is the fatal one, and it is printed.
it("prints only the line a failing command exits with", async () => { it("prints only the line a failing command exits with", async () => {
const fix = ["helper", "fix-missing-thumbnails", "--file", "999"]; expect(await quak("-q", "files", "9")).toEqual({
expect(await quak("-q", ...fix)).toEqual({
code: 1, code: 1,
stdout: "", stdout: "",
stderr: `quak: 1 file(s) failed; see the "Failed files:" list, or the JSON with --json\n`, stderr: "Collection 9 not found\n",
}); });
}); });
@@ -328,7 +326,7 @@ describe("what the library reports", () => {
]); ]);
}); });
// Each operation is named in plain words. // Each operation is named in plain words, as the README names it.
it("logs a refresh's progress at info, and its start and end at debug", () => { it("logs a refresh's progress at info, and its start and end at debug", () => {
const { log, lines } = collect(); const { log, lines } = collect();
const onProgress = logRefresh(log); const onProgress = logRefresh(log);
+9 -130
View File
@@ -41,7 +41,6 @@ import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
import { init, toBase64, encryptBlob } from "../../src/crypto/index.js"; import { init, toBase64, encryptBlob } from "../../src/crypto/index.js";
import * as jpegJs from "jpeg-js"; import * as jpegJs from "jpeg-js";
import { Client } from "../../src/client.js"; import { Client } from "../../src/client.js";
import { DISCOVER_CATEGORIES } from "../../src/clip-keyword-vectors.js";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
import { import {
runMetadataBackup, runMetadataBackup,
@@ -57,8 +56,8 @@ import {
type MetaMockState, type MetaMockState,
} from "../ente-server.js"; } from "../ente-server.js";
// One file per ML data request, so each file of the mock account is fetched in // One file per ML data request, so the two files of the mock account are
// a request of its own and one of them can fail on its own. // fetched in two requests and one of them can fail on its own.
vi.mock("../../src/mldata-fetch.js", async (importOriginal) => ({ vi.mock("../../src/mldata-fetch.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../src/mldata-fetch.js")>()), ...(await importOriginal<typeof import("../../src/mldata-fetch.js")>()),
MLDATA_BATCH_SIZE: 1, MLDATA_BATCH_SIZE: 1,
@@ -67,21 +66,6 @@ vi.mock("../../src/mldata-fetch.js", async (importOriginal) => ({
const TEST_EMAIL = "metabackup@example.com"; const TEST_EMAIL = "metabackup@example.com";
const TEST_PASSWORD = "metapass"; const TEST_PASSWORD = "metapass";
// The CLIP embedding of a photo of a sunset (see clip-keywords.test.ts), which
// the ML data of beach.jpg holds.
const SUNSET_EMBEDDING: number[] = JSON.parse(
readFileSync(
new URL("../clip-keywords-sunset.json", import.meta.url),
"utf-8",
),
).embedding;
// Identity, a Discover category of Ente's photos app that leaves out videos.
const IDENTITY = DISCOVER_CATEGORIES.find((c) => c.title === "Identity")!;
// ML data holding faces and no CLIP embedding, which file 302 has.
const FACES_ONLY = { face: { faces: [{ faceID: "face-def", score: 0.9 }] } };
let mock: MetaMockState; let mock: MetaMockState;
let testDir: string; let testDir: string;
@@ -220,65 +204,6 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
updationTime: 1710000000000000, updationTime: 1710000000000000,
}; };
// A file in coll 10 with the ML data given. `fileType` is Ente's number: 0
// an image, 1 a video.
const vacationFile = (
id: number,
title: string,
fileType: number,
mlData: object,
) => {
const fileKey = sodium.crypto_secretstream_xchacha20poly1305_keygen();
const { ciphertext: encKey, nonce } = encryptSecretbox(fileKey, ck1);
const meta = encryptStreamBlob(
new TextEncoder().encode(
JSON.stringify({
title,
fileType,
creationTime: 1700000000000000,
modificationTime: 1700000000000000,
}),
),
fileKey,
);
const ml = encryptBlob(
new Uint8Array(gzipSync(Buffer.from(JSON.stringify(mlData)))),
fileKey,
);
return {
raw: {
id,
collectionID: 10,
ownerID: 42,
encryptedKey: toBase64(encKey),
keyDecryptionNonce: toBase64(nonce),
metadata: {
encryptedData: toBase64(meta.ciphertext),
decryptionHeader: toBase64(meta.header),
},
file: {
decryptionHeader: toBase64(sodium.randombytes_buf(24)),
},
thumbnail: {
decryptionHeader: toBase64(sodium.randombytes_buf(24)),
},
updationTime: 1700000000000000,
},
mlData: {
encryptedData: toBase64(ml.ciphertext),
decryptionHeader: toBase64(ml.header),
},
};
};
// Files 300 and 301 in coll 10: a photo and a video whose ML data holds
// only a CLIP embedding, Identity's own text embedding.
const identityML = { clip: { embedding: IDENTITY.textEmbedding } };
const identityPhoto = vacationFile(300, "passport.jpg", 0, identityML);
const identityVideo = vacationFile(301, "passport.mp4", 1, identityML);
// File 302 in coll 10: a photo whose ML data holds faces and no CLIP
// embedding.
const facesPhoto = vacationFile(302, "party.jpg", 0, FACES_ONLY);
// Encrypt ML data for file 100 (gzipped JSON, encrypted with file key) // Encrypt ML data for file 100 (gzipped JSON, encrypted with file key)
const mlPayload = JSON.stringify({ const mlPayload = JSON.stringify({
face: { face: {
@@ -305,7 +230,7 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
clip: { clip: {
version: 1, version: 1,
client: "test", client: "test",
embedding: SUNSET_EMBEDDING, embedding: [0.5, 0.6, 0.7],
}, },
}); });
const gzipped = gzipSync(Buffer.from(mlPayload)); const gzipped = gzipSync(Buffer.from(mlPayload));
@@ -345,23 +270,12 @@ const buildMetaMock = async (): Promise<MetaMockState> => {
keyAttributes: account.keyAttributes, keyAttributes: account.keyAttributes,
encryptedToken: account.encryptedToken, encryptedToken: account.encryptedToken,
collections: [rawColl1, rawColl2], collections: [rawColl1, rawColl2],
filesByCollection: { filesByCollection: { 10: [rawFile1], 20: [rawFile2] },
10: [
rawFile1,
identityPhoto.raw,
identityVideo.raw,
facesPhoto.raw,
],
20: [rawFile2],
},
encryptedMLData: { encryptedMLData: {
100: { 100: {
encryptedData: toBase64(mlCiphertext), encryptedData: toBase64(mlCiphertext),
decryptionHeader: toBase64(mlHeader), decryptionHeader: toBase64(mlHeader),
}, },
300: identityPhoto.mlData,
301: identityVideo.mlData,
302: facesPhoto.mlData,
}, },
fileCiphertexts: { 100: encFileBody1 }, fileCiphertexts: { 100: encFileBody1 },
}; };
@@ -518,9 +432,7 @@ describe("quak backup-metadata", () => {
expect(account.email).toBe(TEST_EMAIL); expect(account.email).toBe(TEST_EMAIL);
}); });
// The CLIP embedding is left out of the ML data, and the keywords it gives it("fetches and decrypts ML data by default", async () => {
// are written in its place.
it("fetches and decrypts ML data by default, with keywords for the CLIP embedding", async () => {
const outDir = join(testDir, "ml-data"); const outDir = join(testDir, "ml-data");
await runBackup(outDir); await runBackup(outDir);
@@ -540,41 +452,8 @@ describe("quak backup-metadata", () => {
expect(fileMeta.mlData.face.faces[0].faceID).toBe("face-abc"); expect(fileMeta.mlData.face.faces[0].faceID).toBe("face-abc");
expect(fileMeta.mlData.face.faces[0].score).toBeCloseTo(0.98); expect(fileMeta.mlData.face.faces[0].score).toBeCloseTo(0.98);
expect(fileMeta.mlData.face.faces[0].detection.box.x).toBeCloseTo(0.1); expect(fileMeta.mlData.face.faces[0].detection.box.x).toBeCloseTo(0.1);
expect(fileMeta.mlData).not.toHaveProperty("clip"); expect(fileMeta.mlData.clip).toBeDefined();
expect(fileMeta.keywords).toEqual(["Sunset"]); expect(fileMeta.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
});
// Ente's photos app keeps videos out of some Discover categories, Identity
// among them. Files 300 and 301, a photo and a video, share one CLIP
// embedding, Identity's own text embedding, so the photo gets the keyword
// Identity and the video does not.
it("keeps a video out of a category that leaves out videos", async () => {
expect(IDENTITY.showVideo).toBe(false);
const outDir = join(testDir, "video-keywords");
await runBackup(outDir);
const vacation = join(outDir, "collections", "10-Vacation");
const fileJSON = (fileID: number) =>
JSON.parse(readFileSync(join(vacation, `${fileID}.json`), "utf-8"));
expect(fileJSON(300).keywords).toContain("Identity");
expect(fileJSON(301)).toHaveProperty("keywords");
expect(fileJSON(301).keywords).not.toContain("Identity");
});
// A file's ML data can hold faces and no CLIP embedding. File 302's JSON
// then has the ML data and no keywords.
it("writes no keywords for ML data without a CLIP embedding", async () => {
const outDir = join(testDir, "no-clip");
await runBackup(outDir);
const fileMeta = JSON.parse(
readFileSync(
join(outDir, "collections", "10-Vacation", "302.json"),
"utf-8",
),
);
expect(fileMeta.mlData).toEqual(FACES_ONLY);
expect(fileMeta).not.toHaveProperty("keywords");
}); });
it("extracts EXIF from downloaded files when --exif is set", async () => { it("extracts EXIF from downloaded files when --exif is set", async () => {
@@ -662,7 +541,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
"utf-8", "utf-8",
), ),
); );
expect(ok.keywords).toEqual(["Sunset"]); expect(ok.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
expect(ok.mlDataError).toBeUndefined(); expect(ok.mlDataError).toBeUndefined();
const failed = JSON.parse( const failed = JSON.parse(
@@ -720,7 +599,7 @@ describe("quak backup-metadata when a file's ML data cannot be read", () => {
"utf-8", "utf-8",
), ),
); );
expect(ok.keywords).toEqual(["Sunset"]); expect(ok.mlData.clip.embedding).toEqual([0.5, 0.6, 0.7]);
}); });
it("exits 0 when every ML data request succeeds", async () => { it("exits 0 when every ML data request succeeds", async () => {
+49 -143
View File
@@ -3,25 +3,20 @@
* issue #36, issue #52). * issue #36, issue #52).
* *
* The `collections`, `files`, `get`, and `get-thumb` commands must answer for * The `collections`, `files`, `get`, and `get-thumb` commands must answer for
* current server state, not the local cache. `get`, `get-thumb` and `files` * current server state, not the local cache. `files`, `get` and `get-thumb`
* without a collection ID force a `Library.fresh()` round-trip before they * force a `Library.fresh()` round-trip before they read; the stand-in library
* read. The stand-in library below serves no cached record until `fresh()` has * below serves nothing until `fresh()` has been awaited, so a helper that read
* been awaited, so a helper that read without refreshing would come back empty * without refreshing would come back empty and fail here. `collections` asks
* and fail here. `collections` asks the library for the collection list alone * the library for the collection list alone (`freshCollections()`, issue
* (`freshCollections()`, issue #210). `files` with a collection ID asks for * #210), and must not wait for a refresh, which would fetch the files of every
* that list, then for that one collection's files (`freshFiles()`, issue * collection.
* #217). Neither may wait for a refresh, which would fetch the files of every
* collection. On an empty cache, `get` and `get-thumb` ask for that list, then
* for one collection's files at a time, until they find the file (issue #222).
* `helper fix-missing-thumbnails --file` does the same until it finds every
* file it names, reading the most recently updated collection first (issue
* #223).
* *
* `collections` and `files` also list in the order the library gives them, not * `collections` and `files` also list in the library's enumeration order
* (`listCollections`/`listFiles`) — the order the pre-library CLI printed — not
* the albums/photos projection's newest-first order. The fixtures are seeded in * the albums/photos projection's newest-first order. The fixtures are seeded in
* an order that a newest-first sort would rearrange, so a regression to the * an enumeration order that a newest-first sort would rearrange, so a
* projection order would fail here too. Field values still come from the raw * regression to the projection order would fail here too. Field values still
* metadata via `cli-output.ts`. * come from the raw metadata via `cli-output.ts`.
*/ */
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
@@ -30,10 +25,10 @@ import {
freshCollections, freshCollections,
freshFiles, freshFiles,
freshFile, freshFile,
freshFilesByID,
type FreshReadLibrary, type FreshReadLibrary,
} from "../../src/cli-read.js"; } from "../../src/cli-read.js";
import { fileListRow } from "../../src/cli-output.js"; import { fileListRow } from "../../src/cli-output.js";
import type { Photo } from "../../src/library/index.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
const collection = (id: number, updationTime: number): Collection => ({ const collection = (id: number, updationTime: number): Collection => ({
@@ -67,15 +62,14 @@ const file = (
updationTime: creationTime, updationTime: creationTime,
}); });
// A library that reveals its cached records only after `fresh()` has been // A library that reveals its records only after `fresh()` has been awaited, and
// awaited, and serves them in the enumeration order it was given. // serves them in the enumeration order it was given. `freshCollections()`
// `freshCollections()` and `freshFiles()` answer in that order without a // answers the collection list in that order without a refresh. `photos.byID`
// refresh. // returns a stand-in `Photo` carrying just the fileID the helper passes
// through.
class FakeLibrary implements FreshReadLibrary { class FakeLibrary implements FreshReadLibrary {
freshCalls = 0; freshCalls = 0;
freshCollectionsCalls = 0; freshCollectionsCalls = 0;
// The ID of each collection `freshFiles()` was asked for.
freshFilesCalls: number[] = [];
private refreshed = false; private refreshed = false;
constructor( constructor(
@@ -94,9 +88,9 @@ class FakeLibrary implements FreshReadLibrary {
return this.collections; return this.collections;
} }
async freshFiles(collection: Collection): Promise<EnteFile[]> { getCollection(id: number): Collection | undefined {
this.freshFilesCalls.push(collection.id); if (!this.refreshed) return undefined;
return this.files.filter((f) => f.collectionID === collection.id); return this.collections.find((c) => c.id === id);
} }
listCollections(): Collection[] { listCollections(): Collection[] {
@@ -113,6 +107,14 @@ class FakeLibrary implements FreshReadLibrary {
if (!this.refreshed) return undefined; if (!this.refreshed) return undefined;
return this.files.find((f) => f.id === fileID); return this.files.find((f) => f.id === fileID);
} }
photos = {
byID: ({ fileID }: { fileID: number }): Photo | undefined => {
if (!this.refreshed) return undefined;
if (!this.files.some((f) => f.id === fileID)) return undefined;
return { fileID } as unknown as Photo;
},
};
} }
describe("CLI read helpers (issue #36 amendment, issue #52)", () => { describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
@@ -136,24 +138,18 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
expect(rows.map((c) => c.id)).not.toEqual(newestFirst); expect(rows.map((c) => c.id)).not.toEqual(newestFirst);
}); });
it("freshFiles with a collection ID reads that collection's files alone and keeps their order and raw fields", async () => { it("freshFiles refreshes first, lists in enumeration order, keeps raw fields", async () => {
// Given in the order 10, 11, 12; creationTimes ascending, so a // Enumeration order by id 10, 11, 12; creationTimes ascending, so a
// newest-first sort would reverse them. // newest-first sort would reverse them.
const files = [ const files = [
file(10, 1, 1_700_000_000_000_000), file(10, 1, 1_700_000_000_000_000),
file(11, 1, 1_700_000_000_000_001), file(11, 1, 1_700_000_000_000_001),
file(12, 1, 1_700_000_000_000_002), file(12, 1, 1_700_000_000_000_002),
file(20, 2, 1_700_000_000_000_003),
]; ];
const lib = new FakeLibrary( const lib = new FakeLibrary([collection(1, 100)], files);
[collection(1, 100), collection(2, 200)],
files,
);
const rows = await freshFiles(lib, 1); const rows = await freshFiles(lib, 1);
expect(lib.freshCalls).toBe(0); expect(lib.freshCalls).toBe(1);
expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.freshFilesCalls).toEqual([1]);
expect(rows?.map((f) => f.id)).toEqual([10, 11, 12]); expect(rows?.map((f) => f.id)).toEqual([10, 11, 12]);
// Field values come from raw metadata: microsecond creationTime and the // Field values come from raw metadata: microsecond creationTime and the
@@ -210,117 +206,27 @@ describe("CLI read helpers (issue #36 amendment, issue #52)", () => {
it("freshFiles returns undefined for an unknown collection", async () => { it("freshFiles returns undefined for an unknown collection", async () => {
const lib = new FakeLibrary([collection(1, 100)], []); const lib = new FakeLibrary([collection(1, 100)], []);
const rows = await freshFiles(lib, 999); const rows = await freshFiles(lib, 999);
expect(lib.freshCalls).toBe(0); expect(lib.freshCalls).toBe(1);
expect(lib.freshFilesCalls).toEqual([]);
expect(rows).toBeUndefined(); expect(rows).toBeUndefined();
}); });
// Three collections, each holding one file. it("freshFile refreshes first, then resolves the photo and its raw record", async () => {
const threeCollections = (): FakeLibrary => const f = file(10, 1, 1_700_000_000_000_000);
new FakeLibrary( const lib = new FakeLibrary([collection(1, 100)], [f]);
[collection(1, 100), collection(2, 200), collection(3, 300)],
[ const resolved = await freshFile(lib, 10);
file(10, 1, 1_700_000_000_000_000), expect(lib.freshCalls).toBe(1);
file(20, 2, 1_700_000_000_000_001), expect(resolved?.photo.fileID).toBe(10);
file(30, 3, 1_700_000_000_000_002), expect(resolved?.file.metadata.title).toBe("file-10.jpg");
], expect(resolved?.file.metadata.creationTime).toBe(
1_700_000_000_000_000,
); );
it("freshFile on an empty cache reads each collection's files in the server's order until one holds the file", async () => {
const lib = threeCollections();
const found = await freshFile(lib, 20);
expect(lib.freshCalls).toBe(0);
expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.freshFilesCalls).toEqual([1, 2]);
expect(found?.metadata.title).toBe("file-20.jpg");
expect(found?.metadata.creationTime).toBe(1_700_000_000_000_001);
}); });
// The stand-in's cache holds what the server holds once `fresh()` has run. it("freshFile returns undefined for an unknown file", async () => {
it("freshFile on a cache that holds the collections refreshes, then looks the file up in the cache", async () => { const lib = new FakeLibrary([collection(1, 100)], []);
const lib = threeCollections(); const resolved = await freshFile(lib, 404);
await lib.fresh(); expect(lib.freshCalls).toBe(1);
expect(resolved).toBeUndefined();
const found = await freshFile(lib, 30);
expect(lib.freshCalls).toBe(2);
expect(lib.freshCollectionsCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([]);
expect(found?.collectionID).toBe(3);
});
it("freshFile on an empty cache returns undefined for an unknown file, after reading every collection's files", async () => {
const lib = threeCollections();
const found = await freshFile(lib, 404);
expect(lib.freshCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([1, 2, 3]);
expect(found).toBeUndefined();
});
// The server lists collections 1, 2 and 3, and collection 3 was updated
// most recently. File 11 is in collections 1 and 2.
const fileInTwoCollections = (): FakeLibrary =>
new FakeLibrary(
[collection(1, 100), collection(2, 200), collection(3, 300)],
[
file(10, 1, 1_700_000_000_000_000),
file(11, 1, 1_700_000_000_000_001),
file(11, 2, 1_700_000_000_000_001),
file(20, 2, 1_700_000_000_000_002),
file(30, 3, 1_700_000_000_000_003),
],
);
it("freshFilesByID on an empty cache reads the most recently updated collection's files first, until it has found every file", async () => {
const lib = fileInTwoCollections();
const found = await freshFilesByID(lib, [11, 20]);
expect(lib.freshCalls).toBe(0);
expect(lib.freshCollectionsCalls).toBe(1);
expect(lib.freshFilesCalls).toEqual([3, 2]);
expect(found.get(11)?.collection.id).toBe(2);
expect(found.get(11)?.file.collectionID).toBe(2);
expect(found.get(20)?.collection.id).toBe(2);
});
// Collection 1 was updated last, though collections 2 and 3 have higher
// IDs. File 11 is in collections 1 and 2.
it("freshFilesByID on an empty cache reads the most recently updated collection first, though its ID is the lowest", async () => {
const lib = new FakeLibrary(
[collection(1, 400), collection(2, 200), collection(3, 300)],
[
file(11, 1, 1_700_000_000_000_001),
file(11, 2, 1_700_000_000_000_001),
],
);
const found = await freshFilesByID(lib, [11]);
expect(lib.freshFilesCalls).toEqual([1]);
expect(found.get(11)?.collection.id).toBe(1);
expect(found.get(11)?.file.collectionID).toBe(1);
});
// The stand-in's cache holds what the server holds once `fresh()` has run.
it("freshFilesByID on a cache that holds the collections refreshes, then finds each file in the most recently updated collection that holds it", async () => {
const lib = fileInTwoCollections();
await lib.fresh();
const found = await freshFilesByID(lib, [11, 404]);
expect(lib.freshCalls).toBe(2);
expect(lib.freshCollectionsCalls).toBe(0);
expect(lib.freshFilesCalls).toEqual([]);
expect(found.get(11)?.collection.id).toBe(2);
expect(found.get(11)?.file.collectionID).toBe(2);
expect(found.has(404)).toBe(false);
});
it("freshFilesByID on an empty cache reads every collection's files when a file is in none of them", async () => {
const lib = fileInTwoCollections();
const found = await freshFilesByID(lib, [10, 404]);
expect(lib.freshFilesCalls).toEqual([3, 2, 1]);
expect(found.get(10)?.collection.id).toBe(1);
expect(found.has(404)).toBe(false);
}); });
}); });
-109
View File
@@ -1,109 +0,0 @@
{
"photo": "https://upload.wikimedia.org/wikipedia/commons/d/db/Sunset_off_the_Massachusetts_coast_-_NOAA.jpg",
"sha256": "be62f3289c9bf7b75af68cd6978021d54e8a531d5d4cc493f6e9fbf5e1ab74d3",
"embedding": [
-0.0021123015, -0.04437615, -0.062354308, 0.006249992, 0.022141185,
0.011159019, 0.01227392, -0.00030532264, 0.03186437, 0.09196922,
0.039111234, -0.037084445, -0.032962628, -0.019820206, -0.013141825,
0.0047183544, 0.017137334, 0.046581306, 0.0047622235, 0.04982521,
0.023128154, 0.032080866, 0.037709303, 0.019241406, 0.0032100384,
-0.014275843, 0.049337693, 0.025953151, 0.014263995, -0.0062328726,
-0.040504098, -0.015083303, -0.008123875, -0.006233639, -0.05352683,
-0.0048083956, 0.0763486, -0.013155067, 0.025114346, 0.022016674,
0.0001588023, -0.010341572, -0.0037518279, -0.019181008, 0.04549337,
-0.0010725886, 0.004636866, 0.017177915, -0.0117317885, 0.0026097358,
0.030738998, 0.013158572, -0.08856403, -0.0121391015, 0.011477229,
-0.0033104012, 0.035524756, -0.018607879, -0.0071739843, -0.031906743,
0.0056713386, -0.0044195866, 0.00292471, 0.00061686034, -0.043075223,
0.09040427, 0.001522537, -0.011445652, 0.009609886, -0.0053201183,
-0.0057452377, 0.00972853, -0.0024397348, -0.021255083, 0.015893811,
-0.006020548, -0.09428023, 0.014277583, -0.017975299, -0.011878315,
0.011825521, 0.00014203975, -0.0020437292, -0.05305343, -0.024840152,
-0.011336872, -0.002796541, -0.00036171873, 0.004530499, 0.044486605,
0.021602718, -0.01292857, 0.024530916, 0.019559948, -0.039928272,
-0.0681174, 0.049101, 0.02461532, 0.0365531, -0.04070358, -0.011011939,
-0.0027188526, -0.020370876, 0.012904856, 0.043157287, -0.051658224,
-0.3726012, 0.019549074, -0.0499252, -0.04800559, -0.011493859,
0.02893256, 0.060341954, 0.0013929688, -0.051283866, -0.024286803,
0.044124287, -0.005995806, -0.062817656, 0.007369614, -0.021470634,
-0.028147545, 0.00411659, 0.04234773, 0.03787647, 0.008579682,
-0.038568925, -0.027498025, -0.028416378, 0.0022012168, 0.010906191,
-0.013745787, 0.018267838, 0.013531097, -0.061559845, 0.004682517,
-0.005993303, 0.0028113858, -0.0028286583, 0.0026029332, -0.06693403,
-0.0072381413, -0.02419584, 0.0037009937, -0.0011296495, -0.030310456,
0.0005999609, 0.009945354, 0.024915686, 0.038672045, 0.023009432,
-0.05093051, -0.09101409, -0.06686167, -0.06211481, -0.022600474,
0.0024902367, -0.0067171026, -0.016769778, -0.0032803628, 0.008623083,
-0.0016110836, 0.014925073, 0.033811048, 0.012829449, -0.052575592,
0.03728281, -0.0051201633, 0.021238064, -0.031086605, -0.0043402985,
0.25054494, -0.028396713, -0.010725523, -0.023445267, 0.021751683,
0.003130971, 0.012270548, -0.014836475, 0.014466885, 0.0021478906,
-0.088153616, 0.015356315, 0.039639276, -0.040283255, 0.014923924,
-0.029303031, -0.004475201, 0.047299225, -0.06726505, -0.012491676,
-0.002080582, 0.03506341, 0.04146718, 0.0074114986, -0.009582633,
0.006623749, -0.05454898, -0.0034387247, -0.014780821, 0.03698328,
-0.034441486, 0.22032379, -0.021792699, 0.008001907, 0.0341019,
0.0025784625, -0.0071689896, -0.014151225, -0.050547987, -0.030372301,
-0.03288388, -0.042987447, -0.0033656165, 0.021502906, -0.0030374376,
-0.024413, -0.03506603, 0.030891743, 0.0069994032, 0.004887366,
0.030707076, 0.0065348223, 0.0008240555, 0.016809713, -0.024151647,
-0.0040458064, 0.044567548, 0.03908212, -0.061782435, 0.0026002831,
0.02003242, -0.0115451, -0.045135964, -0.0663542, 0.005240975,
0.0010777109, -0.009276002, -0.011919081, 0.027654119, -0.013634551,
0.0129154315, -0.013195512, 0.040571272, -0.099290505, -0.04656693,
-0.20979854, 0.04181004, -0.06769166, -0.04121946, 0.018260444,
0.005617506, -0.15694222, 0.032472793, 0.014189598, 0.008345103,
-0.041118417, 0.0074527003, -0.011494104, 0.004906474, -0.0027100458,
0.0009994594, -0.041061398, -0.045274835, 0.015187793, 0.034275036,
-0.02813289, 0.02098019, -0.015043198, 0.03716667, 0.013542642,
-0.008104835, 0.014965486, -0.027978558, 0.021163663, -0.006942737,
-0.034969814, -0.038961593, 0.016802894, -0.012318175, -0.047821593,
-0.01933216, 0.0064432374, 0.0322358, -0.020437784, 0.02659447,
-0.03022022, -0.019443465, -0.094178624, -0.017009107, 0.03860671,
-0.00023695534, -0.033089854, -0.014677462, 0.012356399, 0.018040499,
-0.19360113, 0.011710817, -0.019871691, 0.020124774, 0.024948105,
-0.024573864, -0.028349072, -0.041731454, 0.032646254, 0.015098165,
0.0018447012, -0.01129152, -0.02481687, 0.036048, 0.20797811,
0.04317757, -0.017239755, 0.033431605, -0.011384422, 0.0099174455,
-0.19739223, -0.008901062, -0.056426458, 0.014591864, 0.0044474266,
0.008209326, -0.012241819, 0.042553645, -0.021552453, 0.008611734,
0.048944127, -0.013872049, 0.019179989, 0.008183264, -0.053223096,
-0.027327595, -0.0033542586, -0.012353858, -0.010941209, 0.010212546,
0.029714134, -0.011385593, -0.0013529479, 0.014505385, 0.01148663,
0.012613636, -0.0074430504, -0.014008101, -0.028146038, -0.042778917,
0.02143165, 0.016812252, -0.06286215, 0.02085532, -0.035128277,
0.0241282, 0.01397908, -0.034992814, 0.013153766, 0.0068236147,
0.013594184, -0.06859393, 0.051849406, 0.029675119, -0.0037699796,
-0.00013216575, 0.02916647, 0.031859048, -0.015006051, 0.0001439966,
0.0026584554, -0.0046948595, 0.039754897, 0.034873154, 0.0012870439,
-0.011516082, -0.074089095, 0.019241294, 0.00076195505, 0.0027334464,
-0.036206458, -0.0011109262, -0.048838835, -0.00204685, 0.01179991,
0.0068136845, 0.009852495, 0.017308988, 0.03159545, 0.058803353,
0.038116634, 0.008836143, -0.010460637, -0.010994099, -0.0012926942,
-0.048370842, 0.037696566, -0.08093239, -0.0041242186, -0.005866373,
0.02048607, -0.12549639, 0.011705739, -0.0021425558, 0.018078592,
-0.038228087, 0.004245971, -0.11560507, 0.01381203, -0.02734893,
0.029595908, 0.101082794, -0.042842254, -0.08397301, -0.1546237,
-0.037624564, 0.0069606556, -0.008020479, 0.0043268185, 0.016504597,
0.044554908, -0.05240209, 0.020519335, -0.050994243, -0.019861093,
0.022066997, -0.0188911, 0.012919696, -0.022872692, -0.012961374,
-0.020071112, 0.0074553066, 0.06402014, 0.04065211, 0.014139608,
0.06221546, -0.02574805, 0.010114545, 0.020619567, 0.032264773,
-0.009148828, 0.022849955, -0.016593235, -0.032456633, -0.006737303,
-0.0037493357, -0.065912865, -0.0024181998, -0.0037048983, -0.019304488,
0.009085037, 0.057529982, -0.0378338, 0.01792042, 0.01885012,
-0.0043600523, 0.017382368, -0.027792772, 0.006749118, -0.0053297766,
-0.011628587, 0.015736576, 0.024801968, 0.011543913, -0.01159873,
-0.000759976, 0.042183932, 0.05764386, -0.06422416, 0.010577374,
-0.02261249, 0.02282793, -0.05141075, -0.11287078, -0.020670144,
-0.020953743, 0.030885734, -0.012597732, -0.121472664, 0.028447151,
-0.0415664, -0.010061708, 0.06441338, 0.0271207, -0.0016693748,
-0.0045694243, 0.020342957, 0.030528478, 0.007129517, -0.0029600926,
-0.007061397, 0.06036486, -0.004736018, -0.0090786135, -0.07601116,
-0.039556272, -0.051269546, -0.019473685, 0.029858151, 0.028464556,
0.012424476, 0.013742254, 0.008742927, 0.006065751, -0.009892663,
0.07699999, -0.003443197, 0.0027863425, 0.029197995, 0.0211013,
0.01098379, 0.016684106, -0.028917195, 0.008122037, 0.0265241,
-0.04461223
]
}
-129
View File
@@ -1,129 +0,0 @@
// `clipKeywords`: the keywords quak writes for a file in place of its CLIP
// embedding.
//
// Ente's ML data for a file holds a CLIP embedding, 512 numbers its image
// model gave the photo. Ente's photos app puts a file in a category of its
// Discover section when the cosine similarity of that embedding and the text
// embedding of the category's query is at least the category's minimum score,
// and keeps videos out of some categories. `clipKeywords` applies that rule
// and returns the titles of the categories a file falls in, highest score
// first; `quak backup` and `quak backup-metadata` write them as `keywords`.
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { DISCOVER_CATEGORIES } from "../src/clip-keyword-vectors.js";
import { clipKeywords } from "../src/clip-keywords.js";
// 512 numbers: the ones given, then zeros.
const vector = (...start: number[]): number[] => [
...start,
...new Array<number>(512 - start.length).fill(0),
];
const withEmbedding = (embedding: unknown) => ({
face: { faces: [] },
clip: { embedding },
});
// Two made-up categories, each with a text embedding along one axis. The
// embedding [3, 4] has a cosine similarity of 3/5 with "Across" and 4/5 with
// "Up". Videos are kept out of "Across".
const ACROSS = {
title: "Across",
query: "across",
minScore: 0.6,
showVideo: false,
textEmbedding: vector(1),
};
const UP = {
title: "Up",
query: "up",
minScore: 0.5,
showVideo: true,
textEmbedding: vector(0, 1),
};
describe("clipKeywords", () => {
it("gives each category whose minimum score the similarity reaches, highest score first", () => {
expect(
clipKeywords(withEmbedding(vector(3, 4)), false, [ACROSS, UP]),
).toEqual(["Up", "Across"]);
});
it("leaves out a category when the similarity is just below its minimum score", () => {
const stricter = { ...ACROSS, minScore: 0.6 + 1e-9 };
expect(
clipKeywords(withEmbedding(vector(3, 4)), false, [stricter, UP]),
).toEqual(["Up"]);
});
it("keeps a video out of a category that leaves out videos", () => {
expect(
clipKeywords(withEmbedding(vector(3, 4)), true, [ACROSS, UP]),
).toEqual(["Up"]);
});
it("gives an empty list for an embedding that falls in no category", () => {
expect(
clipKeywords(withEmbedding(vector(0, 0, 1)), false, [ACROSS, UP]),
).toEqual([]);
});
it.each([
["no CLIP embedding", { face: { faces: [] } }],
["a CLIP part that is not an object", { clip: null }],
["a CLIP part without an embedding", { clip: {} }],
["an embedding of 511 numbers", withEmbedding(vector(3, 4).slice(1))],
["an embedding with NaN in it", withEmbedding(vector(3, NaN))],
["an embedding with Infinity in it", withEmbedding(vector(Infinity))],
["an embedding of strings", withEmbedding(vector(3, 4).map(String))],
["an embedding of zeros", withEmbedding(vector())],
])("gives no keywords for ML data with %s", (_, mlData) => {
expect(clipKeywords(mlData, false, [ACROSS, UP])).toBeUndefined();
});
});
// The CLIP embedding Ente's image model gives a public-domain NOAA photo of a
// sunset, made by bin/clip-keyword-vectors from the photo at `photo`.
const SUNSET: { photo: string; sha256: string; embedding: number[] } =
JSON.parse(
readFileSync(
new URL("clip-keywords-sunset.json", import.meta.url),
"utf-8",
),
);
describe("Ente's Discover categories", () => {
it("are the 16 of Ente's photos app, each with a text embedding of norm 1", () => {
expect(DISCOVER_CATEGORIES.map((c) => c.title)).toEqual([
"Identity",
"Screenshots",
"QR Codes",
"Receipts",
"Notes",
"Memes",
"Visiting Cards",
"Babies",
"Pets",
"Selfies",
"Wallpapers",
"Food",
"Celebrations",
"Sunset",
"Hills",
"Greenery",
]);
for (const category of DISCOVER_CATEGORIES) {
expect(category.textEmbedding).toHaveLength(512);
expect(Math.hypot(...category.textEmbedding)).toBeCloseTo(1, 5);
}
});
it("give a photo of a sunset the keyword Sunset and no other", () => {
expect(
clipKeywords({ clip: { embedding: SUNSET.embedding } }, false),
).toEqual(["Sunset"]);
});
});
+2 -5
View File
@@ -19,11 +19,8 @@ import { SRP, SrpServer } from "fast-srp-hap";
import { toBase64, deriveKEK, deriveLoginSubkey } from "../src/crypto/index.js"; import { toBase64, deriveKEK, deriveLoginSubkey } from "../src/crypto/index.js";
import type { KeyAttributes } from "../src/auth/types.js"; import type { KeyAttributes } from "../src/auth/types.js";
// The account's password is stretched at libsodium's lowest Argon2id cost. const TEST_OPS = 2;
// Every login stretches it again, and at a realistic cost that takes seconds on const TEST_MEM = 64 * 1024 * 1024;
// a loaded host.
const TEST_OPS = 1;
const TEST_MEM = 8192;
export interface MetaMockState { export interface MetaMockState {
verifier: Buffer; verifier: Buffer;
-6
View File
@@ -253,12 +253,6 @@ describe("Library content wiring", () => {
await expect( await expect(
lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }), lib.thumbnails.ensure({ fileIDs: [1], priority: "visible" }),
).rejects.toThrow(/content cache/i); ).rejects.toThrow(/content cache/i);
await expect(lib.originalOf(file(1, 1))).rejects.toThrow(
/content cache/i,
);
await expect(lib.thumbnailOf(file(1, 1))).rejects.toThrow(
/content cache/i,
);
await lib.close(); await lib.close();
}); });
}); });
-19
View File
@@ -375,25 +375,6 @@ describe("ContentCache.original / thumbnail", () => {
await cache.open(); await cache.open();
await expect(cache.original(999)).rejects.toThrow(/unknown file/i); await expect(cache.original(999)).rejects.toThrow(/unknown file/i);
}); });
// `getFile` knows no file here, so the cache can fetch only the file it is
// given.
it("originalOf and thumbnailOf fetch and cache the file they are given", async () => {
const { cache, source } = buildCache({ files: [] });
await cache.open();
const original = await cache.originalOf(file(5));
const thumbnail = await cache.thumbnailOf(file(5));
expect(source.originalCalls).toEqual([5]);
expect(source.thumbnailCalls).toEqual([5]);
expect(original.path).toBe(join(cacheDir, "originals", "5.jpg"));
expect(thumbnail.path).toBe(join(cacheDir, "thumbnails", "5.jpg"));
expect(cache.pathsFor(5)).toEqual({
originalPath: original.path,
thumbnailPath: thumbnail.path,
});
});
}); });
describe("ContentCache.ensureThumbnails", () => { describe("ContentCache.ensureThumbnails", () => {
+11 -44
View File
@@ -6,12 +6,10 @@
* working thumbnails, others return 404 or empty bodies. The tests * working thumbnails, others return 404 or empty bodies. The tests
* verify that the detection and repair logic handles each case correctly. * verify that the detection and repair logic handles each case correctly.
* *
* `listMissingThumbnails` takes an open `Library` to enumerate the files. Both * As of issue #52 both helpers take an open `Library` for enumeration and the
* helpers take the `Client` for the API operations: the thumbnail existence * `Client` for the API operations that stay unchanged (the thumbnail existence
* check, and the encrypt-and-upload path. `fixMissingThumbnails` takes the * check, and the encrypt-and-upload path). `fixMissingThumbnails` reads each
* record of each file, which these tests look up with `cachedFilesByID`. It * original through the library's content cache (`photo.original()`).
* reads each original through the library's content cache, by
* `lib.originalOf(file)`.
* *
* `fixMissingThumbnails` is the most complex function in quak: it * `fixMissingThumbnails` is the most complex function in quak: it
* downloads the original file, generates a JPEG thumbnail with jpeg-js, * downloads the original file, generates a JPEG thumbnail with jpeg-js,
@@ -43,7 +41,6 @@ import {
import { SRP, SrpServer } from "fast-srp-hap"; import { SRP, SrpServer } from "fast-srp-hap";
import { Client } from "../../src/client.js"; import { Client } from "../../src/client.js";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
import { cachedFilesByID } from "../../src/cli-read.js";
import { import {
listMissingThumbnails, listMissingThumbnails,
fixMissingThumbnails, fixMissingThumbnails,
@@ -57,11 +54,8 @@ import type { RetryOptions } from "../../src/retry.js";
const TEST_EMAIL = "thumb@example.com"; const TEST_EMAIL = "thumb@example.com";
const TEST_PASSWORD = "thumbpass"; const TEST_PASSWORD = "thumbpass";
// The password is stretched at libsodium's lowest Argon2id cost. Each test const TEST_OPS = 2;
// builds an account and logs in, and at a realistic cost that takes seconds on const TEST_MEM = 64 * 1024 * 1024;
// a loaded host.
const TEST_OPS = 1;
const TEST_MEM = 8192;
const TEST_TIME = 1700000000000000; const TEST_TIME = 1700000000000000;
interface ThumbMockState { interface ThumbMockState {
@@ -662,12 +656,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock)); const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [101]);
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close(); lib.close();
expect(results.length).toBe(1); expect(results.length).toBe(1);
@@ -704,12 +693,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock)); const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [999]);
lib,
client,
[999],
cachedFilesByID(lib, [999]),
);
lib.close(); lib.close();
expect(results.length).toBe(1); expect(results.length).toBe(1);
@@ -731,7 +715,6 @@ describe("fixMissingThumbnails", () => {
lib, lib,
client, client,
[101, 102], [101, 102],
cachedFilesByID(lib, [101, 102]),
(msg, level) => { (msg, level) => {
if (level === "error") errors.push(msg); if (level === "error") errors.push(msg);
}, },
@@ -762,7 +745,6 @@ describe("fixMissingThumbnails", () => {
lib, lib,
client, client,
[101, 103, 104], [101, 103, 104],
cachedFilesByID(lib, [101, 103, 104]),
); );
lib.close(); lib.close();
@@ -797,12 +779,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(counted.fetch); const client = await login(counted.fetch);
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [101]);
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close(); lib.close();
expect(results[0]!.status).toBe("skipped"); expect(results[0]!.status).toBe("skipped");
@@ -823,12 +800,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(counted.fetch); const client = await login(counted.fetch);
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [101]);
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close(); lib.close();
expect(results[0]!.status).toBe("skipped"); expect(results[0]!.status).toBe("skipped");
@@ -869,12 +841,7 @@ describe("fixMissingThumbnails", () => {
const client = await login(buildThumbFetch(fixMock)); const client = await login(buildThumbFetch(fixMock));
const lib = await openLib(client); const lib = await openLib(client);
const results = await fixMissingThumbnails( const results = await fixMissingThumbnails(lib, client, [101]);
lib,
client,
[101],
cachedFilesByID(lib, [101]),
);
lib.close(); lib.close();
expect(results[0]!.status).toBe("fixed"); expect(results[0]!.status).toBe("fixed");
-7
View File
@@ -3,15 +3,8 @@ import { configDefaults, defineConfig } from "vitest/config";
// vitest does not read .gitignore when looking for tests. A checkout nested // vitest does not read .gitignore when looking for tests. A checkout nested
// under .claude/ has its own test/ tree, and without this exclude the suite // under .claude/ has its own test/ tree, and without this exclude the suite
// runs once per nested checkout and still reports success. // runs once per nested checkout and still reports success.
//
// vitest starts one worker per CPU core by default, and a shared host has
// dozens. Four keep the suite well inside the Dockerfile's time limit. vitest
// 2 rejects a maxWorkers below its default minWorkers, so minWorkers is set
// too.
export default defineConfig({ export default defineConfig({
test: { test: {
exclude: [...configDefaults.exclude, ".claude/**"], exclude: [...configDefaults.exclude, ".claude/**"],
minWorkers: 1,
maxWorkers: 4,
}, },
}); });