Compare commits

..
1 Commits
Author SHA1 Message Date
clawbot c5a588177c Thumbnail test on main: re-encode a small image so it cannot time out (closes #153)
check / check (push) Successful in 1m19s
The `main` half of the fix for the timed-out thumbnail test, carrying only the test change from `next` (d788c54). The test "re-encodes smaller until the thumbnail fits the recorded size" built a noisy 400x300 JPEG, which came close to vitest's 5 s limit and made `script/cibuild` fail on a busy host. It now uses a noisy 64x48 JPEG, keeps every assertion, and runs in about 1 s.

Model: opus-5-5
2026-10-02 04:22:27 +02:00
29 changed files with 797 additions and 2770 deletions
+135 -264
View File
@@ -8,9 +8,9 @@ and downloads individual images while decrypting them on the way to disk.
quak also includes a resilient backup command that downloads every file in the quak also includes a resilient backup command that downloads every file in the
account into a deduplicated local directory tree, skipping files that already account into a deduplicated local directory tree, skipping files that already
exist on disk and continuing past individual download failures instead of exist on disk and continuing past individual download failures instead of
crashing. For each file it persists the basic metadata fields quak keeps (title, crashing. It decrypts and persists all three metadata layers (basic, private
file type, creation and modification time, latitude, longitude, content hash), magic, public magic) per file, including camera info, GPS coordinates, captions,
and the private and public magic metadata in full. A helper subcommand can and any face/keyword labels the Ente clients have added. A helper subcommand can
detect and regenerate missing thumbnails, encrypting and uploading them back to detect and regenerate missing thumbnails, encrypting and uploading them back to
the server. the server.
@@ -51,8 +51,7 @@ const client = await Client.login({
// Open a cache-backed library. On an empty cache this awaits one server // Open a cache-backed library. On an empty cache this awaits one server
// refresh; on an existing cache it returns immediately and refreshes in the // refresh; on an existing cache it returns immediately and refreshes in the
// background. Later refreshes start `refreshIntervalSeconds` (default 3) after // background every `refreshIntervalSeconds` (default 3).
// the previous one ends.
const lib = await Library.open({ client }); const lib = await Library.open({ client });
// Default reads answer synchronously from the local cache — no network. // Default reads answer synchronously from the local cache — no network.
@@ -63,7 +62,7 @@ for (const album of lib.albums.list()) {
} }
} }
// Fresh reads await a server round-trip, joining one already running. // Fresh reads await a server round-trip and answer with current state.
const { albums } = await lib.fresh(); const { albums } = await lib.fresh();
console.log(`${albums.list().length} albums as of now`); console.log(`${albums.list().length} albums as of now`);
@@ -80,43 +79,12 @@ await lib.close();
The lower-level `Client` (login, session serialization, and the raw The lower-level `Client` (login, session serialization, and the raw
enumeration/download calls) is exported too and documented under Design below. enumeration/download calls) is exported too and documented under Design below.
## Examples
`examples/download-albums.ts` downloads every album's photos and their metadata
into a directory, `photos` in the working directory unless you name another. The
build compiles it; run it after `yarn install`:
```bash
yarn build
QUAK_EMAIL=… QUAK_PASSWORD=… node dist/examples/download-albums.js [dir]
```
It opens the library with `precacheThumbnails` and `precacheOriginals` off, as
`quak backup` does, so the only file content it fetches is the originals it
saves. It asks on the terminal for a two-factor or email code when the account
requires one, and writes:
- each photo's original at its save path under `dir`, as `photo.download()`
writes it: `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>`, and for a live
photo its image, its video and the `.livephoto.json` file naming them
- beside each original, a JSON file named after it with `.json` added, for
example `2026-03-01.12345.jpg.json`: the photo's record (`photo.record()`)
without its cache paths, and every EXIF tag of the photo (`photo.exif()`)
under `exif`
- `albums/<collectionID>.json` for each album: its `collectionID`, its `name`,
and under `savePaths` the save paths of its photos relative to `dir`, newest
first
A photo in several albums is downloaded once. A second run downloads nothing and
rewrites only the JSON files whose content changed. A failed download stops the
run; running it again carries on, since every photo already saved is skipped.
## Entrypoints ## Entrypoints
This repository adheres to the This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and most Makefile targets are thin shims that call them. development workflow, and the Makefile targets are thin shims that call them.
The scripts are POSIX sh (not bash) so they run in minimal containers such as The scripts are POSIX sh (not bash) so they run in minimal containers such as
alpine. We provide: alpine. We provide:
@@ -154,9 +122,9 @@ alpine. We provide:
Linting and testing are phases of the `Dockerfile`. The `lint` phase copies the Linting and testing are phases of the `Dockerfile`. The `lint` phase copies the
repo into a digest-pinned node image and runs eslint and `prettier --check .`; repo into a digest-pinned node image and runs eslint and `prettier --check .`;
the `test` phase does the same with the suite. `script/lint` and `script/test` the `test` phase does the same with the suite. `script/lint` and `script/test`
each build one phase with `docker build --no-cache --target <phase>`. Neither each build one phase with `docker build --no-cache --target <phase>`. There is
script runs the tools on the host: docker is required, and that also works where no host lint or test path: docker is required, and that also works where the
the docker daemon is remote and bind mounts are impossible. docker daemon is remote and bind mounts are impossible.
The last stage of the `Dockerfile` compiles the package, and it copies a file The last stage of the `Dockerfile` compiles the package, and it copies a file
from each phase, so it cannot be built unless lint and the tests pass. That is from each phase, so it cannot be built unless lint and the tests pass. That is
@@ -202,19 +170,16 @@ local cache is reliable, and the UI is responsive on a five-year-old laptop.
All work on quak is test-driven. No exceptions. All work on quak is test-driven. No exceptions.
1. Every change starts on a feature branch off `next`, and its pull request 1. Every change starts on a feature branch off `main`.
targets `next`.
2. The first commit on the branch is the test suite for what is being added or 2. The first commit on the branch is the test suite for what is being added or
changed. Those tests must fail at that commit; the branch is red until the changed. Those tests must fail at that commit; the branch is red until the
implementation lands. implementation lands.
3. Subsequent commits add the implementation and any refactors needed to make 3. Subsequent commits add the implementation and any refactors needed to make
the tests pass. the tests pass.
4. A pull request can only be merged into `next` when `make check` is green. 4. A feature branch can only be merged into `main` when `make check` is green.
Once it has passed review, the repository manager squash-merges it into `main` is always green. CI runs `script/cibuild`, which builds the
`next`. Only sneak merges `next` into `main`. `main` and `next` are always `Dockerfile`: its `lint` and `test` phases, then the compile, so neither a
green. CI runs `script/cibuild`, which builds the `Dockerfile`: its `lint` red branch nor one that does not compile can pass CI.
and `test` phases, then the compile, so neither a red branch nor one that
does not compile can pass CI.
5. Tests are the canonical API documentation for this library. Every test file 5. Tests are the canonical API documentation for this library. Every test file
is commented thoroughly enough that a reader who has never seen quak can is commented thoroughly enough that a reader who has never seen quak can
learn how to use it from the tests alone. Comments explain why a behavior learn how to use it from the tests alone. Comments explain why a behavior
@@ -232,7 +197,7 @@ All work on quak is test-driven. No exceptions.
not the tests, and so not the full `make check`. This is deliberate so the not the tests, and so not the full `make check`. This is deliberate so the
TDD red-phase commit (failing tests, no implementation yet) can land. The TDD red-phase commit (failing tests, no implementation yet) can land. The
`test` phase is part of the image build, which is what CI executes via `test` phase is part of the image build, which is what CI executes via
`script/cibuild`, so a red branch still cannot reach `next`. `script/cibuild`, so a red branch still cannot reach `main`.
## Design ## Design
@@ -244,7 +209,7 @@ the CLI is for humans.
``` ```
quak/ quak/
src/ src/
crypto/ libsodium primitives (boxes, secretstreams, KDF, hash) crypto/ libsodium primitives (boxes, secretstreams, KDF, SRP)
api/ HTTP client (ApiClient class) api/ HTTP client (ApiClient class)
auth/ login flow (SRP + email OTP + TOTP), key unwrap auth/ login flow (SRP + email OTP + TOTP), key unwrap
model/ decrypted Collection, File, Metadata types + decrypt fns model/ decrypted Collection, File, Metadata types + decrypt fns
@@ -254,8 +219,7 @@ quak/
and search, request pools and search, request pools
backup.ts resilient full-account backup with dedup backup.ts resilient full-account backup with dedup
metadata-backup.ts metadata-backup.ts
backup-metadata: the metadata quak keeps, as JSON backup-metadata: all decrypted metadata as JSON
exif.ts EXIF read from an image's bytes with exifreader
mldata-fetch.ts fetch + decrypt per-file ML data mldata-fetch.ts fetch + decrypt per-file ML data
filename.ts safe file names from server metadata filename.ts safe file names from server metadata
errors.ts error types shared across layers errors.ts error types shared across layers
@@ -270,9 +234,6 @@ quak/
index.ts public library exports index.ts public library exports
bin/ bin/
quak.ts CLI entrypoint (commander.js) quak.ts CLI entrypoint (commander.js)
examples/
download-albums.ts
download every album's photos and metadata
test/ unit + integration tests (vitest) test/ unit + integration tests (vitest)
Makefile Makefile
Dockerfile lint phase, test phase, compile Dockerfile lint phase, test phase, compile
@@ -281,18 +242,15 @@ quak/
``` ```
`make build` compiles that tree into `dist/`, preserving its shape: the library `make build` compiles that tree into `dist/`, preserving its shape: the library
lands in `dist/src/`, the examples in `dist/examples/`, and the CLI in lands in `dist/src/` and the CLI in `dist/bin/quak.js`, which is what
`dist/bin/quak.js`, which is what `package.json` points `main`, `types` and `package.json` points `main`, `types` and `bin` at. The compiler's `rootDir` is
`bin` at. The compiler's `rootDir` is the repository root rather than `src/`, the repository root rather than `src/`, because `bin/` is compiled too and
because `bin/` is compiled too and `rootDir` has to contain everything that is `rootDir` has to contain everything that is compiled.
compiled.
### Cryptography ### Cryptography
All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is All cryptography is done by `libsodium-wrappers-sumo` (the "sumo" build is
required for `crypto_pwhash` / Argon2id), except the SRP handshake, which uses required for `crypto_pwhash` / Argon2id). No hand-rolled crypto.
`fast-srp-hap`, and the MD5 checksum sent with a thumbnail upload, which uses
Node's built-in `node:crypto`. No hand-rolled crypto.
The key hierarchy, derived during login, is: The key hierarchy, derived during login, is:
@@ -300,23 +258,19 @@ The key hierarchy, derived during login, is:
2. Argon2id (`crypto_pwhash`) over the password and a server-issued `kekSalt`, 2. Argon2id (`crypto_pwhash`) over the password and a server-issued `kekSalt`,
with server-issued `memLimit` and `opsLimit`, produces a 32-byte Key with server-issued `memLimit` and `opsLimit`, produces a 32-byte Key
Encryption Key (KEK). Encryption Key (KEK).
3. SRP login: `crypto_kdf_derive_from_key` (BLAKE2b) derives a 32-byte subkey 3. SRP login: a 16-byte SRP login subkey is derived from the KEK using
from the KEK with subkey id 1 and context `loginctx`. Its first 16 bytes are `crypto_kdf_derive_from_key` (BLAKE2b) with subkey id 1 and context
the SRP password. `loginctx`. That 16-byte value is the SRP password.
4. When SRP completes, the server returns a blob of "key attributes" plus an 4. After SRP completes (or after email-OTP fallback), the server returns a blob
encrypted auth token, or first asks for a second factor. quak answers a TOTP of "key attributes" plus an encrypted auth token.
request with the code (`POST /users/two-factor/verify`), after which the
server returns them, and cannot answer a passkey request. When the account
has email MFA on (`isEmailMFAEnabled`), an email OTP replaces SRP and the
server returns them after it.
5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK 5. `crypto_secretbox_open_easy` over the encrypted master key with the KEK
yields the 32-byte master key. yields the 32-byte master key.
6. `crypto_secretbox_open_easy` over the encrypted secret key with the master 6. `crypto_secretbox_open_easy` over the encrypted secret key with the master
key yields the user's X25519 private key. The matching public key is key yields the user's X25519 private key. The matching public key is
delivered in cleartext. delivered in cleartext.
7. `crypto_box_seal_open` over the encrypted token with the user's keypair 7. `crypto_box_seal_open` over the encrypted token with the user's keypair
yields the auth token's bytes. Encoded as URL-safe base64 with padding, they yields the URL-safe base64 auth token used in `X-Auth-Token` for all
are the `X-Auth-Token` value for all subsequent calls. subsequent calls.
Per-collection keys are decrypted with `crypto_secretbox_open_easy` using the Per-collection keys are decrypted with `crypto_secretbox_open_easy` using the
master key (for owned collections). Per-file keys are decrypted with master key (for owned collections). Per-file keys are decrypted with
@@ -352,8 +306,7 @@ Endpoints used:
- `POST /users/srp/create-session`: begin SRP handshake. - `POST /users/srp/create-session`: begin SRP handshake.
- `POST /users/srp/verify-session`: complete SRP, receive 2FA challenge or the - `POST /users/srp/verify-session`: complete SRP, receive 2FA challenge or the
encrypted token plus key attributes. encrypted token plus key attributes.
- `POST /users/ott` and `POST /users/verify-email`: email OTP, used instead of - `POST /users/ott` and `POST /users/verify-email`: email OTP fallback path.
SRP when the SRP attributes have `isEmailMFAEnabled` set.
- `POST /users/two-factor/verify`: TOTP second factor. - `POST /users/two-factor/verify`: TOTP second factor.
- `POST /users/logout`: end the calling token's session (`quak logout`). - `POST /users/logout`: end the calling token's session (`quak logout`).
- `GET /collections/v2?sinceTime=<usec>`: list collections changed since - `GET /collections/v2?sinceTime=<usec>`: list collections changed since
@@ -375,9 +328,8 @@ request is repeated only when repeating it could produce a different answer:
- Every other 4xx: not retried. A 404 in particular is an answer, and - Every other 4xx: not retried. A 404 in particular is an answer, and
`listMissingThumbnails` depends on getting it promptly and once. `listMissingThumbnails` depends on getting it promptly and once.
- Transport failures — a `fetch` rejection, `ECONNRESET`, `ETIMEDOUT`, a DNS or - Transport failures — a `fetch` rejection, `ECONNRESET`, `ETIMEDOUT`, a DNS or
TLS failure — and deadline aborts: retried. Node's `fetch` rejects with a TLS failure — and deadline aborts: retried. The errno is looked for in the
plain `TypeError`, so every `TypeError` is retried. The errno is looked for in error's `cause` chain, because that is where Node's `fetch` puts it.
the error's `cause` chain, because that is where Node's `fetch` puts it.
- A truncated download: retried. - A truncated download: retried.
- Anything else, including a secretstream authentication failure that is not - Anything else, including a secretstream authentication failure that is not
truncation: not retried. The default answer is no. For a backup tool, retrying truncation: not retried. The default answer is no. For a backup tool, retrying
@@ -443,13 +395,10 @@ because a socket reset after the response headers have arrived surfaces in the
download layer rather than in `ApiClient`, and that is the common failure for download layer rather than in `ApiClient`, and that is the common failure for
multi-megabyte photos over a CDN. The secretstream pull state is not resumable multi-megabyte photos over a CDN. The secretstream pull state is not resumable
and these endpoints have no Range support, so a retry starts the file over. The and these endpoints have no Range support, so a retry starts the file over. The
atomic write is part of the retried unit: each attempt writes its own temporary atomic write stays outside the retry, so a download that needed three attempts
files, one for most files and two for a live photo (its image and its video), still performs exactly one write and one rename. `runBackup` and
and removes them if it fails. Only the attempt that completes renames anything `runMetadataBackup` are unchanged: the retry sits below them, and a file that
into place. The retry sits below `runBackup` and `runMetadataBackup`. fails after exhausting it is still logged, counted, and stepped over.
`runBackup` logs, counts and steps over a file that still fails after its
retries; `runMetadataBackup`, which downloads only with `--exif`, records the
error in that file's JSON as `imageMetadataError` and goes on.
One imprecision is deliberate and worth knowing about. When a body ends part-way One imprecision is deliberate and worth knowing about. When a body ends part-way
through a secretstream chunk, Poly1305 fails and carries no framing signal, so a through a secretstream chunk, Poly1305 fails and carries no framing signal, so a
@@ -473,11 +422,10 @@ base64-encoded keys) that the consumer can write to disk, a database, or
whatever else fits their use case. `Client.fromJSON(snapshot)` restores a whatever else fits their use case. `Client.fromJSON(snapshot)` restores a
working client from that snapshot without re-authenticating; it checks every working client from that snapshot without re-authenticating; it checks every
field and each key's length first, and throws an error naming the bad field. field and each key's length first, and throws an error naming the bad field.
`client.logout()` clears the token and zeroes the key buffers in place; after `client.logout()` clears the token and zeroes the key buffers in place; every
it, every other method on that client throws. It does not contact the server, so later call on that client throws. It does not contact the server, so the token
the token stays valid there and in any saved snapshot; stays valid there and in any saved snapshot; `await client.logoutOnServer()`
`await client.logoutOnServer()` first ends the session on the server first ends the session on the server (`POST /users/logout`).
(`POST /users/logout`).
The CLI stores the snapshot at the platform-appropriate data directory via The CLI stores the snapshot at the platform-appropriate data directory via
`env-paths`: `~/Library/Application Support/quak/session.json` on macOS, `env-paths`: `~/Library/Application Support/quak/session.json` on macOS,
@@ -485,17 +433,15 @@ The CLI stores the snapshot at the platform-appropriate data directory via
`0600`. The key material is stored in cleartext in the JSON; treat this file as `0600`. The key material is stored in cleartext in the JSON; treat this file as
you would treat the password itself. A missing file is reported as "not logged you would treat the password itself. A missing file is reported as "not logged
in"; a file that exists but is corrupt is reported as such, naming the bad in"; a file that exists but is corrupt is reported as such, naming the bad
field. Both exit with status 1, except that `quak logout` with no file says field. Both exit with status 1.
there is no session and exits 0.
`quak logout` ends the session on the server, so the token in `session.json` `quak logout` ends the session on the server, so the token in `session.json`
stops working even in a copy of the file, and then deletes the file. If the stops working even in a copy of the file, and then deletes the file. If the
server call fails (or the file is corrupt), the file is still deleted, the server call fails (or the file is corrupt), the file is still deleted, the
command says the server session could not be ended, and it exits with status 1. command says the server session could not be ended, and it exits with status 1.
It does not delete the cache. When it knows the cache directory, from It does not delete the cache: it prints the account's cache directory and says
`--cache-dir` or from a session file it could read, it prints it and says it it still holds decrypted data (file keys in `metadata.json`, cached originals
still holds decrypted data (file keys in `metadata.json`, cached originals and and thumbnails), for the user to delete if they want it gone.
thumbnails), for the user to delete if they want it gone.
### CLI surface ### CLI surface
@@ -509,19 +455,19 @@ quak files --collection <id> [--json] list files in a collec
quak get <fileID> [--out path] [--collection] download and decrypt a file quak get <fileID> [--out path] [--collection] download and decrypt a file
quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail
quak backup <dir> [--json] full incremental backup quak backup <dir> [--json] full incremental backup
quak backup-metadata <dir> [--exif] dump the metadata quak keeps as JSON quak backup-metadata <dir> [--exif] dump all decrypted metadata as JSON
quak helper list-missing-thumbnails [--json] find files with missing thumbnails quak helper list-missing-thumbnails [--json] find files with missing thumbnails
quak helper fix-missing-thumbnails [--file ids] [--json] generate + upload missing thumbnails quak helper fix-missing-thumbnails [--file ids] generate + upload missing thumbnails
``` ```
Every command except `login`, `whoami` and `logout` runs on the cache-backed Every command runs on the same cache-backed library. The read commands —
library. The read commands — `collections`, `files`, `get`, `get-thumb`, `collections`, `files`, `get`, `get-thumb`, `backup-metadata`,
`backup-metadata`, `helper list-missing-thumbnails` and `helper list-missing-thumbnails` and `helper fix-missing-thumbnails` — force a
`helper fix-missing-thumbnails` — force a fresh server round-trip before they fresh server round-trip before they answer, so they report current account state
answer, so they report current account state rather than whatever the cache last rather than whatever the cache last held. If that round-trip fails, the command
held. If that round-trip fails, the command prints the error on one line and prints the error on one line and exits 1. `--cache-dir` overrides where the
exits 1. `--cache-dir` overrides where the cache lives; without it each account cache lives; without it each account gets its own directory under the per-user
gets its own directory under the per-user cache path. cache path.
`get` and `get-thumb` resolve the file by ID directly, so `--collection` is `get` and `get-thumb` resolve the file by ID directly, so `--collection` is
accepted for backward compatibility but ignored. For a live photo, `get` writes accepted for backward compatibility but ignored. For a live photo, `get` writes
@@ -529,25 +475,18 @@ its image and its video, each named after the title with its own extension, as
Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With Ente's clients name them (`IMG_0001.heic` and `IMG_0001.mov`). With
`--out PATH`, the image is written to `PATH` and the video beside it, with `--out PATH`, the image is written to `PATH` and the video beside it, with
`PATH`'s name and the video's extension; a `PATH` with the video's extension is `PATH`'s name and the video's extension; a `PATH` with the video's extension is
refused. `backup-metadata --exif` (alias `--all`) additionally fetches each refused. `backup-metadata --exif` (alias `--all`) additionally downloads each
file's original through the cache and records, from it or a live photo's image, file to extract full EXIF/IPTC/XMP metadata. The listing and backup commands
its XMP metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is support `--json` for machine-readable output.
read with [exifreader](https://github.com/mattiasw/ExifReader) from any image
format it reads, JPEG, HEIC/HEIF, AVIF, PNG and WebP among them. The record's
`exif` field is exifreader's EXIF tag output: each tag by name, with its
`value`, `description` and `computed` value. An EXIF block exifreader finds but
reads no tag from is recorded, base64, as `exifRaw`, with the reason in
`exifError`. `collections`, `files`, `backup`, `helper list-missing-thumbnails`
and `helper fix-missing-thumbnails` take `--json` for machine-readable output.
`backup-metadata` fetches ML data in requests of up to 200 files. When a request `backup-metadata` fetches ML data in requests of up to 200 files. When a request
fails, the error is logged, each of its files is written with the reason in an still fails after its retries, the error is logged, each of its files is written
`mlDataError` field instead of `mlData`, and the dump goes on. The exit code is with the reason in an `mlDataError` field instead of `mlData`, and the dump goes
non-zero if any ML data request failed. on. The exit code is non-zero if any ML data request failed.
`helper fix-missing-thumbnails` regenerates thumbnails for JPEG images only, `helper fix-missing-thumbnails` regenerates thumbnails for baseline JPEG images
because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG image only, because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG
(PNG, HEIC) or a video is reported as `skipped` (unsupported format), kept image (PNG, HEIC) or a video is reported as `skipped` (unsupported format), kept
distinct from a `failed` repair, and does not affect the exit code; a genuine distinct from a `failed` repair, and does not affect the exit code; a genuine
failure still exits non-zero. The server accepts a new thumbnail only from the failure still exits non-zero. The server accepts a new thumbnail only from the
file's owner and only when it is no larger than the thumbnail size it records file's owner and only when it is no larger than the thumbnail size it records
@@ -563,36 +502,23 @@ the smallest does not.
``` ```
<dir>/ <dir>/
YYYY/YYYY-MM/YYYY-MM-DD/ originals/
YYYY-MM-DD.<fileID>.<ext> actual file content, at its save path (one <fileID>.<ext> actual file content (one per unique file,
per unique file, two for a live photo: see two for a live photo: see below)
below) <fileID>.json all decrypted metadata for that file
YYYY-MM-DD.<fileID>.json the file's basic metadata fields quak <fileID>.livephoto.json which of a live photo's two files is which
keeps, and its private and public magic
metadata
YYYY-MM-DD.<fileID>.livephoto.json
which of a live photo's two files is which
collections/ collections/
<name>/ <name>/
<title> -> ../../YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext> <title> -> ../../originals/<fileID>.<ext> (symlink)
(symlink)
<name>.json collection metadata + file list <name>.json collection metadata + file list
failures.json files that failed and have not yet succeeded failures.json files that failed and have not yet succeeded
``` ```
Each original is saved at its save path, the same path `photo.savePath` gives
and `photo.download()` writes (see Read surface below). The date is the photo's
`takenAt` (the date set in Ente if it was edited, else its creation time) in the
time zone of the machine running quak. The extension is the one in the file's
name as uploaded, case kept, or `.bin` when it has none or it holds anything but
letters and digits. When the date or the time zone changes, the next run saves
the original at its new path and leaves the old copy where it is.
`failures.json` records each failed file with the kind of failure, how many `failures.json` records each failed file with the kind of failure, how many
times it has been tried and when it was last tried. A file leaves it once it times it has been tried and when it was last tried. A file leaves it once it
succeeds, or once it is no longer in the library or in the backup's scope. The succeeds, or once it is no longer in the library or in the backup's scope. The
library's `lib.backup({ includeThumbnails: true })` also writes library's `lib.backup({ includeThumbnails: true })` also writes
`thumbnails/<fileID>.jpg` beside `collections/`; `quak backup` does not. `thumbnails/<fileID>.jpg` beside `originals/`; `quak backup` does not.
A collection's directory and JSON are named after the collection, and a symlink A collection's directory and JSON are named after the collection, and a symlink
after the file's title, both with unsafe characters replaced. When two after the file's title, both with unsafe characters replaced. When two
@@ -604,47 +530,45 @@ name stays the same from run to run until such a clash appears or goes away.
A live photo, which Ente stores as one ZIP of its image and its video, is stored A live photo, which Ente stores as one ZIP of its image and its video, is stored
as those two files, which a photo viewer can open: each is as those two files, which a photo viewer can open: each is
`YYYY-MM-DD.<fileID>.<ext>` with the extension it has inside the ZIP (for `originals/<fileID>.<ext>` with the extension it has inside the ZIP (for example
example `2026-03-01.12345.heic` and `2026-03-01.12345.mov`), and `12345.heic` and `12345.mov`), and `<fileID>.livephoto.json` names the two. The
`YYYY-MM-DD.<fileID>.livephoto.json` names the two. The live photo counts as live photo counts as stored only when both files are present and not empty. Its
stored only when both files are present and not empty. Its album folder links album folder links both, each named after the title with that file's extension
both, each named after the title with that file's extension (`IMG_0001.heic` and (`IMG_0001.heic` and `IMG_0001.mov`). A live photo that an earlier version of
`IMG_0001.mov`). quak stored as the ZIP, under the image's name, is replaced by its two files on
the next run, and the ZIP and its link are removed.
Each run removes the symlinks into the date folders that no longer belong in Each run removes the symlinks into `originals/` that no longer belong in their
their collection's directory, and the directories (and JSON) of collections that collection's directory, and the directories (and JSON) of collections that were
were deleted or renamed. Nothing else in `collections/` is touched: a file or a deleted or renamed. Nothing else in `collections/` is touched: a file or a
symlink you put there stays, and a directory that still holds one after its symlink you put there stays, and a directory that still holds one after its
symlinks are removed stays too, with its JSON. symlinks are removed stays too, with its JSON.
Each file is downloaded exactly once regardless of how many collections it Each file is downloaded exactly once regardless of how many collections it
appears in, and written once: straight to its save path, with no copy left in appears in, and written once: straight into `originals/`, with no copy left in
the cache. An original the cache already held is copied from there instead. On the cache. An original the cache already held is copied from there instead. On
subsequent runs, existing originals are skipped. If a download fails, the error subsequent runs, existing originals are skipped. If a download fails, the error
is logged and the backup continues with the next file. The exit code is non-zero is logged and the backup continues with the next file. The exit code is non-zero
if any files failed. `quak backup` opens its library with the thumbnail and if any files failed. `quak backup` opens its library with the thumbnail and
originals precache off, so the only file content it fetches is the originals the originals precache off, so it fetches only what the backup stores.
backup stores. The library's ML data fetch still runs and fills the cache's
`mldata/`.
Each original is written to a temporary file in the same directory, synced to Each original is written to a temporary file in the same directory, synced to
disk, and renamed into place, so an original is either complete or absent, even disk, and renamed into place, so an original is either complete or absent, even
after a power cut. A downloaded original's temporary file is named after a power cut. A downloaded original's temporary file is named
`.quak-<pid>-<random>.tmp`, one copied from the cache `.quak-<pid>-<random>.tmp`, one copied from the cache
`.quak-backup-YYYY-MM-DD.<fileID>.<ext>-<pid>-<random>.tmp`. A run that is `.quak-backup-<fileID>.<ext>-<pid>-<random>.tmp`. A run that is killed can leave
killed can leave one of these temporary files behind; the next backup deletes one of these temporary files behind; the next backup deletes those whose process
those whose process is no longer running. The content cache uses the same is no longer running. The content cache uses the same scheme, and opening a
scheme, and opening a library deletes the temporary files in the cache whose library deletes the temporary files in the cache whose process is no longer
process is no longer running, so a download another process has in progress in running, so a download another process has in progress in the same cache is left
the same cache is left alone. The rename replaces whatever was at the alone. The rename replaces whatever was at the destination rather than writing
destination rather than writing through it: a symlink there is replaced, not through it: a symlink there is replaced, not followed, and the new file has the
followed, and the new file has the temporary file's permissions, not those of temporary file's permissions, not those of the file it replaced.
the file it replaced.
## TODO ## TODO
- [x] Retry policy: no retry on 4xx (except `408` and `429`), exponential - [x] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
backoff on 5xx and network errors errors
- [x] Update the API reference section below to match the current implementation - [x] Update the API reference section below to match the current implementation
- [x] `make docker` green - [x] `make docker` green
- [x] Store live photos in a form a photo viewer can open - [x] Store live photos in a form a photo viewer can open
@@ -667,8 +591,7 @@ Future (desktop client, separate repo):
The library's primary surface is the cache-backed `Library`; the lower-level The library's primary surface is the cache-backed `Library`; the lower-level
`Client` sits underneath it and is covered by the Design sections above. The `Client` sits underneath it and is covered by the Design sections above. The
test suite is the canonical, executable documentation — `test/library/` and test suite is the canonical, executable documentation — `test/library/` and
`test/client/usage.test.ts` walk most operations, `test/cli/backup.test.ts` `test/client/usage.test.ts` walk every operation, and `yarn test` verifies them.
walks `lib.backup()`, and `yarn test` verifies them.
### Opening a library ### Opening a library
@@ -682,24 +605,21 @@ background, so an unreachable server does not block opening.
`LibraryOptions`: `LibraryOptions`:
| Option | Default | Meaning | | Option | Default | Meaning |
| ------------------------ | -------------------------------------------------- | -------------------------------------------------------------------- | | ------------------------ | --------------------------- | --------------------------------------------------------------------- |
| `client` | required | the account client (a `Client`, or any `LibraryClient`) | | `client` | required | the account client (a `Client`, or any `LibraryClient`) |
| `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live | | `cacheDirectory` | `<XDG cache>/quak/<userID>` | where `metadata.json` and the content cache live |
| `downloadDirectory` | `photos` in the working directory | root of the save paths; an original stored there counts as cached | | `downloadDirectory` | none | backup destination; an original already stored there counts as cached |
| `refreshIntervalSeconds` | `3` | background refresh cadence | | `refreshIntervalSeconds` | `3` | background refresh cadence |
| `precacheThumbnails` | `true` | prefetch every thumbnail, newest first | | `precacheThumbnails` | `true` | prefetch every thumbnail, newest first |
| `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals | | `precacheOriginals` | `true` | prefetch the favorites album and the latest-window originals |
| `precacheOriginalsDays` | `7` | length in days of that latest window | | `precacheOriginalsDays` | `7` | length in days of that latest window |
| `cacheOriginalsMaxBytes` | 100 GiB | size limit on the originals cache; pinned originals can exceed it | | `cacheOriginalsMaxBytes` | 100 GiB | hard ceiling on the originals cache |
| `freeBelowBytes` | 50 GiB | free space to protect on the volume; the effective limit adapts down | | `freeBelowBytes` | 50 GiB | free space to protect on the volume; the effective limit adapts down |
| `isOriginalPinned` | none | extra predicate for originals that must never be evicted | | `isOriginalPinned` | none | extra predicate for originals that must never be evicted |
| `pools` | fresh `RequestPools` | the bounded request pools (sets concurrency) | | `pools` | fresh `RequestPools` | the bounded request pools (sets concurrency) |
| `onProgress` | none | refresh/ML/precache progress callback (`RefreshEvent`) | | `onProgress` | none | refresh/ML/precache progress callback (`RefreshEvent`) |
| `contentSource` | the client's own | override the byte source (mainly for tests) | | `contentSource` | the client's own | override the byte source (mainly for tests) |
The default `downloadDirectory` is resolved against the working directory once,
when the library opens; `lib.downloadDirectory` holds the result.
Concurrency is set through `pools`: construct Concurrency is set through `pools`: construct
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })` `new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
and pass it. The three pools default to 10 / 5 / 25 (see Request pools below). and pass it. The three pools default to 10 / 5 / 25 (see Request pools below).
@@ -715,21 +635,17 @@ can then be removed.
### Default reads vs. fresh reads ### Default reads vs. fresh reads
Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer Default reads — `lib.albums`, `lib.photos`, `lib.timeline` — answer
synchronously from the copy held in RAM and never touch the network. A refresh synchronously from the last refreshed copy held in RAM and never touch the
changes that copy only once all its server requests have succeeded, and the network. The background timer refreshes that copy every
background timer starts the next refresh `refreshIntervalSeconds` after the `refreshIntervalSeconds`, so a default read is immediate but may be up to one
previous one ends. So a default read is immediate, but only as current as the interval stale.
last refresh whose requests all succeeded; right after opening an existing
cache, it is the copy on disk.
`await lib.fresh()` waits for a refresh to complete and persist, and returns the `await lib.fresh()` forces a refresh, waits for it to complete and persist, and
same `{ albums, photos, timeline }` namespaces, which then reflect a completed returns the same `{ albums, photos, timeline }` namespaces — now guaranteed to
server round-trip. When a refresh is already running, background or not, reflect a completed server round-trip. Concurrent `fresh()` calls coalesce onto
`fresh()` waits for that one, so its answer can come from requests made before one refresh, and a refresh that fails rejects the caller (default reads stay
the call; only when none is running does it start one. A refresh that fails silent and keep serving the last good copy). The CLI's read commands use fresh
rejects the caller (default reads stay silent and keep serving the last good reads (issue https://git.eeqj.de/sneak/quak/issues/75).
copy). The CLI's read commands use fresh reads (issue
https://git.eeqj.de/sneak/quak/issues/75).
### Read surface ### Read surface
@@ -746,61 +662,17 @@ https://git.eeqj.de/sneak/quak/issues/75).
`includeArchived`; hidden photos are always excluded. `includeArchived`; hidden photos are always excluded.
An `Album` exposes its record fields and `album.photos.list()` → `Photo[]` An `Album` exposes its record fields and `album.photos.list()` → `Photo[]`
(newest first). A `Photo` exposes its record fields other than `thumbnailPath` (newest first). A `Photo` exposes its record fields, `photo.record()` →
and `originalPath`, and `photo.year`, the local-time year of `takenAt`, all as `PhotoRecord`, and two content methods:
synchronous getters read from RAM; `photo.record()` → `PhotoRecord`. Two more
synchronous getters look at the disk and never touch the network:
- `photo.savePath` → `string` — where `photo.download()` and `lib.backup()` put
the original, whether or not it is there yet:
`YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>` under the library's
`downloadDirectory` (see Backup layout above for the date and the extension).
For a live photo already stored, its image. For a live photo not yet stored,
it carries the title's extension, and the image may be stored under a
different one, found inside the live photo. It needs no content source.
- `photo.isLocal` → `boolean` — whether the whole original is at `savePath`. A
copy only in the cache does not count.
These async methods may download:
- `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the - `await photo.original(opts?)` → `{ path, bytes, videoPath? }` — the
full-resolution file. For a live photo, `path` and `bytes` are its image's and full-resolution file. For a live photo, `path` and `bytes` are its image's and
`videoPath` is its video. `videoPath` is its video.
- `await photo.download()` → `{ path, bytes, videoPath? }`, as `original()` —
puts the original at `savePath`, creating its folders. When it is already
there, nothing is written. When the cache holds it, it is copied from the
cache; otherwise it is fetched straight to `savePath`, with no copy left in
the cache. Afterwards `isLocal` is true.
- `await photo.thumbnail(opts?)` → `{ path, bytes }`. - `await photo.thumbnail(opts?)` → `{ path, bytes }`.
- `await photo.content(opts?)` → `Uint8Array` — the original's bytes, read
through `original()`; for a live photo, its image's.
- `await photo.exif(opts?)` → `ExifTags` — every EXIF tag in the file, keyed by
tag name, each as exifreader decodes it, with its `id`, `value`, `description`
and `computed` value: for example `Make` is
`{ id: 271, value: ["Canon"], description: "Canon", computed: "Canon" }`. A
tag exifreader has no name for is keyed `undefined-<tag number>`. The embedded
thumbnail's tags are under `Thumbnail`, so they cannot hide the main image's
tags of the same name; the thumbnail image itself is left out. EXIF is read
from any image format exifreader reads (such as JPEG, HEIC/HEIF, AVIF, PNG,
WebP and TIFF), a live photo's image included. Any other original gives `{}`,
and a video gives `{}` without being downloaded.
- `await photo.make(opts?)`, and likewise `model()`, `lensModel()`,
`dateTimeOriginal()`, `offsetTimeOriginal()`, `exposureTime()`, `fNumber()`,
`iso()`, `focalLength()`, `orientation()`, `gpsLatitude()`, `gpsLongitude()`
and `gpsAltitude()` → one common field each, picked from the tags `exif()`
returns and typed as in `PhotoExif`, or `undefined` when the file lacks it.
GPS values are signed decimal degrees and metres. `dateTimeOriginal()` is the
camera's clock reading held in the `Date`'s UTC fields;
`offsetTimeOriginal()`, when present, is that clock's offset from UTC. Each
calls `exif()` with its `opts`, so each call reads the original again.
They serve from the on-disk content cache when the bytes are present and Both serve from the on-disk content cache when the bytes are present and
otherwise fetch through the pools; `original()`, `content()` and `exif()` also otherwise fetch through the pools; `opts.onProgress` reports per-file progress.
serve an original already stored at its save path. `opts.onProgress` reports They throw when the library was opened without a content source.
per-file progress. They throw when the library was opened without a content
source. An original that `original()`, `content()` or `exif()` downloads lands
in the cache, which does not make `isLocal` true; only `download()` and
`lib.backup()` do.
Lower-level accessors that return decrypted model objects (which hold key Lower-level accessors that return decrypted model objects (which hold key
material) are also available: `listCollections()`, `getCollection(id)`, material) are also available: `listCollections()`, `getCollection(id)`,
@@ -812,12 +684,10 @@ material) are also available: `listCollections()`, `getCollection(id)`,
The GUI-facing records hold no key material and no binary, so they survive The GUI-facing records hold no key material and no binary, so they survive
`structuredClone`/JSON across the Electron IPC boundary: `structuredClone`/JSON across the Electron IPC boundary:
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` and `modifiedAt` - `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` (milliseconds),
(milliseconds), `fileType`, optional `caption` / `width` / `height` / `fileType`, optional `caption` / `width` / `height` / `latitude` /
`latitude` / `longitude`, optional `hash` (the content hash recorded at `longitude`, `isArchived`, `isHidden`, and `thumbnailPath` / `originalPath`
upload; very old files have none), `isArchived`, `isHidden`, and once the bytes are cached (for a live photo, `originalPath` is its image).
`thumbnailPath` / `originalPath` once the bytes are cached (for a live photo,
`originalPath` is its image).
- `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and - `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and
`fileIDs` (newest first). `fileIDs` (newest first).
- `LibrarySnapshot`: `{ albums, photos, takenAt }`. - `LibrarySnapshot`: `{ albums, photos, takenAt }`.
@@ -842,16 +712,16 @@ photos newest first). `lib.subscribe({ onChange })` delivers a `LibraryChange`
`SimilarResult[]` (`{ fileID, score }`, cosine similarity, most similar first, `SimilarResult[]` (`{ fileID, score }`, cosine similarity, most similar first,
default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes default limit 20). quak bundles no text encoder, so `searchByEmbedding` takes
a query vector the caller produced elsewhere. a query vector the caller produced elsewhere.
- `await lib.backup(opts?)` → `BackupResult`. It waits for a refresh as - `await lib.backup(opts?)` → `BackupResult`. It refreshes, fetches every
`fresh()` does, puts every in-scope original not already at its save path in-scope original not already in the backup (and, with `includeThumbnails`,
there as `photo.download()` does (and, with `includeThumbnails`, fetches
thumbnails) through the content cache, and rebuilds the on-disk backup tree thumbnails) through the content cache, and rebuilds the on-disk backup tree
with a durable failure ledger. A fetched original is written straight to its with a durable failure ledger. A fetched original is written straight into the
save path and not into the cache, which then counts it as present; one the backup's `originals/` and not into the cache, which then counts it as present;
cache already held is copied from there. `BackupOptions`: `downloadDirectory` one the cache already held is copied from there. `BackupOptions`:
(falls back to the library's), `includeOriginals` (default `true`), `downloadDirectory` (falls back to the one `open()` was given),
`includeThumbnails` (default `false`), `onlyAlbumNames`, and `onProgress`. See `includeOriginals` (default `true`), `includeThumbnails` (default `false`),
Backup layout above for the tree it writes. `onlyAlbumNames`, and `onProgress`. See Backup layout above for the tree it
writes.
### Request pools ### Request pools
@@ -882,9 +752,12 @@ When `metadata.json` belongs to a different account than the client's,
originals and thumbnails are kept; they are reached only through the files the originals and thumbnails are kept; they are reached only through the files the
current account's records name. current account's records name.
A live photo's original is cached as at its save path: its image and its video, A live photo's original is cached as in the backup: its image and its video,
each `originals/<fileID>.<ext>` with its own extension, and each `originals/<fileID>.<ext>` with its own extension, and
`originals/<fileID>.livephoto.json` naming them; the two are evicted together. `originals/<fileID>.livephoto.json` naming them; the two are evicted together. A
live photo that an earlier version cached as its ZIP is not served: the library
removes the ZIP when it opens the cache, and fetches the two files when the
photo is next read or precached.
A stored file appears only via an atomic temp-then-rename, so its presence means A stored file appears only via an atomic temp-then-rename, so its presence means
it is complete. Every downloaded original (by `quak get`, the cache, or it is complete. Every downloaded original (by `quak get`, the cache, or
@@ -903,13 +776,12 @@ from a very old client, is stored unchecked.
- `src/library/index.ts`: `Library`, `LibraryOptions`, `LibraryStatus`, - `src/library/index.ts`: `Library`, `LibraryOptions`, `LibraryStatus`,
`LibraryClient`, `RefreshEvent` `LibraryClient`, `RefreshEvent`
- `src/library/read.ts`: `Album`, `Photo`, `AlbumsAPI`, `PhotosAPI`, - `src/library/read.ts`: `Album`, `Photo`, `AlbumsAPI`, `PhotosAPI`,
`TimelineAPI`, `PhotoFilter`, `TimelineGroup`, `GroupBy`, `SavePathLookup` `TimelineAPI`, `PhotoFilter`, `TimelineGroup`, `GroupBy`
- `src/library/content.ts`: `ContentResult`, `ContentOptions`, `ThumbnailsAPI`, - `src/library/content.ts`: `ContentResult`, `ContentOptions`, `ThumbnailsAPI`,
`EnsureOptions`, `EnsureResult`, `ContentSource` `EnsureOptions`, `EnsureResult`, `ContentSource`
- `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`, - `src/library/records.ts`: `PhotoRecord`, `AlbumRecord`, `LibrarySnapshot`,
`LibraryChange` `LibraryChange`
- `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult` - `src/library/mlsearch.ts`: `MLDataAPI`, `SimilarResult`
- `src/exif.ts`: `ExifTags`, `PhotoExif`
- `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool` - `src/library/pools.ts`: `RequestPools`, `RequestPoolsOptions`, `BoundedPool`
- `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError` - `src/backup.ts`: `BackupOptions`, `BackupResult`, `BackupError`
- `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot` - `src/client.ts`: `Client`, `LoginOptions`, `ClientSnapshot`
@@ -941,15 +813,14 @@ documents:
`yarn.lock`. Never `git add -A`. Never force-push to main. `yarn.lock`. Never `git add -A`. Never force-push to main.
- **The "Development workflow" section above.** All changes go on feature - **The "Development workflow" section above.** All changes go on feature
branches off `next`, and every pull request targets `next`; only sneak merges branches. Tests are written first and committed in a failing state before the
`next` into `main`. Tests are written first and committed in a failing state implementation. Tests are the canonical API documentation and must be
before the implementation. Tests are the canonical API documentation and must commented thoroughly. `main` is always green.
be commented thoroughly. `main` and `next` are always green.
- **Required checks before every commit:** `make lint` must pass — that is - **Required checks before every commit:** `make lint` must pass — that is
eslint plus the prettier check, and it builds the `lint` phase of the eslint plus the prettier check, and it builds the `lint` phase of the
`Dockerfile`, so it needs docker. The pre-commit hook enforces exactly that. `Dockerfile`, so it needs docker. The pre-commit hook enforces exactly that.
`make check` (which also runs the tests) must pass before merging into `next`. `make check` (which also runs the tests) must pass before merging to `main`.
`make fmt-check` is available for a host-side formatting check on its own, but `make fmt-check` is available for a host-side formatting check on its own, but
it is not a separate requirement: `make lint` already covers it, and running it is not a separate requirement: `make lint` already covers it, and running
both would check formatting twice. Never invoke eslint or prettier directly; both would check formatting twice. Never invoke eslint or prettier directly;
+3 -102
View File
@@ -1,15 +1,12 @@
# Workflow # Workflow
- branch from `next` - branch (from `main`)
- do the work in Next Step - do the work in Next Step
- move Next Step to the top of Completed Steps - 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)
- merge to `main` if the branch is not protected, otherwise open a PR
- push - push
- open a pull request that targets `next`
- once the pull request has passed review, the repository manager squash-merges
it into `next`
- only sneak merges `next` into `main`
# Status # Status
@@ -17,109 +14,13 @@ pre-1.0
# Next Step # Next Step
None: no implementation work is open. The cache design, None: every issue still open is done on `next` and waits for it to reach `main`.
https://git.eeqj.de/sneak/quak/issues/36, waits on sneak's review.
Tagging and releases are decided by sneak alone, and happen only when he Tagging and releases are decided by sneak alone, and happen only when he
declares one. declares one.
# Completed Steps # Completed Steps
- 2026-10-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 - 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 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` renamed into place, and the directory after both renames, as the `writeAtomic`
-129
View File
@@ -1,129 +0,0 @@
// Download every album's photos to a directory, with each photo's metadata
// beside it, using only quak's public API. The README's "Examples" section
// describes the files it writes.
//
// yarn build
// QUAK_EMAIL=… QUAK_PASSWORD=… node dist/examples/download-albums.js [dir]
import { realpathSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { stdin, stdout } from "node:process";
import { createInterface } from "node:readline/promises";
import { pathToFileURL } from "node:url";
import { Client, Library } from "../src/index.js";
// Write `text` to `path` unless the file already holds exactly that, so a
// second run rewrites nothing.
async function writeIfChanged(path: string, text: string): Promise<void> {
const current = await readFile(path, "utf-8").catch(() => undefined);
if (current !== text) await writeFile(path, text);
}
const pretty = (value: unknown): string =>
JSON.stringify(value, null, 2) + "\n";
// For every album in `lib`, download each photo to its save path, write the
// photo's metadata to `{savePath}.json`, and write the album's photos to
// `{dir}/albums/{collectionID}.json`. Returns how many photos it downloaded
// and how many were already at their save paths.
export async function downloadAlbums(
lib: Library,
dir: string,
): Promise<{ downloaded: number; alreadyLocal: number }> {
let downloaded = 0;
let alreadyLocal = 0;
// A photo in several albums is handled once.
const done = new Set<number>();
// fresh() waits for a refresh from the server and throws if it fails, so
// albums and photos added since the cache was last written are included.
const { albums } = await lib.fresh();
for (const album of albums.list()) {
const savePaths: string[] = [];
for (const photo of album.photos.list()) {
if (!done.has(photo.fileID)) {
done.add(photo.fileID);
if (photo.isLocal) alreadyLocal++;
else downloaded++;
await photo.download();
// The cache paths say where quak's cache keeps copies, not
// anything about the photo.
const record = { ...photo.record() };
delete record.thumbnailPath;
delete record.originalPath;
const exif = await photo.exif();
// savePath is read after download(): a live photo's names its
// image only once the image is stored.
await writeIfChanged(
`${photo.savePath}.json`,
pretty({ ...record, exif }),
);
}
savePaths.push(relative(dir, photo.savePath));
}
await mkdir(join(dir, "albums"), { recursive: true });
await writeIfChanged(
join(dir, "albums", `${album.collectionID}.json`),
pretty({
collectionID: album.collectionID,
name: album.name,
savePaths,
}),
);
}
return { downloaded, alreadyLocal };
}
// Ask for a login code on the terminal. Client.login calls this only when the
// account requires a code.
async function ask(question: string): Promise<string> {
const terminal = createInterface({ input: stdin, output: stdout });
try {
return await terminal.question(question);
} finally {
terminal.close();
}
}
async function main(): Promise<void> {
const email = process.env.QUAK_EMAIL;
const password = process.env.QUAK_PASSWORD;
if (!email || !password) {
console.error(
"Set QUAK_EMAIL and QUAK_PASSWORD to the account's email and password.",
);
process.exit(1);
}
const dir = process.argv[2] ?? "photos";
const client = await Client.login({
email,
password,
totp: () => ask("Two-factor code: "),
emailOTP: () => ask("Code sent to your email: "),
});
const lib = await Library.open({
client,
downloadDirectory: dir,
// As in `quak backup`: fetch only the originals this script saves,
// not every thumbnail and the recent originals into the cache too.
precacheThumbnails: false,
precacheOriginals: false,
});
try {
const { downloaded, alreadyLocal } = await downloadAlbums(lib, dir);
console.log(
`${downloaded} photos downloaded, ${alreadyLocal} already local, in ${dir}`,
);
} finally {
await lib.close();
}
}
// Run main() when node runs this file, not when a test imports it. argv[1] is
// the path as given, and import.meta.url has symlinks resolved.
const script = process.argv[1];
if (script && pathToFileURL(realpathSync(script)).href === import.meta.url) {
await main();
}
+1 -1
View File
@@ -46,7 +46,7 @@
"@inquirer/prompts": "8.5.2", "@inquirer/prompts": "8.5.2",
"commander": "14.0.3", "commander": "14.0.3",
"env-paths": "4.0.0", "env-paths": "4.0.0",
"exifreader": "4.46.0", "exif-reader": "2.0.3",
"fast-srp-hap": "2.0.4", "fast-srp-hap": "2.0.4",
"fflate": "0.8.3", "fflate": "0.8.3",
"jpeg-js": "0.4.4", "jpeg-js": "0.4.4",
+120 -110
View File
@@ -2,30 +2,29 @@
// //
// `lib.backup()` waits for a completed refresh of the library (a failed one // `lib.backup()` waits for a completed refresh of the library (a failed one
// fails the backup before any file is touched), then, for every file in scope, // fails the backup before any file is touched), then, for every file in scope,
// puts its original at its save path under `downloadDirectory`, as // gets its original bytes onto disk under `downloadDirectory` and rebuilds the
// `Photo.download()` does, and rebuilds the derived views (per-file sidecars, // derived views (per-file sidecars, per-collection symlink trees,
// per-collection symlink trees, per-collection JSON) from the model. The // per-collection JSON) from the model. The on-disk layout is the historical
// on-disk layout: // one:
// //
// <downloadDirectory>/ // <downloadDirectory>/
// YYYY/YYYY-MM/YYYY-MM-DD/ // originals/<fileID>.<ext> the decrypted bytes
// YYYY-MM-DD.<fileID>.<ext> the decrypted bytes (the save path) // originals/<fileID>.json per-file metadata sidecar
// YYYY-MM-DD.<fileID>.json per-file metadata sidecar // collections/<name>/<title> symlink into ../../originals
// collections/<name>/<title> symlink to the original
// collections/<name>.json per-collection metadata // collections/<name>.json per-collection metadata
// failures.json durable ledger of unresolved failures // failures.json durable ledger of unresolved failures
// //
// A live photo's original is its image and its video, each with its own // A live photo's original is its image and its video, `<fileID>.<ext>` each
// extension, beside `YYYY-MM-DD.<fileID>.livephoto.json` naming them; its album // with its own extension, and `originals/<fileID>.livephoto.json` naming them;
// folders link both. // its album folders link both.
// //
// Crash-safety rests on two properties. Bytes are present-means-complete: an // Crash-safety rests on two properties. Bytes are present-means-complete: an
// original appears at its save path only via the content layer's atomic // original appears under `originals/` only via the content layer's atomic
// temp-then-rename, so a file that exists is whole and is never re-fetched — an // temp-then-rename, so a file that exists is whole and is never re-fetched — an
// interrupted run resumes by looking at the save paths. The derived views hold // interrupted run resumes by listing the directory. The derived views hold no
// no unique state, so they are rebuilt every run; that repairs stale sidecars // unique state, so they are rebuilt every run; that repairs stale sidecars and
// and missing or broken symlinks left by an earlier crash. A rebuild also // missing or broken symlinks left by an earlier crash. A rebuild also removes
// removes the symlinks to originals that no longer belong to an album, and the // the symlinks into originals/ that no longer belong to an album, and the
// directories of albums that no longer exist. // directories of albums that no longer exist.
// //
// Resilience (issue #8): no per-file condition aborts the run. A failed // Resilience (issue #8): no per-file condition aborts the run. A failed
@@ -49,25 +48,24 @@ import {
symlinkSync, symlinkSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { dirname, extname, join, relative, resolve } from "node:path"; import { copyFile, rename, rm } from "node:fs/promises";
import { basename, dirname, extname, join, relative } from "node:path";
import { removeLeftoverTempFiles } from "./download/index.js"; import { fsyncPath, removeLeftoverTempFiles } from "./download/index.js";
import { sanitizeFileName, withExtension } from "./filename.js"; import { sanitizeFileName, withExtension } from "./filename.js";
import { import {
copyAtomic, nameInOriginals,
placeOriginal, storedOriginal,
savePath, writeLivePhotoJSON,
storedAtSavePath,
} from "./library/content.js"; } from "./library/content.js";
import { representative } from "./library/records.js";
import type { Collection, EnteFile } from "./model/types.js"; import type { Collection, EnteFile } from "./model/types.js";
export type ProgressCallback = (message: string) => void; export type ProgressCallback = (message: string) => void;
export interface BackupOptions { export interface BackupOptions {
// Where the backup tree lives. `lib.backup()` defaults it to the library's // Where the backup tree lives. Required: with none, `backup()` throws
// download directory; `runBackup` with none throws before any network // before any network traffic. A library opened with a `downloadDirectory`
// traffic. // supplies the default.
downloadDirectory?: string; downloadDirectory?: string;
// Fetch and store full-resolution originals. Default true. // Fetch and store full-resolution originals. Default true.
includeOriginals?: boolean; includeOriginals?: boolean;
@@ -91,7 +89,7 @@ export interface BackupResult {
totalFiles: number; totalFiles: number;
// Originals fetched (or copied from the cache) this run. // Originals fetched (or copied from the cache) this run.
downloaded: number; downloaded: number;
// Originals already at their save path and left untouched. // Originals already present and left untouched.
skipped: number; skipped: number;
// Files with an unresolved failure after this run (the ledger size); the // Files with an unresolved failure after this run (the ledger size); the
// CLI exits non-zero while this is above zero. A file can be both // CLI exits non-zero while this is above zero. A file can be both
@@ -109,8 +107,8 @@ export interface BackupLibrary {
listFiles(collectionID: number): EnteFile[]; listFiles(collectionID: number): EnteFile[];
// Get an original's bytes onto disk through the content cache/pools, // Get an original's bytes onto disk through the content cache/pools,
// returning where they landed: `destination` when they were fetched now, // returning where they landed: `destination` when they were fetched now,
// otherwise wherever they already were (the cache, or the library's save // otherwise wherever they already were (the cache, or a prior backup). A
// path). A live photo lands as its image and its video, fetched now beside // live photo lands as its image and its video, fetched now beside
// `destination`. // `destination`.
original( original(
fileID: number, fileID: number,
@@ -170,6 +168,58 @@ const classify = (err: unknown): FailureClass => {
const errorMessage = (err: unknown): string => const errorMessage = (err: unknown): string =>
err instanceof Error ? err.message : String(err); err instanceof Error ? err.message : String(err);
// Copy bytes into `dest` via a temp file in the same directory plus rename, so
// `dest` appears only once it is whole ("present means complete"). As in the
// download writer, the temp file is fsynced before the rename and the directory
// after it, so a power cut cannot leave a correctly named but short original.
// The temp name carries this process's ID so a later run can tell a leftover
// from a copy still in progress (see `removeLeftoverTempFiles`).
const copyAtomic = async (src: string, dest: string): Promise<void> => {
if (src === dest) return;
const tmp = join(
dirname(dest),
`.quak-backup-${basename(dest)}-${process.pid}-${Math.random()
.toString(36)
.slice(2)}.tmp`,
);
try {
await copyFile(src, tmp);
await fsyncPath(tmp);
// `rename` replaces the destination's directory entry: an existing
// symlink at `dest` is replaced, not followed, and the new file has
// the temp file's permissions (copied from `src`).
await rename(tmp, dest);
await fsyncPath(dirname(dest));
} finally {
await rm(tmp, { force: true });
}
};
// Put an original the library returned at `dest` in originals/, where a fresh
// fetch already wrote it. A live photo's image and video go beside `dest`: when
// they came from the cache they are copied, after removing whatever was at
// `dest` (an earlier version's ZIP of the two). Then the JSON file naming them
// is written, which is what makes the live photo count as stored.
const placeOriginal = async (
file: EnteFile,
dest: string,
got: { path: string; videoPath?: string },
): Promise<void> => {
if (got.videoPath === undefined) {
await copyAtomic(got.path, dest);
return;
}
const originalsDir = dirname(dest);
const path = join(originalsDir, basename(got.path));
const videoPath = join(originalsDir, basename(got.videoPath));
if (got.path !== path) {
await rm(dest, { force: true });
await copyAtomic(got.path, path);
await copyAtomic(got.videoPath, videoPath);
}
await writeLivePhotoJSON(originalsDir, file.id, { path, videoPath });
};
// Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or // Ensure `linkPath` is a symlink to `target`, rebuilding a missing, wrong, or
// non-symlink entry. Throws on failure (a directory in the way, no permission) // non-symlink entry. Throws on failure (a directory in the way, no permission)
// so the caller records it and moves on rather than aborting the run. // so the caller records it and moves on rather than aborting the run.
@@ -241,55 +291,36 @@ const linksFor = (
})); }));
}; };
// Every date folder (`YYYY/YYYY-MM/YYYY-MM-DD/`) under `root`, whether or not a // Remove the symlinks in the album directory `dir` that point into
// file in this backup is saved there. A folder that cannot be read is skipped. // `originalsDir` and are not named in `keep`. Nothing else in the directory
const dateFolders = (root: string): string[] => { // is touched: anything else there was put there by the user.
const subfolders = (dir: string, name: RegExp): string[] => {
try {
return readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && name.test(e.name))
.map((e) => join(dir, e.name));
} catch {
return [];
}
};
return subfolders(root, /^\d{4}$/)
.flatMap((year) => subfolders(year, /^\d{4}-\d\d$/))
.flatMap((month) => subfolders(month, /^\d{4}-\d\d-\d\d$/));
};
// Whether the entry at `path` is a symlink a backup to `root` made: one to an
// original in a `YYYY/YYYY-MM/YYYY-MM-DD/` folder of `root`.
const linksToOriginal = (path: string, root: string): boolean => {
if (!lstatSync(path).isSymbolicLink()) return false;
const target = relative(root, resolve(dirname(path), readlinkSync(path)));
return /^\d{4}\/\d{4}-\d\d\/\d{4}-\d\d-\d\d\/[^/]+$/.test(target);
};
// Remove the symlinks in the album directory `dir` that point to an original
// in `root` and are not named in `keep`. Nothing else in the directory is
// touched: anything else there was put there by the user.
const removeStaleLinks = ( const removeStaleLinks = (
dir: string, dir: string,
keep: Set<string>, keep: Set<string>,
root: string, originalsDir: string,
): void => { ): void => {
const target = relative(dir, originalsDir);
for (const name of readdirSync(dir)) { for (const name of readdirSync(dir)) {
if (keep.has(name)) continue; if (keep.has(name)) continue;
const path = join(dir, name); const path = join(dir, name);
if (linksToOriginal(path, root)) rmSync(path); if (
lstatSync(path).isSymbolicLink() &&
dirname(readlinkSync(path)) === target
) {
rmSync(path);
}
} }
}; };
// Remove the directories under `collectionsDir` that an earlier run wrote for // Remove the directories under `collectionsDir` that an earlier run wrote for
// an album that is gone or renamed: a directory not named in `current` with a // an album that is gone or renamed: a directory not named in `current` with a
// `<name>.json` beside it holding an album ID, which is what a run writes. Its // `<name>.json` beside it holding an album ID, which is what a run writes. Its
// symlinks to originals in `root` are removed; if that leaves it empty, it and // symlinks into originals/ are removed; if that leaves it empty, it and its
// its JSON are deleted, otherwise both stay for what the user put there. // JSON are deleted, otherwise both stay for what the user put there.
const removeStaleAlbumDirs = ( const removeStaleAlbumDirs = (
collectionsDir: string, collectionsDir: string,
current: Set<string>, current: Set<string>,
root: string, originalsDir: string,
): void => { ): void => {
for (const entry of readdirSync(collectionsDir, { withFileTypes: true })) { for (const entry of readdirSync(collectionsDir, { withFileTypes: true })) {
if (!entry.isDirectory() || current.has(entry.name)) continue; if (!entry.isDirectory() || current.has(entry.name)) continue;
@@ -303,7 +334,7 @@ const removeStaleAlbumDirs = (
continue; continue;
} }
const dir = join(collectionsDir, entry.name); const dir = join(collectionsDir, entry.name);
removeStaleLinks(dir, new Set(), root); removeStaleLinks(dir, new Set(), originalsDir);
if (readdirSync(dir).length > 0) continue; if (readdirSync(dir).length > 0) continue;
rmdirSync(dir); rmdirSync(dir);
rmSync(jsonPath); rmSync(jsonPath);
@@ -371,48 +402,34 @@ export const runBackup = async (
log("Refreshing library..."); log("Refreshing library...");
await lib.refresh(); await lib.refresh();
const originalsDir = join(downloadDirectory, "originals");
const collectionsDir = join(downloadDirectory, "collections"); const collectionsDir = join(downloadDirectory, "collections");
const thumbnailsDir = join(downloadDirectory, "thumbnails"); const thumbnailsDir = join(downloadDirectory, "thumbnails");
mkdirSync(originalsDir, { recursive: true });
mkdirSync(collectionsDir, { recursive: true }); mkdirSync(collectionsDir, { recursive: true });
if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true }); if (includeThumbnails) mkdirSync(thumbnailsDir, { recursive: true });
removeLeftoverTempFiles(originalsDir);
removeLeftoverTempFiles(thumbnailsDir); removeLeftoverTempFiles(thumbnailsDir);
for (const dir of dateFolders(downloadDirectory)) {
removeLeftoverTempFiles(dir);
}
const ledgerPath = join(downloadDirectory, "failures.json"); const ledgerPath = join(downloadDirectory, "failures.json");
const ledger = loadLedger(ledgerPath); const ledger = loadLedger(ledgerPath);
const now = Date.now(); const now = Date.now();
// Collections in scope, and the distinct files across them (a file shared // Collections in scope, and the distinct files across them (a file shared
// by two albums is one original). Each file is the membership // by two albums is one original).
// `representative` picks from all of its albums, in scope or not, so it is
// saved at the path `photo.savePath` names.
const allCollections = lib.listCollections(); const allCollections = lib.listCollections();
const collections = allCollections.filter((c) => const collections = allCollections.filter((c) =>
only ? only.has(c.name) : true, only ? only.has(c.name) : true,
); );
const collectionName = new Map<number, string>(); const collectionName = new Map<number, string>();
for (const c of allCollections) collectionName.set(c.id, c.name); for (const c of collections) collectionName.set(c.id, c.name);
const memberships = new Map<number, EnteFile[]>(); const distinct = new Map<number, EnteFile>();
const filesByCollection = new Map<number, EnteFile[]>(); const filesByCollection = new Map<number, EnteFile[]>();
for (const c of allCollections) { for (const c of collections) {
const files = lib.listFiles(c.id); const files = lib.listFiles(c.id);
filesByCollection.set(c.id, files); filesByCollection.set(c.id, files);
for (const f of files) { for (const f of files) if (!distinct.has(f.id)) distinct.set(f.id, f);
const arr = memberships.get(f.id);
if (arr) arr.push(f);
else memberships.set(f.id, [f]);
}
}
const distinct = new Map<number, EnteFile>();
for (const c of collections) {
for (const f of filesByCollection.get(c.id)!) {
if (!distinct.has(f.id)) {
distinct.set(f.id, representative(memberships.get(f.id)!));
}
}
} }
const errors: BackupError[] = []; const errors: BackupError[] = [];
@@ -448,22 +465,25 @@ export const runBackup = async (
failedThisRun.add(file.id); failedThisRun.add(file.id);
}; };
// Phase 1: get the bytes. Put each pending original at its save path // Phase 1: get the bytes. Fetch each pending original (and optional
// through the content cache/pools, as `Photo.download()` does, and fetch // thumbnail) through the content cache/pools and place it under the backup
// the optional thumbnails; a present file is left as is. // tree; a present file is left as is.
if (includeOriginals) { if (includeOriginals) {
for (const [fileID, file] of distinct) { for (const [fileID, file] of distinct) {
if (storedAtSavePath(downloadDirectory, file) !== undefined) { if (storedOriginal(originalsDir, file) !== undefined) {
skipped++; skipped++;
continue; continue;
} }
const dest = join(originalsDir, nameInOriginals(file));
try { try {
log(`Fetching original ${file.metadata.title} (${fileID})...`); log(`Fetching original ${file.metadata.title} (${fileID})...`);
// A fetched original is written straight to its save path (a // A fetched original is written straight to `dest` (a live
// live photo beside it); only one that was already cached // photo beside it); only one that was already cached elsewhere
// elsewhere is copied. // is copied.
await placeOriginal(downloadDirectory, file, (dest) => await placeOriginal(
lib.original(fileID, dest), file,
dest,
await lib.original(fileID, dest),
); );
downloaded++; downloaded++;
} catch (err) { } catch (err) {
@@ -499,10 +519,9 @@ export const runBackup = async (
// Phase 2: rebuild the derived views from the model. Sidecars first, for // Phase 2: rebuild the derived views from the model. Sidecars first, for
// every present original (this repairs stale ones). // every present original (this repairs stale ones).
if (includeOriginals) { if (includeOriginals) {
for (const file of distinct.values()) { for (const [fileID, file] of distinct) {
if (storedAtSavePath(downloadDirectory, file) !== undefined) { if (storedOriginal(originalsDir, file) !== undefined) {
const path = savePath(downloadDirectory, file); writeSidecar(join(originalsDir, `${fileID}.json`), file);
writeSidecar(withExtension(path, ".json"), file);
} }
} }
} }
@@ -524,11 +543,7 @@ export const runBackup = async (
allCollections.map((c, i) => [c.id, dirNames[i]!]), allCollections.map((c, i) => [c.id, dirNames[i]!]),
); );
try { try {
removeStaleAlbumDirs( removeStaleAlbumDirs(collectionsDir, new Set(dirNames), originalsDir);
collectionsDir,
new Set(dirNames),
downloadDirectory,
);
} catch (err) { } catch (err) {
log(`FAILED removing old album directories: ${errorMessage(err)}`); log(`FAILED removing old album directories: ${errorMessage(err)}`);
} }
@@ -538,18 +553,13 @@ export const runBackup = async (
const colDir = join(collectionsDir, colDirName); const colDir = join(collectionsDir, colDirName);
mkdirSync(colDir, { recursive: true }); mkdirSync(colDir, { recursive: true });
// Every album links the one original, saved from the file's entry in
// `distinct`.
const files = filesByCollection.get(c.id) ?? []; const files = filesByCollection.get(c.id) ?? [];
const links = files.flatMap((f) => const links = files.flatMap((f) =>
linksFor( linksFor(f, storedOriginal(originalsDir, f)),
f,
storedAtSavePath(downloadDirectory, distinct.get(f.id)!),
),
); );
const linkNames = uniqueNames(links, true); const linkNames = uniqueNames(links, true);
try { try {
removeStaleLinks(colDir, new Set(linkNames), downloadDirectory); removeStaleLinks(colDir, new Set(linkNames), originalsDir);
} catch (err) { } catch (err) {
log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`); log(`FAILED removing old links in ${c.name}: ${errorMessage(err)}`);
} }
+5 -3
View File
@@ -361,8 +361,8 @@ const openPart = async (
// written unpacked: each part is named `destination` with the extension // written unpacked: each part is named `destination` with the extension
// replaced by its own entry's, and the two must differ ignoring case. When the // replaced by its own entry's, and the two must differ ignoring case. When the
// file records a hash, `<imageHash>:<videoHash>` must match it, each over that // file records a hash, `<imageHash>:<videoHash>` must match it, each over that
// part's own bytes. Only then are the image, then the video, renamed into // part's own bytes. Only then is whatever was at `destination` removed and the
// place; on any failure neither is stored. // image, then the video, renamed into place; on any failure neither is stored.
// //
// The ZIP is chosen by its uploader and may expand enormously, so each part is // The ZIP is chosen by its uploader and may expand enormously, so each part is
// written as it decompresses and never held, and the ZIP is refused once the // written as it decompresses and never held, and the ZIP is refused once the
@@ -486,6 +486,7 @@ const decryptLivePhoto = async (
await part.handle.sync(); await part.handle.sync();
await part.handle.close(); await part.handle.close();
} }
await rm(destination, { force: true });
await rename(image.tmpPath, path); await rename(image.tmpPath, path);
try { try {
await rename(video.tmpPath, videoPath); await rename(video.tmpPath, videoPath);
@@ -569,7 +570,8 @@ const fetchAndDecrypt = async (
}, api.getRetryOptions()); }, api.getRetryOptions());
// Write `file`'s original to `outPath`. A live photo is written as its image // Write `file`'s original to `outPath`. A live photo is written as its image
// and its video beside `outPath` instead (see `decryptLivePhoto`). // and its video beside `outPath` instead, and whatever was at `outPath` is
// removed (see `decryptLivePhoto`).
export const downloadFile = async ( export const downloadFile = async (
api: ApiClient, api: ApiClient,
file: EnteFile, file: EnteFile,
-164
View File
@@ -1,164 +0,0 @@
// EXIF in an original's bytes, read with exifreader, which reads it from JPEG,
// HEIC/HEIF, AVIF, PNG, WebP and the other image formats it supports.
// `backup-metadata --exif` records every EXIF tag it finds except the
// thumbnail's. `Photo.exif()` returns every tag, the thumbnail's included, and
// `Photo`'s typed methods return the common fields picked from them here.
import ExifReader, { type ExpandedTags } from "exifreader";
// The EXIF tags in `bytes` (`exif`), the embedded thumbnail's tags
// (`Thumbnail`), and where the EXIF block lies in `bytes` (`metadataRange`).
// Undefined when exifreader cannot read the file at all, such as a video. An
// EXIF block it finds but reads no tag from comes back as an empty `exif`. A
// tag exifreader has no name for is keyed `undefined-<tag number>`. Each tag's
// `computed` holds its value as a string or number, or as an array of them for
// a tag with several values, such as `GPSLatitude`'s `[40, 26, 46]`. A
// fraction with a zero denominator computes to null.
export const readExifTags = (bytes: Uint8Array): ExpandedTags | undefined => {
try {
return ExifReader.loadView(
new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength),
{
expanded: true,
computed: true,
includeOffsets: true,
includeUnknown: true,
includeTags: { exif: true, thumbnail: true },
},
);
} catch {
return undefined;
}
};
// Every EXIF tag of an original, keyed by name, each as exifreader decodes it
// (see `readExifTags`). The embedded thumbnail's own tags are under
// `Thumbnail`, so its `Orientation` or `ImageWidth` cannot hide the main
// image's.
export type ExifTags = Omit<NonNullable<ExpandedTags["exif"]>, "Thumbnail"> & {
Thumbnail?: Omit<
NonNullable<ExpandedTags["Thumbnail"]>,
"type" | "image" | "base64"
>;
};
// Every EXIF tag in `bytes`: `{}` when the file has no EXIF, exifreader cannot
// read its EXIF, or it is not an image exifreader reads.
export const readAllExifTags = (bytes: Uint8Array): ExifTags => {
const tags = readExifTags(bytes);
if (!tags?.Thumbnail) return tags?.exif ?? {};
// exifreader puts the thumbnail's JPEG image beside its tags, as `type`,
// `image` and `base64`. The image is not a tag, so it is left out.
const {
type: _type,
image: _image,
base64: _base64,
...thumbnail
} = tags.Thumbnail;
return { ...tags.exif, Thumbnail: thumbnail };
};
// The common EXIF fields of an original, one for each of `Photo`'s typed
// methods. Each is absent when the file lacks it.
export interface PhotoExif {
make?: string;
model?: string;
lensModel?: string;
// When the photo was taken, by the camera's clock. EXIF writes this as text
// with no time zone, and it is read as if it were UTC: the Date's UTC
// fields are the clock reading, which is the moment it was taken only when
// the clock was set to UTC.
dateTimeOriginal?: Date;
// The camera clock's offset from UTC, such as "+02:00".
offsetTimeOriginal?: string;
// Seconds.
exposureTime?: number;
fNumber?: number;
iso?: number;
// Millimetres.
focalLength?: number;
// The EXIF orientation code, 1 to 8.
orientation?: number;
// Decimal degrees, negative south of the equator and west of Greenwich.
gpsLatitude?: number;
gpsLongitude?: number;
// Metres, negative below sea level.
gpsAltitude?: number;
}
// exifreader gives "<faulty value>" for a tag whose value lies outside the
// file; that tag is left out like one the file lacks.
const asString = (v: unknown): string | undefined =>
typeof v === "string" && v.length > 0 && v !== "<faulty value>"
? v
: undefined;
const asNumber = (v: unknown): number | undefined =>
typeof v === "number" && Number.isFinite(v) ? v : undefined;
// EXIF writes a date and time as "2021:07:15 14:30:00". This is that reading
// in a Date's UTC fields.
const asDate = (v: unknown): Date | undefined => {
const m =
typeof v === "string"
? /^(\d{4}):(\d{2}):(\d{2}) (\d{2}:\d{2}:\d{2})$/.exec(v)
: null;
if (!m) return undefined;
const date = new Date(`${m[1]}-${m[2]}-${m[3]}T${m[4]}Z`);
return Number.isNaN(date.getTime()) ? undefined : date;
};
// GPSLatitude and GPSLongitude hold degrees, minutes and seconds, computed as
// three numbers. This is them in decimal degrees, negative when `ref`, the
// GPSLatitudeRef or GPSLongitudeRef tag, is `negativeRef` ("S" or "W").
// Without that tag the hemisphere is unknown, so it is undefined.
const asDegrees = (
dms: unknown,
ref: unknown,
negativeRef: string,
): number | undefined => {
if (!Array.isArray(dms) || ref === undefined) return undefined;
const [d, m, s] = dms.map(asNumber);
if (d === undefined || m === undefined || s === undefined) return undefined;
const degrees = d + m / 60 + s / 3600;
return ref === negativeRef ? -degrees : degrees;
};
// The common fields picked from an original's EXIF tags, `readAllExifTags`'s
// result: `{}` when there are none.
export const readPhotoExif = (tags: ExifTags): PhotoExif => {
const altitude = asNumber(tags.GPSAltitude?.computed);
const fields: PhotoExif = {
make: asString(tags.Make?.computed),
model: asString(tags.Model?.computed),
lensModel: asString(tags.LensModel?.computed),
dateTimeOriginal: asDate(tags.DateTimeOriginal?.computed),
offsetTimeOriginal: asString(tags.OffsetTimeOriginal?.computed),
exposureTime: asNumber(tags.ExposureTime?.computed),
fNumber: asNumber(tags.FNumber?.computed),
// Only when the tag holds a single number, as most cameras write it.
iso: asNumber(tags.ISOSpeedRatings?.computed),
focalLength: asNumber(tags.FocalLength?.computed),
orientation: asNumber(tags.Orientation?.computed),
gpsLatitude: asDegrees(
tags.GPSLatitude?.computed,
tags.GPSLatitudeRef?.computed,
"S",
),
gpsLongitude: asDegrees(
tags.GPSLongitude?.computed,
tags.GPSLongitudeRef?.computed,
"W",
),
// A GPSAltitudeRef of 1 means the altitude is below sea level.
gpsAltitude:
altitude !== undefined && tags.GPSAltitudeRef?.value === 1
? -altitude
: altitude,
};
// Leave out what the file lacks, so a missing field is absent rather than
// present and undefined.
return Object.fromEntries(
Object.entries(fields).filter(([, v]) => v !== undefined),
) as PhotoExif;
};
-2
View File
@@ -53,7 +53,6 @@ export {
type PhotoFilter, type PhotoFilter,
type TimelineGroup, type TimelineGroup,
type GroupBy, type GroupBy,
type SavePathLookup,
type ContentSource, type ContentSource,
type ContentResult, type ContentResult,
type ContentEvent, type ContentEvent,
@@ -85,7 +84,6 @@ export type {
LibrarySnapshot, LibrarySnapshot,
LibraryChange, LibraryChange,
} from "./library/records.js"; } from "./library/records.js";
export type { ExifTags, PhotoExif } from "./exif.js";
export { decryptCollection, decryptFile } from "./model/index.js"; export { decryptCollection, decryptFile } from "./model/index.js";
export { downloadFile, downloadThumbnail } from "./download/index.js"; export { downloadFile, downloadThumbnail } from "./download/index.js";
export type { export type {
+79 -159
View File
@@ -23,19 +23,19 @@
// original with no recorded hash is stored unchecked, as the upstream client // original with no recorded hash is stored unchecked, as the upstream client
// does; thumbnails have none. On top of that this module refuses to record a // does; thumbnails have none. On top of that this module refuses to record a
// stored file that came out empty. // stored file that came out empty.
//
// It also names each original's save path under the download directory
// (`savePath`), where `Photo.download()` and `lib.backup()` put it. A copy in
// the cache does not count as saved there, but is copied there rather than
// fetched again.
import { existsSync, readFileSync, statSync } from "node:fs"; import {
closeSync,
existsSync,
openSync,
readFileSync,
readSync,
statSync,
} from "node:fs";
import { import {
chmod, chmod,
copyFile,
mkdir, mkdir,
readdir, readdir,
rename,
rm, rm,
stat, stat,
statfs, statfs,
@@ -47,15 +47,13 @@ import type { ApiClient } from "../api/client.js";
import { import {
downloadFile, downloadFile,
downloadThumbnail, downloadThumbnail,
fsyncPath,
type ProgressCallback, type ProgressCallback,
removeLeftoverTempFiles, removeLeftoverTempFiles,
writeAtomic, writeAtomic,
} from "../download/index.js"; } from "../download/index.js";
import { safeExtension, withExtension } from "../filename.js"; import { safeExtension } from "../filename.js";
import type { EnteFile } from "../model/types.js"; import type { EnteFile } from "../model/types.js";
import type { Priority, RequestPools } from "./pools.js"; import type { Priority, RequestPools } from "./pools.js";
import { takenAtOf } from "./records.js";
const DIR_MODE = 0o700; const DIR_MODE = 0o700;
const FILE_MODE = 0o600; const FILE_MODE = 0o600;
@@ -110,9 +108,6 @@ export interface ContentOptions {
export interface PhotoContent { export interface PhotoContent {
original(fileID: number, opts?: ContentOptions): Promise<ContentResult>; original(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>; thumbnail(fileID: number, opts?: ContentOptions): Promise<ContentResult>;
// Put the original at the save path of `file`, the copy the `Photo` holds,
// and return it there.
download(file: EnteFile): Promise<ContentResult>;
} }
export interface EnsureResult { export interface EnsureResult {
@@ -199,10 +194,10 @@ export interface ContentCacheOptions {
pools: RequestPools; pools: RequestPools;
source: ContentSource; source: ContentSource;
cacheDirectory: string; cacheDirectory: string;
// The root of the save paths. An original already stored at its save path // The backup destination (issue-level `downloadDirectory`). An original
// counts as present, so the cache serves it rather than fetching a second // already stored there by a backup counts as present, so the cache serves
// copy. // it rather than fetching a second copy.
downloadDirectory: string; downloadDirectory?: string;
// Resolve any membership of a file; every membership shares the underlying // Resolve any membership of a file; every membership shares the underlying
// content key, so any one decrypts the same bytes. // content key, so any one decrypts the same bytes.
getFile: (fileID: number) => EnteFile | undefined; getFile: (fileID: number) => EnteFile | undefined;
@@ -229,27 +224,11 @@ class AbortDrop extends Error {
} }
} }
// The name of a file's original in the cache's originals/: `<fileID><ext>`, the // The name of a file's original in originals/: `<fileID><ext>`, the extension
// extension taken from the title (or `.bin`). // taken from the title (or `.bin`). A backup names its originals the same way.
export const nameInOriginals = (file: EnteFile): string => export const nameInOriginals = (file: EnteFile): string =>
`${file.id}${safeExtension(file.metadata.title)}`; `${file.id}${safeExtension(file.metadata.title)}`;
const pad = (n: number): string => String(n).padStart(2, "0");
// Where the original of `file` is saved under `root`:
// `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID><ext>`, dated by the photo's
// `takenAt` in this machine's time zone, with the extension taken from the
// title (or `.bin`). A live photo is stored as its image and its video beside
// this path, each with the extension found inside the live photo.
export const savePath = (root: string, file: EnteFile): string => {
const taken = new Date(takenAtOf(file));
const year = String(taken.getFullYear());
const month = `${year}-${pad(taken.getMonth() + 1)}`;
const day = `${month}-${pad(taken.getDate())}`;
const ext = safeExtension(file.metadata.title);
return join(root, year, month, day, `${day}.${file.id}${ext}`);
};
// The fileID a cache filename encodes, or undefined when the name is not one // The fileID a cache filename encodes, or undefined when the name is not one
// the cache writes (`<digits><ext>`). // the cache writes (`<digits><ext>`).
const fileIDFromName = (name: string): number | undefined => { const fileIDFromName = (name: string): number | undefined => {
@@ -274,31 +253,49 @@ const fileSize = (path: string): number | undefined => {
const hasContent = (path: string | undefined): boolean => const hasContent = (path: string | undefined): boolean =>
path !== undefined && (fileSize(path) ?? 0) > 0; path !== undefined && (fileSize(path) ?? 0) > 0;
// Whether the file at `path` begins as a ZIP does, with `PK\x03\x04`. False
// when it cannot be read.
const isZip = (path: string): boolean => {
try {
const fd = openSync(path, "r");
try {
const head = Buffer.alloc(4);
return (
readSync(fd, head, 0, 4, 0) === 4 &&
head.toString("latin1") === "PK\x03\x04"
);
} finally {
closeSync(fd);
}
} catch {
return false;
}
};
// A live photo's image and video are named with the extensions from inside its // A live photo's image and video are named with the extensions from inside its
// ZIP, so their names alone do not say which is which. Wherever the cache or a // ZIP, so their names alone do not say which is which. Wherever the cache or a
// save path stores one, a JSON file of this name beside them names both. // backup stores one, a JSON file of this name beside them names both.
// `name` is the original's name without its extension: `<fileID>` in the const livePhotoJSONName = (fileID: number): string =>
// cache, `YYYY-MM-DD.<fileID>` at the save path. `${fileID}.livephoto.json`;
const livePhotoJSONName = (name: string): string => `${name}.livephoto.json`;
// The image and video that the live photo's JSON file in `dir` names, or // The image and video that the live photo's JSON file in `dir` names, or
// undefined when there is none. Only `name` with an extension is taken, so the // undefined when there is none. Only names of the form the cache writes are
// file cannot point outside `dir`. // taken, so the file cannot point outside `dir`.
const readLivePhotoJSON = ( const readLivePhotoJSON = (
dir: string, dir: string,
name: string, fileID: number,
): { path: string; videoPath: string } | undefined => { ): { path: string; videoPath: string } | undefined => {
const valid = (part: unknown): part is string => const valid = (name: unknown): name is string =>
typeof part === "string" && part === `${name}${safeExtension(part)}`; typeof name === "string" && name === `${fileID}${safeExtension(name)}`;
try { try {
const { image, video } = JSON.parse( const { image, video } = JSON.parse(
readFileSync(join(dir, livePhotoJSONName(name)), "utf-8"), readFileSync(join(dir, livePhotoJSONName(fileID)), "utf-8"),
); );
if (valid(image) && valid(video)) { if (valid(image) && valid(video)) {
return { path: join(dir, image), videoPath: join(dir, video) }; return { path: join(dir, image), videoPath: join(dir, video) };
} }
} catch { } catch {
// No such file, or not one quak wrote. // No such file, or not one the cache wrote.
} }
return undefined; return undefined;
}; };
@@ -306,11 +303,11 @@ const readLivePhotoJSON = (
// Write the JSON file naming a live photo's image and video, both in `dir`. // Write the JSON file naming a live photo's image and video, both in `dir`.
export const writeLivePhotoJSON = ( export const writeLivePhotoJSON = (
dir: string, dir: string,
name: string, fileID: number,
stored: { path: string; videoPath: string }, stored: { path: string; videoPath: string },
): Promise<void> => ): Promise<void> =>
writeAtomic( writeAtomic(
join(dir, livePhotoJSONName(name)), join(dir, livePhotoJSONName(fileID)),
new TextEncoder().encode( new TextEncoder().encode(
JSON.stringify({ JSON.stringify({
image: basename(stored.path), image: basename(stored.path),
@@ -319,19 +316,17 @@ export const writeLivePhotoJSON = (
), ),
); );
// The original of `file` as stored in `dir` under `name` (without its // The original of `file` as the cache or a backup stored it in `dir`, when all
// extension), when all of it is there: `<name><ext>`, or a live photo's image // of it is there: `<fileID><ext>`, or a live photo's image and video.
// and video.
export const storedOriginal = ( export const storedOriginal = (
dir: string, dir: string,
name: string,
file: EnteFile, file: EnteFile,
): { path: string; videoPath?: string } | undefined => { ): { path: string; videoPath?: string } | undefined => {
if (file.metadata.fileType !== "livePhoto") { if (file.metadata.fileType !== "livePhoto") {
const path = join(dir, `${name}${safeExtension(file.metadata.title)}`); const path = join(dir, nameInOriginals(file));
return hasContent(path) ? { path } : undefined; return hasContent(path) ? { path } : undefined;
} }
const stored = readLivePhotoJSON(dir, name); const stored = readLivePhotoJSON(dir, file.id);
return stored !== undefined && return stored !== undefined &&
hasContent(stored.path) && hasContent(stored.path) &&
hasContent(stored.videoPath) hasContent(stored.videoPath)
@@ -339,76 +334,10 @@ export const storedOriginal = (
: undefined; : undefined;
}; };
// The original of `file` as stored at its save path under `root`, when all of
// it is there.
export const storedAtSavePath = (
root: string,
file: EnteFile,
): { path: string; videoPath?: string } | undefined => {
const path = savePath(root, file);
return storedOriginal(dirname(path), basename(path, extname(path)), file);
};
// Copy bytes into `dest` via a temp file in the same directory plus rename, so
// `dest` appears only once it is whole ("present means complete"). As in the
// download writer, the temp file is fsynced before the rename and the directory
// after it, so a power cut cannot leave a correctly named but short original.
// The temp name carries this process's ID so a later run can tell a leftover
// from a copy still in progress (see `removeLeftoverTempFiles`).
export const copyAtomic = async (src: string, dest: string): Promise<void> => {
if (src === dest) return;
const tmp = join(
dirname(dest),
`.quak-backup-${basename(dest)}-${process.pid}-${Math.random()
.toString(36)
.slice(2)}.tmp`,
);
try {
await copyFile(src, tmp);
await fsyncPath(tmp);
// `rename` replaces the destination's directory entry: an existing
// symlink at `dest` is replaced, not followed, and the new file has
// the temp file's permissions (copied from `src`).
await rename(tmp, dest);
await fsyncPath(dirname(dest));
} finally {
await rm(tmp, { force: true });
}
};
// Put the original of `file` at its save path under `root`, creating its
// folders. `get` is given the save path and returns where the original is: a
// fetch writes it there, and a copy the cache holds is copied there. A live
// photo's image and video go beside the save path, each with its own
// extension: when they came from the cache they are copied. Then the JSON file
// naming them is written, which is what makes the live photo count as stored.
export const placeOriginal = async (
root: string,
file: EnteFile,
get: (dest: string) => Promise<{ path: string; videoPath?: string }>,
): Promise<{ path: string; videoPath?: string }> => {
const dest = savePath(root, file);
await mkdir(dirname(dest), { recursive: true });
const got = await get(dest);
if (got.videoPath === undefined) {
await copyAtomic(got.path, dest);
return { path: dest };
}
const path = withExtension(dest, extname(got.path));
const videoPath = withExtension(dest, extname(got.videoPath));
if (got.path !== path) {
await copyAtomic(got.path, path);
await copyAtomic(got.videoPath, videoPath);
}
const name = basename(dest, extname(dest));
await writeLivePhotoJSON(dirname(dest), name, { path, videoPath });
return { path, videoPath };
};
export class ContentCache implements PhotoContent, ThumbnailsAPI { export class ContentCache implements PhotoContent, ThumbnailsAPI {
private readonly pools: RequestPools; private readonly pools: RequestPools;
private readonly source: ContentSource; private readonly source: ContentSource;
private readonly downloadDirectory: string; private readonly downloadDirectory?: string;
private readonly getFile: (fileID: number) => EnteFile | undefined; private readonly getFile: (fileID: number) => EnteFile | undefined;
private readonly originalsDir: string; private readonly originalsDir: string;
private readonly thumbnailsDir: string; private readonly thumbnailsDir: string;
@@ -506,23 +435,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress); return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
} }
// Put the original at the save path of `file` under the download directory // Get an original for a backup. One not present anywhere is written
// and return it there. `file` is the copy the `Photo` holds, so the path is
// the one its `savePath` names, even after a refresh changed the date. One
// already stored there is returned as it is; one the cache holds is copied
// from it; any other is fetched straight to the save path, with no copy
// left in the cache.
async download(file: EnteFile): Promise<ContentResult> {
const root = this.downloadDirectory;
const saved =
storedAtSavePath(root, file) ??
(await placeOriginal(root, file, (dest) =>
this.backupOriginal(file.id, dest),
));
return { ...saved, bytes: fileSize(saved.path) ?? 0 };
}
// Get an original for a save path. One not present anywhere is written
// straight to `destination` and recorded there, so no second copy lands // straight to `destination` and recorded there, so no second copy lands
// in the cache; one already present is returned where it is. // in the cache; one already present is returned where it is.
async backupOriginal( async backupOriginal(
@@ -687,16 +600,17 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
await this.touch(cached.path); await this.touch(cached.path);
return { ...cached, bytes: size, cached: true }; return { ...cached, bytes: size, cached: true };
} }
// A recorded file that has since gone re-fetches below. So does a // A recorded file that has since gone, or a live photo an earlier
// live photo recorded with no video: the cache opened before the // version stored as one ZIP, re-fetches below.
// library's records said it is a live photo, while its image and
// video had no JSON file beside them yet.
known.delete(fileID); known.delete(fileID);
} }
// An original already stored at its save path counts as present. // An original a backup already stored counts as present.
if (kind === "original") { if (kind === "original" && this.downloadDirectory !== undefined) {
const stored = storedAtSavePath(this.downloadDirectory, file); const stored = storedOriginal(
join(this.downloadDirectory, "originals"),
file,
);
if (stored !== undefined) { if (stored !== undefined) {
this.originals.set(fileID, stored); this.originals.set(fileID, stored);
return { return {
@@ -732,7 +646,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
? this.beginOriginalWrite(fileID) ? this.beginOriginalWrite(fileID)
: null; : null;
try { try {
const stored = await this.fetchInto( const stored = await this.download(
file, file,
dest, dest,
kind, kind,
@@ -747,12 +661,12 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
); );
} }
} }
// `placeOriginal` records a live photo it saves. // A backup records its own live photos.
if ( if (
stored.videoPath !== undefined && stored.videoPath !== undefined &&
opts?.destination === undefined opts?.destination === undefined
) { ) {
await writeLivePhotoJSON(dir, String(fileID), { await writeLivePhotoJSON(dir, fileID, {
path: stored.path, path: stored.path,
videoPath: stored.videoPath, videoPath: stored.videoPath,
}); });
@@ -775,7 +689,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
// Fetch into `destination`, returning where the bytes landed: there, or // Fetch into `destination`, returning where the bytes landed: there, or
// for a live photo, its image and video beside it. // for a live photo, its image and video beside it.
private async fetchInto( private async download(
file: EnteFile, file: EnteFile,
destination: string, destination: string,
kind: Kind, kind: Kind,
@@ -800,10 +714,10 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
await utimes(path, now, now).catch(() => undefined); await utimes(path, now, now).catch(() => undefined);
} }
// Every stored original that lives under `originalsDir` (a save-path hit // Every stored original that lives under `originalsDir` (a backup-directory
// recorded in the map is excluded), with its size and mtime; a live // hit recorded in the map is excluded), with its size and mtime; a live
// photo's size includes its video. Entries whose file has vanished are // photo's size includes its video. Entries whose file has vanished are
// dropped from the map. Save paths and thumbnails are never counted. // dropped from the map. Backups and thumbnails are never counted.
private async measureOriginals(): Promise<{ private async measureOriginals(): Promise<{
entries: { entries: {
fileID: number; fileID: number;
@@ -909,7 +823,7 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
await rm( await rm(
join( join(
this.originalsDir, this.originalsDir,
livePhotoJSONName(String(e.fileID)), livePhotoJSONName(e.fileID),
), ),
{ force: true }, { force: true },
); );
@@ -959,14 +873,20 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
if (id === undefined || !existsSync(path)) continue; if (id === undefined || !existsSync(path)) continue;
// A live photo's image and video are one entry, as the JSON file // A live photo's image and video are one entry, as the JSON file
// beside them names them. A live photo's file with no such JSON // beside them names them. A live photo's file with no such JSON
// file is not its original and is left alone: another process may // file is not its original. If it is a ZIP, it is the one an
// have just stored it and not yet written the JSON file. // earlier version stored under the image's name, and is removed.
const livePhoto = names.has(livePhotoJSONName(String(id))) // Any other is left alone: another process may have just stored
? readLivePhotoJSON(dir, String(id)) // it and not yet written the JSON file.
const livePhoto = names.has(livePhotoJSONName(id))
? readLivePhotoJSON(dir, id)
: undefined; : undefined;
if (livePhoto !== undefined) { if (livePhoto !== undefined) {
into.set(id, livePhoto); into.set(id, livePhoto);
} else if (!isLivePhoto(id)) { } else if (isLivePhoto(id)) {
if (isZip(path)) {
await rm(path, { force: true }).catch(() => undefined);
}
} else {
into.set(id, { path }); into.set(id, { path });
} }
} }
+28 -41
View File
@@ -27,7 +27,7 @@
// never masked by a subsequent empty refresh. // never masked by a subsequent empty refresh.
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { join, resolve } from "node:path"; import { join } from "node:path";
import envPaths from "env-paths"; import envPaths from "env-paths";
import { MetadataStore } from "./store.js"; import { MetadataStore } from "./store.js";
@@ -49,12 +49,9 @@ import {
type PhotosAPI, type PhotosAPI,
type TimelineAPI, type TimelineAPI,
type FreshReads, type FreshReads,
type SavePathLookup,
} from "./read.js"; } from "./read.js";
import { import {
ContentCache, ContentCache,
savePath,
storedAtSavePath,
type ContentSource, type ContentSource,
type ThumbnailsAPI, type ThumbnailsAPI,
type EnsureOptions, type EnsureOptions,
@@ -73,7 +70,6 @@ export {
type PhotoFilter, type PhotoFilter,
type TimelineGroup, type TimelineGroup,
type GroupBy, type GroupBy,
type SavePathLookup,
} from "./read.js"; } from "./read.js";
export { export {
type ContentSource, type ContentSource,
@@ -173,10 +169,8 @@ export interface LibraryOptions {
// Where `metadata.json` lives. Defaults to the env-paths cache directory // Where `metadata.json` lives. Defaults to the env-paths cache directory
// plus the user id, so each account has its own cache. // plus the user id, so each account has its own cache.
cacheDirectory?: string; cacheDirectory?: string;
// The root of every photo's save path, where `Photo.download()` and // Persistent backup destination. The refresh loop does not use it; the
// `lib.backup()` put originals. Defaults to `photos` in the working // content cache treats an original already stored there as present.
// directory at open. The content cache treats an original already stored
// at its save path as present.
downloadDirectory?: string; downloadDirectory?: string;
refreshIntervalSeconds?: number; refreshIntervalSeconds?: number;
onProgress?: RefreshProgressCallback; onProgress?: RefreshProgressCallback;
@@ -240,7 +234,7 @@ export interface LibraryStatus {
export class Library { export class Library {
readonly cacheDirectory: string; readonly cacheDirectory: string;
readonly downloadDirectory: string; readonly downloadDirectory?: string;
// The in-process read surface (issue #44). Each namespace answers // The in-process read surface (issue #44). Each namespace answers
// synchronously from the live record projection; no read touches the // synchronously from the live record projection; no read touches the
@@ -302,7 +296,7 @@ export class Library {
store: MetadataStore; store: MetadataStore;
userID: number; userID: number;
cacheDirectory: string; cacheDirectory: string;
downloadDirectory: string; downloadDirectory?: string;
intervalMs: number; intervalMs: number;
onProgress?: RefreshProgressCallback; onProgress?: RefreshProgressCallback;
pools: RequestPools; pools: RequestPools;
@@ -326,14 +320,8 @@ export class Library {
// The read namespaces derive fresh from the store on each call, so they // The read namespaces derive fresh from the store on each call, so they
// always reflect the latest refresh. // always reflect the latest refresh.
const derive = (): DerivedRecords => this.deriveNow(); const derive = (): DerivedRecords => this.deriveNow();
const root = this.downloadDirectory; this.albums = makeAlbumsAPI(derive, this.cache);
const saves: SavePathLookup = { this.photos = makePhotosAPI(derive, this.cache);
savePath: (file) =>
storedAtSavePath(root, file)?.path ?? savePath(root, file),
isLocal: (file) => storedAtSavePath(root, file) !== undefined,
};
this.albums = makeAlbumsAPI(derive, saves, this.cache);
this.photos = makePhotosAPI(derive, saves, this.cache);
this.timeline = makeTimelineAPI(derive); this.timeline = makeTimelineAPI(derive);
this.thumbnails = { this.thumbnails = {
ensure: (opts: EnsureOptions): Promise<EnsureResult[]> => { ensure: (opts: EnsureOptions): Promise<EnsureResult[]> => {
@@ -362,13 +350,6 @@ export class Library {
const { userID } = opts.client.whoami(); const { userID } = opts.client.whoami();
const cacheDirectory = const cacheDirectory =
opts.cacheDirectory ?? defaultCacheDirectory(userID); opts.cacheDirectory ?? defaultCacheDirectory(userID);
if (opts.downloadDirectory === "") {
throw new Error(
"library: downloadDirectory is empty (leave it out to save " +
"under photos/ in the working directory)",
);
}
const downloadDirectory = opts.downloadDirectory ?? resolve("photos");
const metadataPath = join(cacheDirectory, "metadata.json"); const metadataPath = join(cacheDirectory, "metadata.json");
let store = await MetadataStore.load(metadataPath); let store = await MetadataStore.load(metadataPath);
// A cache directory given explicitly can hold another account's cache. // A cache directory given explicitly can hold another account's cache.
@@ -423,7 +404,7 @@ export class Library {
pools, pools,
source, source,
cacheDirectory, cacheDirectory,
downloadDirectory, downloadDirectory: opts.downloadDirectory,
getFile: (fileID) => store.getFileByID(fileID), getFile: (fileID) => store.getFileByID(fileID),
cacheOriginalsMaxBytes: opts.cacheOriginalsMaxBytes, cacheOriginalsMaxBytes: opts.cacheOriginalsMaxBytes,
freeBelowBytes: opts.freeBelowBytes, freeBelowBytes: opts.freeBelowBytes,
@@ -445,7 +426,7 @@ export class Library {
store, store,
userID, userID,
cacheDirectory, cacheDirectory,
downloadDirectory, downloadDirectory: opts.downloadDirectory,
intervalMs, intervalMs,
onProgress: opts.onProgress, onProgress: opts.onProgress,
pools, pools,
@@ -489,10 +470,9 @@ export class Library {
return this.store.getFile(collectionID, fileID); return this.store.getFile(collectionID, fileID);
} }
// The membership of a file its record is read from, addressed by file id // Any membership of a file, addressed by file id alone. A file's own
// alone. A file's own metadata (title, creationTime) is identical across // metadata (title, creationTime) is identical across the collections it
// the collections it belongs to, so this serves the point commands that // belongs to, so this serves the point commands that hold only a fileID.
// hold only a fileID.
getFileByID(fileID: number): EnteFile | undefined { getFileByID(fileID: number): EnteFile | undefined {
return this.store.getFileByID(fileID); return this.store.getFileByID(fileID);
} }
@@ -563,20 +543,27 @@ export class Library {
}; };
} }
// Back up every in-scope file to `opts.downloadDirectory`, or else the // Back up every in-scope file to `downloadDirectory` in the historical
// library's, each original at its save path, with a durable failure // on-disk layout, with a durable failure ledger (issue #51). Waits for a
// ledger (issue #51). Waits for a completed refresh first, as `fresh()` // completed refresh first, as `fresh()` does, joining one already running,
// does, joining one already running, and rejects before touching any file // and rejects before touching any file when it fails. Then fetches pending
// when it fails. Then puts pending originals at their save paths as // originals (and optional thumbnails) through the content cache and pools,
// `Photo.download()` does (and optional thumbnails) through the content // and rebuilds the derived symlink/JSON views from the model. Throws before
// cache and pools, and rebuilds the derived symlink/JSON views from the // any network work when no download directory is available or no content
// model. Throws before any network work when no content cache backs the // cache backs the originals it must fetch.
// originals it must fetch.
backup(opts?: BackupOptions): Promise<BackupResult> { backup(opts?: BackupOptions): Promise<BackupResult> {
const downloadDirectory = const downloadDirectory =
opts?.downloadDirectory ?? this.downloadDirectory; opts?.downloadDirectory ?? this.downloadDirectory;
const includeOriginals = opts?.includeOriginals ?? true; const includeOriginals = opts?.includeOriginals ?? true;
const includeThumbnails = opts?.includeThumbnails ?? false; const includeThumbnails = opts?.includeThumbnails ?? false;
if (!downloadDirectory) {
return Promise.reject(
new Error(
"backup requires a downloadDirectory (pass one to " +
"backup() or open the library with one)",
),
);
}
if ((includeOriginals || includeThumbnails) && !this.cache) { if ((includeOriginals || includeThumbnails) && !this.cache) {
return Promise.reject( return Promise.reject(
new Error( new Error(
+21 -169
View File
@@ -11,32 +11,15 @@
// access and, for an album, its photos. They are not sent across IPC — the // access and, for an album, its photos. They are not sent across IPC — the
// plain records are the serializable surface, and `record()` returns one. // plain records are the serializable surface, and `record()` returns one.
// //
// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`, // A `Photo` also fetches its own bytes: `original()` and `thumbnail()` go
// `download()`, `content()`, `exif()` and the methods that each return one // through the on-disk content cache (issue #46), the one place in this module
// EXIF field go through the on-disk content cache (issue #46), and are // that is not synchronous and RAM-only. A library opened without a content
// the one place in this module that may touch the network. A library opened // source leaves that cache absent, and those two methods then throw.
// without a content source leaves that cache absent, and those methods then
// throw. `savePath` and `isLocal` look only at the disk and need no cache.
import { readFile } from "node:fs/promises"; import type { CollectionType, FileType } from "../model/types.js";
import {
readAllExifTags,
readPhotoExif,
type ExifTags,
type PhotoExif,
} from "../exif.js";
import type { CollectionType, EnteFile, FileType } from "../model/types.js";
import type { ContentOptions, ContentResult, PhotoContent } from "./content.js"; import type { ContentOptions, ContentResult, PhotoContent } from "./content.js";
import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js"; import type { AlbumRecord, PhotoRecord, DerivedRecords } from "./records.js";
// Where a photo's original is saved, and whether all of it is there. The
// library answers both from the disk, with or without a content cache.
export interface SavePathLookup {
savePath(file: EnteFile): string;
isLocal(file: EnteFile): boolean;
}
// Newest first, with fileID as a stable tiebreak so equal-timed files order // Newest first, with fileID as a stable tiebreak so equal-timed files order
// deterministically — the same order the record projection uses. // deterministically — the same order the record projection uses.
const byNewest = (a: PhotoRecord, b: PhotoRecord): number => const byNewest = (a: PhotoRecord, b: PhotoRecord): number =>
@@ -47,23 +30,12 @@ const byNewest = (a: PhotoRecord, b: PhotoRecord): number =>
const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number => const byNewestAlbum = (a: AlbumRecord, b: AlbumRecord): number =>
b.updationTime - a.updationTime || b.collectionID - a.collectionID; b.updationTime - a.updationTime || b.collectionID - a.collectionID;
// A method for each `PhotoExif` field, named after it, taking the options
// `exif()` takes and giving that field. `Photo` implements it, so the build's
// type check fails when `PhotoExif` has a field `Photo` has no method for.
type PhotoExifMethods = {
[K in keyof PhotoExif]-?: (opts?: ContentOptions) => Promise<PhotoExif[K]>;
};
// A single photo. Field access mirrors `PhotoRecord`; `record()` returns the // A single photo. Field access mirrors `PhotoRecord`; `record()` returns the
// underlying plain record for callers that need the IPC-safe value. `file` is // underlying plain record for callers that need the IPC-safe value.
// the membership the record is read from, so the save path carries the date of export class Photo {
// `takenAt` and stays known after a refresh removes the file from the library.
export class Photo implements PhotoExifMethods {
constructor( constructor(
private readonly rec: PhotoRecord, private readonly rec: PhotoRecord,
private readonly file: EnteFile, private readonly content?: PhotoContent,
private readonly saves: SavePathLookup,
private readonly cache?: PhotoContent,
) {} ) {}
get fileID(): number { get fileID(): number {
@@ -78,13 +50,6 @@ export class Photo implements PhotoExifMethods {
get takenAt(): number { get takenAt(): number {
return this.rec.takenAt; return this.rec.takenAt;
} }
get modifiedAt(): number {
return this.rec.modifiedAt;
}
// The local-time year of `takenAt`.
get year(): number {
return new Date(this.rec.takenAt).getFullYear();
}
get fileType(): FileType { get fileType(): FileType {
return this.rec.fileType; return this.rec.fileType;
} }
@@ -103,9 +68,6 @@ export class Photo implements PhotoExifMethods {
get longitude(): number | undefined { get longitude(): number | undefined {
return this.rec.longitude; return this.rec.longitude;
} }
get hash(): string | undefined {
return this.rec.hash;
}
get isArchived(): boolean { get isArchived(): boolean {
return this.rec.isArchived; return this.rec.isArchived;
} }
@@ -113,132 +75,30 @@ export class Photo implements PhotoExifMethods {
return this.rec.isHidden; return this.rec.isHidden;
} }
// Where `download()` and `lib.backup()` put the original under the
// library's download directory, whether or not it is there yet:
// `YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID><ext>`. For a live photo
// already stored, its image. For a live photo not yet stored, it carries
// the title's extension, and the image may be stored under a different
// one.
get savePath(): string {
return this.saves.savePath(this.file);
}
// Whether the whole original is at `savePath`. A copy only in the cache
// does not count.
get isLocal(): boolean {
return this.saves.isLocal(this.file);
}
record(): PhotoRecord { record(): PhotoRecord {
return this.rec; return this.rec;
} }
// Fetch and cache the full-resolution original, returning its on-disk path // Fetch and cache the full-resolution original, returning its on-disk path
// and byte length; for a live photo, its image's, and its video's path as // and byte length; for a live photo, its image's, and its video's path as
// `videoPath`. Served from the cache (or the save path) when already // `videoPath`. Served from the cache (or the backup download directory)
// present, otherwise fetched through the content pool. // when already present, otherwise fetched through the content pool.
async original(opts?: ContentOptions): Promise<ContentResult> { async original(opts?: ContentOptions): Promise<ContentResult> {
return this.cacheOrThrow().original(this.rec.fileID, opts); return this.contentOrThrow().original(this.rec.fileID, opts);
}
// Put the original at `savePath` and return it there, as `original()`
// does. When it is already there, nothing is written. When the cache holds
// it, it is copied from there; otherwise it is fetched straight to
// `savePath`.
async download(): Promise<ContentResult> {
return this.cacheOrThrow().download(this.file);
} }
// As `original`, for the thumbnail, through the thumbnail pool. // As `original`, for the thumbnail, through the thumbnail pool.
async thumbnail(opts?: ContentOptions): Promise<ContentResult> { async thumbnail(opts?: ContentOptions): Promise<ContentResult> {
return this.cacheOrThrow().thumbnail(this.rec.fileID, opts); return this.contentOrThrow().thumbnail(this.rec.fileID, opts);
} }
// The original's bytes, read from where `original()` puts it. For a live private contentOrThrow(): PhotoContent {
// photo, its image's. if (!this.content) {
async content(opts?: ContentOptions): Promise<Uint8Array> {
const { path } = await this.original(opts);
return readFile(path);
}
// Every EXIF tag of the original, keyed by name (see `ExifTags`), read
// from `content()`, so this may download it. EXIF is read from any image
// format exifreader reads, JPEG and HEIC/HEIF among them; any other file
// gives `{}`, and a video gives it without fetching anything. Like the
// other content methods, it throws when there is no content cache, video
// or not.
async exif(opts?: ContentOptions): Promise<ExifTags> {
this.cacheOrThrow();
if (this.rec.fileType === "video") return {};
return readAllExifTags(await this.content(opts));
}
// One field each, named and typed as in `PhotoExif`, picked from the tags
// `exif()` returns, and undefined when the file lacks it. Each call runs
// `exif()`, which reads the original again.
async make(opts?: ContentOptions): Promise<PhotoExif["make"]> {
return readPhotoExif(await this.exif(opts)).make;
}
async model(opts?: ContentOptions): Promise<PhotoExif["model"]> {
return readPhotoExif(await this.exif(opts)).model;
}
async lensModel(opts?: ContentOptions): Promise<PhotoExif["lensModel"]> {
return readPhotoExif(await this.exif(opts)).lensModel;
}
async dateTimeOriginal(
opts?: ContentOptions,
): Promise<PhotoExif["dateTimeOriginal"]> {
return readPhotoExif(await this.exif(opts)).dateTimeOriginal;
}
async offsetTimeOriginal(
opts?: ContentOptions,
): Promise<PhotoExif["offsetTimeOriginal"]> {
return readPhotoExif(await this.exif(opts)).offsetTimeOriginal;
}
async exposureTime(
opts?: ContentOptions,
): Promise<PhotoExif["exposureTime"]> {
return readPhotoExif(await this.exif(opts)).exposureTime;
}
async fNumber(opts?: ContentOptions): Promise<PhotoExif["fNumber"]> {
return readPhotoExif(await this.exif(opts)).fNumber;
}
async iso(opts?: ContentOptions): Promise<PhotoExif["iso"]> {
return readPhotoExif(await this.exif(opts)).iso;
}
async focalLength(
opts?: ContentOptions,
): Promise<PhotoExif["focalLength"]> {
return readPhotoExif(await this.exif(opts)).focalLength;
}
async orientation(
opts?: ContentOptions,
): Promise<PhotoExif["orientation"]> {
return readPhotoExif(await this.exif(opts)).orientation;
}
async gpsLatitude(
opts?: ContentOptions,
): Promise<PhotoExif["gpsLatitude"]> {
return readPhotoExif(await this.exif(opts)).gpsLatitude;
}
async gpsLongitude(
opts?: ContentOptions,
): Promise<PhotoExif["gpsLongitude"]> {
return readPhotoExif(await this.exif(opts)).gpsLongitude;
}
async gpsAltitude(
opts?: ContentOptions,
): Promise<PhotoExif["gpsAltitude"]> {
return readPhotoExif(await this.exif(opts)).gpsAltitude;
}
private cacheOrThrow(): PhotoContent {
if (!this.cache) {
throw new Error( throw new Error(
"Photo content requires a library opened with a content cache", "Photo content requires a library opened with a content cache",
); );
} }
return this.cache; return this.content;
} }
} }
@@ -248,7 +108,6 @@ export class Album {
constructor( constructor(
private readonly rec: AlbumRecord, private readonly rec: AlbumRecord,
private readonly records: DerivedRecords, private readonly records: DerivedRecords,
private readonly saves: SavePathLookup,
private readonly content?: PhotoContent, private readonly content?: PhotoContent,
) {} ) {}
@@ -283,10 +142,7 @@ export class Album {
const out: Photo[] = []; const out: Photo[] = [];
for (const id of this.rec.fileIDs) { for (const id of this.rec.fileIDs) {
const p = this.records.photos.get(id); const p = this.records.photos.get(id);
const file = this.records.files.get(id); if (p) out.push(new Photo(p, this.content));
if (p && file) {
out.push(new Photo(p, file, this.saves, this.content));
}
} }
return out; return out;
} }
@@ -349,19 +205,18 @@ export interface FreshReads {
export const makeAlbumsAPI = ( export const makeAlbumsAPI = (
derive: () => DerivedRecords, derive: () => DerivedRecords,
saves: SavePathLookup,
content?: PhotoContent, content?: PhotoContent,
): AlbumsAPI => ({ ): AlbumsAPI => ({
list: (): Album[] => { list: (): Album[] => {
const records = derive(); const records = derive();
return [...records.albums.values()] return [...records.albums.values()]
.sort(byNewestAlbum) .sort(byNewestAlbum)
.map((rec) => new Album(rec, records, saves, content)); .map((rec) => new Album(rec, records, content));
}, },
byID: ({ collectionID }): Album | undefined => { byID: ({ collectionID }): Album | undefined => {
const records = derive(); const records = derive();
const rec = records.albums.get(collectionID); const rec = records.albums.get(collectionID);
return rec ? new Album(rec, records, saves, content) : undefined; return rec ? new Album(rec, records, content) : undefined;
}, },
byName: ({ albumName }): Album | undefined => { byName: ({ albumName }): Album | undefined => {
const records = derive(); const records = derive();
@@ -370,20 +225,17 @@ export const makeAlbumsAPI = (
const match = [...records.albums.values()] const match = [...records.albums.values()]
.sort(byNewestAlbum) .sort(byNewestAlbum)
.find((rec) => rec.name === albumName); .find((rec) => rec.name === albumName);
return match ? new Album(match, records, saves, content) : undefined; return match ? new Album(match, records, content) : undefined;
}, },
}); });
export const makePhotosAPI = ( export const makePhotosAPI = (
derive: () => DerivedRecords, derive: () => DerivedRecords,
saves: SavePathLookup,
content?: PhotoContent, content?: PhotoContent,
): PhotosAPI => ({ ): PhotosAPI => ({
byID: ({ fileID }): Photo | undefined => { byID: ({ fileID }): Photo | undefined => {
const records = derive(); const rec = derive().photos.get(fileID);
const rec = records.photos.get(fileID); return rec ? new Photo(rec, content) : undefined;
const file = records.files.get(fileID);
return rec && file ? new Photo(rec, file, saves, content) : undefined;
}, },
records: ({ fileIDs }): PhotoRecord[] => { records: ({ fileIDs }): PhotoRecord[] => {
const { photos } = derive(); const { photos } = derive();
+17 -43
View File
@@ -5,11 +5,10 @@
// owner ruling 5). The decrypted `Collection`/`EnteFile` objects stay in RAM in // owner ruling 5). The decrypted `Collection`/`EnteFile` objects stay in RAM in
// the main process; the window only ever sees these records. // the main process; the window only ever sees these records.
// //
// Ente holds edited/basic times in microseconds; records expose `takenAt` and // Ente holds edited/basic times in microseconds; records expose `takenAt` in
// `modifiedAt` in milliseconds. The magic-metadata field names below are the // milliseconds. The magic-metadata field names below are the ones the Ente
// ones the Ente clients write, confirmed against the repo's own fixtures: // clients write, confirmed against the repo's own fixtures: `w`/`h` in
// `w`/`h` in test/cli/metadata-backup.test.ts, `visibility` in // test/cli/metadata-backup.test.ts, `visibility` in test/library/store.test.ts.
// test/library/store.test.ts.
import type { import type {
Collection, Collection,
@@ -34,17 +33,12 @@ export interface PhotoRecord {
// Milliseconds. `pubMagicMetadata.editedTime` when the user edited the // Milliseconds. `pubMagicMetadata.editedTime` when the user edited the
// date, else basic-metadata `creationTime`. // date, else basic-metadata `creationTime`.
takenAt: number; takenAt: number;
// Milliseconds. Basic-metadata `modificationTime`.
modifiedAt: number;
fileType: FileType; fileType: FileType;
caption?: string; caption?: string;
width?: number; width?: number;
height?: number; height?: number;
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
// The content hash the uploader recorded (`FileMetadata.hash`); files from
// very old clients have none.
hash?: string;
isArchived: boolean; isArchived: boolean;
isHidden: boolean; isHidden: boolean;
// Local cache paths, set once a later phase caches the bytes; unset here. // Local cache paths, set once a later phase caches the bytes; unset here.
@@ -86,10 +80,6 @@ export interface LibraryChange {
export interface DerivedRecords { export interface DerivedRecords {
albums: Map<number, AlbumRecord>; albums: Map<number, AlbumRecord>;
photos: Map<number, PhotoRecord>; photos: Map<number, PhotoRecord>;
// The membership each photo's record is read from, for its `Photo`'s save
// path. It holds the file's key, so it stays in this process: no snapshot
// or change carries it.
files: Map<number, EnteFile>;
} }
const asString = (v: unknown): string | undefined => const asString = (v: unknown): string | undefined =>
@@ -105,29 +95,10 @@ const microsToMillis = (micros: number): number => Math.floor(micros / 1000);
const byNewestPhoto = (a: PhotoRecord, b: PhotoRecord): number => const byNewestPhoto = (a: PhotoRecord, b: PhotoRecord): number =>
b.takenAt - a.takenAt || b.fileID - a.fileID; b.takenAt - a.takenAt || b.fileID - a.fileID;
// A photo's `takenAt` in milliseconds: `pubMagicMetadata.editedTime` when the
// user edited the date, else basic-metadata `creationTime`.
export const takenAtOf = (file: EnteFile): number =>
microsToMillis(
asNumber(file.pubMagicMetadata?.editedTime) ??
file.metadata.creationTime,
);
// The membership a file's record is read from: the most recently synced, lowest
// collection id to break ties. Whatever dates a file's save path takes this
// membership too, so the path always carries the record's `takenAt`.
export const representative = (memberships: EnteFile[]): EnteFile =>
memberships.reduce((best, m) =>
m.updationTime > best.updationTime ||
(m.updationTime === best.updationTime &&
m.collectionID < best.collectionID)
? m
: best,
);
// Build one PhotoRecord from every membership of a file. The memberships share // Build one PhotoRecord from every membership of a file. The memberships share
// the same underlying file, so metadata is read from a single representative; // the same underlying file, so metadata is read from a single representative
// `albumIDs` gathers them all. // (the most recently synced, lowest collection id to break ties); `albumIDs`
// gathers them all.
const toPhotoRecord = ( const toPhotoRecord = (
fileID: number, fileID: number,
memberships: EnteFile[], memberships: EnteFile[],
@@ -135,19 +106,25 @@ const toPhotoRecord = (
const albumIDs = memberships const albumIDs = memberships
.map((m) => m.collectionID) .map((m) => m.collectionID)
.sort((a, b) => a - b); .sort((a, b) => a - b);
const rep = representative(memberships); const rep = memberships.reduce((best, m) =>
m.updationTime > best.updationTime ||
(m.updationTime === best.updationTime &&
m.collectionID < best.collectionID)
? m
: best,
);
const pub = rep.pubMagicMetadata ?? {}; const pub = rep.pubMagicMetadata ?? {};
const priv = rep.magicMetadata ?? {}; const priv = rep.magicMetadata ?? {};
const takenAtMicros = asNumber(pub.editedTime) ?? rep.metadata.creationTime;
const visibility = asNumber(priv.visibility); const visibility = asNumber(priv.visibility);
const record: PhotoRecord = { const record: PhotoRecord = {
fileID, fileID,
albumIDs, albumIDs,
title: asString(pub.editedName) ?? rep.metadata.title, title: asString(pub.editedName) ?? rep.metadata.title,
takenAt: takenAtOf(rep), takenAt: microsToMillis(takenAtMicros),
modifiedAt: microsToMillis(rep.metadata.modificationTime),
fileType: rep.metadata.fileType, fileType: rep.metadata.fileType,
isArchived: visibility === VISIBILITY_ARCHIVED, isArchived: visibility === VISIBILITY_ARCHIVED,
isHidden: visibility === VISIBILITY_HIDDEN, isHidden: visibility === VISIBILITY_HIDDEN,
@@ -163,7 +140,6 @@ const toPhotoRecord = (
record.latitude = rep.metadata.latitude; record.latitude = rep.metadata.latitude;
if (rep.metadata.longitude !== undefined) if (rep.metadata.longitude !== undefined)
record.longitude = rep.metadata.longitude; record.longitude = rep.metadata.longitude;
if (rep.metadata.hash !== undefined) record.hash = rep.metadata.hash;
return record; return record;
}; };
@@ -214,7 +190,6 @@ export const deriveRecords = (
} }
const photos = new Map<number, PhotoRecord>(); const photos = new Map<number, PhotoRecord>();
const photoFiles = new Map<number, EnteFile>();
const takenAtByFile = new Map<number, number>(); const takenAtByFile = new Map<number, number>();
for (const [fileID, memberships] of byFileID) { for (const [fileID, memberships] of byFileID) {
const record = toPhotoRecord(fileID, memberships); const record = toPhotoRecord(fileID, memberships);
@@ -226,7 +201,6 @@ export const deriveRecords = (
record.thumbnailPath = paths.thumbnailPath; record.thumbnailPath = paths.thumbnailPath;
} }
photos.set(fileID, record); photos.set(fileID, record);
photoFiles.set(fileID, representative(memberships));
takenAtByFile.set(fileID, record.takenAt); takenAtByFile.set(fileID, record.takenAt);
} }
@@ -235,7 +209,7 @@ export const deriveRecords = (
albums.set(c.id, toAlbumRecord(c, files, takenAtByFile)); albums.set(c.id, toAlbumRecord(c, files, takenAtByFile));
} }
return { albums, photos, files: photoFiles }; return { albums, photos };
}; };
// Sorted, GUI-ready arrays: albums newest updated first, photos newest first. // Sorted, GUI-ready arrays: albums newest updated first, photos newest first.
+5 -8
View File
@@ -16,7 +16,6 @@ import { dirname } from "node:path";
import { writeAtomic } from "../download/index.js"; import { writeAtomic } from "../download/index.js";
import type { Collection, EnteFile, Microseconds } from "../model/types.js"; import type { Collection, EnteFile, Microseconds } from "../model/types.js";
import { representative } from "./records.js";
// Bumped only when the on-disk shape changes incompatibly. A file written // Bumped only when the on-disk shape changes incompatibly. A file written
// under a different version is discarded on load (see `load`): re-fetching // under a different version is discarded on load (see `load`): re-fetching
@@ -180,16 +179,14 @@ export class MetadataStore {
return this.files.get(fileKey(collectionID, fileID)); return this.files.get(fileKey(collectionID, fileID));
} }
// The membership of a file its record is read from (`representative`), or // Any membership of a file, or undefined. Every membership re-wraps the
// undefined. Any membership could fetch the bytes, but the content cache // same underlying content key, so any one is enough to fetch the bytes;
// resolves a fileID to a file this way so that it dates the save path // the content cache resolves a fileID to a file this way.
// from the same membership as `photo.savePath`.
getFileByID(fileID: number): EnteFile | undefined { getFileByID(fileID: number): EnteFile | undefined {
const memberships: EnteFile[] = [];
for (const file of this.files.values()) { for (const file of this.files.values()) {
if (file.id === fileID) memberships.push(file); if (file.id === fileID) return file;
} }
return memberships.length > 0 ? representative(memberships) : undefined; return undefined;
} }
listFiles(collectionID: number): EnteFile[] { listFiles(collectionID: number): EnteFile[] {
+67 -16
View File
@@ -1,8 +1,8 @@
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 exifReader from "exif-reader";
import type { Client } from "./client.js"; import type { Client } from "./client.js";
import { readExifTags } from "./exif.js";
import type { Library, Photo } from "./library/index.js"; import type { Library, Photo } from "./library/index.js";
import { sanitizeFileName } from "./filename.js"; import { sanitizeFileName } from "./filename.js";
import { import {
@@ -19,10 +19,63 @@ export interface MetadataBackupOptions {
onProgress?: ProgressCallback; onProgress?: ProgressCallback;
} }
// Extract dimensions, EXIF and XMP from a file's bytes. `exif` is the EXIF tags // Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment
// exifreader returns, from any image format it reads. When it finds an EXIF // data, starting at the "Exif\0\0" header) when there is one, nothing when the
// block but reads no tag from it, the record keeps the block's bytes, base64, // bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is
// in `exifRaw`, with the reason in `exifError`. // malformed. Each segment length is checked against the bytes that remain and
// each step moves forward by at least 4 bytes, so the scan ends on any input.
export const extractExifFromJpeg = (
buf: Uint8Array,
): { exif?: Buffer; error?: string } => {
if (buf[0] !== 0xff || buf[1] !== 0xd8) return {};
let offset = 2;
while (offset < buf.length) {
if (offset + 2 > buf.length)
return { error: `truncated segment marker at byte ${offset}` };
if (buf[offset] !== 0xff)
return { error: `no segment marker at byte ${offset}` };
const marker = buf[offset + 1]!;
if (marker === 0xda) return {}; // start of scan, no more markers
if (offset + 4 > buf.length)
return { error: `truncated segment length at byte ${offset}` };
const len = (buf[offset + 2]! << 8) | buf[offset + 3]!;
// The length counts its own two bytes, so anything under 2 is invalid.
if (len < 2)
return {
error: `segment length ${len} at byte ${offset} is too small`,
};
if (offset + 2 + len > buf.length)
return {
error: `segment length ${len} at byte ${offset} runs past the end of the file`,
};
if (marker === 0xe1) {
// APP1 — check for "Exif\0\0" header. A length under 8 cannot hold
// the six-byte header, so the segment is not EXIF; below 6 the
// bytes compared would also lie past the segment.
if (
len >= 8 &&
buf[offset + 4] === 0x45 &&
buf[offset + 5] === 0x78 &&
buf[offset + 6] === 0x69 &&
buf[offset + 7] === 0x66
) {
return {
exif: Buffer.from(
buf.buffer,
buf.byteOffset + offset + 4,
len - 2,
),
};
}
}
offset += 2 + len;
}
return { error: "file ends before the image data" };
};
// Extract dimensions, EXIF and XMP from a file's bytes. When the EXIF segment
// is malformed or cannot be parsed, the record carries the reason in
// `exifError`.
export const extractImageMetadata = ( export const extractImageMetadata = (
fileBytes: Uint8Array, fileBytes: Uint8Array,
): Record<string, unknown> | undefined => { ): Record<string, unknown> | undefined => {
@@ -39,21 +92,19 @@ export const extractImageMetadata = (
result.height = decoded.height; result.height = decoded.height;
} catch { } catch {
// Not every original is a JPEG (PNG, HEIC, video), so a failed decode // Not every original is a JPEG (PNG, HEIC, video), so a failed decode
// is expected and only means no dimensions; unreadable EXIF is still // is expected and only means no dimensions; a malformed JPEG is still
// reported below through `exifError`. // reported below through `exifError`.
} }
const tags = readExifTags(fileBytes); const { exif, error } = extractExifFromJpeg(fileBytes);
if (tags?.exif && Object.keys(tags.exif).length > 0) { if (error) result.exifError = error;
result.exif = tags.exif; if (exif) {
} else if (tags?.exif) { try {
const block = tags.metadataRange?.blocks.find((b) => b.type === "exif"); result.exif = exifReader(exif);
if (block) { } catch (err) {
result.exifRaw = Buffer.from( result.exifRaw = exif.toString("base64");
fileBytes.subarray(block.start, block.end), result.exifError = err instanceof Error ? err.message : String(err);
).toString("base64");
} }
result.exifError = "no tag could be read from the EXIF block";
} }
// Extract XMP (look for "http://ns.adobe.com/xap" in the bytes) // Extract XMP (look for "http://ns.adobe.com/xap" in the bytes)
+146 -178
View File
@@ -1,16 +1,16 @@
/** /**
* Tests for the `quak backup` logic, now built on the library API (issue #51). * Tests for the `quak backup` logic, now built on the library API (issue #51).
* *
* `lib.backup({ downloadDirectory })` refreshes the library, puts each pending * `lib.backup({ downloadDirectory })` refreshes the library, fetches each
* file's original at its save path through the content cache/pools, and * pending file's original through the content cache/pools, and materialises the
* materialises the on-disk layout: * unchanged on-disk layout:
* *
* <downloadDirectory>/ * <downloadDirectory>/
* YYYY/YYYY-MM/YYYY-MM-DD/ * originals/
* YYYY-MM-DD.<fileID>.<ext> the decrypted bytes ("present means complete") * <fileID>.<ext> the decrypted bytes ("present means complete")
* YYYY-MM-DD.<fileID>.json per-file metadata sidecar (rebuilt each run) * <fileID>.json per-file metadata sidecar (rebuilt each run)
* collections/ * collections/
* <name>/<title> symlink to the original (rebuilt each run) * <name>/<title> symlink into ../originals (rebuilt each run)
* <name>.json per-collection metadata (rebuilt each run) * <name>.json per-collection metadata (rebuilt each run)
* failures.json durable ledger of unresolved failures * failures.json durable ledger of unresolved failures
* *
@@ -94,18 +94,6 @@ const USER_ID = 42;
// Decrypted-byte length each stub original writes, keyed by fileID. // Decrypted-byte length each stub original writes, keyed by fileID.
const SIZE_BY_ID: Record<number, number> = { 100: 3000, 101: 2000, 200: 1500 }; const SIZE_BY_ID: Record<number, number> = { 100: 3000, 101: 2000, 200: 1500 };
// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente
// stores times, so the machine's time zone cannot move it to another day. Its
// original is saved in the folder `DAY`, named `2026-03-01.<fileID>.<ext>`.
const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000;
const DAY = join("2026", "2026-03", "2026-03-01");
// Where the backup in `outDir` saves `name` (`<fileID>.<ext>`), and the target
// of an album folder's symlink to it.
const saved = (outDir: string, name: string): string =>
join(outDir, DAY, `2026-03-01.${name}`);
const linkTo = (name: string): string => `../../${DAY}/2026-03-01.${name}`;
const collection = (id: number, name: string): Collection => ({ const collection = (id: number, name: string): Collection => ({
id, id,
ownerID: USER_ID, ownerID: USER_ID,
@@ -124,7 +112,7 @@ const file = (id: number, collectionID: number, title: string): EnteFile => ({
metadata: { metadata: {
title, title,
fileType: "image", fileType: "image",
creationTime: TAKEN, creationTime: 1,
modificationTime: 1, modificationTime: 1,
}, },
file: { decryptionHeader: "aGVhZGVy" }, file: { decryptionHeader: "aGVhZGVy" },
@@ -265,11 +253,7 @@ describe("lib.backup", () => {
it("throws before any network when no downloadDirectory is given", async () => { it("throws before any network when no downloadDirectory is given", async () => {
const source = stubSource(); const source = stubSource();
const lib = await openLibrary(source); const lib = await openLibrary(source);
// The library always has a download directory, so only an empty one await expect(lib.backup()).rejects.toThrow(/downloadDirectory/i);
// given to backup() reaches runBackup as none.
await expect(lib.backup({ downloadDirectory: "" })).rejects.toThrow(
/downloadDirectory/i,
);
expect(source.originalCalls).toBe(0); expect(source.originalCalls).toBe(0);
lib.close(); lib.close();
}); });
@@ -287,22 +271,28 @@ describe("lib.backup", () => {
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(result.errors).toEqual([]); expect(result.errors).toEqual([]);
// Originals at YYYY/YYYY-MM/YYYY-MM-DD/YYYY-MM-DD.<fileID>.<ext>. // Originals under originals/<fileID>.<ext>.
expect(readFileSync(saved(outDir, "100.jpg")).length).toBe(3000); expect(readFileSync(join(outDir, "originals", "100.jpg")).length).toBe(
expect(readFileSync(saved(outDir, "101.jpg")).length).toBe(2000); 3000,
expect(readFileSync(saved(outDir, "200.png")).length).toBe(1500); );
expect(readFileSync(join(outDir, "originals", "101.jpg")).length).toBe(
2000,
);
expect(readFileSync(join(outDir, "originals", "200.png")).length).toBe(
1500,
);
// Per-file metadata sidecar. // Per-file metadata sidecar.
const sidecar = JSON.parse( const sidecar = JSON.parse(
readFileSync(saved(outDir, "100.json"), "utf-8"), readFileSync(join(outDir, "originals", "100.json"), "utf-8"),
); );
expect(sidecar.id).toBe(100); expect(sidecar.id).toBe(100);
expect(sidecar.metadata.title).toBe("beach.jpg"); expect(sidecar.metadata.title).toBe("beach.jpg");
// Collection dirs contain symlinks into the date folders. // Collection dirs contain symlinks into ../originals.
const beach = join(outDir, "collections", "Vacation", "beach.jpg"); const beach = join(outDir, "collections", "Vacation", "beach.jpg");
expect(lstatSync(beach).isSymbolicLink()).toBe(true); expect(lstatSync(beach).isSymbolicLink()).toBe(true);
expect(readlinkSync(beach)).toContain(DAY); expect(readlinkSync(beach)).toContain("originals");
expect(readFileSync(beach).length).toBe(3000); expect(readFileSync(beach).length).toBe(3000);
// Per-collection metadata JSON. // Per-collection metadata JSON.
@@ -326,7 +316,7 @@ describe("lib.backup", () => {
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
// The title has no usable extension, so the original is `.bin`. // The title has no usable extension, so the original is `.bin`.
expect(existsSync(saved(outDir, "300.bin"))).toBe(true); expect(existsSync(join(outDir, "originals", "300.bin"))).toBe(true);
const link = join( const link = join(
outDir, outDir,
"collections", "collections",
@@ -376,9 +366,9 @@ describe("lib.backup", () => {
expect(result.errors[0]!.title).toBe("sunset.jpg"); expect(result.errors[0]!.title).toBe("sunset.jpg");
// The two good files are on disk; the failed one is not. // The two good files are on disk; the failed one is not.
expect(existsSync(saved(outDir, "100.jpg"))).toBe(true); expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(true);
expect(existsSync(saved(outDir, "200.png"))).toBe(true); expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
expect(existsSync(saved(outDir, "101.jpg"))).toBe(false); expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(false);
expect( expect(
existsSync(join(outDir, "collections", "Vacation", "sunset.jpg")), existsSync(join(outDir, "collections", "Vacation", "sunset.jpg")),
).toBe(false); ).toBe(false);
@@ -413,7 +403,7 @@ describe("lib.backup", () => {
const r3 = await lib.backup({ downloadDirectory: outDir }); const r3 = await lib.backup({ downloadDirectory: outDir });
expect(r3.failed).toBe(0); expect(r3.failed).toBe(0);
expect(r3.skipped).toBe(2); expect(r3.skipped).toBe(2);
expect(existsSync(saved(outDir, "101.jpg"))).toBe(true); expect(existsSync(join(outDir, "originals", "101.jpg"))).toBe(true);
// A ledger with no remaining failures is removed. // A ledger with no remaining failures is removed.
expect(existsSync(join(outDir, "failures.json"))).toBe(false); expect(existsSync(join(outDir, "failures.json"))).toBe(false);
lib.close(); lib.close();
@@ -433,8 +423,8 @@ describe("lib.backup", () => {
const result = await lib.backup({ downloadDirectory: outDir }); const result = await lib.backup({ downloadDirectory: outDir });
// Every original still downloads despite the symlink failure. // Every original still downloads despite the symlink failure.
expect(existsSync(saved(outDir, "100.jpg"))).toBe(true); expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(true);
expect(existsSync(saved(outDir, "200.png"))).toBe(true); expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
// The other symlinks are still built. // The other symlinks are still built.
expect( expect(
lstatSync( lstatSync(
@@ -464,7 +454,7 @@ describe("lib.backup", () => {
await lib.backup({ downloadDirectory: outDir }); await lib.backup({ downloadDirectory: outDir });
// Corrupt a sidecar and delete a symlink between runs. // Corrupt a sidecar and delete a symlink between runs.
writeFileSync(saved(outDir, "100.json"), "not json"); writeFileSync(join(outDir, "originals", "100.json"), "not json");
rmSync(join(outDir, "collections", "Vacation", "beach.jpg")); rmSync(join(outDir, "collections", "Vacation", "beach.jpg"));
const result = await lib.backup({ downloadDirectory: outDir }); const result = await lib.backup({ downloadDirectory: outDir });
@@ -472,7 +462,7 @@ describe("lib.backup", () => {
// The derived views are repaired from the model. // The derived views are repaired from the model.
const sidecar = JSON.parse( const sidecar = JSON.parse(
readFileSync(saved(outDir, "100.json"), "utf-8"), readFileSync(join(outDir, "originals", "100.json"), "utf-8"),
); );
expect(sidecar.metadata.title).toBe("beach.jpg"); expect(sidecar.metadata.title).toBe("beach.jpg");
expect( expect(
@@ -495,8 +485,8 @@ describe("lib.backup", () => {
expect(result.totalFiles).toBe(1); expect(result.totalFiles).toBe(1);
expect(result.downloaded).toBe(1); expect(result.downloaded).toBe(1);
expect(existsSync(saved(outDir, "200.png"))).toBe(true); expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
expect(existsSync(saved(outDir, "100.jpg"))).toBe(false); expect(existsSync(join(outDir, "originals", "100.jpg"))).toBe(false);
expect(existsSync(join(outDir, "collections", "Work.json"))).toBe(true); expect(existsSync(join(outDir, "collections", "Work.json"))).toBe(true);
expect(existsSync(join(outDir, "collections", "Vacation.json"))).toBe( expect(existsSync(join(outDir, "collections", "Vacation.json"))).toBe(
false, false,
@@ -540,7 +530,7 @@ describe("lib.backup", () => {
expect(result.totalFiles).toBe(1); expect(result.totalFiles).toBe(1);
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(existsSync(saved(outDir, "200.png"))).toBe(true); expect(existsSync(join(outDir, "originals", "200.png"))).toBe(true);
expect(existsSync(join(outDir, "failures.json"))).toBe(false); expect(existsSync(join(outDir, "failures.json"))).toBe(false);
lib.close(); lib.close();
}); });
@@ -582,7 +572,7 @@ describe("lib.backup", () => {
it("fetches each original once and writes it only into the backup", async () => { it("fetches each original once and writes it only into the backup", async () => {
// A backup of a 500 GB account must write 500 GB, not a copy in the // A backup of a 500 GB account must write 500 GB, not a copy in the
// cache as well: an original fetched for the backup goes straight // cache as well: an original fetched for the backup goes straight
// to its save path, and the cache records it there. // into its originals/, and the cache records it there.
const source = stubSource(); const source = stubSource();
const lib = await openLibrary(source); const lib = await openLibrary(source);
const outDir = join(root, "backup"); const outDir = join(root, "backup");
@@ -592,78 +582,23 @@ describe("lib.backup", () => {
expect(result.downloaded).toBe(3); expect(result.downloaded).toBe(3);
expect(source.originalCalls).toBe(3); expect(source.originalCalls).toBe(3);
expect(readdirSync(join(root, "cache", "originals"))).toEqual([]); expect(readdirSync(join(root, "cache", "originals"))).toEqual([]);
const stored = readdirSync(join(outDir, DAY)).filter( const stored = readdirSync(join(outDir, "originals")).filter(
(name) => !name.endsWith(".json"), (name) => !name.endsWith(".json"),
); );
expect(stored.sort()).toEqual([ expect(stored.sort()).toEqual(["100.jpg", "101.jpg", "200.png"]);
"2026-03-01.100.jpg",
"2026-03-01.101.jpg",
"2026-03-01.200.png",
]);
// The cache counts the backup's copy as present: reading the // The cache counts the backup's copy as present: reading the
// original afterwards fetches nothing and answers with that copy. // original afterwards fetches nothing and answers with that copy.
const read = await lib.photos.byID({ fileID: 100 })!.original(); const read = await lib.photos.byID({ fileID: 100 })!.original();
expect(read.path).toBe(saved(outDir, "100.jpg")); expect(read.path).toBe(join(outDir, "originals", "100.jpg"));
expect(source.originalCalls).toBe(3); expect(source.originalCalls).toBe(3);
await lib.close(); await lib.close();
}); });
it("saves a file in two albums at its photo's save path and links it from both", async () => {
// The date edited in Ente has reached Work's copy, synced later, but
// Vacation's copy still has an earlier edit.
const older = file(100, 1, "beach.jpg");
older.pubMagicMetadata = {
editedTime: new Date(2026, 1, 1, 12).getTime() * 1000,
};
const newer = file(100, 2, "beach.jpg");
newer.updationTime = 2;
newer.pubMagicMetadata = {
editedTime: new Date(2026, 3, 15, 12).getTime() * 1000,
};
class SharedFileClient extends MockClient {
override async filesSince(args: {
collectionID: number;
}): Promise<FilesPage> {
const files = [args.collectionID === 1 ? older : newer];
return { files, deleted: [], cursor: 1 };
}
}
const outDir = join(root, "backup");
const lib = await Library.open({
client: new SharedFileClient(),
cacheDirectory: join(root, "cache"),
downloadDirectory: outDir,
contentSource: stubSource(),
refreshIntervalSeconds: 3600,
precacheThumbnails: false,
precacheOriginals: false,
});
const photo = lib.photos.byID({ fileID: 100 })!;
const result = await lib.backup();
expect(result.totalFiles).toBe(1);
expect(result.downloaded).toBe(1);
expect(result.failed).toBe(0);
expect(photo.savePath).toBe(
join(outDir, "2026", "2026-04", "2026-04-15", "2026-04-15.100.jpg"),
);
expect(photo.isLocal).toBe(true);
expect(existsSync(join(outDir, "2026", "2026-02"))).toBe(false);
const target = "../../2026/2026-04/2026-04-15/2026-04-15.100.jpg";
for (const album of ["Vacation", "Work"]) {
expect(
readlinkSync(join(outDir, "collections", album, "beach.jpg")),
).toBe(target);
}
await lib.close();
});
it("fsyncs an original copied from the cache before the rename and its directory after", async () => { it("fsyncs an original copied from the cache before the rename and its directory after", async () => {
const lib = await openLibrary(stubSource()); const lib = await openLibrary(stubSource());
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const day = join(outDir, DAY); const originals = join(outDir, "originals");
const dest = saved(outDir, "100.jpg"); const dest = join(originals, "100.jpg");
// Only an original already in the cache is copied into the backup; // Only an original already in the cache is copied into the backup;
// one fetched for the backup is written there by the download writer. // one fetched for the backup is written there by the download writer.
await lib.photos.byID({ fileID: 100 })!.original(); await lib.photos.byID({ fileID: 100 })!.original();
@@ -674,50 +609,34 @@ describe("lib.backup", () => {
const at = fsEvents.indexOf(`rename:${dest}`); const at = fsEvents.indexOf(`rename:${dest}`);
expect(at).toBeGreaterThan(0); expect(at).toBeGreaterThan(0);
expect(fsEvents[at - 1]).toMatch( expect(fsEvents[at - 1]).toMatch(
/^sync:.*\/\.quak-backup-2026-03-01\.100\.jpg-\d+-[0-9a-z]*\.tmp$/, /^sync:.*\/\.quak-backup-100\.jpg-\d+-[0-9a-z]*\.tmp$/,
); );
expect(fsEvents[at + 1]).toBe(`sync:${day}`); expect(fsEvents[at + 1]).toBe(`sync:${originals}`);
lib.close(); lib.close();
}); });
it("removes temp files left by a killed backup but not those of one still running", async () => { it("removes temp files left by a killed backup but not those of one still running", async () => {
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const day = join(outDir, DAY); const originals = join(outDir, "originals");
mkdirSync(day, { recursive: true }); mkdirSync(originals, { recursive: true });
// A child that has already exited: its process ID is not running. // A child that has already exited: its process ID is not running.
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid; const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
const leftover = `.quak-backup-2026-03-01.100.jpg-${exitedPID}-abc123.tmp`; const leftover = `.quak-backup-100.jpg-${exitedPID}-abc123.tmp`;
// This test's own process stands in for a backup running at the same // This test's own process stands in for a backup running at the same
// time. // time.
const inProgress = `.quak-backup-2026-03-01.101.jpg-${process.pid}-def456.tmp`; const inProgress = `.quak-backup-101.jpg-${process.pid}-def456.tmp`;
writeFileSync(join(day, leftover), "partial"); writeFileSync(join(originals, leftover), "partial");
writeFileSync(join(day, inProgress), "partial"); writeFileSync(join(originals, inProgress), "partial");
const lib = await openLibrary(stubSource()); const lib = await openLibrary(stubSource());
await lib.backup({ downloadDirectory: outDir }); await lib.backup({ downloadDirectory: outDir });
const names = readdirSync(day); const names = readdirSync(originals);
expect(names).not.toContain(leftover); expect(names).not.toContain(leftover);
expect(names).toContain(inProgress); expect(names).toContain(inProgress);
lib.close(); lib.close();
}); });
it("removes temp files left in a date folder no file in the backup is saved in", async () => {
const outDir = join(root, "backup");
// As for a file since deleted, or given another date, after a run was
// killed while writing it.
const otherDay = join(outDir, "2025", "2025-01", "2025-01-02");
mkdirSync(otherDay, { recursive: true });
const exitedPID = spawnSync(process.execPath, ["-e", ""]).pid;
writeFileSync(join(otherDay, `.quak-${exitedPID}-abc123.tmp`), "x");
const lib = await openLibrary(stubSource());
await lib.backup({ downloadDirectory: outDir });
expect(readdirSync(otherDay)).toEqual([]);
lib.close();
});
it("removes leftover temp files in thumbnails/ but not those of a backup still running", async () => { it("removes leftover temp files in thumbnails/ but not those of a backup still running", async () => {
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const thumbnails = join(outDir, "thumbnails"); const thumbnails = join(outDir, "thumbnails");
@@ -790,7 +709,7 @@ describe("the refresh before a backup", () => {
const result = await backup; const result = await backup;
expect(result.totalFiles).toBe(4); expect(result.totalFiles).toBe(4);
expect(existsSync(saved(outDir, "300.jpg"))).toBe(true); expect(existsSync(join(outDir, "originals", "300.jpg"))).toBe(true);
await lib.close(); await lib.close();
}); });
@@ -809,7 +728,7 @@ describe("the refresh before a backup", () => {
expect(source.originalCalls).toBe(0); expect(source.originalCalls).toBe(0);
expect(readFileSync(ledgerPath, "utf-8")).toBe(ledgerBefore); expect(readFileSync(ledgerPath, "utf-8")).toBe(ledgerBefore);
expect(existsSync(join(outDir, DAY))).toBe(false); expect(existsSync(join(outDir, "originals"))).toBe(false);
await lib.close(); await lib.close();
}); });
@@ -900,13 +819,13 @@ describe("backup album folders", () => {
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([ expect(tree(outDir)).toEqual([
"Trip (10)/", "Trip (10)/",
`Trip (10)/IMG_0001 (1).JPG -> ${linkTo("1.JPG")}`, "Trip (10)/IMG_0001 (1).JPG -> ../../originals/1.JPG",
`Trip (10)/IMG_0001 (2).JPG -> ${linkTo("2.JPG")}`, "Trip (10)/IMG_0001 (2).JPG -> ../../originals/2.JPG",
`Trip (10)/img_0001 (4).jpg -> ${linkTo("4.jpg")}`, "Trip (10)/img_0001 (4).jpg -> ../../originals/4.jpg",
`Trip (10)/other.jpg -> ${linkTo("3.jpg")}`, "Trip (10)/other.jpg -> ../../originals/3.jpg",
"Trip (10).json", "Trip (10).json",
"Trip (11)/", "Trip (11)/",
`Trip (11)/other.jpg -> ${linkTo("3.jpg")}`, "Trip (11)/other.jpg -> ../../originals/3.jpg",
"Trip (11).json", "Trip (11).json",
]); ]);
expect(albumID(outDir, "Trip (10).json")).toBe(10); expect(albumID(outDir, "Trip (10).json")).toBe(10);
@@ -939,14 +858,14 @@ describe("backup album folders", () => {
expect(result.failed).toBe(0); expect(result.failed).toBe(0);
expect(tree(outDir)).toEqual([ expect(tree(outDir)).toEqual([
"Trip (10)/", "Trip (10)/",
`Trip (10)/IMG (6) (5).JPG -> ${linkTo("5.JPG")}`, "Trip (10)/IMG (6) (5).JPG -> ../../originals/5.JPG",
`Trip (10)/IMG (6).JPG -> ${linkTo("6.JPG")}`, "Trip (10)/IMG (6).JPG -> ../../originals/6.JPG",
`Trip (10)/IMG (7).JPG -> ${linkTo("7.JPG")}`, "Trip (10)/IMG (7).JPG -> ../../originals/7.JPG",
"Trip (10).json", "Trip (10).json",
"Trip (11)/", "Trip (11)/",
`Trip (11)/a.jpg -> ${linkTo("8.jpg")}`, "Trip (11)/a.jpg -> ../../originals/8.jpg",
"Trip (11) (12)/", "Trip (11) (12)/",
`Trip (11) (12)/b.jpg -> ${linkTo("9.jpg")}`, "Trip (11) (12)/b.jpg -> ../../originals/9.jpg",
"Trip (11) (12).json", "Trip (11) (12).json",
"Trip (11).json", "Trip (11).json",
]); ]);
@@ -1012,13 +931,13 @@ describe("backup album folders", () => {
expect(scoped.failed).toBe(0); expect(scoped.failed).toBe(0);
expect(before).toEqual([ expect(before).toEqual([
"Trip (10)/", "Trip (10)/",
`Trip (10)/a.jpg -> ${linkTo("1.jpg")}`, "Trip (10)/a.jpg -> ../../originals/1.jpg",
"Trip (10).json", "Trip (10).json",
"Work/", "Work/",
`Work/c.jpg -> ${linkTo("3.jpg")}`, "Work/c.jpg -> ../../originals/3.jpg",
"Work.json", "Work.json",
"trip (11)/", "trip (11)/",
`trip (11)/b.jpg -> ${linkTo("2.jpg")}`, "trip (11)/b.jpg -> ../../originals/2.jpg",
"trip (11).json", "trip (11).json",
]); ]);
expect(tree(outDir)).toEqual(before); expect(tree(outDir)).toEqual(before);
@@ -1071,13 +990,13 @@ describe("backup album folders", () => {
"Mine/", "Mine/",
"Mine/keep.txt", "Mine/keep.txt",
"Office/", "Office/",
`Office/a.jpg -> ${linkTo("5.jpg")}`, "Office/a.jpg -> ../../originals/5.jpg",
"Office.json", "Office.json",
"Trip/", "Trip/",
`Trip/IMG_0001.JPG -> ${linkTo("1.JPG")}`, "Trip/IMG_0001.JPG -> ../../originals/1.JPG",
"Trip/mine -> ../elsewhere", "Trip/mine -> ../elsewhere",
"Trip/notes.txt", "Trip/notes.txt",
`Trip/other.jpg -> ${linkTo("3.jpg")}`, "Trip/other.jpg -> ../../originals/3.jpg",
"Trip.json", "Trip.json",
"Work/", "Work/",
"Work/keep.txt", "Work/keep.txt",
@@ -1086,7 +1005,7 @@ describe("backup album folders", () => {
}); });
// One album backed up, then a folder the user made beside it holding a // One album backed up, then a folder the user made beside it holding a
// symlink to an original, with `json` (if given) as its sibling JSON. // symlink into originals/, with `json` (if given) as its sibling JSON.
const backupWithUserFolder = async ( const backupWithUserFolder = async (
json: string | undefined, json: string | undefined,
): Promise<{ outDir: string; failed: number }> => { ): Promise<{ outDir: string; failed: number }> => {
@@ -1100,7 +1019,10 @@ describe("backup album folders", () => {
await runBackup(lib, { downloadDirectory: outDir }); await runBackup(lib, { downloadDirectory: outDir });
const collectionsDir = join(outDir, "collections"); const collectionsDir = join(outDir, "collections");
mkdirSync(join(collectionsDir, "Mine")); mkdirSync(join(collectionsDir, "Mine"));
symlinkSync(linkTo("1.jpg"), join(collectionsDir, "Mine", "a.jpg")); symlinkSync(
"../../originals/1.jpg",
join(collectionsDir, "Mine", "a.jpg"),
);
if (json !== undefined) { if (json !== undefined) {
writeFileSync(join(collectionsDir, "Mine.json"), json); writeFileSync(join(collectionsDir, "Mine.json"), json);
} }
@@ -1114,9 +1036,9 @@ describe("backup album folders", () => {
expect(failed).toBe(0); expect(failed).toBe(0);
expect(tree(outDir)).toEqual([ expect(tree(outDir)).toEqual([
"Mine/", "Mine/",
`Mine/a.jpg -> ${linkTo("1.jpg")}`, "Mine/a.jpg -> ../../originals/1.jpg",
"Trip/", "Trip/",
`Trip/a.jpg -> ${linkTo("1.jpg")}`, "Trip/a.jpg -> ../../originals/1.jpg",
"Trip.json", "Trip.json",
]); ]);
}); });
@@ -1128,10 +1050,10 @@ describe("backup album folders", () => {
expect(failed).toBe(0); expect(failed).toBe(0);
expect(tree(outDir)).toEqual([ expect(tree(outDir)).toEqual([
"Mine/", "Mine/",
`Mine/a.jpg -> ${linkTo("1.jpg")}`, "Mine/a.jpg -> ../../originals/1.jpg",
"Mine.json", "Mine.json",
"Trip/", "Trip/",
`Trip/a.jpg -> ${linkTo("1.jpg")}`, "Trip/a.jpg -> ../../originals/1.jpg",
"Trip.json", "Trip.json",
]); ]);
expect( expect(
@@ -1165,16 +1087,23 @@ describe("backup of live photos", () => {
const open = (files: EnteFile[], bodies: Map<number, Uint8Array>) => const open = (files: EnteFile[], bodies: Map<number, Uint8Array>) =>
openLibrary(cdnSource(bodies), new TripClient(files)); openLibrary(cdnSource(bodies), new TripClient(files));
const stored = [ // What an earlier version stored for live photo 500: the ZIP under the
"2026-03-01.500.heic", // image's name, and its link.
"2026-03-01.500.json", const earlierZIP = (outDir: string): void => {
"2026-03-01.500.livephoto.json", mkdirSync(join(outDir, "originals"), { recursive: true });
"2026-03-01.500.mov", mkdirSync(join(outDir, "collections", "Trip"), { recursive: true });
]; writeFileSync(join(outDir, "originals", "500.HEIC"), livePhotoZip());
symlinkSync(
"../../originals/500.HEIC",
join(outDir, "collections", "Trip", "IMG_0500.HEIC"),
);
};
const stored = ["500.heic", "500.json", "500.livephoto.json", "500.mov"];
const linked = [ const linked = [
"Trip/", "Trip/",
`Trip/IMG_0500.heic -> ${linkTo("500.heic")}`, "Trip/IMG_0500.heic -> ../../originals/500.heic",
`Trip/IMG_0500.mov -> ${linkTo("500.mov")}`, "Trip/IMG_0500.mov -> ../../originals/500.mov",
"Trip.json", "Trip.json",
]; ];
@@ -1184,15 +1113,16 @@ describe("backup of live photos", () => {
); );
const lib = await open([live], new Map([[500, body]])); const lib = await open([live], new Map([[500, body]]));
const outDir = join(root, "backup"); const outDir = join(root, "backup");
const originals = join(outDir, "originals");
const result = await lib.backup({ downloadDirectory: outDir }); const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({ downloaded: 1, failed: 0 }); expect(result).toMatchObject({ downloaded: 1, failed: 0 });
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored); expect(readdirSync(originals).sort()).toEqual(stored);
expect(readFileSync(saved(outDir, "500.heic"))).toEqual( expect(readFileSync(join(originals, "500.heic"))).toEqual(
Buffer.from(IMAGE), Buffer.from(IMAGE),
); );
expect(readFileSync(saved(outDir, "500.mov"))).toEqual( expect(readFileSync(join(originals, "500.mov"))).toEqual(
Buffer.from(VIDEO), Buffer.from(VIDEO),
); );
expect(tree(outDir)).toEqual(linked); expect(tree(outDir)).toEqual(linked);
@@ -1219,22 +1149,39 @@ describe("backup of live photos", () => {
expect(tree(outDir)).toEqual([ expect(tree(outDir)).toEqual([
"Trip/", "Trip/",
`Trip/IMG_0001 (500).heic -> ${linkTo("500.heic")}`, "Trip/IMG_0001 (500).heic -> ../../originals/500.heic",
`Trip/IMG_0001 (500).mov -> ${linkTo("500.mov")}`, "Trip/IMG_0001 (500).mov -> ../../originals/500.mov",
`Trip/IMG_0001 (501).heic -> ${linkTo("501.heic")}`, "Trip/IMG_0001 (501).heic -> ../../originals/501.heic",
`Trip/IMG_0001 (501).mov -> ${linkTo("501.mov")}`, "Trip/IMG_0001 (501).mov -> ../../originals/501.mov",
"Trip.json", "Trip.json",
]); ]);
await lib.close(); await lib.close();
}); });
it("stores nothing for a live photo that fails its hash", async () => { it("replaces the ZIP an earlier version stored, and its link", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
const outDir = join(root, "backup");
earlierZIP(outDir);
const lib = await open([live], new Map([[500, body]]));
const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored);
expect(tree(outDir)).toEqual(linked);
await lib.close();
});
it("stores nothing for a live photo that fails its hash, and keeps what was there", async () => {
const { file: live, body } = await asLivePhoto( const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"), file(500, 10, "IMG_0500.HEIC"),
livePhotoZip(), livePhotoZip(),
"not:the recorded hash", "not:the recorded hash",
); );
const outDir = join(root, "backup"); const outDir = join(root, "backup");
earlierZIP(outDir);
const lib = await open([live], new Map([[500, body]])); const lib = await open([live], new Map([[500, body]]));
const result = await lib.backup({ downloadDirectory: outDir }); const result = await lib.backup({ downloadDirectory: outDir });
@@ -1242,8 +1189,12 @@ describe("backup of live photos", () => {
expect(result).toMatchObject({ downloaded: 0, failed: 1 }); expect(result).toMatchObject({ downloaded: 0, failed: 1 });
expect(result.errors.map((e) => e.fileID)).toEqual([500]); expect(result.errors.map((e) => e.fileID)).toEqual([500]);
expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]); expect(Object.keys(readLedger(outDir).files)).toEqual(["500"]);
expect(readdirSync(join(outDir, DAY))).toEqual([]); expect(readdirSync(join(outDir, "originals"))).toEqual(["500.HEIC"]);
expect(tree(outDir)).toEqual(["Trip/", "Trip.json"]); expect(tree(outDir)).toEqual([
"Trip/",
"Trip/IMG_0500.HEIC -> ../../originals/500.HEIC",
"Trip.json",
]);
await lib.close(); await lib.close();
}); });
@@ -1258,8 +1209,8 @@ describe("backup of live photos", () => {
const result = await lib.backup({ downloadDirectory: outDir }); const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({ downloaded: 1, failed: 0 }); expect(result).toMatchObject({ downloaded: 1, failed: 0 });
expect(readdirSync(join(outDir, DAY)).sort()).toEqual(stored); expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored);
expect(readFileSync(saved(outDir, "500.mov"))).toEqual( expect(readFileSync(join(outDir, "originals", "500.mov"))).toEqual(
Buffer.from(VIDEO), Buffer.from(VIDEO),
); );
expect(tree(outDir)).toEqual(linked); expect(tree(outDir)).toEqual(linked);
@@ -1268,6 +1219,23 @@ describe("backup of live photos", () => {
await lib.close(); await lib.close();
}); });
it("replaces an earlier ZIP and its link with the image and video the cache holds", async () => {
const { file: live, body } = await asLivePhoto(
file(500, 10, "IMG_0500.HEIC"),
);
const lib = await open([live], new Map([[500, body]]));
await lib.photos.byID({ fileID: 500 })!.original();
const outDir = join(root, "backup");
earlierZIP(outDir);
const result = await lib.backup({ downloadDirectory: outDir });
expect(result).toMatchObject({ downloaded: 1, failed: 0 });
expect(readdirSync(join(outDir, "originals")).sort()).toEqual(stored);
expect(tree(outDir)).toEqual(linked);
await lib.close();
});
it.each(["missing", "empty"])( it.each(["missing", "empty"])(
"fetches a live photo again when the video its JSON file names is %s", "fetches a live photo again when the video its JSON file names is %s",
async (state) => { async (state) => {
@@ -1277,7 +1245,7 @@ describe("backup of live photos", () => {
const lib = await open([live], new Map([[500, body]])); const lib = await open([live], new Map([[500, body]]));
const outDir = join(root, "backup"); const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir }); await lib.backup({ downloadDirectory: outDir });
const video = saved(outDir, "500.mov"); const video = join(outDir, "originals", "500.mov");
if (state === "missing") rmSync(video); if (state === "missing") rmSync(video);
else writeFileSync(video, ""); else writeFileSync(video, "");
@@ -1303,7 +1271,7 @@ describe("backup of live photos", () => {
const lib = await open([live], new Map([[500, body]])); const lib = await open([live], new Map([[500, body]]));
const outDir = join(root, "backup"); const outDir = join(root, "backup");
await lib.backup({ downloadDirectory: outDir }); await lib.backup({ downloadDirectory: outDir });
const image = saved(outDir, "500.heic"); const image = join(outDir, "originals", "500.heic");
if (state === "missing") rmSync(image); if (state === "missing") rmSync(image);
else writeFileSync(image, ""); else writeFileSync(image, "");
@@ -1342,8 +1310,8 @@ describe("backup of live photos", () => {
const read = await reader.photos.byID({ fileID: 500 })!.original(); const read = await reader.photos.byID({ fileID: 500 })!.original();
expect(read).toEqual({ expect(read).toEqual({
path: saved(outDir, "500.heic"), path: join(outDir, "originals", "500.heic"),
videoPath: saved(outDir, "500.mov"), videoPath: join(outDir, "originals", "500.mov"),
bytes: IMAGE.length, bytes: IMAGE.length,
}); });
await reader.close(); await reader.close();
+1 -27
View File
@@ -56,7 +56,6 @@ import type { ContentSource } from "../../src/library/content.js";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
import { init, toBase64 } from "../../src/crypto/index.js"; import { init, toBase64 } from "../../src/crypto/index.js";
import { defaultCacheDirectory } from "../../src/library/index.js"; import { defaultCacheDirectory } from "../../src/library/index.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js";
import { import {
asLivePhoto, asLivePhoto,
cdnSource, cdnSource,
@@ -654,31 +653,6 @@ describe("a live photo", () => {
height: 4, height: 4,
}); });
}); });
it("backup-metadata --exif records the EXIF of a HEIC image", async () => {
const dir = join(root, "dump");
expect(
await backupMetadataCommand(
context(await livePhotoClient(HEIC_WITH_EXIF)),
dir,
{ exif: true },
),
).toBe(0);
const record = JSON.parse(
readFileSync(
join(dir, "collections", "1-Vacation", "300.json"),
"utf-8",
),
);
expect(record.imageMetadata.exifError).toBeUndefined();
expect(record.imageMetadata.exif).toMatchObject({
Make: { value: ["Canon"] },
Model: { value: ["EOS R5"] },
DateTimeOriginal: { value: ["2021:07:15 14:30:00"] },
});
});
}); });
describe("backup", () => { describe("backup", () => {
@@ -757,7 +731,7 @@ describe("backup", () => {
expect(code).toBe(1); expect(code).toBe(1);
expect(runText).toBe("quak: HTTP 401 from server\n"); expect(runText).toBe("quak: HTTP 401 from server\n");
expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n"); expect(stderr.text).toBe("Starting backup...\nRefreshing library...\n");
expect(existsSync(dir)).toBe(false); expect(existsSync(join(dir, "originals"))).toBe(false);
}); });
}); });
+73 -242
View File
@@ -1,28 +1,21 @@
/** /**
* Tests for reading EXIF (`src/exif.ts`) and the image metadata * Tests for the JPEG EXIF scan behind `quak backup-metadata --exif`.
* `quak backup-metadata --exif` records.
* *
* The originals come from users' libraries, so a truncated or corrupt file * The originals come from users' libraries, so a truncated or corrupt JPEG
* must neither hang the read nor throw out of it: `readAllExifTags` gives `{}`, * must neither hang the scan nor throw out of it, and a malformed file must be
* and `backup-metadata` tells an EXIF block it cannot read apart from a file * told apart from one that simply has no EXIF: the record carries the reason in
* that simply has no EXIF, carrying the reason in `exifError`. Each JPEG below * `exifError`. Each input below is a short hand-built byte array.
* is a short hand-built byte array; the HEIC is a real file.
*/ */
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
readAllExifTags, extractExifFromJpeg,
readExifTags, extractImageMetadata,
readPhotoExif, } from "../../src/metadata-backup.js";
} from "../../src/exif.js";
import { extractImageMetadata } from "../../src/metadata-backup.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js";
const SOI = [0xff, 0xd8]; // start of image const SOI = [0xff, 0xd8]; // start of image
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0" const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
const APP0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
const ZERO_LENGTH_APP0 = [0xff, 0xe0, 0x00, 0x00];
// A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6. // A big-endian TIFF block with one IFD entry: Orientation (0x0112), SHORT, 6.
const TIFF_ORIENTATION_6 = [ const TIFF_ORIENTATION_6 = [
@@ -31,87 +24,6 @@ const TIFF_ORIENTATION_6 = [
0x00, 0x00, 0x00, 0x00,
]; ];
// A big-endian TIFF block holding Orientation 6 and DateTimeOriginal
// "0000:00:00 00:00:00", which a camera with an unset clock writes.
const TIFF_UNSET_DATE = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: two entries, then no next IFD.
...[0x00, 0x02],
// Orientation (0x0112), SHORT, 6.
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
// The Exif IFD's offset (0x8769), LONG, 38.
...[0x87, 0x69, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x26],
...[0x00, 0x00, 0x00, 0x00],
// The Exif IFD, at 38: one entry, then no next IFD.
...[0x00, 0x01],
// DateTimeOriginal (0x9003), 20 ASCII bytes at 56.
...[0x90, 0x03, 0x00, 0x02, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x38],
...[0x00, 0x00, 0x00, 0x00],
...new TextEncoder().encode("0000:00:00 00:00:00\0"), // at 56
];
// A big-endian TIFF block holding a GPSAltitude of 12.5 m and no
// GPSAltitudeRef.
const TIFF_ALTITUDE_WITHOUT_REF = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: one entry, then no next IFD.
...[0x00, 0x01],
// The GPS IFD's offset (0x8825), LONG, 26.
...[0x88, 0x25, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x1a],
...[0x00, 0x00, 0x00, 0x00],
// The GPS IFD, at 26: one entry, then no next IFD.
...[0x00, 0x01],
// GPSAltitude (0x0006), one RATIONAL at 44.
...[0x00, 0x06, 0x00, 0x05, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x2c],
...[0x00, 0x00, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x19, 0x00, 0x00, 0x00, 0x02], // 25/2, at 44
];
// A big-endian TIFF block holding Orientation 6 and a Make whose value lies
// past the end of the file.
const TIFF_MAKE_PAST_END = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: two entries, then no next IFD.
...[0x00, 0x02],
// Make (0x010f), 6 ASCII bytes at 4096.
...[0x01, 0x0f, 0x00, 0x02, 0x00, 0x00, 0x00, 0x06, 0x00, 0x00, 0x10, 0x00],
// Orientation (0x0112), SHORT, 6.
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x00],
];
// A big-endian TIFF block with one IFD entry that exifreader has no name for:
// tag 0xc000 (49152), SHORT, 7.
const TIFF_UNNAMED_TAG = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: one entry, then no next IFD.
...[0x00, 0x01],
// Tag 0xc000, SHORT, 7.
...[0xc0, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x07, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x00],
];
// A big-endian TIFF block holding Orientation 6, and a thumbnail IFD holding
// its own Orientation 1 and a 4-byte JPEG thumbnail.
const TIFF_WITH_THUMBNAIL = [
...[0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x08], // the first IFD at 8
// The first IFD, at 8: one entry, then the thumbnail IFD at 26.
...[0x00, 0x01],
// Orientation (0x0112), SHORT, 6.
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x06, 0x00, 0x00],
...[0x00, 0x00, 0x00, 0x1a],
// The thumbnail IFD, at 26: three entries, then no next IFD.
...[0x00, 0x03],
// Orientation (0x0112), SHORT, 1.
...[0x01, 0x12, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00],
// JPEGInterchangeFormat (0x0201), LONG: the thumbnail is at 68.
...[0x02, 0x01, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x44],
// JPEGInterchangeFormatLength (0x0202), LONG: 4 bytes.
...[0x02, 0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04],
...[0x00, 0x00, 0x00, 0x00],
...[0xff, 0xd8, 0xff, 0xd9], // the thumbnail, at 68: an empty JPEG
];
// An APP1 segment whose length field matches its data. // An APP1 segment whose length field matches its data.
const app1 = (data: number[]): number[] => { const app1 = (data: number[]): number[] => {
const len = data.length + 2; const len = data.length + 2;
@@ -121,129 +33,74 @@ const app1 = (data: number[]): number[] => {
const bytes = (...parts: number[][]): Uint8Array => const bytes = (...parts: number[][]): Uint8Array =>
new Uint8Array(parts.flat()); new Uint8Array(parts.flat());
describe("readAllExifTags", () => { describe("extractExifFromJpeg", () => {
it("keys a tag exifreader has no name for by its number", () => { it("returns the EXIF segment of a valid JPEG", () => {
const data = [...EXIF_HEADER, ...TIFF_UNNAMED_TAG];
expect(readAllExifTags(bytes(SOI, app1(data), SOS))).toStrictEqual({
"undefined-49152": {
id: 49152,
value: 7,
description: 7,
computed: 7,
},
});
});
it("puts the thumbnail's tags under Thumbnail, without its image", () => {
const input = bytes(
SOI,
app1([...EXIF_HEADER, ...TIFF_WITH_THUMBNAIL]),
SOS,
);
// exifreader finds the thumbnail's image.
expect(readExifTags(input)?.Thumbnail?.type).toBe("image/jpeg");
const tags = readAllExifTags(input);
expect(tags.Orientation?.value).toBe(6);
expect(Object.keys(tags.Thumbnail ?? {}).sort()).toEqual([
"JPEGInterchangeFormat",
"JPEGInterchangeFormatLength",
"Orientation",
]);
expect(tags.Thumbnail?.Orientation?.value).toBe(1);
expect(readPhotoExif(tags)).toStrictEqual({ orientation: 6 });
});
});
describe("readPhotoExif", () => {
it("reads the common fields of a valid JPEG", () => {
const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6]; const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6];
expect( const scan = extractExifFromJpeg(bytes(SOI, app1(data), SOS));
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))), expect(scan.error).toBeUndefined();
).toStrictEqual({ expect([...scan.exif!]).toEqual(data);
orientation: 6,
});
}); });
it("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => { it("returns nothing for a file that is not a JPEG", () => {
const data = [...EXIF_HEADER, ...TIFF_UNSET_DATE]; const png = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
expect( expect(extractExifFromJpeg(png)).toEqual({});
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
).toStrictEqual({
orientation: 6,
});
}); });
it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => { it("returns nothing for a JPEG without EXIF", () => {
const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF]; const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
expect( expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({});
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))),
).toStrictEqual({
gpsAltitude: 12.5,
});
}); });
it("gives no make for a Make whose value lies past the end of the file", () => { it("ignores an APP1 segment too short to hold the Exif header", () => {
const data = [...EXIF_HEADER, ...TIFF_MAKE_PAST_END]; // A length under 8 cannot hold the six-byte "Exif\0\0" header, so the
expect( // segment is not EXIF. This one has length 7 and holds only "Exif\0",
readPhotoExif(readAllExifTags(bytes(SOI, app1(data), SOS))), // which the old code, lacking the length check, returned as EXIF.
).toStrictEqual({ const short = app1(EXIF_HEADER.slice(0, 5));
orientation: 6, expect(extractExifFromJpeg(bytes(SOI, short, SOS))).toEqual({});
});
}); });
it.each([ it("accepts an APP1 segment of length 8 holding just the Exif header", () => {
[ const scan = extractExifFromJpeg(bytes(SOI, app1(EXIF_HEADER), SOS));
"a file that is not an image", expect(scan.error).toBeUndefined();
new TextEncoder().encode("just some text, not an image"), expect([...scan.exif!]).toEqual(EXIF_HEADER);
], });
[
"a PNG without EXIF", it("reports a JPEG truncated inside a segment header", () => {
bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00]));
], expect(scan.exif).toBeUndefined();
["a JPEG without EXIF", bytes(SOI, APP0, SOS)], expect(scan.error).toMatch(/truncated segment length/);
// A length under 8 cannot hold the six-byte "Exif\0\0" header. This one });
// has length 7 and holds only "Exif\0", so a read past its end would
// take the next segment's bytes as EXIF. it("reports a JPEG that ends before the image data", () => {
[ const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
"an APP1 segment too short to hold the Exif header", const scan = extractExifFromJpeg(bytes(SOI, app0));
bytes(SOI, app1(EXIF_HEADER.slice(0, 5)), SOS), expect(scan.error).toMatch(/ends before the image data/);
], });
[
"an APP1 segment holding just the Exif header", it("stops on a zero-length segment instead of looping", () => {
bytes(SOI, app1(EXIF_HEADER), SOS), // A length of 0 would otherwise step the scan by 2 bytes at a time
], // through the rest of the file, reading garbage as markers.
[ const zero = [0xff, 0xe0, 0x00, 0x00];
"a JPEG truncated inside a segment header", const scan = extractExifFromJpeg(
bytes(SOI, [0xff, 0xe1, 0x00]), bytes(SOI, zero, zero, zero, zero, SOS),
], );
["a JPEG that ends before the image data", bytes(SOI, APP0)], expect(scan.error).toMatch(/segment length 0 at byte 2 is too small/);
// A length of 0 would step a scan by 2 bytes at a time through the });
// rest of the file, reading garbage as markers.
[ it("stops on a segment length of 1", () => {
"a zero-length segment", const scan = extractExifFromJpeg(
bytes( bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS),
SOI, );
ZERO_LENGTH_APP0, expect(scan.error).toMatch(/segment length 1 at byte 2 is too small/);
ZERO_LENGTH_APP0, });
ZERO_LENGTH_APP0,
ZERO_LENGTH_APP0, it("reports a segment length that runs past the end of the file", () => {
SOS,
),
],
["a segment length of 1", bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS)],
// APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows. // APP1 claims 0x4000 bytes but only the "Exif\0\0" header follows.
[ const scan = extractExifFromJpeg(
"a segment length that runs past the end of the file",
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER), bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
], );
// "XX" where the TIFF byte order belongs. expect(scan.exif).toBeUndefined();
[ expect(scan.error).toMatch(/runs past the end of the file/);
"an EXIF block that cannot be parsed",
bytes(SOI, app1([...EXIF_HEADER, 0x58, 0x58]), SOS),
],
])("returns no tags and no fields for %s", (_, input) => {
expect(readAllExifTags(input)).toStrictEqual({});
expect(readPhotoExif(readAllExifTags(input))).toStrictEqual({});
}); });
}); });
@@ -253,30 +110,10 @@ describe("extractImageMetadata", () => {
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS), bytes(SOI, app1([...EXIF_HEADER, ...TIFF_ORIENTATION_6]), SOS),
); );
expect(meta?.exifError).toBeUndefined(); expect(meta?.exifError).toBeUndefined();
expect(meta?.exif).toMatchObject({ Orientation: { value: 6 } }); expect(meta?.exif).toMatchObject({ Image: { Orientation: 6 } });
}); });
it("parses EXIF from a HEIC", () => { it("returns nothing for a file that is not a JPEG", () => {
const meta = extractImageMetadata(HEIC_WITH_EXIF);
expect(meta?.exifError).toBeUndefined();
expect(meta?.exif).toMatchObject({
Make: { value: ["Canon"] },
Model: { value: ["EOS R5"] },
DateTimeOriginal: { value: ["2021:07:15 14:30:00"] },
Orientation: { value: 6 },
GPSLatitudeRef: { value: ["N"] },
});
});
it("keys a tag exifreader has no name for by its number", () => {
const meta = extractImageMetadata(
bytes(SOI, app1([...EXIF_HEADER, ...TIFF_UNNAMED_TAG]), SOS),
);
expect(meta?.exifError).toBeUndefined();
expect(meta?.exif).toMatchObject({ "undefined-49152": { value: 7 } });
});
it("returns nothing for a file that is not an image", () => {
const text = new TextEncoder().encode("just some text, not an image"); const text = new TextEncoder().encode("just some text, not an image");
expect(extractImageMetadata(text)).toBeUndefined(); expect(extractImageMetadata(text)).toBeUndefined();
}); });
@@ -286,20 +123,14 @@ describe("extractImageMetadata", () => {
bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER), bytes(SOI, [0xff, 0xe1, 0x40, 0x00], EXIF_HEADER),
); );
expect(meta?.exif).toBeUndefined(); expect(meta?.exif).toBeUndefined();
expect(meta?.exifError).toBe( expect(meta?.exifError).toMatch(/runs past the end of the file/);
"no tag could be read from the EXIF block",
);
}); });
it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => { it("keeps the raw bytes and the reason when EXIF cannot be parsed", () => {
// The raw bytes are the whole EXIF block as exifreader finds it: for a const data = [...EXIF_HEADER, 0x58, 0x58];
// JPEG, the APP1 segment, marker and length included. const meta = extractImageMetadata(bytes(SOI, app1(data), SOS));
const segment = app1([...EXIF_HEADER, 0x58, 0x58]);
const meta = extractImageMetadata(bytes(SOI, segment, SOS));
expect(meta?.exif).toBeUndefined(); expect(meta?.exif).toBeUndefined();
expect(meta?.exifRaw).toBe(Buffer.from(segment).toString("base64")); expect(meta?.exifRaw).toBe(Buffer.from(data).toString("base64"));
expect(meta?.exifError).toBe( expect(meta?.exifError).toEqual(expect.any(String));
"no tag could be read from the EXIF block",
);
}); });
}); });
+9
View File
@@ -1883,6 +1883,15 @@ describe("downloadFile live photos", () => {
expect(readdirSync(t.dir).sort()).toEqual(["f.JPG", "f.bin"]); expect(readdirSync(t.dir).sort()).toEqual(["f.JPG", "f.bin"]);
}); });
it("replaces what was at the destination, such as an earlier ZIP of the two", async () => {
const t = setup(livePhotoZip(), livePhoto);
writeFileSync(t.outPath, livePhotoZip());
await t.run();
expect(readdirSync(t.dir).sort()).toEqual(["f.heic", "f.mov"]);
});
it("renames the image and then the video into place, each from its own temp file", async () => { it("renames the image and then the video into place, each from its own temp file", async () => {
const t = setup(livePhotoZip(), livePhoto); const t = setup(livePhotoZip(), livePhoto);
-303
View File
@@ -1,303 +0,0 @@
/**
* The example script `examples/download-albums.ts` (issue #144), run twice
* against a stand-in account, as a user would run it twice.
*
* The account has two albums sharing one photo, and one of its photos is a
* live photo whose image is a HEIC with EXIF. The first run puts every
* original at its save path, writes each photo's metadata beside it and each
* album's photos under `albums/`. Before the second run the account gains an
* album holding a new photo. The second run downloads that photo and writes
* its files and the new album's, and fetches nothing else and changes no
* other file.
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
existsSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { downloadAlbums } from "../../examples/download-albums.js";
import { Library, type ContentSource } from "../../src/index.js";
import type { CollectionsPage, FilesPage } from "../../src/client.js";
import type { Collection, EnteFile } from "../../src/model/types.js";
import { readAllExifTags } from "../../src/exif.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js";
import {
asLivePhoto,
cdnSource,
livePhotoHash,
livePhotoZip,
VIDEO,
} from "../live-photo.js";
const USER_ID = 7;
// Every photo is taken at noon local time on 2026-03-01, so the machine's time
// zone cannot move it to another day; it is saved in the folder `DAY`. Ente
// stores times in microseconds.
const TAKEN_MS = new Date(2026, 2, 1, 12).getTime();
const DAY = join("2026", "2026-03", "2026-03-01");
const collection = (id: number, name: string): Collection => ({
id,
ownerID: USER_ID,
key: new Uint8Array([id]),
name,
type: "album",
updationTime: 1,
isShared: false,
});
const file = (id: number, collectionID: number): EnteFile => ({
id,
collectionID,
ownerID: USER_ID,
key: new Uint8Array([id]),
metadata: {
title: `file-${id}.jpg`,
fileType: "image",
creationTime: TAKEN_MS * 1000,
modificationTime: TAKEN_MS * 1000,
},
file: { decryptionHeader: "aGVhZGVy" },
thumbnail: { decryptionHeader: "dGh1bWI=" },
updationTime: 1,
});
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "quak-download-albums-"));
});
afterEach(() => {
if (root && existsSync(root))
rmSync(root, { recursive: true, force: true });
});
// Every file and directory under `dir`, by path.
const entries = (dir: string): string[] =>
readdirSync(dir, { recursive: true, encoding: "utf-8" });
// Set the modification time of everything under `dir` to the epoch, so that
// anything written there afterwards has a later one, however soon it comes.
const backdate = (dir: string): void => {
for (const name of entries(dir)) utimesSync(join(dir, name), 0, 0);
};
// The modification time of everything under `dir`, by path.
const mtimes = (dir: string): Map<string, number> =>
new Map(
entries(dir).map((name) => [name, statSync(join(dir, name)).mtimeMs]),
);
const readJSON = (path: string): unknown =>
JSON.parse(readFileSync(path, "utf-8"));
describe("examples/download-albums.ts", () => {
it("downloads every album's photos with their metadata, and on a second run only what the account gained", async () => {
// Album 1, "Trip", holds photos 1 and 2. Album 2, "Family", holds
// photo 2 and photo 3, a live photo.
const live = await asLivePhoto(
file(3, 2),
livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }),
livePhotoHash(HEIC_WITH_EXIF, VIDEO),
);
const collections = [collection(1, "Trip"), collection(2, "Family")];
const filesByAlbum = new Map<number, EnteFile[]>([
[1, [file(1, 1), file(2, 1)]],
[2, [file(2, 2), live.file]],
]);
const client = {
whoami: () => ({ email: "u@example.com", userID: USER_ID }),
collectionsSince: async (): Promise<CollectionsPage> => ({
collections: [...collections],
deleted: [],
cursor: 1,
}),
filesSince: async (args: {
collectionID: number;
}): Promise<FilesPage> => ({
files: filesByAlbum.get(args.collectionID) ?? [],
deleted: [],
cursor: 1,
}),
};
// Photo 3 comes from a stand-in server, encrypted as Ente serves a
// live photo. Any other original is a few bytes naming its photo.
// `calls` counts every fetch, thumbnails included.
const server = cdnSource(new Map([[3, live.body]]));
let calls = 0;
const source: ContentSource = {
original: async (args) => {
calls++;
if (args.file.id === 3) return server.original(args);
const bytes = `original-${args.file.id}`;
writeFileSync(args.destination, bytes);
return { bytesWritten: bytes.length };
},
thumbnail: async (args) => {
calls++;
return server.thumbnail(args);
},
};
const dir = join(root, "photos");
const open = (): Promise<Library> =>
Library.open({
client,
cacheDirectory: join(root, "cache"),
downloadDirectory: dir,
contentSource: source,
refreshIntervalSeconds: 3600,
precacheThumbnails: false,
precacheOriginals: false,
});
const first = await open();
// The cache already holds photo 1's original, so its record names a
// cache path, which the metadata leaves out. download() copies it
// from the cache rather than fetching it again.
await first.photos.byID({ fileID: 1 })!.original();
expect(await downloadAlbums(first, dir)).toEqual({
downloaded: 3,
alreadyLocal: 0,
});
await first.close();
expect(calls).toBe(3);
// Each original at its save path, a live photo as its image, its video
// and the file naming them, and each photo's metadata beside it.
const day = join(dir, DAY);
expect(readdirSync(day).sort()).toEqual([
"2026-03-01.1.jpg",
"2026-03-01.1.jpg.json",
"2026-03-01.2.jpg",
"2026-03-01.2.jpg.json",
"2026-03-01.3.heic",
"2026-03-01.3.heic.json",
"2026-03-01.3.livephoto.json",
"2026-03-01.3.mov",
]);
expect(readFileSync(join(day, "2026-03-01.1.jpg"), "utf-8")).toBe(
"original-1",
);
expect(readFileSync(join(day, "2026-03-01.2.jpg"), "utf-8")).toBe(
"original-2",
);
expect(readFileSync(join(day, "2026-03-01.3.heic"))).toEqual(
Buffer.from(HEIC_WITH_EXIF),
);
expect(readFileSync(join(day, "2026-03-01.3.mov"))).toEqual(
Buffer.from(VIDEO),
);
// The metadata is the photo's record and its EXIF tags. The originals
// of photos 1 and 2 are not image data, so they have no EXIF tags.
// Photo 3's are every tag of its image, as `photo.exif()` returns
// them. `record` holds the fields the three records share.
const record = {
takenAt: TAKEN_MS,
modifiedAt: TAKEN_MS,
fileType: "image",
isArchived: false,
isHidden: false,
};
expect(readJSON(join(day, "2026-03-01.1.jpg.json"))).toEqual({
...record,
fileID: 1,
albumIDs: [1],
title: "file-1.jpg",
exif: {},
});
expect(readJSON(join(day, "2026-03-01.2.jpg.json"))).toEqual({
...record,
fileID: 2,
albumIDs: [1, 2],
title: "file-2.jpg",
exif: {},
});
expect(readJSON(join(day, "2026-03-01.3.heic.json"))).toEqual({
...record,
fileID: 3,
albumIDs: [2],
title: "file-3.jpg",
fileType: "livePhoto",
hash: livePhotoHash(HEIC_WITH_EXIF, VIDEO),
exif: readAllExifTags(HEIC_WITH_EXIF),
});
// Each album's photos, newest first, by save path relative to `dir`.
// Photo 2 is in both.
expect(readdirSync(join(dir, "albums")).sort()).toEqual([
"1.json",
"2.json",
]);
expect(readJSON(join(dir, "albums", "1.json"))).toEqual({
collectionID: 1,
name: "Trip",
savePaths: [
join(DAY, "2026-03-01.2.jpg"),
join(DAY, "2026-03-01.1.jpg"),
],
});
expect(readJSON(join(dir, "albums", "2.json"))).toEqual({
collectionID: 2,
name: "Family",
savePaths: [
join(DAY, "2026-03-01.3.heic"),
join(DAY, "2026-03-01.2.jpg"),
],
});
// Before the second run the account gains album 3, "Garden", holding
// a new photo 4. The second run, with a newly opened library, opens
// the cache written by the first and still downloads photo 4. It finds
// the other photos already local and fetches nothing else.
collections.push(collection(3, "Garden"));
filesByAlbum.set(3, [file(4, 3)]);
backdate(dir);
const before = mtimes(dir);
const second = await open();
expect(await downloadAlbums(second, dir)).toEqual({
downloaded: 1,
alreadyLocal: 3,
});
await second.close();
expect(calls).toBe(4);
expect(readFileSync(join(day, "2026-03-01.4.jpg"), "utf-8")).toBe(
"original-4",
);
expect(readJSON(join(dir, "albums", "3.json"))).toEqual({
collectionID: 3,
name: "Garden",
savePaths: [join(DAY, "2026-03-01.4.jpg")],
});
// The second run adds only photo 4's files and album 3's, and
// rewrites, renames or removes no file from the first run. The two
// directories that gain a file are the only other changes.
const after = mtimes(dir);
expect(
[...after.keys()].filter((name) => !before.has(name)).sort(),
).toEqual([
join(DAY, "2026-03-01.4.jpg"),
join(DAY, "2026-03-01.4.jpg.json"),
join("albums", "3.json"),
]);
for (const [name, mtime] of before) {
if (name === DAY || name === "albums") continue;
expect(after.get(name), name).toBe(mtime);
}
});
});
-27
View File
@@ -1,27 +0,0 @@
/**
* `exif.heic`, beside this file: a real 64x64 HEIC whose EXIF holds the same
* values as the hand-built JPEG in `library/content-library.test.ts`, for the
* tests of `exif()` and `backup-metadata --exif`.
*
* It was made once, in a throwaway node:22-alpine container (Alpine 3.23.3),
* with libheif 1.23.0 and exiftool 13.55:
*
* apk add libheif-tools exiftool imagemagick
* magick -size 64x64 gradient:red-blue -depth 8 in.png
* heif-enc -q 30 -o exif.heic in.png
* exiftool -overwrite_original \
* -Make=Canon -Model="EOS R5" -LensModel="RF50mm F1.8 STM" \
* -DateTimeOriginal="2021:07:15 14:30:00" -OffsetTimeOriginal="+02:00" \
* -ExposureTime=1/250 -FNumber=2.8 -ISO=400 -FocalLength=50 \
* -Orientation#=6 \
* -GPSLatitude="40 26 46" -GPSLatitudeRef=N \
* -GPSLongitude="79 58 56" -GPSLongitudeRef=W \
* -GPSAltitude=12.5 -GPSAltitudeRef#=1 \
* exif.heic
*/
import { readFileSync } from "node:fs";
export const HEIC_WITH_EXIF = new Uint8Array(
readFileSync(new URL("exif.heic", import.meta.url)),
);
BIN
View File
Binary file not shown.
-1
View File
@@ -142,7 +142,6 @@ const buildCache = (args: {
pools: new RequestPools(), pools: new RequestPools(),
source, source,
cacheDirectory: cacheDir, cacheDirectory: cacheDir,
downloadDirectory: join(root, "photos"),
getFile: (id) => byID.get(id), getFile: (id) => byID.get(id),
statfs: args.statfs, statfs: args.statfs,
cacheOriginalsMaxBytes: args.cacheOriginalsMaxBytes, cacheOriginalsMaxBytes: args.cacheOriginalsMaxBytes,
+11 -627
View File
@@ -6,18 +6,15 @@
* `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and * `Photo` objects that fetch through it, `lib.thumbnails.ensure` drives it, and
* a cached path shows up on the projected record. A library opened without a * a cached path shows up on the projected record. A library opened without a
* content source leaves those methods throwing rather than silently doing * content source leaves those methods throwing rather than silently doing
* nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `download()`, * nothing.
* `content()`, `exif()` and the methods that each return one EXIF field.
*/ */
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { import {
mkdtempSync, mkdtempSync,
readdirSync, readdirSync,
readFileSync,
rmSync, rmSync,
existsSync, existsSync,
statSync,
writeFileSync, writeFileSync,
} from "node:fs"; } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
@@ -27,25 +24,10 @@ import { Library, type LibraryOptions } 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";
import type { Collection, EnteFile } from "../../src/model/types.js"; import type { Collection, EnteFile } from "../../src/model/types.js";
import { readPhotoExif, type PhotoExif } from "../../src/exif.js"; import { asLivePhoto, cdnSource, livePhotoZip } from "../live-photo.js";
import { HEIC_WITH_EXIF } from "../exif-heic.js";
import {
asLivePhoto,
cdnSource,
IMAGE,
livePhotoHash,
livePhotoZip,
VIDEO,
} from "../live-photo.js";
const USER_ID = 7; const USER_ID = 7;
// Every file is taken at noon local time on 2026-03-01, in microseconds as Ente
// stores times, so the machine's time zone cannot move it to another day; it
// is saved in the folder `DAY`.
const TAKEN = new Date(2026, 2, 1, 12).getTime() * 1000;
const DAY = join("2026", "2026-03", "2026-03-01");
const collection = (id: number): Collection => ({ const collection = (id: number): Collection => ({
id, id,
ownerID: USER_ID, ownerID: USER_ID,
@@ -64,7 +46,7 @@ const file = (id: number, collectionID: number): EnteFile => ({
metadata: { metadata: {
title: `file-${id}.jpg`, title: `file-${id}.jpg`,
fileType: "image", fileType: "image",
creationTime: TAKEN, creationTime: 1,
modificationTime: 1, modificationTime: 1,
}, },
file: { decryptionHeader: "aGVhZGVy" }, file: { decryptionHeader: "aGVhZGVy" },
@@ -88,23 +70,14 @@ class MockClient {
} }
} }
// A content source that writes `original` as every original and a marker file // A content source that writes a marker file and counts thumbnail fetches.
// as every thumbnail, and counts the fetches of each. const stubSource = (): ContentSource & { thumbCalls: () => number } => {
const stubSource = (
original: string | Uint8Array = "orig-bytes",
): ContentSource & {
originalCalls: () => number;
thumbCalls: () => number;
} => {
let originalCalls = 0;
let thumbCalls = 0; let thumbCalls = 0;
return { return {
originalCalls: () => originalCalls,
thumbCalls: () => thumbCalls, thumbCalls: () => thumbCalls,
original: async ({ destination }) => { original: async ({ destination }) => {
originalCalls++; writeFileSync(destination, "orig-bytes");
writeFileSync(destination, original); return { bytesWritten: 10 };
return { bytesWritten: original.length };
}, },
thumbnail: async ({ destination }) => { thumbnail: async ({ destination }) => {
thumbCalls++; thumbCalls++;
@@ -176,7 +149,7 @@ describe("Library content wiring", () => {
await lib.close(); await lib.close();
}); });
it("does not take a live photo's image or video with no JSON file as its original when it opens, and precaches both", async () => { it("removes a live photo's ZIP an earlier version cached when it opens, and precaches its image and video", async () => {
const { file: live, body } = await asLivePhoto(file(1, 1)); const { file: live, body } = await asLivePhoto(file(1, 1));
class LiveClient extends MockClient { class LiveClient extends MockClient {
override async filesSince(): Promise<FilesPage> { override async filesSince(): Promise<FilesPage> {
@@ -197,8 +170,7 @@ describe("Library content wiring", () => {
// A first run records the library, so the next one knows that file 1 // A first run records the library, so the next one knows that file 1
// is a live photo when it opens the cache. // is a live photo when it opens the cache.
await (await open({})).close(); await (await open({})).close();
writeFileSync(join(originals, "1.heic"), "an image"); writeFileSync(join(originals, "1.jpg"), livePhotoZip());
writeFileSync(join(originals, "1.mov"), "a video");
let precached!: () => void; let precached!: () => void;
const done = new Promise<void>((r) => (precached = r)); const done = new Promise<void>((r) => (precached = r));
@@ -209,9 +181,7 @@ describe("Library content wiring", () => {
precached(); precached();
}, },
}); });
expect( expect(existsSync(join(originals, "1.jpg"))).toBe(false);
lib.photos.byID({ fileID: 1 })!.record().originalPath,
).toBeUndefined();
await done; await done;
expect(readdirSync(originals).sort()).toEqual([ expect(readdirSync(originals).sort()).toEqual([
@@ -219,17 +189,6 @@ describe("Library content wiring", () => {
"1.livephoto.json", "1.livephoto.json",
"1.mov", "1.mov",
]); ]);
expect(readFileSync(join(originals, "1.heic"))).toEqual(
Buffer.from(IMAGE),
);
expect(readFileSync(join(originals, "1.mov"))).toEqual(
Buffer.from(VIDEO),
);
expect(
JSON.parse(
readFileSync(join(originals, "1.livephoto.json"), "utf-8"),
),
).toEqual({ image: "1.heic", video: "1.mov" });
expect(lib.photos.byID({ fileID: 1 })!.record().originalPath).toBe( expect(lib.photos.byID({ fileID: 1 })!.record().originalPath).toBe(
join(originals, "1.heic"), join(originals, "1.heic"),
); );
@@ -246,584 +205,9 @@ describe("Library content wiring", () => {
await expect( await expect(
lib.photos.byID({ fileID: 1 })!.thumbnail(), lib.photos.byID({ fileID: 1 })!.thumbnail(),
).rejects.toThrow(/content cache/i); ).rejects.toThrow(/content cache/i);
await expect(
lib.photos.byID({ fileID: 1 })!.download(),
).rejects.toThrow(/content cache/i);
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 lib.close(); await lib.close();
}); });
}); });
// Big-endian bytes for the hand-built JPEG below.
const u16 = (n: number): number[] => [n >> 8, n & 0xff];
const u32 = (n: number): number[] => [...u16(n >>> 16), ...u16(n & 0xffff)];
const ascii = (s: string): number[] => [...new TextEncoder().encode(s), 0];
const rational = (num: number, den: number): number[] => [
...u32(num),
...u32(den),
];
// One IFD entry: tag, type (1 BYTE, 2 ASCII, 3 SHORT, 4 LONG, 5 RATIONAL),
// count, then the value when it fits in 4 bytes, else its offset.
const entry = (
tag: number,
type: number,
count: number,
value: number[],
): number[] => [...u16(tag), ...u16(type), ...u32(count), ...value];
// The TIFF block of a JPEG's EXIF segment, holding every field `Photo`'s typed
// methods return: the camera in the first IFD, the exposure in the Exif IFD,
// and a GPS position of 40°26'46" N, 79°58'56" W, 12.5 m below sea level.
// Offsets count from the start of this block.
const TIFF = [
...[0x4d, 0x4d, 0x00, 0x2a], // big-endian TIFF
...u32(8), // the first IFD's offset
// The first IFD, at 8: five entries, then no next IFD.
...u16(5),
...entry(0x010f, 2, 6, u32(74)), // Make
...entry(0x0110, 2, 7, u32(80)), // Model
...entry(0x0112, 3, 1, [...u16(6), 0, 0]), // Orientation
...entry(0x8769, 4, 1, u32(88)), // the Exif IFD's offset
...entry(0x8825, 4, 1, u32(246)), // the GPS IFD's offset
...u32(0),
...ascii("Canon"), // at 74
...ascii("EOS R5"), // at 80
0, // a pad byte
// The Exif IFD, at 88: seven entries, then no next IFD.
...u16(7),
...entry(0x829a, 5, 1, u32(178)), // ExposureTime
...entry(0x829d, 5, 1, u32(186)), // FNumber
...entry(0x8827, 3, 1, [...u16(400), 0, 0]), // ISOSpeedRatings
...entry(0x9003, 2, 20, u32(194)), // DateTimeOriginal
...entry(0x9011, 2, 7, u32(214)), // OffsetTimeOriginal
...entry(0x920a, 5, 1, u32(222)), // FocalLength
...entry(0xa434, 2, 16, u32(230)), // LensModel
...u32(0),
...rational(1, 250), // at 178
...rational(28, 10), // at 186
...ascii("2021:07:15 14:30:00"), // at 194
...ascii("+02:00"), // at 214
0, // a pad byte
...rational(50, 1), // at 222
...ascii("RF50mm F1.8 STM"), // at 230
// The GPS IFD, at 246: six entries, then no next IFD.
...u16(6),
...entry(0x0001, 2, 2, [...ascii("N"), 0, 0]), // GPSLatitudeRef
...entry(0x0002, 5, 3, u32(324)), // GPSLatitude
...entry(0x0003, 2, 2, [...ascii("W"), 0, 0]), // GPSLongitudeRef
...entry(0x0004, 5, 3, u32(348)), // GPSLongitude
...entry(0x0005, 1, 1, [1, 0, 0, 0]), // GPSAltitudeRef: below sea level
...entry(0x0006, 5, 1, u32(372)), // GPSAltitude
...u32(0),
...[...rational(40, 1), ...rational(26, 1), ...rational(46, 1)], // at 324
...[...rational(79, 1), ...rational(58, 1), ...rational(56, 1)], // at 348
...rational(25, 2), // at 372
];
const JPEG_WITH_EXIF = new Uint8Array([
...[0xff, 0xd8], // start of image
...[0xff, 0xe1, ...u16(2 + 6 + TIFF.length)], // APP1 and its length
...[...ascii("Exif"), 0], // "Exif\0\0"
...TIFF,
...[0xff, 0xda, 0x00, 0x02], // start of scan
]);
// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF
// byte order belongs, so exifreader cannot parse it.
const JPEG_WITH_BAD_EXIF = new Uint8Array([
...[0xff, 0xd8], // start of image
...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length
...[...ascii("Exif"), 0], // "Exif\0\0"
...[0x58, 0x58], // "XX"
...[0xff, 0xda, 0x00, 0x02], // start of scan
]);
describe("Photo save path, local copy, content and EXIF", () => {
// The same account, with `files` in its album instead.
class FilesClient extends MockClient {
constructor(private readonly files: EnteFile[]) {
super();
}
override async filesSince(): Promise<FilesPage> {
return { files: this.files, deleted: [], cursor: 1 };
}
}
const open = (opts: Partial<LibraryOptions> = {}): Promise<Library> =>
Library.open({
client: new MockClient(),
cacheDirectory: join(root, "cache"),
downloadDirectory: join(root, "backup"),
contentSource: stubSource(),
refreshIntervalSeconds: 3600,
precacheThumbnails: false,
precacheOriginals: false,
...opts,
});
it("names where a backup writes the original, which is local once the backup has written it", async () => {
const lib = await open();
const photo = lib.photos.byID({ fileID: 1 })!;
const savePath = join(root, "backup", DAY, "2026-03-01.1.jpg");
expect(photo.savePath).toBe(savePath);
expect(photo.isLocal).toBe(false);
await lib.backup();
expect(photo.savePath).toBe(savePath);
expect(existsSync(savePath)).toBe(true);
expect(photo.isLocal).toBe(true);
await lib.close();
});
it("returns the original's bytes, and a copy only in the cache is not local", async () => {
const lib = await open();
const photo = lib.photos.byID({ fileID: 1 })!;
expect(await photo.content()).toEqual(Buffer.from("orig-bytes"));
expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe(
true,
);
expect(existsSync(photo.savePath)).toBe(false);
expect(photo.isLocal).toBe(false);
await lib.close();
});
it("saves under photos/ in the working directory at open without a download directory", async () => {
const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
const lib = await open({ downloadDirectory: undefined });
cwd.mockRestore();
const photo = lib.photos.byID({ fileID: 1 })!;
expect(lib.downloadDirectory).toBe(join(root, "photos"));
expect(photo.savePath).toBe(
join(root, "photos", DAY, "2026-03-01.1.jpg"),
);
expect(photo.isLocal).toBe(false);
await lib.close();
});
it("refuses an empty download directory", async () => {
await expect(open({ downloadDirectory: "" })).rejects.toThrow(
/downloadDirectory is empty/,
);
});
it("keeps a photo's save path after a refresh removes its file", async () => {
// The same account, whose album is deleted on the second refresh.
class AlbumDeletedClient extends MockClient {
override async collectionsSince(): Promise<CollectionsPage> {
if (!this.served) return super.collectionsSince();
return { collections: [], deleted: [1], cursor: 2 };
}
}
const lib = await open({ client: new AlbumDeletedClient() });
const photo = lib.photos.byID({ fileID: 1 })!;
await photo.download();
await lib.fresh();
expect(lib.photos.byID({ fileID: 1 })).toBeUndefined();
expect(photo.savePath).toBe(
join(root, "backup", DAY, "2026-03-01.1.jpg"),
);
expect(photo.isLocal).toBe(true);
await lib.close();
});
it("dates the save path and download() by the membership its takenAt comes from", async () => {
// One file in two albums. The date edited in Ente has reached album 2's
// copy, synced later, but album 1 still has an earlier edit.
const older = file(1, 1);
older.pubMagicMetadata = {
editedTime: new Date(2026, 1, 1, 12).getTime() * 1000,
};
const newer = file(1, 2);
newer.updationTime = 2;
newer.pubMagicMetadata = {
editedTime: new Date(2026, 3, 15, 12).getTime() * 1000,
};
const client = {
whoami: () => ({ email: "u@example.com", userID: USER_ID }),
collectionsSince: async (): Promise<CollectionsPage> => ({
collections: [collection(1), collection(2)],
deleted: [],
cursor: 1,
}),
filesSince: async (args: {
collectionID: number;
}): Promise<FilesPage> => ({
files: [args.collectionID === 1 ? older : newer],
deleted: [],
cursor: 1,
}),
};
const lib = await open({ client });
const photo = lib.photos.byID({ fileID: 1 })!;
expect(photo.takenAt).toBe(new Date(2026, 3, 15, 12).getTime());
expect(photo.savePath).toBe(
join(
root,
"backup",
"2026",
"2026-04",
"2026-04-15",
"2026-04-15.1.jpg",
),
);
// download() writes to that same path, so the photo is then local.
const saved = await photo.download();
expect(saved.path).toBe(photo.savePath);
expect(photo.isLocal).toBe(true);
expect(existsSync(join(root, "backup", "2026", "2026-02"))).toBe(false);
await lib.close();
});
it("downloads a photo held across a refresh that edits its date to the save path it names", async () => {
// The same account, whose second refresh brings a date edited in Ente.
const edited = file(1, 1);
edited.updationTime = 2;
edited.pubMagicMetadata = {
editedTime: new Date(2026, 3, 15, 12).getTime() * 1000,
};
class DateEditedClient extends MockClient {
refreshes = 0;
override async collectionsSince(): Promise<CollectionsPage> {
this.refreshes++;
return {
collections: [
{ ...collection(1), updationTime: this.refreshes },
],
deleted: [],
cursor: this.refreshes,
};
}
override async filesSince(): Promise<FilesPage> {
return {
files: [this.refreshes === 1 ? file(1, 1) : edited],
deleted: [],
cursor: this.refreshes,
};
}
}
const lib = await open({ client: new DateEditedClient() });
const photo = lib.photos.byID({ fileID: 1 })!;
await lib.fresh();
expect(lib.photos.byID({ fileID: 1 })!.takenAt).toBe(
new Date(2026, 3, 15, 12).getTime(),
);
const saved = await photo.download();
expect(saved.path).toBe(photo.savePath);
expect(saved.path).toBe(join(root, "backup", DAY, "2026-03-01.1.jpg"));
expect(photo.isLocal).toBe(true);
await lib.close();
});
it("has a save path, and is not local, without a content source", async () => {
const lib = await open({ contentSource: undefined });
const photo = lib.photos.byID({ fileID: 1 })!;
expect(photo.savePath).toBe(
join(root, "backup", DAY, "2026-03-01.1.jpg"),
);
expect(photo.isLocal).toBe(false);
await lib.close();
});
it("gives a live photo's image as its save path once a backup has stored it", async () => {
const { file: live, body } = await asLivePhoto(file(1, 1));
const lib = await open({
client: new FilesClient([live]),
contentSource: cdnSource(new Map([[1, body]])),
});
const photo = lib.photos.byID({ fileID: 1 })!;
const day = join(root, "backup", DAY);
// Until then the name comes from the title, file-1.jpg; the backup
// stores the image with the extension found inside the live photo.
expect(photo.savePath).toBe(join(day, "2026-03-01.1.jpg"));
await lib.backup();
expect(photo.savePath).toBe(join(day, "2026-03-01.1.heic"));
expect(photo.isLocal).toBe(true);
expect(await photo.content()).toEqual(Buffer.from(IMAGE));
await lib.close();
});
it("downloads an original the cache holds by copying it, without fetching it again", async () => {
const source = stubSource();
const lib = await open({ contentSource: source });
const photo = lib.photos.byID({ fileID: 1 })!;
await photo.original();
expect(source.originalCalls()).toBe(1);
expect(photo.isLocal).toBe(false);
const saved = await photo.download();
expect(source.originalCalls()).toBe(1);
expect(saved).toEqual({
path: photo.savePath,
bytes: "orig-bytes".length,
});
expect(readFileSync(photo.savePath, "utf-8")).toBe("orig-bytes");
expect(photo.isLocal).toBe(true);
// The cache keeps its own copy.
expect(existsSync(join(root, "cache", "originals", "1.jpg"))).toBe(
true,
);
await lib.close();
});
it("downloads an original the cache does not hold straight to its save path", async () => {
const source = stubSource();
const lib = await open({ contentSource: source });
const photo = lib.photos.byID({ fileID: 1 })!;
const saved = await photo.download();
expect(source.originalCalls()).toBe(1);
expect(saved.path).toBe(join(root, "backup", DAY, "2026-03-01.1.jpg"));
expect(readFileSync(saved.path, "utf-8")).toBe("orig-bytes");
expect(photo.isLocal).toBe(true);
expect(readdirSync(join(root, "cache", "originals"))).toEqual([]);
await lib.close();
});
it("does nothing when download() finds the original already at its save path", async () => {
const source = stubSource();
const lib = await open({ contentSource: source });
const photo = lib.photos.byID({ fileID: 1 })!;
const first = await photo.download();
const written = statSync(first.path).ino;
const second = await photo.download();
expect(second).toEqual(first);
expect(source.originalCalls()).toBe(1);
expect(statSync(second.path).ino).toBe(written);
await lib.close();
});
it("downloads a live photo the cache holds as its image, its video and the JSON file naming them", async () => {
const { file: live, body } = await asLivePhoto(file(1, 1));
const lib = await open({
client: new FilesClient([live]),
contentSource: cdnSource(new Map([[1, body]])),
});
const photo = lib.photos.byID({ fileID: 1 })!;
await photo.original();
const day = join(root, "backup", DAY);
const saved = await photo.download();
expect(saved).toEqual({
path: join(day, "2026-03-01.1.heic"),
videoPath: join(day, "2026-03-01.1.mov"),
bytes: IMAGE.length,
});
expect(readdirSync(day).sort()).toEqual([
"2026-03-01.1.heic",
"2026-03-01.1.livephoto.json",
"2026-03-01.1.mov",
]);
expect(
JSON.parse(
readFileSync(join(day, "2026-03-01.1.livephoto.json"), "utf-8"),
),
).toEqual({ image: "2026-03-01.1.heic", video: "2026-03-01.1.mov" });
expect(photo.savePath).toBe(saved.path);
expect(photo.isLocal).toBe(true);
await lib.close();
});
// Every tag in JPEG_WITH_EXIF, by name, in the order of its IFDs.
const jpegTags = [
"Make",
"Model",
"Orientation",
"Exif IFD Pointer",
"GPS Info IFD Pointer",
"ExposureTime",
"FNumber",
"ISOSpeedRatings",
"DateTimeOriginal",
"OffsetTimeOriginal",
"FocalLength",
"LensModel",
"GPSLatitudeRef",
"GPSLatitude",
"GPSLongitudeRef",
"GPSLongitude",
"GPSAltitudeRef",
"GPSAltitude",
];
// HEIC_WITH_EXIF holds those and the tags exiftool adds to every file.
const heicTags = [
...jpegTags,
"YCbCrPositioning",
"ExifVersion",
"ComponentsConfiguration",
"ColorSpace",
"GPSVersionID",
];
it.each([
[
"JPEG",
JPEG_WITH_EXIF,
jpegTags,
{
"Exif IFD Pointer": { value: 88 },
GPSLatitudeRef: { value: ["N"], description: "North latitude" },
},
],
[
"HEIC",
HEIC_WITH_EXIF,
heicTags,
{
ColorSpace: { value: 0xffff, description: "Uncalibrated" },
ExifVersion: { description: "0232" },
},
],
])(
"returns every EXIF tag of a %s original, by name",
async (_, bytes, names, others) => {
const lib = await open({ contentSource: stubSource(bytes) });
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
expect(Object.keys(exif).sort()).toEqual([...names].sort());
// Tags outside the thirteen fields, as exifreader decodes them.
expect(exif).toMatchObject(others);
await lib.close();
},
);
it("picks the common EXIF fields from a JPEG original's tags", async () => {
const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) });
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
expect(readPhotoExif(exif)).toStrictEqual({
make: "Canon",
model: "EOS R5",
lensModel: "RF50mm F1.8 STM",
// The camera's clock reading, held in the Date's UTC fields.
dateTimeOriginal: new Date(Date.UTC(2021, 6, 15, 14, 30)),
offsetTimeOriginal: "+02:00",
exposureTime: 1 / 250,
fNumber: 2.8,
iso: 400,
focalLength: 50,
orientation: 6,
gpsLatitude: 40 + 26 / 60 + 46 / 3600,
gpsLongitude: -(79 + 58 / 60 + 56 / 3600),
gpsAltitude: -12.5,
});
await lib.close();
});
// The fields picked from HEIC_WITH_EXIF's tags, and from JPEG_WITH_EXIF's,
// which hold the same values.
const heicFields: PhotoExif = {
make: "Canon",
model: "EOS R5",
lensModel: "RF50mm F1.8 STM",
dateTimeOriginal: new Date(Date.UTC(2021, 6, 15, 14, 30)),
offsetTimeOriginal: "+02:00",
exposureTime: 1 / 250,
fNumber: 2.8,
iso: 400,
focalLength: 50,
orientation: 6,
gpsLatitude: 40 + 26 / 60 + 46 / 3600,
gpsLongitude: -(79 + 58 / 60 + 56 / 3600),
gpsAltitude: -12.5,
};
it("picks the same common EXIF fields from a HEIC original's tags", async () => {
const lib = await open({ contentSource: stubSource(HEIC_WITH_EXIF) });
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
expect(readPhotoExif(exif)).toStrictEqual(heicFields);
await lib.close();
});
it("reads the EXIF of a live photo whose image is a HEIC", async () => {
const { file: live, body } = await asLivePhoto(
file(1, 1),
livePhotoZip({ "image.heic": HEIC_WITH_EXIF, "video.mov": VIDEO }),
livePhotoHash(HEIC_WITH_EXIF, VIDEO),
);
const lib = await open({
client: new FilesClient([live]),
contentSource: cdnSource(new Map([[1, body]])),
});
const exif = await lib.photos.byID({ fileID: 1 })!.exif();
expect(readPhotoExif(exif)).toStrictEqual(heicFields);
await lib.close();
});
// The build's type check, not this test, makes sure `Photo` has a method
// for every `PhotoExif` field, whatever the fixtures hold: `Photo`
// implements a type with one method per field. This test checks that each
// method gives the field picked from the tags exif() returns.
it.each([
["JPEG", JPEG_WITH_EXIF],
["HEIC", HEIC_WITH_EXIF],
])(
"has a method for each field, agreeing with the tags exif() returns, for a %s",
async (_, bytes) => {
const lib = await open({ contentSource: stubSource(bytes) });
const photo = lib.photos.byID({ fileID: 1 })!;
const fields = readPhotoExif(await photo.exif());
// The file holds every field, so every method is checked.
expect(fields).toStrictEqual(heicFields);
for (const [field, value] of Object.entries(fields)) {
expect(await photo[field as keyof PhotoExif]()).toStrictEqual(
value,
);
}
await lib.close();
},
);
it("returns no EXIF tags for an original that is not an image", async () => {
const lib = await open();
const photo = lib.photos.byID({ fileID: 1 })!;
expect(await photo.exif()).toStrictEqual({});
expect(await photo.dateTimeOriginal()).toBeUndefined();
await lib.close();
});
it("returns no EXIF tags for a JPEG whose EXIF cannot be parsed", async () => {
const lib = await open({
contentSource: stubSource(JPEG_WITH_BAD_EXIF),
});
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
await lib.close();
});
it("throws from exif() on a video without a content source, as the other content methods do", async () => {
const video = file(1, 1);
video.metadata.fileType = "video";
const lib = await open({
client: new FilesClient([video]),
contentSource: undefined,
});
await expect(lib.photos.byID({ fileID: 1 })!.exif()).rejects.toThrow(
/content cache/i,
);
await lib.close();
});
it("returns no EXIF tags for a video, without fetching it", async () => {
const video = file(1, 1);
video.metadata.fileType = "video";
const source = stubSource(JPEG_WITH_EXIF);
const lib = await open({
client: new FilesClient([video]),
contentSource: source,
});
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
expect(source.originalCalls()).toBe(0);
await lib.close();
});
});
+43 -80
View File
@@ -7,8 +7,8 @@
* 1. **Fetch once, then serve from disk.** The first `original`/`thumbnail` * 1. **Fetch once, then serve from disk.** The first `original`/`thumbnail`
* fetches through the request pool and stores the bytes; the next finds the * fetches through the request pool and stores the bytes; the next finds the
* file present and returns its path with a single `skipped` event and no * file present and returns its path with a single `skipped` event and no
* network. A file already stored at its save path under the * network. A file already sitting in the backup `downloadDirectory` counts
* `downloadDirectory` counts as present too. * as present too.
* 2. **Present-means-complete.** Content appears only by the streaming atomic * 2. **Present-means-complete.** Content appears only by the streaming atomic
* writer's rename, so a file that exists is whole. The directory listing * writer's rename, so a file that exists is whole. The directory listing
* taken at `open()` is the record of what is cached, and the orphan temp * taken at `open()` is the record of what is cached, and the orphan temp
@@ -41,7 +41,6 @@ import { join } from "node:path";
import { import {
ContentCache, ContentCache,
savePath,
type ContentSource, type ContentSource,
type EnsureEvent, type EnsureEvent,
} from "../../src/library/content.js"; } from "../../src/library/content.js";
@@ -153,48 +152,12 @@ const buildCache = (
pools: args.pools ?? new RequestPools(), pools: args.pools ?? new RequestPools(),
source, source,
cacheDirectory: cacheDir, cacheDirectory: cacheDir,
downloadDirectory: args.downloadDirectory ?? join(root, "photos"), downloadDirectory: args.downloadDirectory,
getFile: (id) => byID.get(id), getFile: (id) => byID.get(id),
}); });
return { cache, source }; return { cache, source };
}; };
// Microseconds, as Ente stores times, for noon local time on a day, so the
// machine's time zone cannot move the photo to another day.
const noon = (year: number, month: number, day: number): number =>
new Date(year, month - 1, day, 12).getTime() * 1000;
describe("savePath", () => {
it("files an original by year, month and day under the root", () => {
const f = file(12345, "IMG_0001.HEIC");
f.metadata.creationTime = noon(2026, 3, 1);
expect(savePath("/photos", f)).toBe(
"/photos/2026/2026-03/2026-03-01/2026-03-01.12345.HEIC",
);
});
it("dates an original by the date the user set, when there is one", () => {
const f = file(7, "a.jpg");
f.metadata.creationTime = noon(2026, 3, 1);
f.pubMagicMetadata = { editedTime: noon(1999, 12, 31) };
expect(savePath("/photos", f)).toBe(
"/photos/1999/1999-12/1999-12-31/1999-12-31.7.jpg",
);
});
it("takes the extension from the title it was uploaded with, not a new name", () => {
const f = file(8, "upload");
f.metadata.creationTime = noon(2026, 3, 1);
f.pubMagicMetadata = { editedName: "renamed.png" };
expect(savePath("/photos", f)).toBe(
"/photos/2026/2026-03/2026-03-01/2026-03-01.8.bin",
);
});
});
describe("ContentCache.open", () => { describe("ContentCache.open", () => {
it("creates the cache directories with 0700 permissions", async () => { it("creates the cache directories with 0700 permissions", async () => {
const { cache } = buildCache(); const { cache } = buildCache();
@@ -311,17 +274,11 @@ describe("ContentCache.original / thumbnail", () => {
it("serves a file already present in the download directory without fetching", async () => { it("serves a file already present in the download directory without fetching", async () => {
const downloadDirectory = join(root, "backup"); const downloadDirectory = join(root, "backup");
const day = join(downloadDirectory, "2026", "2026-03", "2026-03-01"); mkdirSync(join(downloadDirectory, "originals"), { recursive: true });
mkdirSync(day, { recursive: true }); const backupPath = join(downloadDirectory, "originals", "1.jpg");
const backupPath = join(day, "2026-03-01.1.jpg");
writeFileSync(backupPath, "from-backup"); writeFileSync(backupPath, "from-backup");
const f = file(1);
f.metadata.creationTime = noon(2026, 3, 1);
const { cache, source } = buildCache({ const { cache, source } = buildCache({ downloadDirectory });
downloadDirectory,
files: [f],
});
await cache.open(); await cache.open();
const events: EnsureEvent["status"][] = []; const events: EnsureEvent["status"][] = [];
@@ -566,6 +523,43 @@ describe("ContentCache live photos", () => {
expect(events).toEqual(["skipped"]); expect(events).toEqual(["skipped"]);
}); });
it("replaces a live photo an earlier version stored as a ZIP under the image's name", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
mkdirSync(originals(), { recursive: true });
writeFileSync(join(originals(), "5.HEIC"), livePhotoZip());
const cache = cacheOf([live], new Map([[5, body]]));
// Opened without being told that file 5 is a live photo, the cache
// records the ZIP, and does not serve it.
await cache.open();
const result = await cache.original(5);
expect(result.videoPath).toBe(join(originals(), "5.mov"));
expect(readdirSync(originals()).sort()).toEqual([
"5.heic",
"5.livephoto.json",
"5.mov",
]);
});
it("removes a live photo's ZIP an earlier version stored when it opens, so the precache fetches the image and video", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
mkdirSync(originals(), { recursive: true });
writeFileSync(join(originals(), "5.HEIC"), livePhotoZip());
const cache = cacheOf([live], new Map([[5, body]]));
await cache.open((fileID) => fileID === 5);
expect(readdirSync(originals())).toEqual([]);
expect(cache.pathsFor(5)).toEqual({});
const [fetched] = await cache.ensureOriginals({ fileIDs: [5] });
expect(fetched).toEqual({
fileID: 5,
path: join(originals(), "5.heic"),
});
expect(cache.pathsFor(5)).toEqual({ originalPath: fetched!.path });
});
it("leaves the image and video another process has just stored when it opens before their JSON file is written", async () => { it("leaves the image and video another process has just stored when it opens before their JSON file is written", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC")); const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
const server = cdnSource(new Map([[5, body]])); const server = cdnSource(new Map([[5, body]]));
@@ -596,36 +590,6 @@ describe("ContentCache live photos", () => {
expect(second!.pathsFor(5)).toEqual({}); expect(second!.pathsFor(5)).toEqual({});
}); });
it("fetches a live photo's image and video again when the cache opened before knowing it is a live photo and no JSON file names them", async () => {
const { file: live, body } = await asLivePhoto(file(5, "IMG_5.HEIC"));
mkdirSync(originals(), { recursive: true });
writeFileSync(join(originals(), "5.heic"), "an image");
writeFileSync(join(originals(), "5.mov"), "a video");
const cache = cacheOf([live], new Map([[5, body]]));
// Opened without being told that file 5 is a live photo, the cache
// records one of the two files as its original, with no video.
await cache.open();
const events: string[] = [];
const result = await cache.original(5, {
onProgress: (e) => events.push(e.status),
});
expect(events.at(-1)).toBe("done");
expect(result).toEqual({
path: join(originals(), "5.heic"),
videoPath: join(originals(), "5.mov"),
bytes: IMAGE.length,
});
expect(readFileSync(result.path)).toEqual(Buffer.from(IMAGE));
expect(readFileSync(result.videoPath!)).toEqual(Buffer.from(VIDEO));
expect(
JSON.parse(
readFileSync(join(originals(), "5.livephoto.json"), "utf-8"),
),
).toEqual({ image: "5.heic", video: "5.mov" });
});
it.each(["missing", "empty"])( it.each(["missing", "empty"])(
"fetches a live photo again when the video its JSON file names is %s", "fetches a live photo again when the video its JSON file names is %s",
async (state) => { async (state) => {
@@ -685,7 +649,6 @@ describe("ContentCache live photos", () => {
]), ]),
), ),
cacheDirectory: cacheDir, cacheDirectory: cacheDir,
downloadDirectory: join(root, "photos"),
getFile: (id) => [a.file, b.file].find((f) => f.id === id), getFile: (id) => [a.file, b.file].find((f) => f.id === id),
// Room for one live photo, on a disk with plenty free. // Room for one live photo, on a disk with plenty free.
cacheOriginalsMaxBytes: size, cacheOriginalsMaxBytes: size,
-2
View File
@@ -280,7 +280,6 @@ describe("Precache eviction integration", () => {
pools: new RequestPools(), pools: new RequestPools(),
source, source,
cacheDirectory: cacheDir, cacheDirectory: cacheDir,
downloadDirectory: join(root, "photos"),
getFile: (id) => byID.get(id), getFile: (id) => byID.get(id),
statfs, statfs,
cacheOriginalsMaxBytes: 25, // holds two 10-byte originals cacheOriginalsMaxBytes: 25, // holds two 10-byte originals
@@ -349,7 +348,6 @@ describe("Precache preemption", () => {
pools: new RequestPools({ contentConcurrency: 1 }), pools: new RequestPools({ contentConcurrency: 1 }),
source, source,
cacheDirectory: join(root, "cache"), cacheDirectory: join(root, "cache"),
downloadDirectory: join(root, "photos"),
getFile: (id) => byID.get(id), getFile: (id) => byID.get(id),
statfs: async () => ({ bsize: 1, bavail: 1_000_000_000 }), statfs: async () => ({ bsize: 1, bavail: 1_000_000_000 }),
freeBelowBytes: 0, freeBelowBytes: 0,
+3 -34
View File
@@ -36,7 +36,6 @@ import {
makeAlbumsAPI, makeAlbumsAPI,
makePhotosAPI, makePhotosAPI,
makeTimelineAPI, makeTimelineAPI,
type SavePathLookup,
type TimelineGroup, type TimelineGroup,
} from "../../src/library/read.js"; } from "../../src/library/read.js";
import { Library } from "../../src/library/index.js"; import { Library } from "../../src/library/index.js";
@@ -102,19 +101,12 @@ const file = (
}; };
// Build the three API objects over one fixed projection, the way `Library` // Build the three API objects over one fixed projection, the way `Library`
// wires them over its live store. Save paths are covered in // wires them over its live store.
// content-library.test.ts; these tests never ask for one.
const apis = (records: DerivedRecords) => { const apis = (records: DerivedRecords) => {
const derive = () => records; const derive = () => records;
const saves: SavePathLookup = {
savePath: () => {
throw new Error("no save paths in these tests");
},
isLocal: () => false,
};
return { return {
albums: makeAlbumsAPI(derive, saves), albums: makeAlbumsAPI(derive),
photos: makePhotosAPI(derive, saves), photos: makePhotosAPI(derive),
timeline: makeTimelineAPI(derive), timeline: makeTimelineAPI(derive),
}; };
}; };
@@ -217,29 +209,6 @@ describe("lib.photos", () => {
expect("key" in photo.record()).toBe(false); expect("key" in photo.record()).toBe(false);
}); });
it("byID exposes modifiedAt, hash and the year taken", () => {
// Mid-July, so the year is 2021 in every time zone.
const takenAt = Date.UTC(2021, 6, 15, 12);
const records = deriveRecords(
[collection(1)],
[
file(1001, 1, {
metadata: {
title: "IMG.jpg",
fileType: "image",
creationTime: micros(takenAt),
modificationTime: micros(1_700_000_123_456),
hash: "aGFzaA==",
},
}),
],
);
const photo = apis(records).photos.byID({ fileID: 1001 })!;
expect(photo.modifiedAt).toBe(ms(1_700_000_123_456));
expect(photo.hash).toBe("aGFzaA==");
expect(photo.year).toBe(2021);
});
it("byID returns undefined for an unknown file id", () => { it("byID returns undefined for an unknown file id", () => {
const records = deriveRecords([collection(1)], [file(1, 1)]); const records = deriveRecords([collection(1)], [file(1, 1)]);
expect(apis(records).photos.byID({ fileID: 999 })).toBeUndefined(); expect(apis(records).photos.byID({ fileID: 999 })).toBeUndefined();
-1
View File
@@ -159,7 +159,6 @@ describe("deriveRecords: photo mapping", () => {
expect("width" in rec).toBe(false); expect("width" in rec).toBe(false);
expect("height" in rec).toBe(false); expect("height" in rec).toBe(false);
expect("latitude" in rec).toBe(false); expect("latitude" in rec).toBe(false);
expect("hash" in rec).toBe(false);
}); });
it("reads archived and hidden from private magicMetadata.visibility", () => { it("reads archived and hidden from private magicMetadata.visibility", () => {
+1 -1
View File
@@ -18,5 +18,5 @@
"sourceMap": true, "sourceMap": true,
"resolveJsonModule": true "resolveJsonModule": true
}, },
"include": ["src/**/*", "bin/**/*", "examples/**/*"] "include": ["src/**/*", "bin/**/*"]
} }
+4 -11
View File
@@ -702,11 +702,6 @@
loupe "^3.1.2" loupe "^3.1.2"
tinyrainbow "^1.2.0" tinyrainbow "^1.2.0"
"@xmldom/xmldom@^0.9.10":
version "0.9.12"
resolved "https://registry.yarnpkg.com/@xmldom/xmldom/-/xmldom-0.9.12.tgz#1f84c07cb95ccf28202299f77b5fd7fc257151e8"
integrity sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==
acorn-jsx@^5.3.2: acorn-jsx@^5.3.2:
version "5.3.2" version "5.3.2"
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
@@ -1007,12 +1002,10 @@ esutils@^2.0.2:
resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
exifreader@4.46.0: exif-reader@2.0.3:
version "4.46.0" version "2.0.3"
resolved "https://registry.yarnpkg.com/exifreader/-/exifreader-4.46.0.tgz#b6216eae512997587c45114f972cc14ca979205f" resolved "https://registry.yarnpkg.com/exif-reader/-/exif-reader-2.0.3.tgz#259997735080bc6bb959c37b32c60f004ec4391d"
integrity sha512-ksHTpjXKWzbckY+bYlGaomG0EobHJkaMWLg5OPbzlTPda04F2dfrDfYSz8iAPp/kXYUSQdINBVI6nCAjQ8PQ/Q== integrity sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A==
optionalDependencies:
"@xmldom/xmldom" "^0.9.10"
expect-type@^1.1.0: expect-type@^1.1.0:
version "1.3.0" version "1.3.0"