Compare commits
7
Commits
main
..
4986ebd889
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4986ebd889 | ||
|
|
b669df220a | ||
|
|
67d554fb46 | ||
|
|
2b598d3622 | ||
|
|
e6825abcdb | ||
|
|
c27e2cb629 | ||
|
|
e6a9e929c2 |
@@ -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. It decrypts and persists all three metadata layers (basic, private
|
crashing. For each file it persists the basic metadata fields quak keeps (title,
|
||||||
magic, public magic) per file, including camera info, GPS coordinates, captions,
|
file type, creation and modification time, latitude, longitude, content hash),
|
||||||
and any face/keyword labels the Ente clients have added. A helper subcommand can
|
and the private and public magic metadata in full. 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,7 +51,8 @@ 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 every `refreshIntervalSeconds` (default 3).
|
// background. Later refreshes start `refreshIntervalSeconds` (default 3) after
|
||||||
|
// 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.
|
||||||
@@ -62,7 +63,7 @@ for (const album of lib.albums.list()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fresh reads await a server round-trip and answer with current state.
|
// Fresh reads await a server round-trip, joining one already running.
|
||||||
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`);
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ enumeration/download calls) is exported too and documented under Design below.
|
|||||||
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 the Makefile targets are thin shims that call them.
|
development workflow, and most Makefile targets are thin shims that call them.
|
||||||
The scripts are POSIX sh (not bash) so they run in minimal containers such as
|
The scripts are POSIX sh (not bash) so they run in minimal containers such as
|
||||||
alpine. We provide:
|
alpine. We provide:
|
||||||
|
|
||||||
@@ -122,9 +123,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>`. There is
|
each build one phase with `docker build --no-cache --target <phase>`. Neither
|
||||||
no host lint or test path: docker is required, and that also works where the
|
script runs the tools on the host: docker is required, and that also works where
|
||||||
docker daemon is remote and bind mounts are impossible.
|
the 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
|
||||||
@@ -170,16 +171,19 @@ 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 `main`.
|
1. Every change starts on a feature branch off `next`, and its pull request
|
||||||
|
targets `next`.
|
||||||
2. The first commit on the branch is the 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 feature branch can only be merged into `main` when `make check` is green.
|
4. A pull request can only be merged into `next` when `make check` is green.
|
||||||
`main` is always green. CI runs `script/cibuild`, which builds the
|
Once it has passed review, the repository manager squash-merges it into
|
||||||
`Dockerfile`: its `lint` and `test` phases, then the compile, so neither a
|
`next`. Only sneak merges `next` into `main`. `main` and `next` are always
|
||||||
red branch nor one that does not compile can pass CI.
|
green. CI runs `script/cibuild`, which builds the `Dockerfile`: its `lint`
|
||||||
|
and `test` phases, then the compile, so neither a red branch nor one that
|
||||||
|
does not compile can pass CI.
|
||||||
5. Tests are the canonical API documentation for this library. Every test file
|
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
|
||||||
@@ -197,7 +201,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 `main`.
|
`script/cibuild`, so a red branch still cannot reach `next`.
|
||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
@@ -209,7 +213,7 @@ the CLI is for humans.
|
|||||||
```
|
```
|
||||||
quak/
|
quak/
|
||||||
src/
|
src/
|
||||||
crypto/ libsodium primitives (boxes, secretstreams, KDF, SRP)
|
crypto/ libsodium primitives (boxes, secretstreams, KDF, hash)
|
||||||
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
|
||||||
@@ -219,7 +223,8 @@ 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: all decrypted metadata as JSON
|
backup-metadata: the metadata quak keeps, 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
|
||||||
@@ -250,7 +255,9 @@ the repository root rather than `src/`, because `bin/` is compiled too and
|
|||||||
### 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). No hand-rolled crypto.
|
required for `crypto_pwhash` / Argon2id), except the SRP handshake, which uses
|
||||||
|
`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:
|
||||||
|
|
||||||
@@ -258,19 +265,23 @@ 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: a 16-byte SRP login subkey is derived from the KEK using
|
3. SRP login: `crypto_kdf_derive_from_key` (BLAKE2b) derives a 32-byte subkey
|
||||||
`crypto_kdf_derive_from_key` (BLAKE2b) with subkey id 1 and context
|
from the KEK with subkey id 1 and context `loginctx`. Its first 16 bytes are
|
||||||
`loginctx`. That 16-byte value is the SRP password.
|
the SRP password.
|
||||||
4. After SRP completes (or after email-OTP fallback), the server returns a blob
|
4. When SRP completes, the server returns a blob of "key attributes" plus an
|
||||||
of "key attributes" plus an encrypted auth token.
|
encrypted auth token, or first asks for a second factor. quak answers a TOTP
|
||||||
|
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 URL-safe base64 auth token used in `X-Auth-Token` for all
|
yields the auth token's bytes. Encoded as URL-safe base64 with padding, they
|
||||||
subsequent calls.
|
are the `X-Auth-Token` value for all 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
|
||||||
@@ -306,7 +317,8 @@ 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 fallback path.
|
- `POST /users/ott` and `POST /users/verify-email`: email OTP, used instead of
|
||||||
|
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
|
||||||
@@ -328,8 +340,9 @@ 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. The errno is looked for in the
|
TLS failure — and deadline aborts: retried. Node's `fetch` rejects with a
|
||||||
error's `cause` chain, because that is where Node's `fetch` puts it.
|
plain `TypeError`, so every `TypeError` is retried. The errno is looked for in
|
||||||
|
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
|
||||||
@@ -395,10 +408,13 @@ 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 stays outside the retry, so a download that needed three attempts
|
atomic write is part of the retried unit: each attempt writes its own temporary
|
||||||
still performs exactly one write and one rename. `runBackup` and
|
files, one for most files and two for a live photo (its image and its video),
|
||||||
`runMetadataBackup` are unchanged: the retry sits below them, and a file that
|
and removes them if it fails. Only the attempt that completes renames anything
|
||||||
fails after exhausting it is still logged, counted, and stepped over.
|
into place. The retry sits below `runBackup` and `runMetadataBackup`.
|
||||||
|
`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
|
||||||
@@ -422,10 +438,11 @@ 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; every
|
`client.logout()` clears the token and zeroes the key buffers in place; after
|
||||||
later call on that client throws. It does not contact the server, so the token
|
it, every other method on that client throws. It does not contact the server, so
|
||||||
stays valid there and in any saved snapshot; `await client.logoutOnServer()`
|
the token stays valid there and in any saved snapshot;
|
||||||
first ends the session on the server (`POST /users/logout`).
|
`await client.logoutOnServer()` first ends the session on the server
|
||||||
|
(`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,
|
||||||
@@ -433,41 +450,43 @@ 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.
|
field. Both exit with status 1, except that `quak logout` with no file says
|
||||||
|
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: it prints the account's cache directory and says
|
It does not delete the cache. When it knows the cache directory, from
|
||||||
it still holds decrypted data (file keys in `metadata.json`, cached originals
|
`--cache-dir` or from a session file it could read, it prints it and says it
|
||||||
and thumbnails), for the user to delete if they want it gone.
|
still holds decrypted data (file keys in `metadata.json`, cached originals and
|
||||||
|
thumbnails), for the user to delete if they want it gone.
|
||||||
|
|
||||||
### CLI surface
|
### CLI surface
|
||||||
|
|
||||||
```
|
```
|
||||||
quak [--cache-dir <path>] <command> global: local metadata/content cache location
|
quak [--cache-dir <path>] <command> global: local metadata/content cache location
|
||||||
quak login interactive or QUAK_EMAIL/QUAK_PASSWORD
|
quak login interactive or QUAK_EMAIL/QUAK_PASSWORD
|
||||||
quak whoami print logged-in account as JSON
|
quak whoami print logged-in account as JSON
|
||||||
quak logout end the session, delete it
|
quak logout end the session, delete it
|
||||||
quak collections [--json] list all collections
|
quak collections [--json] list all collections
|
||||||
quak files --collection <id> [--json] list files in a collection
|
quak files --collection <id> [--json] list files in a collection
|
||||||
quak get <fileID> [--out path] [--collection] download and decrypt a file
|
quak get <fileID> [--out path] [--collection] download and decrypt a file
|
||||||
quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail
|
quak get-thumb <fileID> [--out] [--collection] download and decrypt a thumbnail
|
||||||
quak backup <dir> [--json] full incremental backup
|
quak backup <dir> [--json] full incremental backup
|
||||||
quak backup-metadata <dir> [--exif] dump all decrypted metadata as JSON
|
quak backup-metadata <dir> [--exif] dump the metadata quak keeps 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] generate + upload missing thumbnails
|
quak helper fix-missing-thumbnails [--file ids] [--json] generate + upload missing thumbnails
|
||||||
```
|
```
|
||||||
|
|
||||||
Every command runs on the same cache-backed library. The read commands —
|
Every command except `login`, `whoami` and `logout` runs on the cache-backed
|
||||||
`collections`, `files`, `get`, `get-thumb`, `backup-metadata`,
|
library. The read commands — `collections`, `files`, `get`, `get-thumb`,
|
||||||
`helper list-missing-thumbnails` and `helper fix-missing-thumbnails` — force a
|
`backup-metadata`, `helper list-missing-thumbnails` and
|
||||||
fresh server round-trip before they answer, so they report current account state
|
`helper fix-missing-thumbnails` — force a fresh server round-trip before they
|
||||||
rather than whatever the cache last held. If that round-trip fails, the command
|
answer, so they report current account state rather than whatever the cache last
|
||||||
prints the error on one line and exits 1. `--cache-dir` overrides where the
|
held. If that round-trip fails, the command prints the error on one line and
|
||||||
cache lives; without it each account gets its own directory under the per-user
|
exits 1. `--cache-dir` overrides where the cache lives; without it each account
|
||||||
cache path.
|
gets its own directory under the per-user 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
|
||||||
@@ -475,18 +494,25 @@ 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 downloads each
|
refused. `backup-metadata --exif` (alias `--all`) additionally fetches each
|
||||||
file to extract full EXIF/IPTC/XMP metadata. The listing and backup commands
|
file's original through the cache and records, from it or a live photo's image,
|
||||||
support `--json` for machine-readable output.
|
its XMP metadata, its EXIF metadata and, for a JPEG, its dimensions. EXIF is
|
||||||
|
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
|
||||||
still fails after its retries, the error is logged, each of its files is written
|
fails, the error is logged, each of its files is written with the reason in an
|
||||||
with the reason in an `mlDataError` field instead of `mlData`, and the dump goes
|
`mlDataError` field instead of `mlData`, and the dump goes on. The exit code is
|
||||||
on. The exit code is non-zero if any ML data request failed.
|
non-zero if any ML data request failed.
|
||||||
|
|
||||||
`helper fix-missing-thumbnails` regenerates thumbnails for baseline JPEG images
|
`helper fix-missing-thumbnails` regenerates thumbnails for JPEG images only,
|
||||||
only, because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG
|
because the bundled decoder (`jpeg-js`) decodes only JPEG. A non-JPEG image
|
||||||
image (PNG, HEIC) or a video is reported as `skipped` (unsupported format), kept
|
(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
|
||||||
@@ -505,7 +531,9 @@ the smallest does not.
|
|||||||
originals/
|
originals/
|
||||||
<fileID>.<ext> actual file content (one per unique file,
|
<fileID>.<ext> actual file content (one per unique file,
|
||||||
two for a live photo: see below)
|
two for a live photo: see below)
|
||||||
<fileID>.json all decrypted metadata for that file
|
<fileID>.json the file's basic metadata fields quak
|
||||||
|
keeps, and its private and public magic
|
||||||
|
metadata
|
||||||
<fileID>.livephoto.json which of a live photo's two files is which
|
<fileID>.livephoto.json which of a live photo's two files is which
|
||||||
collections/
|
collections/
|
||||||
<name>/
|
<name>/
|
||||||
@@ -550,7 +578,9 @@ 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 it fetches only what the backup stores.
|
originals precache off, so the only file content it fetches is the originals the
|
||||||
|
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
|
||||||
@@ -567,8 +597,8 @@ temporary file's permissions, not those of the file it replaced.
|
|||||||
|
|
||||||
## TODO
|
## TODO
|
||||||
|
|
||||||
- [x] Retry policy: no retry on 4xx, exponential backoff on 5xx and network
|
- [x] Retry policy: no retry on 4xx (except `408` and `429`), exponential
|
||||||
errors
|
backoff on 5xx and network 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
|
||||||
@@ -591,7 +621,8 @@ 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 every operation, and `yarn test` verifies them.
|
`test/client/usage.test.ts` walk most operations, `test/cli/backup.test.ts`
|
||||||
|
walks `lib.backup()`, and `yarn test` verifies them.
|
||||||
|
|
||||||
### Opening a library
|
### Opening a library
|
||||||
|
|
||||||
@@ -604,21 +635,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` | `<XDG cache>/quak/<userID>` | where `metadata.json` and the content cache live |
|
| `cacheDirectory` | `quak/<userID>` under the per-user cache directory | where `metadata.json` and the content cache live |
|
||||||
| `downloadDirectory` | none | backup destination; an original already 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 | hard ceiling on the originals cache |
|
| `cacheOriginalsMaxBytes` | 100 GiB | size limit on the originals cache; pinned originals can exceed it |
|
||||||
| `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) |
|
||||||
|
|
||||||
Concurrency is set through `pools`: construct
|
Concurrency is set through `pools`: construct
|
||||||
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
|
`new RequestPools({ metadataConcurrency, contentConcurrency, thumbnailConcurrency })`
|
||||||
@@ -635,17 +666,21 @@ 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 last refreshed copy held in RAM and never touch the
|
synchronously from the copy held in RAM and never touch the network. A refresh
|
||||||
network. The background timer refreshes that copy every
|
changes that copy only once all its server requests have succeeded, and the
|
||||||
`refreshIntervalSeconds`, so a default read is immediate but may be up to one
|
background timer starts the next refresh `refreshIntervalSeconds` after the
|
||||||
interval stale.
|
previous one ends. So a default read is immediate, but only as current as the
|
||||||
|
last refresh whose requests all succeeded; right after opening an existing
|
||||||
|
cache, it is the copy on disk.
|
||||||
|
|
||||||
`await lib.fresh()` forces a refresh, waits for it to complete and persist, and
|
`await lib.fresh()` waits for a refresh to complete and persist, and returns the
|
||||||
returns the same `{ albums, photos, timeline }` namespaces — now guaranteed to
|
same `{ albums, photos, timeline }` namespaces, which then reflect a completed
|
||||||
reflect a completed server round-trip. Concurrent `fresh()` calls coalesce onto
|
server round-trip. When a refresh is already running, background or not,
|
||||||
one refresh, and a refresh that fails rejects the caller (default reads stay
|
`fresh()` waits for that one, so its answer can come from requests made before
|
||||||
silent and keep serving the last good copy). The CLI's read commands use fresh
|
the call; only when none is running does it start one. A refresh that fails
|
||||||
reads (issue https://git.eeqj.de/sneak/quak/issues/75).
|
rejects the caller (default reads stay silent and keep serving the last good
|
||||||
|
copy). The CLI's read commands use fresh reads (issue
|
||||||
|
https://git.eeqj.de/sneak/quak/issues/75).
|
||||||
|
|
||||||
### Read surface
|
### Read surface
|
||||||
|
|
||||||
@@ -662,17 +697,50 @@ reads (issue 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, `photo.record()` →
|
(newest first). A `Photo` exposes its record fields other than `thumbnailPath`
|
||||||
`PhotoRecord`, and two content methods:
|
and `originalPath`, and `photo.year`, the local-time year of `takenAt`, all as
|
||||||
|
synchronous getters read from RAM; `photo.record()` → `PhotoRecord`. Two more
|
||||||
|
synchronous getters look at the disk and never touch the network:
|
||||||
|
|
||||||
|
- `photo.savePath` → `string | undefined` — where `lib.backup()` writes the
|
||||||
|
original, `originals/<fileID>.<ext>` under the `downloadDirectory` the library
|
||||||
|
was opened with, whether or not it is there yet: for a live photo already
|
||||||
|
stored, its image. For a live photo not yet stored, it carries the title's
|
||||||
|
extension, and the backup may store the image under a different one, found
|
||||||
|
inside the live photo. `undefined` when the library has no `downloadDirectory`
|
||||||
|
or no content source.
|
||||||
|
- `photo.isLocal` → `boolean` — whether the whole original is at `savePath`.
|
||||||
|
|
||||||
|
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.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?)` → `PhotoExif` — `make`, `model`, `lensModel`,
|
||||||
|
`dateTimeOriginal`, `offsetTimeOriginal`, `exposureTime`, `fNumber`, `iso`,
|
||||||
|
`focalLength`, `orientation`, `gpsLatitude`, `gpsLongitude` and `gpsAltitude`,
|
||||||
|
each absent 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. 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 field of `exif()` each, typed as in `PhotoExif`, or
|
||||||
|
`undefined` when the file lacks it. Each calls `exif()` with its `opts`, so
|
||||||
|
each call reads the original again.
|
||||||
|
|
||||||
Both serve from the on-disk content cache when the bytes are present and
|
They serve from the on-disk content cache when the bytes are present and
|
||||||
otherwise fetch through the pools; `opts.onProgress` reports per-file progress.
|
otherwise fetch through the pools; `original()`, `content()` and `exif()` also
|
||||||
They throw when the library was opened without a content source.
|
serve an original the backup has already stored. `opts.onProgress` reports
|
||||||
|
per-file progress. They throw when the library was opened without a content
|
||||||
|
source. An original that `content()` or `exif()` downloads lands in the cache,
|
||||||
|
which does not make `isLocal` true; only `lib.backup()` does.
|
||||||
|
|
||||||
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)`,
|
||||||
@@ -684,10 +752,12 @@ 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` (milliseconds),
|
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` and `modifiedAt`
|
||||||
`fileType`, optional `caption` / `width` / `height` / `latitude` /
|
(milliseconds), `fileType`, optional `caption` / `width` / `height` /
|
||||||
`longitude`, `isArchived`, `isHidden`, and `thumbnailPath` / `originalPath`
|
`latitude` / `longitude`, optional `hash` (the content hash recorded at
|
||||||
once the bytes are cached (for a live photo, `originalPath` is its image).
|
upload; very old files have none), `isArchived`, `isHidden`, and
|
||||||
|
`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 }`.
|
||||||
@@ -712,16 +782,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 refreshes, fetches every
|
- `await lib.backup(opts?)` → `BackupResult`. It waits for a refresh as
|
||||||
in-scope original not already in the backup (and, with `includeThumbnails`,
|
`fresh()` does, fetches every in-scope original not already in the backup
|
||||||
thumbnails) through the content cache, and rebuilds the on-disk backup tree
|
(and, with `includeThumbnails`, thumbnails) through the content cache, and
|
||||||
with a durable failure ledger. A fetched original is written straight into the
|
rebuilds the on-disk backup tree with a durable failure ledger. A fetched
|
||||||
backup's `originals/` and not into the cache, which then counts it as present;
|
original is written straight into the backup's `originals/` and not into the
|
||||||
one the cache already held is copied from there. `BackupOptions`:
|
cache, which then counts it as present; one the cache already held is copied
|
||||||
`downloadDirectory` (falls back to the one `open()` was given),
|
from there. `BackupOptions`: `downloadDirectory` (falls back to the one
|
||||||
`includeOriginals` (default `true`), `includeThumbnails` (default `false`),
|
`open()` was given), `includeOriginals` (default `true`), `includeThumbnails`
|
||||||
`onlyAlbumNames`, and `onProgress`. See Backup layout above for the tree it
|
(default `false`), `onlyAlbumNames`, and `onProgress`. See Backup layout above
|
||||||
writes.
|
for the tree it writes.
|
||||||
|
|
||||||
### Request pools
|
### Request pools
|
||||||
|
|
||||||
@@ -782,6 +852,7 @@ from a very old client, is stored unchecked.
|
|||||||
- `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`: `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`
|
||||||
@@ -813,14 +884,15 @@ 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. Tests are written first and committed in a failing state before the
|
branches off `next`, and every pull request targets `next`; only sneak merges
|
||||||
implementation. Tests are the canonical API documentation and must be
|
`next` into `main`. Tests are written first and committed in a failing state
|
||||||
commented thoroughly. `main` is always green.
|
before the implementation. Tests are the canonical API documentation and must
|
||||||
|
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 to `main`.
|
`make check` (which also runs the tests) must pass before merging into `next`.
|
||||||
`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;
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
# Workflow
|
# Workflow
|
||||||
|
|
||||||
- branch (from `main`)
|
- branch from `next`
|
||||||
- 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
|
||||||
|
|
||||||
@@ -14,13 +17,81 @@ pre-1.0
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
None: every issue still open is done on `next` and waits for it to reach `main`.
|
None: no implementation work is open. The cache design,
|
||||||
|
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-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: `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`
|
||||||
|
|||||||
+1
-1
@@ -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",
|
||||||
"exif-reader": "2.0.3",
|
"exifreader": "4.46.0",
|
||||||
"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",
|
||||||
|
|||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
// 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 the common fields picked from them here.
|
||||||
|
|
||||||
|
import ExifReader, { type ExpandedTags } from "exifreader";
|
||||||
|
|
||||||
|
// The EXIF tags in `bytes` (`exif`), the GPS position exifreader computes from
|
||||||
|
// them (`gps`), 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`.
|
||||||
|
// `exif` holds every tag except the thumbnail's; 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, gps: true },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The common EXIF fields of an original. 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The common fields of an original's EXIF: `{}` when the file has no EXIF,
|
||||||
|
// exifreader cannot read its EXIF, or it is not an image exifreader reads.
|
||||||
|
export const readPhotoExif = (bytes: Uint8Array): PhotoExif => {
|
||||||
|
const tags = readExifTags(bytes);
|
||||||
|
const exif = tags?.exif;
|
||||||
|
const gps = tags?.gps;
|
||||||
|
const altitude = asNumber(exif?.GPSAltitude?.computed);
|
||||||
|
const fields: PhotoExif = {
|
||||||
|
make: asString(exif?.Make?.computed),
|
||||||
|
model: asString(exif?.Model?.computed),
|
||||||
|
lensModel: asString(exif?.LensModel?.computed),
|
||||||
|
dateTimeOriginal: asDate(exif?.DateTimeOriginal?.computed),
|
||||||
|
offsetTimeOriginal: asString(exif?.OffsetTimeOriginal?.computed),
|
||||||
|
exposureTime: asNumber(exif?.ExposureTime?.computed),
|
||||||
|
fNumber: asNumber(exif?.FNumber?.computed),
|
||||||
|
// Only when the tag holds a single number, as most cameras write it.
|
||||||
|
iso: asNumber(exif?.ISOSpeedRatings?.computed),
|
||||||
|
focalLength: asNumber(exif?.FocalLength?.computed),
|
||||||
|
orientation: asNumber(exif?.Orientation?.computed),
|
||||||
|
gpsLatitude: asNumber(gps?.Latitude),
|
||||||
|
gpsLongitude: asNumber(gps?.Longitude),
|
||||||
|
// A GPSAltitudeRef of 1 means the altitude is below sea level.
|
||||||
|
gpsAltitude:
|
||||||
|
altitude !== undefined && exif?.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;
|
||||||
|
};
|
||||||
@@ -84,6 +84,7 @@ export type {
|
|||||||
LibrarySnapshot,
|
LibrarySnapshot,
|
||||||
LibraryChange,
|
LibraryChange,
|
||||||
} from "./library/records.js";
|
} from "./library/records.js";
|
||||||
|
export type { 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 {
|
||||||
|
|||||||
@@ -108,6 +108,11 @@ 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>;
|
||||||
|
// Where a backup stores the original, whether or not it is there yet. For
|
||||||
|
// a live photo not yet stored, it carries the title's extension, and the
|
||||||
|
// backup may store the image under a different one.
|
||||||
|
savePath(fileID: number): string | undefined;
|
||||||
|
isLocal(fileID: number): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnsureResult {
|
export interface EnsureResult {
|
||||||
@@ -435,6 +440,31 @@ export class ContentCache implements PhotoContent, ThumbnailsAPI {
|
|||||||
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
|
return this.get(fileID, "thumbnail", "on-demand", opts?.onProgress);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where a backup to the download directory stores the file's original,
|
||||||
|
// whether or not it is there yet: for a live photo already stored, its
|
||||||
|
// image. For a live photo not yet stored, it carries the title's
|
||||||
|
// extension, and the backup may store the image under a different one.
|
||||||
|
// Undefined with no download directory.
|
||||||
|
savePath(fileID: number): string | undefined {
|
||||||
|
const file = this.getFile(fileID);
|
||||||
|
if (this.downloadDirectory === undefined || file === undefined)
|
||||||
|
return undefined;
|
||||||
|
const dir = join(this.downloadDirectory, "originals");
|
||||||
|
return (
|
||||||
|
storedOriginal(dir, file)?.path ?? join(dir, nameInOriginals(file))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the whole original is in the download directory, as a backup
|
||||||
|
// stores it. A copy only in the cache does not count.
|
||||||
|
isLocal(fileID: number): boolean {
|
||||||
|
const file = this.getFile(fileID);
|
||||||
|
if (this.downloadDirectory === undefined || file === undefined)
|
||||||
|
return false;
|
||||||
|
const dir = join(this.downloadDirectory, "originals");
|
||||||
|
return storedOriginal(dir, file) !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Get an original for a backup. One not present anywhere is written
|
// Get an original for a backup. 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.
|
||||||
|
|||||||
+126
-11
@@ -11,11 +11,16 @@
|
|||||||
// 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()` and `thumbnail()` go
|
// A `Photo` also fetches its own bytes: `original()`, `thumbnail()`,
|
||||||
// through the on-disk content cache (issue #46), the one place in this module
|
// `content()`, `exif()` and the methods that each return one field of `exif()`
|
||||||
// that is not synchronous and RAM-only. A library opened without a content
|
// go through the on-disk content cache (issue #46), and are the one place in
|
||||||
// source leaves that cache absent, and those two methods then throw.
|
// this module that may touch the network. A library opened without a content
|
||||||
|
// source leaves that cache absent, and those methods then throw. `savePath` and
|
||||||
|
// `isLocal` look only at the disk.
|
||||||
|
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
import { readPhotoExif, type PhotoExif } from "../exif.js";
|
||||||
import type { CollectionType, FileType } from "../model/types.js";
|
import type { CollectionType, 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";
|
||||||
@@ -30,12 +35,19 @@ 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.
|
// underlying plain record for callers that need the IPC-safe value.
|
||||||
export class Photo {
|
export class Photo implements PhotoExifMethods {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly rec: PhotoRecord,
|
private readonly rec: PhotoRecord,
|
||||||
private readonly content?: PhotoContent,
|
private readonly cache?: PhotoContent,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get fileID(): number {
|
get fileID(): number {
|
||||||
@@ -50,6 +62,13 @@ export class Photo {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -68,6 +87,9 @@ export class Photo {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -75,6 +97,22 @@ export class Photo {
|
|||||||
return this.rec.isHidden;
|
return this.rec.isHidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Where `lib.backup()` stores the original in the library's download
|
||||||
|
// directory, whether or not it is there yet: for a live photo already
|
||||||
|
// stored, its image. For a live photo not yet stored, it carries the
|
||||||
|
// title's extension, and the backup may store the image under a different
|
||||||
|
// one. Undefined when the library has no download directory or no content
|
||||||
|
// cache.
|
||||||
|
get savePath(): string | undefined {
|
||||||
|
return this.cache?.savePath(this.rec.fileID);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether the whole original is at `savePath`. A copy only in the cache
|
||||||
|
// does not count.
|
||||||
|
get isLocal(): boolean {
|
||||||
|
return this.cache?.isLocal(this.rec.fileID) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
record(): PhotoRecord {
|
record(): PhotoRecord {
|
||||||
return this.rec;
|
return this.rec;
|
||||||
}
|
}
|
||||||
@@ -84,21 +122,98 @@ export class Photo {
|
|||||||
// `videoPath`. Served from the cache (or the backup download directory)
|
// `videoPath`. Served from the cache (or the backup download directory)
|
||||||
// when already 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.contentOrThrow().original(this.rec.fileID, opts);
|
return this.cacheOrThrow().original(this.rec.fileID, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.contentOrThrow().thumbnail(this.rec.fileID, opts);
|
return this.cacheOrThrow().thumbnail(this.rec.fileID, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private contentOrThrow(): PhotoContent {
|
// The original's bytes, read from where `original()` puts it. For a live
|
||||||
if (!this.content) {
|
// photo, its image's.
|
||||||
|
async content(opts?: ContentOptions): Promise<Uint8Array> {
|
||||||
|
const { path } = await this.original(opts);
|
||||||
|
return readFile(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The common EXIF fields of the original, 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<PhotoExif> {
|
||||||
|
this.cacheOrThrow();
|
||||||
|
if (this.rec.fileType === "video") return {};
|
||||||
|
return readPhotoExif(await this.content(opts));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One field of `exif()` each, named and typed as in `PhotoExif`, and
|
||||||
|
// undefined when the file lacks it. Each call runs `exif()`, which reads
|
||||||
|
// the original again.
|
||||||
|
async make(opts?: ContentOptions): Promise<PhotoExif["make"]> {
|
||||||
|
return (await this.exif(opts)).make;
|
||||||
|
}
|
||||||
|
async model(opts?: ContentOptions): Promise<PhotoExif["model"]> {
|
||||||
|
return (await this.exif(opts)).model;
|
||||||
|
}
|
||||||
|
async lensModel(opts?: ContentOptions): Promise<PhotoExif["lensModel"]> {
|
||||||
|
return (await this.exif(opts)).lensModel;
|
||||||
|
}
|
||||||
|
async dateTimeOriginal(
|
||||||
|
opts?: ContentOptions,
|
||||||
|
): Promise<PhotoExif["dateTimeOriginal"]> {
|
||||||
|
return (await this.exif(opts)).dateTimeOriginal;
|
||||||
|
}
|
||||||
|
async offsetTimeOriginal(
|
||||||
|
opts?: ContentOptions,
|
||||||
|
): Promise<PhotoExif["offsetTimeOriginal"]> {
|
||||||
|
return (await this.exif(opts)).offsetTimeOriginal;
|
||||||
|
}
|
||||||
|
async exposureTime(
|
||||||
|
opts?: ContentOptions,
|
||||||
|
): Promise<PhotoExif["exposureTime"]> {
|
||||||
|
return (await this.exif(opts)).exposureTime;
|
||||||
|
}
|
||||||
|
async fNumber(opts?: ContentOptions): Promise<PhotoExif["fNumber"]> {
|
||||||
|
return (await this.exif(opts)).fNumber;
|
||||||
|
}
|
||||||
|
async iso(opts?: ContentOptions): Promise<PhotoExif["iso"]> {
|
||||||
|
return (await this.exif(opts)).iso;
|
||||||
|
}
|
||||||
|
async focalLength(
|
||||||
|
opts?: ContentOptions,
|
||||||
|
): Promise<PhotoExif["focalLength"]> {
|
||||||
|
return (await this.exif(opts)).focalLength;
|
||||||
|
}
|
||||||
|
async orientation(
|
||||||
|
opts?: ContentOptions,
|
||||||
|
): Promise<PhotoExif["orientation"]> {
|
||||||
|
return (await this.exif(opts)).orientation;
|
||||||
|
}
|
||||||
|
async gpsLatitude(
|
||||||
|
opts?: ContentOptions,
|
||||||
|
): Promise<PhotoExif["gpsLatitude"]> {
|
||||||
|
return (await this.exif(opts)).gpsLatitude;
|
||||||
|
}
|
||||||
|
async gpsLongitude(
|
||||||
|
opts?: ContentOptions,
|
||||||
|
): Promise<PhotoExif["gpsLongitude"]> {
|
||||||
|
return (await this.exif(opts)).gpsLongitude;
|
||||||
|
}
|
||||||
|
async gpsAltitude(
|
||||||
|
opts?: ContentOptions,
|
||||||
|
): Promise<PhotoExif["gpsAltitude"]> {
|
||||||
|
return (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.content;
|
return this.cache;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -5,10 +5,11 @@
|
|||||||
// 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` in
|
// Ente holds edited/basic times in microseconds; records expose `takenAt` and
|
||||||
// milliseconds. The magic-metadata field names below are the ones the Ente
|
// `modifiedAt` in milliseconds. The magic-metadata field names below are the
|
||||||
// clients write, confirmed against the repo's own fixtures: `w`/`h` in
|
// ones the Ente clients write, confirmed against the repo's own fixtures:
|
||||||
// test/cli/metadata-backup.test.ts, `visibility` in test/library/store.test.ts.
|
// `w`/`h` in test/cli/metadata-backup.test.ts, `visibility` in
|
||||||
|
// test/library/store.test.ts.
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Collection,
|
Collection,
|
||||||
@@ -33,12 +34,17 @@ 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.
|
||||||
@@ -125,6 +131,7 @@ const toPhotoRecord = (
|
|||||||
albumIDs,
|
albumIDs,
|
||||||
title: asString(pub.editedName) ?? rep.metadata.title,
|
title: asString(pub.editedName) ?? rep.metadata.title,
|
||||||
takenAt: microsToMillis(takenAtMicros),
|
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,
|
||||||
@@ -140,6 +147,7 @@ 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;
|
||||||
};
|
};
|
||||||
|
|||||||
+16
-67
@@ -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,63 +19,10 @@ export interface MetadataBackupOptions {
|
|||||||
onProgress?: ProgressCallback;
|
onProgress?: ProgressCallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the raw EXIF APP1 segment in JPEG bytes. Returns `exif` (the segment
|
// Extract dimensions, EXIF and XMP from a file's bytes. `exif` is the EXIF tags
|
||||||
// data, starting at the "Exif\0\0" header) when there is one, nothing when the
|
// exifreader returns, from any image format it reads. When it finds an EXIF
|
||||||
// bytes are not a JPEG or carry no EXIF, and `error` when the segment layout is
|
// block but reads no tag from it, the record keeps the block's bytes, base64,
|
||||||
// malformed. Each segment length is checked against the bytes that remain and
|
// in `exifRaw`, with the reason in `exifError`.
|
||||||
// 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 => {
|
||||||
@@ -92,19 +39,21 @@ 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; a malformed JPEG is still
|
// is expected and only means no dimensions; unreadable EXIF is still
|
||||||
// reported below through `exifError`.
|
// reported below through `exifError`.
|
||||||
}
|
}
|
||||||
|
|
||||||
const { exif, error } = extractExifFromJpeg(fileBytes);
|
const tags = readExifTags(fileBytes);
|
||||||
if (error) result.exifError = error;
|
if (tags?.exif && Object.keys(tags.exif).length > 0) {
|
||||||
if (exif) {
|
result.exif = tags.exif;
|
||||||
try {
|
} else if (tags?.exif) {
|
||||||
result.exif = exifReader(exif);
|
const block = tags.metadataRange?.blocks.find((b) => b.type === "exif");
|
||||||
} catch (err) {
|
if (block) {
|
||||||
result.exifRaw = exif.toString("base64");
|
result.exifRaw = Buffer.from(
|
||||||
result.exifError = err instanceof Error ? err.message : String(err);
|
fileBytes.subarray(block.start, block.end),
|
||||||
|
).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)
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ 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,
|
||||||
@@ -653,6 +654,31 @@ 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", () => {
|
||||||
|
|||||||
+176
-74
@@ -1,21 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Tests for the JPEG EXIF scan behind `quak backup-metadata --exif`.
|
* Tests for reading EXIF (`src/exif.ts`) and the image metadata
|
||||||
|
* `quak backup-metadata --exif` records.
|
||||||
*
|
*
|
||||||
* The originals come from users' libraries, so a truncated or corrupt JPEG
|
* The originals come from users' libraries, so a truncated or corrupt file
|
||||||
* must neither hang the scan nor throw out of it, and a malformed file must be
|
* must neither hang the read nor throw out of it: `readPhotoExif` gives `{}`,
|
||||||
* told apart from one that simply has no EXIF: the record carries the reason in
|
* and `backup-metadata` tells an EXIF block it cannot read apart from a file
|
||||||
* `exifError`. Each input below is a short hand-built byte array.
|
* that simply has no EXIF, carrying the reason in `exifError`. Each JPEG below
|
||||||
|
* 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 { readPhotoExif } from "../../src/exif.js";
|
||||||
extractExifFromJpeg,
|
import { extractImageMetadata } from "../../src/metadata-backup.js";
|
||||||
extractImageMetadata,
|
import { HEIC_WITH_EXIF } from "../exif-heic.js";
|
||||||
} from "../../src/metadata-backup.js";
|
|
||||||
|
|
||||||
const SOI = [0xff, 0xd8]; // start of image
|
const SOI = [0xff, 0xd8]; // start of image
|
||||||
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan, where the scan stops
|
const SOS = [0xff, 0xda, 0x00, 0x02]; // start of scan
|
||||||
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 = [
|
||||||
@@ -24,6 +27,66 @@ 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],
|
||||||
|
];
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -33,74 +96,87 @@ const app1 = (data: number[]): number[] => {
|
|||||||
const bytes = (...parts: number[][]): Uint8Array =>
|
const bytes = (...parts: number[][]): Uint8Array =>
|
||||||
new Uint8Array(parts.flat());
|
new Uint8Array(parts.flat());
|
||||||
|
|
||||||
describe("extractExifFromJpeg", () => {
|
describe("readPhotoExif", () => {
|
||||||
it("returns the EXIF segment of a valid JPEG", () => {
|
it("reads the common fields of a valid JPEG", () => {
|
||||||
const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6];
|
const data = [...EXIF_HEADER, ...TIFF_ORIENTATION_6];
|
||||||
const scan = extractExifFromJpeg(bytes(SOI, app1(data), SOS));
|
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({
|
||||||
expect(scan.error).toBeUndefined();
|
orientation: 6,
|
||||||
expect([...scan.exif!]).toEqual(data);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns nothing for a file that is not a JPEG", () => {
|
it("gives no dateTimeOriginal for a DateTimeOriginal of 0000:00:00 00:00:00", () => {
|
||||||
const png = bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
const data = [...EXIF_HEADER, ...TIFF_UNSET_DATE];
|
||||||
expect(extractExifFromJpeg(png)).toEqual({});
|
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({
|
||||||
|
orientation: 6,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns nothing for a JPEG without EXIF", () => {
|
it("reads a GPSAltitude without GPSAltitudeRef as above sea level", () => {
|
||||||
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
|
const data = [...EXIF_HEADER, ...TIFF_ALTITUDE_WITHOUT_REF];
|
||||||
expect(extractExifFromJpeg(bytes(SOI, app0, SOS))).toEqual({});
|
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({
|
||||||
|
gpsAltitude: 12.5,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores an APP1 segment too short to hold the Exif header", () => {
|
it("gives no make for a Make whose value lies past the end of the file", () => {
|
||||||
// A length under 8 cannot hold the six-byte "Exif\0\0" header, so the
|
const data = [...EXIF_HEADER, ...TIFF_MAKE_PAST_END];
|
||||||
// segment is not EXIF. This one has length 7 and holds only "Exif\0",
|
expect(readPhotoExif(bytes(SOI, app1(data), SOS))).toStrictEqual({
|
||||||
// which the old code, lacking the length check, returned as EXIF.
|
orientation: 6,
|
||||||
const short = app1(EXIF_HEADER.slice(0, 5));
|
});
|
||||||
expect(extractExifFromJpeg(bytes(SOI, short, SOS))).toEqual({});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts an APP1 segment of length 8 holding just the Exif header", () => {
|
it.each([
|
||||||
const scan = extractExifFromJpeg(bytes(SOI, app1(EXIF_HEADER), SOS));
|
[
|
||||||
expect(scan.error).toBeUndefined();
|
"a file that is not an image",
|
||||||
expect([...scan.exif!]).toEqual(EXIF_HEADER);
|
new TextEncoder().encode("just some text, not an image"),
|
||||||
});
|
],
|
||||||
|
[
|
||||||
it("reports a JPEG truncated inside a segment header", () => {
|
"a PNG without EXIF",
|
||||||
const scan = extractExifFromJpeg(bytes(SOI, [0xff, 0xe1, 0x00]));
|
bytes([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
expect(scan.exif).toBeUndefined();
|
],
|
||||||
expect(scan.error).toMatch(/truncated segment length/);
|
["a JPEG without EXIF", bytes(SOI, APP0, SOS)],
|
||||||
});
|
// 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
|
||||||
it("reports a JPEG that ends before the image data", () => {
|
// take the next segment's bytes as EXIF.
|
||||||
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
|
[
|
||||||
const scan = extractExifFromJpeg(bytes(SOI, app0));
|
"an APP1 segment too short to hold the Exif header",
|
||||||
expect(scan.error).toMatch(/ends before the image data/);
|
bytes(SOI, app1(EXIF_HEADER.slice(0, 5)), SOS),
|
||||||
});
|
],
|
||||||
|
[
|
||||||
it("stops on a zero-length segment instead of looping", () => {
|
"an APP1 segment holding just the Exif header",
|
||||||
// A length of 0 would otherwise step the scan by 2 bytes at a time
|
bytes(SOI, app1(EXIF_HEADER), SOS),
|
||||||
// through the rest of the file, reading garbage as markers.
|
],
|
||||||
const zero = [0xff, 0xe0, 0x00, 0x00];
|
[
|
||||||
const scan = extractExifFromJpeg(
|
"a JPEG truncated inside a segment header",
|
||||||
bytes(SOI, zero, zero, zero, zero, SOS),
|
bytes(SOI, [0xff, 0xe1, 0x00]),
|
||||||
);
|
],
|
||||||
expect(scan.error).toMatch(/segment length 0 at byte 2 is too small/);
|
["a JPEG that ends before the image data", bytes(SOI, APP0)],
|
||||||
});
|
// 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", () => {
|
[
|
||||||
const scan = extractExifFromJpeg(
|
"a zero-length segment",
|
||||||
bytes(SOI, [0xff, 0xe0, 0x00, 0x01], SOS),
|
bytes(
|
||||||
);
|
SOI,
|
||||||
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", () => {
|
ZERO_LENGTH_APP0,
|
||||||
|
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),
|
||||||
);
|
],
|
||||||
expect(scan.exif).toBeUndefined();
|
// "XX" where the TIFF byte order belongs.
|
||||||
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 fields for %s", (_, input) => {
|
||||||
|
expect(readPhotoExif(input)).toStrictEqual({});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,10 +186,30 @@ 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({ Image: { Orientation: 6 } });
|
expect(meta?.exif).toMatchObject({ Orientation: { value: 6 } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns nothing for a file that is not a JPEG", () => {
|
it("parses EXIF from a HEIC", () => {
|
||||||
|
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();
|
||||||
});
|
});
|
||||||
@@ -123,14 +219,20 @@ 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).toMatch(/runs past the end of the file/);
|
expect(meta?.exifError).toBe(
|
||||||
|
"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", () => {
|
||||||
const data = [...EXIF_HEADER, 0x58, 0x58];
|
// The raw bytes are the whole EXIF block as exifreader finds it: for a
|
||||||
const meta = extractImageMetadata(bytes(SOI, app1(data), SOS));
|
// JPEG, the APP1 segment, marker and length included.
|
||||||
|
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(data).toString("base64"));
|
expect(meta?.exifRaw).toBe(Buffer.from(segment).toString("base64"));
|
||||||
expect(meta?.exifError).toEqual(expect.any(String));
|
expect(meta?.exifError).toBe(
|
||||||
|
"no tag could be read from the EXIF block",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* `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)),
|
||||||
|
);
|
||||||
Binary file not shown.
@@ -6,7 +6,8 @@
|
|||||||
* `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.
|
* nothing. It also covers a `Photo`'s `savePath`, `isLocal`, `content()`,
|
||||||
|
* `exif()` and the methods that each return one field of `exif()`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
@@ -24,7 +25,16 @@ 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 { asLivePhoto, cdnSource, livePhotoZip } from "../live-photo.js";
|
import type { PhotoExif } from "../../src/exif.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;
|
||||||
|
|
||||||
@@ -70,14 +80,23 @@ class MockClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A content source that writes a marker file and counts thumbnail fetches.
|
// A content source that writes `original` as every original and a marker file
|
||||||
const stubSource = (): ContentSource & { thumbCalls: () => number } => {
|
// as every thumbnail, and counts the fetches of each.
|
||||||
|
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 }) => {
|
||||||
writeFileSync(destination, "orig-bytes");
|
originalCalls++;
|
||||||
return { bytesWritten: 10 };
|
writeFileSync(destination, original);
|
||||||
|
return { bytesWritten: original.length };
|
||||||
},
|
},
|
||||||
thumbnail: async ({ destination }) => {
|
thumbnail: async ({ destination }) => {
|
||||||
thumbCalls++;
|
thumbCalls++;
|
||||||
@@ -211,3 +230,301 @@ describe("Library content wiring", () => {
|
|||||||
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 `exif()` picks:
|
||||||
|
// 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", "originals", "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("has no save path and is not local without a download directory", async () => {
|
||||||
|
const lib = await open({ downloadDirectory: undefined });
|
||||||
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||||
|
expect(photo.savePath).toBeUndefined();
|
||||||
|
expect(photo.isLocal).toBe(false);
|
||||||
|
await lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has no 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).toBeUndefined();
|
||||||
|
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 originals = join(root, "backup", "originals");
|
||||||
|
// 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(originals, "1.jpg"));
|
||||||
|
|
||||||
|
await lib.backup();
|
||||||
|
expect(photo.savePath).toBe(join(originals, "1.heic"));
|
||||||
|
expect(photo.isLocal).toBe(true);
|
||||||
|
expect(await photo.content()).toEqual(Buffer.from(IMAGE));
|
||||||
|
await lib.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads the common EXIF fields of a JPEG original", async () => {
|
||||||
|
const lib = await open({ contentSource: stubSource(JPEG_WITH_EXIF) });
|
||||||
|
expect(await lib.photos.byID({ fileID: 1 })!.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();
|
||||||
|
});
|
||||||
|
|
||||||
|
// What exif() returns for HEIC_WITH_EXIF, and for JPEG_WITH_EXIF, which
|
||||||
|
// holds 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("reads the same common EXIF fields from a HEIC original", async () => {
|
||||||
|
const lib = await open({ contentSource: stubSource(HEIC_WITH_EXIF) });
|
||||||
|
expect(await lib.photos.byID({ fileID: 1 })!.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]])),
|
||||||
|
});
|
||||||
|
expect(await lib.photos.byID({ fileID: 1 })!.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 same value as exif().
|
||||||
|
it.each([
|
||||||
|
["JPEG", JPEG_WITH_EXIF],
|
||||||
|
["HEIC", HEIC_WITH_EXIF],
|
||||||
|
])(
|
||||||
|
"has a method for each field exif() returns, giving the same value, for a %s",
|
||||||
|
async (_, bytes) => {
|
||||||
|
const lib = await open({ contentSource: stubSource(bytes) });
|
||||||
|
const photo = lib.photos.byID({ fileID: 1 })!;
|
||||||
|
const exif = await photo.exif();
|
||||||
|
// The file holds every field, so every method is checked.
|
||||||
|
expect(exif).toStrictEqual(heicFields);
|
||||||
|
for (const [field, value] of Object.entries(exif)) {
|
||||||
|
expect(await photo[field as keyof PhotoExif]()).toStrictEqual(
|
||||||
|
value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await lib.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("returns no EXIF fields 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 fields 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 fields 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -209,6 +209,29 @@ 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();
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ 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", () => {
|
||||||
|
|||||||
@@ -787,13 +787,12 @@ describe("fixMissingThumbnails", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("re-encodes smaller until the thumbnail fits the recorded size", async () => {
|
it("re-encodes smaller until the thumbnail fits the recorded size", async () => {
|
||||||
// A noisy 64x48 JPEG, which the default encoding (quality 50, not
|
// A noisy 400x300 JPEG, which the default encoding (quality 50, not
|
||||||
// resized because it is under 720 px) cannot compress below the size
|
// resized because it is under 720 px) cannot compress below the size
|
||||||
// recorded here: one byte less than that encoding's ciphertext. It is
|
// recorded here: one byte less than that encoding's ciphertext.
|
||||||
// small so that each encode is quick even on a busy host.
|
|
||||||
const fixMock = await buildThumbMock();
|
const fixMock = await buildThumbMock();
|
||||||
const w = 64;
|
const w = 400;
|
||||||
const h = 48;
|
const h = 300;
|
||||||
const noisy = new Uint8Array(
|
const noisy = new Uint8Array(
|
||||||
jpegJs.encode(
|
jpegJs.encode(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -702,6 +702,11 @@
|
|||||||
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"
|
||||||
@@ -1002,10 +1007,12 @@ 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==
|
||||||
|
|
||||||
exif-reader@2.0.3:
|
exifreader@4.46.0:
|
||||||
version "2.0.3"
|
version "4.46.0"
|
||||||
resolved "https://registry.yarnpkg.com/exif-reader/-/exif-reader-2.0.3.tgz#259997735080bc6bb959c37b32c60f004ec4391d"
|
resolved "https://registry.yarnpkg.com/exifreader/-/exifreader-4.46.0.tgz#b6216eae512997587c45114f972cc14ca979205f"
|
||||||
integrity sha512-zFbQvguwT9JkqyYhR7pjE1Yn8SagwaGLNRU0Oh14xFa1paSf5Gzxn4gxgk0XhnudI0UIqU+HgnBX93+nva592A==
|
integrity sha512-ksHTpjXKWzbckY+bYlGaomG0EobHJkaMWLg5OPbzlTPda04F2dfrDfYSz8iAPp/kXYUSQdINBVI6nCAjQ8PQ/Q==
|
||||||
|
optionalDependencies:
|
||||||
|
"@xmldom/xmldom" "^0.9.10"
|
||||||
|
|
||||||
expect-type@^1.1.0:
|
expect-type@^1.1.0:
|
||||||
version "1.3.0"
|
version "1.3.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user